# Rooftop Spatial Resolution Plan — v1 (W0, plan only)

> 상태: 작성자 = claude / 검토 = codex (대기). 코드 수정 0. 이미지 생성 0. API 호출 0. DB write 0. production 미수정. 본 문서는 "방향 전환 합의" 를 위한 설계 초안.

> 비교 대상 run: `scripts_output/rooftop_spatial_pipeline_plan/20260523_2226_4b552b/` (이하 `rspp/4b552b`).

> 입력 원천:
> - source bible: `scripts_output/rooftop_source_grounding/codex_entry_sanity_gemini_ok/gemini_rooftop_bible.json`
> - source evidence: `scripts_output/rooftop_source_grounding/codex_entry_sanity_gemini_ok/source_evidence.tsv`
> - 14 L05 shots: DB (project `6cb862d9-590c-4dce-86e6-d10c2977db19`, episode `08ad2cd3-3e96-4d84-808f-869ee628473c`, location short_id `L05`)
> - 기존 base plate assets (참고): `scripts_output/rooftop_spatial_bg_experiment/20260523_1956_d22e35/base_plates/`

---

## 1. Current diagnosis — 왜 rspp/4b552b 는 "이미지 생성 준비 완료" 가 아닌가

`rspp/4b552b/index.html` 의 §4 는 "recommended: 4 vs current 4 (delta=+0)" 라고 보고한다. 이 수치를 "지금 4장 만들면 된다" 로 해석하면 안 된다. 실제 산출물을 뜯어 보면 다음 결락이 모두 동시 존재한다.

### 1-1. shot coverage 의 절반이 비어 있다 (14 shots 중 6 only assigned)

| base_plate_id                                         | shots                | assigned | notes |
| ----------------------------------------------------- | -------------------- | -------- | ----- |
| bp_main_room__eye_level_table_close                   | S5_S2, S5_S6         | 2        | 거실 close framing |
| bp_main_room__eye_level_wide                          | S27_S1, S27_S4       | 2        | 거실 wide framing — 같은 covers_nodes=[거실] 인 plate 가 위와 중복 |
| bp_bedroom__eye_level_doorway_wide__수리영의_방       | S14_S4               | 1        | only 1 shot uses this — shot_specific |
| bp_bathroom__eye_level_mirror_close                   | S18_S9               | 1        | only 1 shot uses this — shot_specific |
| (unassigned via slot_bedroom__…__unresolved)          | S12_S4, S12_S6, S12_S14, S25_S7 | 0 | bedroom 수리영의_방 vs 민숙의_방_안방 분류 불가 |
| (unassigned via slot_manual_review_needed__fallback)  | S14_S5, S14_S9, S18_S5, S25_S3 | 0 | sub_space 분류 자체 실패 |

총합: 6 with base_plate / 8 without. 즉 **이 결과를 그대로 이미지 생성에 넘기면 8 shots 가 base_plate_id="" 인 채 production 으로 흘러간다**. 이건 디자인 누락이지 "OK 4장" 이 아니다.

### 1-2. base plate count 자체가 master / derived 미구분 + topology 부분 cover

- 4 plate 중 2 plate (`bp_main_room__eye_level_table_close`, `bp_main_room__eye_level_wide`) 가 `covers_nodes=[거실]` 로 동일. `overlap_with_other_base_plates` 로만 표시될 뿐 master vs derived 관계 데이터가 없다. 즉 **master plate 가 1장이면 충분한지, 2장이 필요한지 판단할 수 있는 필드가 plan 안에 없다**.
- `covers_nodes` 가 채우지 못한 topology 노드:
  - `민숙의_방_안방` — 0 plate.
  - `주방_영역` — 0 plate. (di_002 가 unmatched 로 흘러나옴)
  - `현관` — 0 plate. (di_003, di_013 가 unmatched 로 흘러나옴)
  - `rooftop_outer` — 0 plate. (L05 = 옥탑방 내부 entity 이므로 의도된 결락이긴 함)
- 즉 base plate 4 = 거실 2 + 수리영의_방 1 + 욕실 1. **bible 의 7-node topology 중 4 node 만 image 화 후보** 인데 이게 plan 산출물 어디에도 명시되어 있지 않다.

### 1-3. unmatched design items 4개의 의미

`base_plate_plan.json.unmatched_design_items`:

| item_id | description | applies_to_nodes | 누락 사유 |
| ------- | ----------- | ---------------- | ------ |
| di_002  | 싱크대 수도꼭지와 끓는 냄비, 씻다 만 석창포 약초 | [주방_영역] | 주방_영역 cover plate 0 |
| di_003  | 바람에 덜커덩거리는 낡은 현관 철문과 틈새의 전단지 | [현관] | 현관 cover plate 0 |
| di_011  | 침대 (안방) | [민숙의_방_안방] | 안방 cover plate 0 |
| di_013  | 현관문 — 철제 (Iron) | [현관] | 현관 cover plate 0 |

이 4건은 "추론 실패" 가 아니라 **"topology 의 X 노드 위로 매핑되는 design item 인데 그 노드에 대응되는 base plate 가 만들어지지 않아서 어디에도 들어갈 자리가 없다"** 는 신호. 흡수 정책 (X 가 다른 node 의 contained 인지) 도 같이 결정해야 한다.

### 1-4. rejected slots 2개 + low confidence 2 slot

`camera_slot_plan.json` 의 slot 6개 중:

- `slot_bedroom__eye_level_doorway_wide__unresolved` — confidence=low, slot_role=rejected_manual_review, multi_node_unresolved=true (candidates: 수리영의_방, 민숙의_방_안방). 4 shots 가 이 slot 으로 fall-through.
- `slot_manual_review_needed__fallback_unknown` — confidence=low, slot_role=rejected_manual_review, base_space_node=manual_review_needed. 4 shots 가 이 slot 으로 fall-through.

이 두 slot 은 "이미지 생성 비대상" 으로 명시되어 있다. 그러면 그 8 shots 는 **별도 결정 절차 없이는 영원히 base plate 를 못 받는다**. 현재 plan 은 이 결정 절차를 정의하지 않는다.

### 1-5. topology edge 누락 (거실 ↔ 주방_영역)

`gemini_rooftop_bible.json.layout_relations` 는 4개 (현관→거실, 거실→수리영의_방, 거실→민숙의_방_안방, 거실→욕실). 즉 **`거실 → 주방_영역` edge 가 bible 에 없다**. bible `sub_spaces` 의 "주방 영역" layout_notes 는 "거실과 인접하거나 통합된 형태" 라고만 적혀 있다. bible.unknowns 에 "거실과 주방 영역을 구분하는 물리적 경계(가벽 등)의 유무" 가 명시되어 있다.

해석: **주방_영역 은 거실 의 contained sub-region 일 가능성이 매우 높다** (가벽 없을 가능성). 그러나 현재 topology card 는 이 두 노드를 동격 sibling 으로 처리하므로 di_002 가 거실 plate 에 흡수되지 못한다.

### 1-6. 결론

rspp/4b552b 는 **diagnostic 으로 훌륭하다** — 어디서 누락이 발생하는지 표 형태로 정확히 보여준다. 그러나 그 상태로 이미지 생성을 시작하면 8 shots 가 base_plate_id="" 인 채로 production 으로 넘어가고, 디자인 누락 4건 + 안방/주방/현관 cover plate 0 인 상태로 i2i 가 호출된다. 따라서 **이미지 생성 전에 한 단계가 더 필요하다**.

---

## 2. New target data contracts

방향 = "camera slot clustering → plate count 추천" 이 아니라 **"잠그고 → 채우고 → 확인하고 → 다음 단계"** (lock → resolve → cluster → ledger).

아래 4개 schema 는 implementation 전 합의용 초안. Codex 리뷰 후 W1 에서 dataclass 확정.

### 2-A. `SetTopologyLock` — 공간 위상 잠금

```json
{
  "lock_id": "rooftop_topology_v1",
  "frozen_at": "2026-05-23T22:46+09:00",
  "evidence_source": "gemini_rooftop_bible.json @ codex_entry_sanity_gemini_ok",
  "nodes": [
    {
      "id": "거실",
      "label": "거실 (living kitchen)",
      "kind": "living_kitchen",
      "role": "primary_hub",
      "containment": {
        "parent_of": ["주방_영역"],
        "contained_in": null
      },
      "active": true,
      "confidence": "trusted",
      "evidence_ev_ids": ["ev_episode_755", "ev_episode_3082", "ev_episode_6120"]
    },
    {
      "id": "주방_영역",
      "label": "주방 영역 (sink + stove)",
      "kind": "kitchen_region",
      "role": "contained_region",
      "containment": {
        "parent_of": [],
        "contained_in": "거실"
      },
      "active": true,
      "confidence": "inferred",
      "evidence_ev_ids": ["ev_episode_782"],
      "inference_basis": "bible.sub_spaces 의 '주방 영역' layout_notes='거실과 인접하거나 통합된 형태'. bible.unknowns 의 '거실과 주방 영역을 구분하는 물리적 경계(가벽 등)의 유무' 미해소. tie-break: contained_in='거실' 으로 잠그되 confidence=inferred."
    },
    {
      "id": "수리영의_방", "kind": "bedroom", "role": "private_room",
      "containment": { "contained_in": null, "parent_of": [] },
      "active": true, "confidence": "trusted"
    },
    {
      "id": "민숙의_방_안방", "kind": "bedroom", "role": "private_room",
      "containment": { "contained_in": null, "parent_of": [] },
      "active": "needs_decision",
      "confidence": "trusted",
      "needs_decision_reason": "selected 14 shots 중 안방으로 명시 진입하는 텍스트가 있는지 W1 에서 검사 — bedroom_unresolved 4 shots (S12_*, S25_S7) 의 shot_description 을 한 번 더 분류."
    },
    {
      "id": "욕실", "kind": "bathroom", "role": "private_room",
      "containment": { "contained_in": null, "parent_of": [] },
      "active": true, "confidence": "trusted"
    },
    {
      "id": "현관",
      "kind": "entry",
      "role": "boundary_opening",
      "containment": {
        "parent_of": [],
        "contained_in": null,
        "visually_part_of": "거실",
        "visually_part_of_basis": "현관 철문은 거실 벽면에 면해 있고 카메라가 거실 master plate 에서 보였을 때 등장 (di_014 'applies_to_nodes=[거실]'). 단 별도 master plate 후보로는 약함."
      },
      "active": true,
      "confidence": "inferred"
    },
    {
      "id": "rooftop_outer",
      "kind": "outer",
      "role": "out_of_L05_scope",
      "active": false,
      "out_of_scope_reason": "entity_canon 상 L04 (외부+옥상 마당) 가 별도 location entity. L05 spatial plan 은 내부만 다룬다."
    }
  ],
  "edges": [
    { "from_node": "현관", "to_node": "거실", "kind": "opening", "confidence": "trusted" },
    { "from_node": "거실", "to_node": "수리영의_방", "kind": "door", "confidence": "trusted" },
    { "from_node": "거실", "to_node": "민숙의_방_안방", "kind": "door", "confidence": "trusted" },
    { "from_node": "거실", "to_node": "욕실", "kind": "door", "confidence": "trusted" },
    { "from_node": "거실", "to_node": "주방_영역", "kind": "containment", "confidence": "inferred",
      "inference_basis": "bible.sub_spaces 의 주방 영역 = 거실 통합. bible.unknowns 미해소." },
    { "from_node": "현관", "to_node": "rooftop_outer", "kind": "door", "confidence": "inferred",
      "annotation": "out_of_L05_scope — base plate 후보 아님" }
  ],
  "visibility_from": [
    { "from_node": "거실", "camera_family": "eye_level_wide",
      "default_visible": ["거실", "주방_영역", "현관"],
      "default_offscreen": ["수리영의_방", "민숙의_방_안방", "욕실"] },
    { "from_node": "거실", "camera_family": "eye_level_table_close",
      "default_visible": ["거실"],
      "default_offscreen": ["주방_영역", "수리영의_방", "민숙의_방_안방", "욕실", "현관"] },
    { "from_node": "수리영의_방", "camera_family": "doorway_wide",
      "default_visible": ["수리영의_방"],
      "default_offscreen": ["거실", "민숙의_방_안방", "욕실"] },
    { "from_node": "민숙의_방_안방", "camera_family": "doorway_wide",
      "default_visible": ["민숙의_방_안방"],
      "default_offscreen": ["거실", "수리영의_방", "욕실"] },
    { "from_node": "욕실", "camera_family": "mirror_close",
      "default_visible": ["욕실"],
      "default_offscreen": ["거실"] }
  ],
  "frozen_unknowns": [
    "옥탑방 내부의 정확한 전체 면적 및 평수",
    "욕실의 구체적인 타일 색상 및 내부 위생 설비 구성",
    "거실과 주방 영역을 구분하는 물리적 경계(가벽 등)의 유무"
  ]
}
```

핵심 변경 (vs rspp/4b552b 의 `topology` card):

1. `containment` 필드 추가 — parent_of / contained_in / visually_part_of. 주방_영역 ⊂ 거실, 현관 visually_part_of 거실.
2. `active` 필드 추가 — rooftop_outer=false, 민숙의_방_안방=needs_decision. inactive 노드는 master plate 후보에서 제외.
3. `containment` edge kind 추가 (`door`/`opening` 외 `containment`/`window_view`).
4. `visibility_from` block 추가 — (room, camera_family) 단위로 default visible / offscreen. rspp/4b552b 는 build_camera_slots 안에 하드코딩되어 있던 부분이라 topology 와 분리.

### 2-B. `ShotSpatialResolution` — 샷 단위 공간 결정

```json
{
  "shot_id": "S12_S6",
  "scene_index": 12,
  "shot_index": 6,
  "state_overlay": "corpse_marks",
  "visible_entity_short_ids": ["C04", "L05"],
  "room_node": null,
  "room_node_candidates": ["수리영의_방", "민숙의_방_안방"],
  "room_node_resolution": {
    "rule": "shot_description 텍스트 키워드 + scene_summary 에 안방/수리영 명시어 검사",
    "matched_keywords": [],
    "confidence": "low"
  },
  "camera_family": "doorway_wide",
  "camera_anchor": "방 입구 문턱 standing eye-level",
  "looking_toward": "wide framing, 방 안 전체",
  "visible_nodes": null,
  "offscreen_nodes": null,
  "entity_layout_markers": [
    { "entity_id": "C04", "kind": "character", "zone": "midground_center",
      "scale_hint": "adult human standing height", "contact_surface": "floor",
      "pose_hint": "collapsed sitting", "render_as_placeholder": "translucent_shape" },
    { "entity_id": "L05", "kind": "location", "zone": "midground_center" }
  ],
  "state_overlay_marks": [
    "dark floor stain near body location",
    "disturbed bedding",
    "scattered objects — NO human figure"
  ],
  "blocking_unknowns": [
    {
      "field": "room_node",
      "question": "S12_S6 의 corpse_marks 가 발견되는 방은 수리영의 방인가 민숙의 안방인가?",
      "evidence_pointers": [
        "ep planning_doc ev_planning_doc_8767 '엇갈렸나 싶어 서둘러 집에 돌아온 순간, 역겨운 냄새와 반쯤 열린 현관문에 숨이 턱 막힌 수리영' — 사건 발견 컨텍스트는 안방 가능성 강함 (수리영이 진입하는 측)",
        "ep planning_doc ev_planning_doc_8821 '커튼 뒤에 고개를 떨군' — 커튼 = 수리영 방 (bible.di_004)",
        "두 evidence 가 충돌 — manual_decision 필요"
      ],
      "resolution_options": [
        "A. 수리영의_방 (커튼 evidence 우선)",
        "B. 민숙의_방_안방 (planning_doc context 우선)",
        "C. 두 가지를 모두 plate 화하고 shot 별 binding 은 W1 후 별도 decision"
      ]
    }
  ],
  "resolution_status": "needs_manual_room"
}
```

`resolution_status` enum (총 5):

| status | 의미 | W1 에서의 처리 |
| ------ | ---- | -------------- |
| `resolved` | room_node / camera_anchor / visible_nodes 모두 채워짐. base_plate_id 매핑 가능. | BasePlateCluster 매핑 |
| `needs_manual_room` | camera_anchor + family 는 잠겼으나 room_node 결정 텍스트 부족 (S12_*, S25_S7 bedroom_unresolved). | GapLedger 등재 |
| `needs_camera_anchor` | room_node 는 추정 가능하나 camera_family/anchor 가 fallback_unknown (S14_S5, S14_S9, S18_S5, S25_S3 manual_review_needed). | GapLedger 등재 + W1 에서 shot_description 한 번 더 분류 시도 |
| `needs_both` | room 도 anchor 도 미해소. | GapLedger blocking |
| `out_of_scope` | L05 와 무관 (현재 14 shots 안에는 없음, 미래 대비). | skip |

### 2-C. `BasePlateCluster` — master vs derived 명시

```json
{
  "cluster_id": "cluster_main_room",
  "topology_anchor_node": "거실",
  "active": true,
  "members": [
    {
      "plate_id": "bp_main_room_master",
      "role": "master_plate",
      "camera_family": "eye_level_wide",
      "camera_anchor": "거실 중앙 standing eye-level",
      "covers_nodes": ["거실", "주방_영역", "현관"],
      "covers_basis": "topology containment: 주방_영역 ⊂ 거실 (inferred). 현관 visually_part_of 거실.",
      "must_show": [
        "di_001: 거실의 작은 창문으로 스며드는 가느다란 햇살",
        "di_006: 식탁 위에 놓인 찻잔들과 가족 사진",
        "di_007: 식탁과 의자",
        "di_008: TV",
        "di_009: 거실 스탠드 조명",
        "di_002: 싱크대 수도꼭지와 끓는 냄비, 씻다 만 석창포 약초",
        "di_003: 바람에 덜커덩거리는 낡은 현관 철문과 틈새의 전단지",
        "di_013: 현관문 — 철제",
        "di_014: 현관 철문 @ 옥탑방 외부와 거실 사이의 경계",
        "di_015: 거실 창문 @ 거실 벽면"
      ],
      "must_not_show": ["대리석 아일랜드 식탁", "화려한 크리스탈 샹들리에 조명", "..."],
      "shots_using_this_plate": ["S27_S1", "S27_S4"],
      "shared_layout_constraints": ["거실 전체 좌우 폭", "창문 위치", "주방 코너 위치"]
    },
    {
      "plate_id": "bp_main_room_table_close",
      "role": "derived_shot_plate",
      "derived_from_master": "bp_main_room_master",
      "camera_family": "eye_level_table_close",
      "camera_anchor": "식탁 옆 seated height",
      "covers_nodes": ["거실"],
      "must_show_overlay_relative_to_master": {
        "emphasize": ["di_006: 찻잔/가족 사진", "di_007: 식탁/의자"],
        "may_omit": ["di_003 현관 전단지 (close framing 으로 안 보임)", "di_002 주방 (close framing 으로 안 보임)"]
      },
      "must_not_show": ["..."],
      "shots_using_this_plate": ["S5_S2", "S5_S6"],
      "shared_layout_constraints_inherited_from_master": ["식탁 윗면 위치", "주변 의자 위치"]
    }
  ]
}
```

`BasePlateCluster` 의 master/derived 규칙:

- **master_plate** = topology_anchor_node 를 가장 wide 하게 cover 하는 framing. cluster 당 1장만.
- **derived_shot_plate** = master_plate 의 sub-framing (close-up, OTS, mirror_reflection 등). `derived_from_master` 필수, `shared_layout_constraints_inherited_from_master` 로 master 와 정합.
- master 가 없는 cluster 는 불가능 (derived 만 있는 cluster 는 invalid).

cluster 후보 (rooftop 14 shots 기준 잠정):

| cluster_id | topology_anchor | master? | derived 후보 | shots 합계 (잠정) |
| ---------- | --------------- | ------- | ------------ | ----------------- |
| cluster_main_room | 거실 | bp_main_room_master (wide) | bp_main_room_table_close | 4 (S5_S2, S5_S6, S27_S1, S27_S4) |
| cluster_suryeong_bedroom | 수리영의_방 | bp_suryeong_bedroom_master (doorway_wide) | (없음, 또는 close-up 추가 후보) | 1+α (S14_S4 확정, S12_S14/S25_S7 후보) |
| cluster_minsook_bedroom | 민숙의_방_안방 | bp_minsook_bedroom_master? | (없음) | 0~3 (S12_S4/S12_S6 후보, manual_decision 필요) |
| cluster_bathroom | 욕실 | bp_bathroom_master (mirror_close) | (close-up 만 있으면 master = close-up 으로 elevate) | 1 (S18_S9) |

→ master plate 후보 **3~4** + derived plate **1+α** = 총 **4~5+** 장. rspp/4b552b 의 "4" 와는 의미가 다름 — coverage 기반.

### 2-D. `GapLedger` — 결정 보류 항목 통합 표

```json
{
  "ledger_id": "rooftop_resolution_gaps_v1",
  "generated_at": "...",
  "entries": [
    {
      "gap_id": "gap_001",
      "category": "unresolved_room_node",
      "scope": "shot",
      "shot_ids": ["S12_S4", "S12_S6", "S12_S14", "S25_S7"],
      "summary": "bedroom shot 4건 — 수리영의_방 vs 민숙의_방_안방 분류 텍스트 부족",
      "proposed_resolution": "W1 에서 shot_description 키워드 재분류 → fail 시 user_decision required",
      "blocks": ["cluster_suryeong_bedroom 완성", "cluster_minsook_bedroom 활성화 여부"]
    },
    {
      "gap_id": "gap_002",
      "category": "unresolved_camera_anchor",
      "scope": "shot",
      "shot_ids": ["S14_S5", "S14_S9", "S18_S5", "S25_S3"],
      "summary": "sub_space classifier fallback — manual_review_needed",
      "proposed_resolution": "W1 에서 shot_description + scene_summary 텍스트 검사 → camera_family 추정 → 실패 시 needs_camera_anchor 마킹",
      "blocks": ["BasePlateCluster 매핑"]
    },
    {
      "gap_id": "gap_003",
      "category": "unmatched_design_item",
      "scope": "design",
      "design_item_ids": ["di_002", "di_003", "di_013", "di_011"],
      "summary": "applies_to_nodes 가 어느 master plate 의 covers_nodes 와도 매칭 안 됨",
      "proposed_resolution": "topology containment 적용 (di_002/di_003/di_013 → cluster_main_room.covers_nodes 흡수). di_011 → cluster_minsook_bedroom 활성화 시 master must_show 로 흡수, 비활성 시 inactive_design_item 으로 마킹.",
      "blocks": ["cluster_main_room.master.must_show 확정", "cluster_minsook_bedroom 활성화 여부"]
    },
    {
      "gap_id": "gap_004",
      "category": "missing_topology_edge",
      "scope": "topology",
      "summary": "bible.layout_relations 에 거실 → 주방_영역 edge 없음",
      "proposed_resolution": "containment edge (kind=containment, confidence=inferred) 로 추가. bible.unknowns 의 '거실/주방 가벽 유무' 가 직접 evidence.",
      "blocks": ["SetTopologyLock.edges"]
    },
    {
      "gap_id": "gap_005",
      "category": "active_decision_required",
      "scope": "topology",
      "node_id": "민숙의_방_안방",
      "summary": "selected 14 shots 에서 안방 진입 evidence 가 명확한지 W1 에서 검사 후 결정",
      "proposed_resolution": "W1 에서 shot_description + scene_summary 키워드 ('안방', '엄마', '민숙') 매칭 → 1건 이상 강 evidence 시 active=true, 아니면 inactive",
      "blocks": ["cluster_minsook_bedroom 활성화"]
    }
  ]
}
```

규칙:

- 모든 gap 은 `proposed_resolution` 필드를 가져야 한다. "deferred" 라고만 적힌 entry 금지.
- gap 의 `blocks` 가 비어 있으면 그 gap 은 plan 진행을 막지 않는다 (informational only).
- 어떤 shot 도 `resolution_status != "resolved"` 인 채로 BasePlateCluster.members[*].shots_using_this_plate 에 들어가서는 안 된다 (W1 invariant).

---

## 3. Direction change recommendations

### 3-1. 주방_영역 = 거실 contained sub-region

- topology edge 추가: `(거실 → 주방_영역, kind=containment, confidence=inferred)`.
- visibility: `(거실, eye_level_wide)` default_visible 에 `주방_영역` 포함.
- design item 흡수: di_002 가 cluster_main_room.master.must_show 에 자연 진입.
- 단서: bible.unknowns "거실/주방 가벽 유무" 미해소 → confidence=inferred 유지. 가벽 있음이 사후 확정되면 contained → adjacent_room 으로 강등 + 별도 master plate 후보로 승격.

### 3-2. 현관 = 거실 의 boundary_opening (visually_part_of)

- topology 자체에선 별도 node 유지 (door evidence 명시).
- 단 master plate 후보로 승격하지 않음 — `role=boundary_opening`, `visually_part_of=거실`.
- design item 흡수: di_003 / di_013 / di_014 가 cluster_main_room.master.must_show 에 흡수.
- camera_family `eye_level_wide` 의 default_visible 에 `현관` 포함.
- 단서: di_014 의 applies_to_nodes 는 이미 `[거실]` 이라 그 시점부터 일관. 다른 di_003/di_013 도 같은 정책.

### 3-3. 민숙의_방_안방 = needs_decision (W1 에서 결정)

- 단순 비활성화 X. selected 14 shots 안에서 안방 evidence 가 있는지 검사 후 결정.
- W1 검사 기준:
  - shot.shot_description / scene_summary 에 "안방" 키워드 직접 등장 → 안방 confirmed.
  - "엄마" / "민숙" 만 등장 + 침대 + 시신 발견 컨텍스트 → 안방 가능성 높음 (weak evidence).
  - "수리영" + 침대 → 수리영의_방.
- 결정 결과는 `SetTopologyLock.nodes[민숙의_방_안방].active` 에 반영.
- active=true 시 → cluster_minsook_bedroom master plate 후보 1장 추가. 안 그러면 di_011 = inactive_design_item.

### 3-4. table_close 는 별도 master 가 아니라 wide master 의 derived_shot_plate

- rspp/4b552b 는 같은 covers_nodes=[거실] 인 plate 2장을 sibling 으로 처리.
- 새 방향에서는 `bp_main_room_master` (wide) 가 master, `bp_main_room_table_close` 는 derived. derived 는 master 의 shared_layout_constraints 를 inherit.
- production 함의: master plate 가 먼저 생성되어야 derived plate i2i 가 가능 (master = wide reference). 이건 production sequencing 에도 영향이 있으므로 W1 schema 에 명시.

### 3-5. base plate count 산출 = topology coverage 기반

- 잘못된 산출: "camera_slot clustering eligible → 4".
- 올바른 산출 후보 (W1 결과에 따라 조정):
  - master plate 후보 = topology.nodes 중 (active=true AND role∈{primary_hub, private_room}) — 최소 3개 (거실, 수리영의_방, 욕실), 최대 4개 (+ 민숙의_방_안방 if active).
  - derived plate 후보 = shot framing variation — 잠정 1개 (main_room_table_close). 추가 발견되면 W1 ledger 에 등재.
- 총 base plate count = master + derived. **그러나 이 숫자는 plan 의 "성공 지표" 가 아니다** — 진짜 지표는 "14 shots 가 모두 resolved 인가" 와 "topology active 노드 전부 master plate 보유인가".

### 3-6. shot background payload 의 unassigned 처리

- `base_plate_id=""` 빈 문자열 금지.
- 대신 `resolution_status` 필드 + `blocking_unknowns` array 명시.
- HTML 은 "(unassigned)" 라는 친절한 그룹이 아니라 "BLOCKED — needs_X" 그룹으로 표시. fold 안 함 (open by default), 첫 화면 첫 섹션.

---

## 4. Proposed next script change

선호 = **새 script `backend/scripts/experiment_rooftop_spatial_resolution_plan.py` 신설**.

이유:

1. `experiment_rooftop_spatial_pipeline_plan.py` 는 diagnostic artifact 로 보존해야 함. rspp/4b552b 결과는 "왜 자동화가 부족한가" 의 증거.
2. 새 schema (`SetTopologyLock`, `ShotSpatialResolution`, `BasePlateCluster`, `GapLedger`) 는 기존 dataclass 와 명시적으로 다르다 (containment / role / derived_from_master / blocking_unknowns / resolution_status). 같은 모듈 안에서 마이그레이션하면 어느 dataclass 가 어느 schema 인지 추적 어려움.
3. 출력 디렉토리도 분리: `scripts_output/rooftop_spatial_resolution_plan/<run_id>/`. 이미 본 plan 문서가 이 경로에 위치.

공유할 helper (기존 script 에서 import):

- `experiment_rooftop_spatial_bg.load_l05_shots` (DB shot 로더) — 유지, 재사용.
- `experiment_rooftop_spatial_bg.ShotMeta` dataclass — 유지, 재사용.
- `experiment_rooftop_spatial_bg.build_shot_plans` — **재사용 안 함**. 기존 plan 은 (sub_space, camera_slot) clustering 을 하고 결과를 그대로 manual_review 로 떨군다. 새 script 는 resolution 절차를 새로 짠다.

신규 모듈 구성 (W1 에서 확정 예정, draft):

- `_load_bible_and_evidence()` — 입력 로드.
- `_load_l05_shots()` — DB 로드.
- `build_set_topology_lock(bible, source_evidence)` — SetTopologyLock JSON.
- `resolve_shot_spatial(shot, topology_lock)` — shot 별 ShotSpatialResolution. needs_X 분기 포함.
- `cluster_base_plates(topology_lock, resolutions)` — BasePlateCluster list.
- `build_gap_ledger(topology_lock, resolutions, design_items, clusters)` — GapLedger.
- `render_html(...)` — gap-first 레이아웃.
- `write_outputs(...)` — JSON/TSV/HTML.

---

## 5. Proposed W1 dry-run implementation scope

### 절대 금지

- 이미지 생성 0.
- Gemini / OpenAI API 호출 0.
- DB write 0 (read-only).
- production pipeline 코드 0건 수정.
- 기존 base plate PNG / spatial_bg 디렉토리 수정 0.

### 산출물 디렉토리

`scripts_output/rooftop_spatial_resolution_plan/<run_id>/`

- `topology_lock.json` — SetTopologyLock 전체.
- `shot_resolutions.json` — 14 shots ShotSpatialResolution.
- `shot_resolutions.tsv` — shot/room/anchor/status 1줄.
- `base_plate_clusters.json` — BasePlateCluster list.
- `gap_ledger.json` — GapLedger.
- `gap_ledger.tsv` — gap_id/category/scope/summary/proposed_resolution.
- `index.html` — gap-first 시각화.
- `run_meta.json` — 입력/출력/version.

### `index.html` 레이아웃 (blocking gaps first)

```
§1. Gap ledger summary (open by default)
     - 5 gap entries 표 + proposed_resolution.
     - "blocks" 컬럼이 비어있는 entry 와 비어있지 않은 entry 색 구분.

§2. Shot resolution status (14 shots, status 별 group)
     - 첫 group = needs_X (8 shots, 보통 위로).
     - 그 뒤 resolved (6 shots).
     - 각 shot 마다 blocking_unknowns + evidence_pointers 표시.

§3. SetTopologyLock
     - nodes 표 (id, kind, role, active, containment, confidence).
     - edges 표.
     - visibility_from 표.
     - frozen_unknowns 리스트.

§4. BasePlateCluster
     - cluster 별 master / derived 표.
     - covers_nodes / must_show / shots_using_this_plate / shared_layout_constraints.
     - master 가 없는 cluster (invalid) 는 빨강 배경.

§5. Production design inference (vs base plate cluster matching)
     - 16 design items.
     - 각 item 이 어떤 cluster.member.must_show 에 들어갔는지 표.
     - 안 들어간 item 은 GapLedger gap_id 와 함께 표시.

§6. Reference: existing base plate PNG thumbnails (read-only)
     - rspp/4b552b 와 동일 thumbnail. 비교용.

§7. Open questions for user decision
     - 민숙의_방_안방 active?
     - bedroom_unresolved 4 shots binding?
     - manual_review_needed 4 shots binding?
```

### W1 의 분류 규칙 (LLM 0)

1. **room_node 키워드 분류** (shot_description + scene_summary):
   - "안방" → 민숙의_방_안방.
   - "수리영" + ("방" 또는 "침대" 또는 "커튼") → 수리영의_방.
   - "엄마" / "민숙" 단독 + bedroom_unresolved → planning_doc evidence pointer 만 표시, status=needs_manual_room.
   - "거울" / "씻" → 욕실.
   - "식탁" / "TV" / "현관" / "거실" / "주방" / "냄비" → 거실.
2. **camera_family 키워드 분류** (shot_description):
   - "거울" → bathroom mirror_close.
   - "식탁 옆" / "찻잔" close → main_room table_close.
   - "방 안 전체" / "doorway" / "문턱" → bedroom doorway_wide.
   - "거실 중앙" / "wide" → main_room eye_level_wide.
   - 매칭 0 → camera_family=null, status=needs_camera_anchor.
3. **design item 흡수**:
   - 모든 design item 에 대해 (applies_to_nodes ∩ cluster_member.covers_nodes) 검사.
   - applies_to_nodes 가 containment.contained_in 으로 cluster_member.covers_nodes 와 연결되면 흡수 (주방_영역 → 거실).
   - 매칭 0 → GapLedger gap_003 등재, inactive_design_item 마킹.

### W1 acceptance gate (W2 이미지 생성으로 넘어가기 전 조건)

W1 결과는 **사용자 + Codex 둘 다** 확인 후 W2 진행. 자동 진행 X.

---

## 6. Acceptance criteria (이 plan 자체의)

이 plan v1 이 "Codex 와 합의 가능한 수준" 이 되려면 아래 6개를 모두 만족해야 함.

1. **shot 결정 가능성**: 14 shots 모두 `resolution_status` ∈ {resolved, needs_manual_room, needs_camera_anchor, needs_both, out_of_scope} 중 하나로 분류 가능. unknown 상태로 남겨두는 shot 0.
2. **unresolved 의 evidence 동봉**: needs_manual_room / needs_camera_anchor 인 shot 마다 evidence_pointers 가 최소 1개 (shot_description quote 또는 planning_doc ev_id). 결정 옵션 A/B/C 명시.
3. **unmatched design item 처리 후보**: 4 unmatched item (di_002/di_003/di_011/di_013) 마다 (a) topology containment 로 흡수 가능 여부, (b) 별도 master plate 필요 여부 판정 후보가 GapLedger 에 등재.
4. **base plate count 분리**: 출력에 "총 N장" 단일 숫자 X. 대신 `master_plate_count` (topology coverage 기반) + `derived_shot_plate_count` (framing variation) 각각.
5. **HTML usability**: index.html 을 열었을 때 첫 화면 (스크롤 없이) 안에 "다음 결정이 필요" 가 보여야 함. base plate 추천 4장은 그 다음에 와야 함.
6. **production 무영향**: 본 plan 의 어떤 항목도 backend production pipeline 의 schema/step/DB 를 즉시 변경하지 않는다. 모든 변경은 별도 W2+ plan 으로 분리.

---

## 7. Open questions for Codex review

(Codex 리뷰가 이 list 의 각 항목에 명시적 yes/no/대안 을 달기 위함)

- Q1. SetTopologyLock 의 containment 관계로 di_002 (주방_영역) 를 cluster_main_room.must_show 에 흡수하는 것은 안전한가? bible.unknowns 의 "거실/주방 가벽 유무" 가 미해소인데 plate 차원에서 통합해도 되는가? 만약 가벽 있는 것으로 사후 확정되면 plate 분리 시점에 i2i 결과 재생성이 필요한가?
- Q2. 현관 을 별도 master plate 후보로 두지 않고 거실 master 의 boundary_opening 으로 흡수하는 정책에 동의하는가? 향후 "현관 close-up" framing 이 발생하면 derived_shot_plate 로 추가하면 충분한가?
- Q3. 민숙의_방_안방 active 결정을 W1 의 키워드 검사 1회로 끝내도 되는가? evidence 가 약하면 default 를 inactive=false 로 가는 것이 안전한가, 아니면 plate 1장 만들어 두는 것이 안전한가? 사용자 결정 필요 시점은 W1 결과 직후로 잡는 것이 맞는가?
- Q4. bedroom_unresolved 4 shots (S12_S4, S12_S6, S12_S14, S25_S7) 와 manual_review_needed 4 shots (S14_S5, S14_S9, S18_S5, S25_S3) 가 같은 GapLedger 안에 두 카테고리로 분리되는데, 한 GapLedger entry 가 여러 shot 을 묶는 현재 구조와 1 entry per shot 중 어느 쪽이 W1 검사에 더 친화적인가?
- Q5. derived_shot_plate 의 must_show 를 master 와 별도로 정의하지 않고 `must_show_overlay_relative_to_master` (emphasize / may_omit) 만 쓰는 방식은 production 의 i2i prompt 합성과 호환되는가? master 의 must_show 전체가 derived 에 inherit 되는 가정인가?
- Q6. 새 script 신설 대 기존 script 교체 — Codex 의 별도 의견이 있는가? 기존 script 의 `bg.build_shot_plans` / classifier 를 새 script 가 import 해서 사용하는 정도는 허용되는가?
- Q7. SetTopologyLock.frozen_at 같은 timestamp 필드가 plan 재현성에 도움이 되는가, 아니면 input hash (bible.json content hash) 가 더 안전한가?
- Q8. W1 의 "키워드 분류" 가 LLM 0 인데, 이 정책이 충분히 deterministic 한가? shot_description 토큰화 규칙 (substring vs whitespace) 을 더 좁히는 편이 좋은가?

---

(끝. plan v1.)
