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

> 상태: 작성자 = claude / 검토 = codex (재리뷰 대기). plan_v1.md 의 후속. 코드 수정 0. 이미지 생성 0. API 호출 0. DB write 0. production 미수정.

> 이전 버전: `plan_v1.md` (580 lines, 보존). 본 v2 는 Codex 의 `NEEDS_REVISION_BEFORE_W1` 6 항목 (BLOCKING 1+2, IMPORTANT 1+2, MINOR 2) 을 반영한 self-contained 재진술. v1 과 v2 차이는 §0 changelog 에 line range 포함하여 명시.

> 입력 원천 (v1 과 동일):
> - 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/`

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

---

## 0. Changelog vs plan_v1.md

| # | 분류 | v1 결함 | v2 패치 위치 |
| - | ---- | ------- | ------------ |
| C1 | BLOCKING 1 | `active` 필드 타입 혼합 (bool + "needs_decision" string) — v1.md :99/:125/:144/:151 | v2 §2-A 의 SetTopologyLock.nodes[].`active_status` enum + `plate_policy` enum 신설 |
| C2 | BLOCKING 2 | shot-scoped GapLedger 가 그룹 entry 1개 (4 shots 묶음) — v1.md :333-340 (gap_001) / :342-349 (gap_002) | v2 §2-D 의 GapLedger 가 shot-scoped 는 per-shot entry + `rollup_group_id` 추가. design/topology scope 는 grouped 유지 |
| C3 | IMPORTANT 1 | evidence_pointers 가 결론 문구 포함 ("안방 가능성 강함", "커튼=수리영 방") — v1.md :234-235 | v2 §2-B 의 `evidence_pointers[].relation = supports \| contradicts \| ambiguous` 만 허용 + 결론은 `candidate_interpretations` 별도 필드 |
| C4 | IMPORTANT 2 | W1 keyword 분류 룰의 충돌 우선순위 부재 — v1.md :529-540 | v2 §5 의 conflict policy 5종 (direct room 우선 / 수리영+민숙 동시 = needs_manual / 엄마+침대 = weak only / "방" 단독 사용 금지 / Korean whitelist + no fuzzy) |
| C5 | MINOR — Q7 | timestamp 만 있고 input integrity hash 부재 — v1.md :43 | v2 §2-A 의 `source_hashes: {bible_sha256, evidence_sha256, shot_snapshot_hash}` + `generated_at` 은 metadata 로 유지 |
| C6 | MINOR — Q5 | derived plate 가 master 의 must_show 전체를 inherit 하는 것처럼 읽힘 — v1.md :297-305 | v2 §2-C 의 derived 가 `inherited_layout_constraints` 와 `visible_required_items.{emphasize, may_omit, allow}` 분리. must_show 전체 inherit 금지 |
| C7 | Q1 보강 | 주방_영역 containment 가 reversible 인지 명시 안 함 | v2 §3-1 끝에 "가벽 사후 확정 시 main_room master/derived 는 invalidated, i2i 결과 재사용 금지" 명시 |
| C8 | Q3 보강 | 안방 active 결정 시점이 "W1 키워드 검사 1회" 로 읽힘 | v2 §3-3 의 W1 = evidence table + recommendation only. active=needs_decision 유지가 default, 사용자 결정은 W1 HTML 검토 직후 |
| C9 | Q8 보강 | keyword rule 이 "결정 엔진" 으로 읽힘 | v2 §5 의 "keyword rule = resolve 가능/불가능 분리 엔진" 정의 명시 + 애매 시 needs_manual_* 강제 |
| C10 | MINOR — HTML | "recommended 4 vs current 4" 같은 count first 표시 위험 | v2 §5 의 HTML 첫 지표 = `blocking_gaps_count`. count 는 둘째 |
| C11 | MINOR — schema | unresolved shot 의 base_plate_id="" 가 빈 문자열 | v2 §2-B 의 `base_plate_binding: null` + `blocking_unknowns[]` 로 nullable + structured |

(C1, C2 는 BLOCKING. 나머지는 IMPORTANT/MINOR. Q1-Q8 답변은 §3 와 §7 에 분산 반영.)

---

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

(v1 §1 과 동일. 변경 없음. 요약만 재기재.)

- 14 shots 중 6 only assigned, 8 unassigned (bedroom_unresolved 4 + manual_review fallback 4).
- 4 base plate 중 2 plate 가 `covers_nodes=[거실]` 로 중복. master vs derived 구분 없음.
- topology 의 7 노드 중 4 노드 (거실, 수리영의_방, 욕실, 수리영의_방 again) 만 plate 후보. 안방/주방/현관/rooftop_outer 의 plate 0.
- unmatched design items 4 (di_002/di_003/di_011/di_013) — 각각 applies_to_nodes 가 plate covers_nodes 와 0 교집합.
- topology edge 누락 (거실 ↔ 주방_영역). bible.layout_relations 에 미명시.
- rejected slot 2 (slot_bedroom__unresolved + slot_manual_review_needed). 8 shots 가 이 slot 으로 fall-through.
- 결과: rspp/4b552b 는 diagnostic 으로 훌륭하나, 그대로 이미지 생성으로 넘기면 8 shots 가 base_plate_id="" 인 채 production 진입. 한 단계 더 필요.

---

## 2. New target data contracts (v2 patched)

### 2-A. `SetTopologyLock` — 공간 위상 잠금 (C1 + C5 반영)

```json
{
  "lock_id": "rooftop_topology_v1",
  "metadata": {
    "generated_at": "2026-05-23T22:46+09:00",
    "evidence_source": "gemini_rooftop_bible.json @ codex_entry_sanity_gemini_ok",
    "plan_version": "v2"
  },
  "source_hashes": {
    "bible_sha256": "<sha256 of gemini_rooftop_bible.json>",
    "evidence_sha256": "<sha256 of source_evidence.tsv>",
    "shot_snapshot_hash": "<sha256 of canonical shot list dump (id+shot_description+scene_summary+visible_short_ids)>"
  },
  "nodes": [
    {
      "id": "거실",
      "label": "거실 (living kitchen)",
      "kind": "living_kitchen",
      "active_status": "active",
      "plate_policy": "master_candidate",
      "containment": {
        "parent_of": ["주방_영역"],
        "contained_in": null,
        "visually_part_of": null
      },
      "confidence": "trusted",
      "evidence_ev_ids": ["ev_episode_755", "ev_episode_3082", "ev_episode_6120"],
      "evidence_basis": "bible.sub_spaces.거실.evidence_quotes 의 3 quote (line lookup 가능)"
    },
    {
      "id": "주방_영역",
      "label": "주방 영역 (sink + stove)",
      "kind": "kitchen_region",
      "active_status": "active",
      "plate_policy": "contained_region",
      "containment": {
        "parent_of": [],
        "contained_in": "거실",
        "containment_confidence": "inferred",
        "containment_reversibility": "reversible_on_evidence — bible.unknowns 의 '거실/주방 가벽 유무' 가 사후 확정되어 가벽 존재로 확인되면 contained → adjacent_room 으로 강등 + 별도 master_candidate 승격. 그 시점에 main_room master/derived 는 모두 invalidated 처리, i2i 재사용 금지 (Codex Q1 반영)."
      },
      "confidence": "inferred",
      "evidence_ev_ids": ["ev_episode_782"],
      "inference_basis": "bible.sub_spaces.주방 영역.layout_notes = '거실과 인접하거나 통합된 형태의 주방 공간'. bible.unknowns 의 '거실/주방 가벽 유무 미해소'."
    },
    {
      "id": "수리영의_방",
      "label": "수리영의 방",
      "kind": "bedroom",
      "active_status": "active",
      "plate_policy": "master_candidate",
      "containment": { "parent_of": [], "contained_in": null, "visually_part_of": null },
      "confidence": "trusted",
      "evidence_ev_ids": ["ev_episode_958", "ev_episode_3554", "ev_episode_5673"]
    },
    {
      "id": "민숙의_방_안방",
      "label": "민숙의 방 (안방)",
      "kind": "bedroom",
      "active_status": "needs_decision",
      "plate_policy": "needs_decision",
      "needs_decision_protocol": {
        "stage": "W1",
        "engine": "keyword_evidence_collector (LLM 0)",
        "engine_output": "evidence_table + recommendation (NOT a binding decision)",
        "decision_owner": "user (after reviewing W1 HTML)",
        "default_on_ambiguity": "active_status='needs_decision' 유지, plate 미생성 (Codex Q3 반영)"
      },
      "containment": { "parent_of": [], "contained_in": null, "visually_part_of": null },
      "confidence": "trusted_for_existence_only",
      "evidence_ev_ids": ["ev_episode_3061", "ev_episode_5443"]
    },
    {
      "id": "욕실",
      "label": "욕실",
      "kind": "bathroom",
      "active_status": "active",
      "plate_policy": "master_candidate",
      "containment": { "parent_of": [], "contained_in": null, "visually_part_of": null },
      "confidence": "trusted",
      "evidence_ev_ids": ["ev_episode_5993", "ev_episode_6048"]
    },
    {
      "id": "현관",
      "label": "현관",
      "kind": "entry",
      "active_status": "active",
      "plate_policy": "boundary_opening",
      "containment": {
        "parent_of": [],
        "contained_in": null,
        "visually_part_of": "거실",
        "visually_part_of_basis": "di_014 (현관 철문 @ 옥탑방 외부와 거실 사이의 경계) applies_to_nodes 가 이미 [거실]. master plate 별도 승격 X. 향후 현관 close-up framing 발생 시 derived_shot_plate 또는 entry_close_derived 로 추가 (Codex Q2 반영)."
      },
      "confidence": "inferred"
    },
    {
      "id": "rooftop_outer",
      "label": "옥상/옥탑 외부",
      "kind": "outer",
      "active_status": "inactive",
      "plate_policy": "out_of_scope",
      "out_of_scope_reason": "entity_canon L04 (외부+옥상 마당) 가 별도 location entity. L05 spatial plan 은 내부만 다룬다.",
      "containment": { "parent_of": [], "contained_in": null, "visually_part_of": null }
    }
  ],
  "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.주방_영역.layout_notes + bible.unknowns 미해소 — reversible (위 주방_영역.containment_reversibility 참조)" },
    { "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": [
    "옥탑방 내부의 정확한 전체 면적 및 평수",
    "욕실의 구체적인 타일 색상 및 내부 위생 설비 구성",
    "거실과 주방 영역을 구분하는 물리적 경계(가벽 등)의 유무"
  ]
}
```

**`active_status` enum (C1)**:

| 값 | 의미 | plate 산출 가능 |
| -- | ---- | --------------- |
| `active` | topology 잠금 완료, plate 생성 정책에 따라 처리 | yes (plate_policy 가 master_candidate 또는 contained_region 또는 boundary_opening 이면) |
| `needs_decision` | bible/evidence 만으로는 노드 활용 여부 미정 — W1 evidence_table 기반 사용자 결정 필요 | no (사용자 결정 전까지) |
| `inactive` | 명시적으로 L05 spatial plan 범위 밖 | no |

**`plate_policy` enum (C1)**:

| 값 | 의미 | 처리 |
| -- | ---- | ---- |
| `master_candidate` | cluster 의 master_plate 1장 후보 | BasePlateCluster.members 에서 role="master_plate" 로 등재 |
| `contained_region` | 부모 노드의 master plate 안에 흡수 | 별도 plate 미생성, must_show 만 부모 plate 에 합류 |
| `boundary_opening` | 시각적 경계 요소 (문/창 등) — 부모 노드 plate 의 visible element | 별도 plate 미생성, must_show 만 부모 plate 에 합류 |
| `out_of_scope` | plate 생성 안 함, design item 흡수 안 함 | skip |
| `needs_decision` | 정책 결정 보류 — W1 evidence_table 후 결정 | skip until decided |

### 2-B. `ShotSpatialResolution` — 샷 단위 공간 결정 (C3 + C11 반영)

```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 의 direct room keyword 검사 (§5 keyword conflict policy 참조)",
    "matched_direct_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"
  ],
  "evidence_pointers": [
    {
      "quote_source": "planning_doc",
      "ev_id_candidate": "ev_planning_doc_<offset>",
      "quote": "엇갈렸나 싶어 서둘러 집에 돌아온 순간, 역겨운 냄새와 반쯤 열린 현관문에 숨이 턱 막힌 수리영",
      "relation": "ambiguous",
      "applies_to_field": "room_node"
    },
    {
      "quote_source": "planning_doc",
      "ev_id_candidate": "ev_planning_doc_<offset>",
      "quote": "커튼 뒤에 고개를 떨군",
      "relation": "ambiguous",
      "applies_to_field": "room_node"
    }
  ],
  "candidate_interpretations": [
    {
      "option_id": "A",
      "candidate_value": "수리영의_방",
      "rationale_hint": "bible.di_004 가 '수리영 방의 침대와 창문의 커튼' 으로 커튼 = 수리영 방 design item 으로 등재됨. quote '커튼 뒤에 고개를 떨군' 이 di_004 와 형식적으로 양립 가능.",
      "strength": "weak",
      "note_for_decision": "다른 sub_space 에 커튼이 있을 가능성 (bible 에 안방 커튼 명시 없음) — verbal evidence 부족"
    },
    {
      "option_id": "B",
      "candidate_value": "민숙의_방_안방",
      "rationale_hint": "planning_doc 의 '반쯤 열린 현관문' 직후 첫 진입 룸이 안방이라는 시퀀스 해석 가능 (사건 발견 컨텍스트).",
      "strength": "weak",
      "note_for_decision": "시퀀스가 안방 first 라는 직접 quote 부재"
    },
    {
      "option_id": "C",
      "candidate_value": "both_plates_needed",
      "rationale_hint": "두 방을 모두 plate 화하고 shot 별 binding 은 별도 결정.",
      "strength": "fallback",
      "note_for_decision": "Q3 default (active_status=needs_decision 유지) 와 충돌 — 별도 사용자 결정 필요"
    }
  ],
  "blocking_unknowns": [
    {
      "field": "room_node",
      "question": "S12_S6 의 corpse_marks 발견 방은 어느 노드?",
      "rolled_up_to_gap_id": "gap_room_S12_S6",
      "rollup_group_id": "bedroom_unresolved"
    }
  ],
  "base_plate_binding": null,
  "resolution_status": "needs_manual_room"
}
```

**evidence_pointers 규칙 (C3)**:

- `relation` enum = `supports` / `contradicts` / `ambiguous` 만 허용.
- `quote` 는 원문 그대로, 가공/해석 금지.
- `applies_to_field` 로 어느 결정에 영향 미치는지 명시.
- 결론 문구는 본 객체에 절대 금지. 결론은 `candidate_interpretations[]` 로만.

**`candidate_interpretations[]` 규칙 (C3)**:

- 각 옵션의 `strength` enum = `strong` / `weak` / `fallback`.
- `note_for_decision` 은 evidence 가 부족한 점을 적시. "가능성 강함" 같은 자가확신 문구 금지.
- 최소 2개 옵션 (A/B). C 는 선택적.

**resolution_status 5종 (v1 과 동일)**:

| status | 의미 | W1 처리 |
| ------ | ---- | ------- |
| `resolved` | room/anchor/visible 모두 결정 | BasePlateCluster 매핑 |
| `needs_manual_room` | anchor OK / room 미결정 | GapLedger per-shot gap 등재 |
| `needs_camera_anchor` | room 가능 / anchor 미결정 | GapLedger per-shot gap 등재 + shot_description 키워드 재검사 시도 |
| `needs_both` | 둘 다 미결정 | GapLedger blocking |
| `out_of_scope` | L05 무관 (현재 14 shots 에 없음) | skip |

**`base_plate_binding` 규칙 (C11)**:

- resolved 인 shot 만 `base_plate_binding = {cluster_id, plate_id, role}` 형태.
- 그 외엔 모두 `base_plate_binding: null` + `blocking_unknowns[]` 채워져 있어야 한다.
- 빈 문자열 `""` 절대 금지.

### 2-C. `BasePlateCluster` — master vs derived 명시 (C6 반영)

```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: 주방_영역 contained_in 거실 (inferred). 현관 visually_part_of 거실 (boundary_opening).",
      "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": ["거실"],
      "covers_nodes_basis": "derived 는 master 의 covers_nodes 의 subset 이어야 한다. close framing 으로 주방_영역/현관은 시야 밖.",
      "inherited_layout_constraints": [
        "식탁 윗면 위치 (master 에서 inherit)",
        "주변 의자 위치 (master 에서 inherit)"
      ],
      "visible_required_items": {
        "emphasize": [
          "di_006: 식탁 위 찻잔/가족 사진 — close framing 의 주제",
          "di_007: 식탁/의자 — close framing 의 contact surface"
        ],
        "may_omit": [
          "di_003: 현관 전단지 (close framing 시야 밖, 정상)",
          "di_002: 주방 (close framing 시야 밖, 정상)",
          "di_008: TV (close framing 시야 밖, 정상)"
        ],
        "allow": [
          "di_001: 거실 햇살 (bokeh 로 배경에 나타나도 OK, 필수는 아님)"
        ]
      },
      "must_not_show": ["대리석 아일랜드 식탁", "화려한 크리스탈 샹들리에 조명", "..."],
      "shots_using_this_plate": ["S5_S2", "S5_S6"]
    }
  ]
}
```

**derived 규칙 (C6 = Codex Q5 반영)**:

- derived 는 master 의 `must_show` 를 **자동 inherit 하지 않는다**. close framing 으로 안 보이는 item 은 정상이다.
- 대신 derived 는 3 분리 필드를 가진다:
  - `inherited_layout_constraints`: master 와 정합성 잠금 (식탁 위치, 창문 위치 등). 어긋나면 generation 결과 reject.
  - `visible_required_items.emphasize`: derived 가 반드시 보여야 하는 item.
  - `visible_required_items.may_omit`: master 에는 있지만 derived framing 에서 안 보여도 OK.
  - `visible_required_items.allow`: derived 에 등장해도 좋지만 필수는 아님 (배경 bokeh 등).
- master 가 없는 cluster 는 invalid (derived only cluster 금지).
- `covers_nodes` 는 master ⊇ derived (derived 는 master 의 subset).

**cluster 후보 (rooftop 14 shots, v1 과 같음)**:

| 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) | — | 1+α (S14_S4 확정, S12_*/S25_S7 후보 — gap_room_* 해소 후) |
| cluster_minsook_bedroom | 민숙의_방_안방 | (active=needs_decision 동안 conditional) | — | 0~3 (gap_room_* + active 결정 후) |
| cluster_bathroom | 욕실 | bp_bathroom_master (mirror_close) | — | 1 (S18_S9) |

→ master plate 후보 = 3 (확정) ~ 4 (안방 active 시) / derived plate 후보 = 1 (확정) ~ N (추가 framing 발견 시).

### 2-D. `GapLedger` — shot-scoped per-shot + grouped (C2 반영)

```json
{
  "ledger_id": "rooftop_resolution_gaps_v1",
  "metadata": { "plan_version": "v2", "generated_at": "..." },
  "entries": [
    {
      "gap_id": "gap_room_S12_S4",
      "category": "unresolved_room_node",
      "scope": "shot",
      "shot_id": "S12_S4",
      "rollup_group_id": "bedroom_unresolved",
      "summary": "S12_S4 — bedroom doorway_wide 인데 수리영의_방 vs 민숙의_방_안방 분류 텍스트 부족",
      "evidence_pointer_count": 0,
      "proposed_resolution": "W1 keyword rule 적용 → 미해소 시 user_decision 보류",
      "blocks": ["cluster_suryeong_bedroom 또는 cluster_minsook_bedroom 매핑"]
    },
    {
      "gap_id": "gap_room_S12_S6",
      "category": "unresolved_room_node",
      "scope": "shot",
      "shot_id": "S12_S6",
      "rollup_group_id": "bedroom_unresolved",
      "summary": "S12_S6 — corpse_marks state 의 doorway_wide. evidence_pointer 2 (relation=ambiguous)",
      "evidence_pointer_count": 2,
      "proposed_resolution": "W1 keyword rule + evidence_pointers → 사용자 결정",
      "blocks": ["cluster_suryeong_bedroom 또는 cluster_minsook_bedroom 매핑"]
    },
    {
      "gap_id": "gap_room_S12_S14",
      "category": "unresolved_room_node",
      "scope": "shot",
      "shot_id": "S12_S14",
      "rollup_group_id": "bedroom_unresolved",
      "summary": "S12_S14 — vandalized state, P01 visible. room evidence 부족",
      "evidence_pointer_count": 0,
      "proposed_resolution": "W1 keyword rule",
      "blocks": ["cluster matching"]
    },
    {
      "gap_id": "gap_room_S25_S7",
      "category": "unresolved_room_node",
      "scope": "shot",
      "shot_id": "S25_S7",
      "rollup_group_id": "bedroom_unresolved",
      "summary": "S25_S7 — vandalized state, C01 in bed. bed 가 어느 방 침대인지 결정 필요",
      "evidence_pointer_count": 0,
      "proposed_resolution": "W1 keyword rule + bed entity context",
      "blocks": ["cluster matching"]
    },

    {
      "gap_id": "gap_camera_S14_S5",
      "category": "unresolved_camera_anchor",
      "scope": "shot",
      "shot_id": "S14_S5",
      "rollup_group_id": "manual_review_needed",
      "summary": "S14_S5 — sub_space classifier fallback. cleaned state.",
      "proposed_resolution": "W1 keyword rule 으로 camera_family 추정 → 실패 시 needs_camera_anchor 유지",
      "blocks": ["cluster matching"]
    },
    {
      "gap_id": "gap_camera_S14_S9",
      "category": "unresolved_camera_anchor",
      "scope": "shot",
      "shot_id": "S14_S9",
      "rollup_group_id": "manual_review_needed",
      "summary": "S14_S9 — sub_space classifier fallback. cleaned state, C01+C02 embrace.",
      "proposed_resolution": "W1 keyword rule",
      "blocks": ["cluster matching"]
    },
    {
      "gap_id": "gap_camera_S18_S5",
      "category": "unresolved_camera_anchor",
      "scope": "shot",
      "shot_id": "S18_S5",
      "rollup_group_id": "manual_review_needed",
      "summary": "S18_S5 — sub_space classifier fallback. normal state.",
      "proposed_resolution": "W1 keyword rule",
      "blocks": ["cluster matching"]
    },
    {
      "gap_id": "gap_camera_S25_S3",
      "category": "unresolved_camera_anchor",
      "scope": "shot",
      "shot_id": "S25_S3",
      "rollup_group_id": "manual_review_needed",
      "summary": "S25_S3 — sub_space classifier fallback. vandalized state, P01.",
      "proposed_resolution": "W1 keyword rule",
      "blocks": ["cluster matching"]
    },

    {
      "gap_id": "gap_design_unmatched",
      "category": "unmatched_design_item",
      "scope": "design",
      "design_item_ids": ["di_002", "di_003", "di_013", "di_011"],
      "summary": "4 design items: di_002 (주방), di_003 (현관), di_013 (현관), di_011 (안방 침대) — applies_to_nodes 가 어느 master plate 의 covers_nodes 와도 매칭 안 됨",
      "proposed_resolution": "topology containment 적용 (di_002/di_003/di_013 → cluster_main_room.master.must_show 흡수, containment+boundary_opening 정책). di_011 → cluster_minsook_bedroom 활성화 시 master must_show 흡수, 비활성 시 inactive_design_item 마킹.",
      "blocks": ["cluster_main_room.master.must_show 확정", "cluster_minsook_bedroom 활성화 여부"]
    },
    {
      "gap_id": "gap_topology_edge_kitchen",
      "category": "missing_topology_edge",
      "scope": "topology",
      "summary": "bible.layout_relations 에 거실 → 주방_영역 edge 없음",
      "proposed_resolution": "containment edge (kind=containment, confidence=inferred) 추가. bible.unknowns 의 '거실/주방 가벽 유무' 가 직접 evidence. reversible_on_evidence 정책 적용 (가벽 확인 시 invalidated).",
      "blocks": ["SetTopologyLock.edges"]
    },
    {
      "gap_id": "gap_active_minsook_bedroom",
      "category": "active_decision_required",
      "scope": "topology",
      "node_id": "민숙의_방_안방",
      "summary": "selected 14 shots 에서 안방 진입 evidence 가 명확한지 W1 evidence_table 작성 후 사용자 결정 필요",
      "proposed_resolution": "W1 keyword rule + evidence_pointers 수집 → 사용자가 W1 HTML 검토 후 active=true / active=false 둘 중 결정 (Codex Q3 반영, 자동 결정 금지).",
      "blocks": ["cluster_minsook_bedroom 활성화", "gap_room_S12_S* 4건 / gap_room_S25_S7 의 후보 옵션 B"]
    }
  ]
}
```

**규칙 (C2)**:

- `scope` enum = `shot` / `design` / `topology`.
- `scope="shot"` 인 entry 는 반드시 `shot_id` 1개 (string). `shot_ids` array 금지.
- `scope="shot"` 은 `rollup_group_id` 선택 가능 — HTML 요약에서 묶어 표시 가능. JSON SOT 는 per-shot.
- `scope="design"` / `scope="topology"` 는 그룹 entry 허용 (id list).
- `proposed_resolution` 필수. "deferred" 만 적힌 entry 금지.
- `blocks` 가 비면 informational only.
- shot-scoped gap 의 evidence 는 `ShotSpatialResolution.evidence_pointers` 를 참조. GapLedger 자체엔 quote 중복 X.

---

## 3. Direction change recommendations (v2 patched)

### 3-1. 주방_영역 = 거실 contained sub-region (C7 = Codex Q1 보강)

- topology edge 추가: `(거실 → 주방_영역, kind=containment, confidence=inferred)`.
- visibility: `(거실, eye_level_wide).default_visible` 에 `주방_영역` 포함.
- design item 흡수: di_002 가 cluster_main_room.master.must_show 에 자연 진입 (containment policy).
- **reversible_on_evidence (Codex Q1)**: bible.unknowns 의 "거실/주방 가벽 유무" 가 사후 확정되어 가벽 존재로 확인되면:
  - SetTopologyLock.nodes[주방_영역].plate_policy 를 `contained_region` → `master_candidate` 로 승격.
  - SetTopologyLock.edges 에서 containment edge 제거 + `(거실 ↔ 주방_영역, kind=adjacent_room)` 추가.
  - cluster_main_room 의 master/derived plate 모두 `invalidated` 상태 마킹.
  - **invalidated 된 i2i 결과는 재사용 금지** (must show set 이 달라지므로). 별도 wave 에서 재생성.

### 3-2. 현관 = 거실 의 boundary_opening (Codex Q2 반영)

- topology 자체엔 별도 node 유지 (door evidence 명시).
- plate_policy=boundary_opening — master plate 후보 X.
- design item 흡수: di_003 / di_013 / di_014 가 cluster_main_room.master.must_show 에 흡수.
- camera_family `eye_level_wide` default_visible 에 `현관` 포함.
- 향후 "현관 close-up" framing 발생 시 cluster_main_room.members 에 `bp_main_room_entry_close_derived` 같은 derived_shot_plate 로 추가. master 별도 승격 X.

### 3-3. 민숙의_방_안방 = active_status=needs_decision (C8 = Codex Q3 반영)

- W1 의 역할은 **evidence_table + recommendation** 작성. **decision 아님**.
- W1 evidence_table 작성 규칙:
  - selected 14 shots 안에서 shot.shot_description / scene_summary 의 direct room keyword 검사.
  - direct keyword "안방" 1건 이상 → recommendation = `active_for_minsook`.
  - direct keyword "수리영의_방" / "수리영의 방" 1건 이상 + 안방 direct 0 → recommendation = `inactive`.
  - 둘 다 없음 + bedroom_unresolved 4 shots 의 evidence_pointers 만 있음 → recommendation = `needs_user_decision`.
- default (recommendation 미정 또는 needs_user_decision): `active_status=needs_decision` 유지. plate 생성 X.
- 사용자 결정은 W1 HTML 검토 직후. 자동 active=true 진입 금지.

### 3-4. table_close = main_room_master 의 derived_shot_plate (C6 = Codex Q5 보강)

- 위 §2-C 의 derived 규칙 적용. 자세한 내용 위 참조.
- **W2 에서의 기존 PNG 정합성 처리**: rspp/4b552b 의 `base_plate_assets/20260523_1956_d22e35/` 에 4 PNG 가 보존되어 있다. 이 4 PNG 는 v2 schema 와 매핑 X (master/derived 구분 없이 생성됨). W2 에서 다음 중 하나로 처리:
  - (a) 4 PNG 를 reference-only artifact 로 격리 (별도 디렉토리 이동 X — 원본 보존). W2 새 base plate 는 v2 schema 로 처음부터 생성.
  - (b) main_room 2 PNG (`main_room__eye_level_wide.png`, `main_room__eye_level_table_close.png`) 를 master/derived 페어로 사후 라벨링하고 정합성 검증 후 W2 에서 reuse 여부 결정.
  - 권장 = (a). reuse 비용 < 재생성 비용 검증 부재.

### 3-5. base plate count = topology coverage 기반 (v1 §3-5 유지)

- 잘못된 산출: "camera_slot clustering eligible → 4".
- 올바른 산출 (W1 결과에 따라 조정):
  - master_plate_count = topology.nodes 중 (active_status=active AND plate_policy=master_candidate). 최소 3 (거실, 수리영의_방, 욕실), 최대 4 (+ 민숙의_방_안방 if user_decision=active).
  - derived_shot_plate_count = framing variation. 잠정 1 (main_room_table_close). 추가 발견 시 GapLedger 등재.
- 총 base plate count = master + derived. 그러나 **plan 의 "성공 지표" 가 아니다**. 성공 지표는 `blocking_gaps_count`.

### 3-6. shot background payload 의 unresolved 처리 (C11)

- `base_plate_id=""` 빈 문자열 금지.
- 대신 `base_plate_binding: null` + `blocking_unknowns[]` 배열.
- HTML 은 "(unassigned)" 친절한 그룹 X. "BLOCKED — needs_X" 그룹으로 표시. fold X (open by default). 첫 화면 첫 섹션 (§5 HTML layout 참조).

---

## 4. Proposed next script change (v1 §4 유지)

- 새 script `backend/scripts/experiment_rooftop_spatial_resolution_plan.py` 신설 (Codex Q6 YES).
- 기존 `experiment_rooftop_spatial_pipeline_plan.py` = diagnostic artifact 로 보존.
- 출력 디렉토리: `scripts_output/rooftop_spatial_resolution_plan/<run_id>/`.
- 공유 helper import 범위 (Codex Q6 명시):
  - `experiment_rooftop_spatial_bg.ShotMeta` — 재사용.
  - `experiment_rooftop_spatial_bg.load_l05_shots` — 재사용.
  - `experiment_rooftop_spatial_bg.build_shot_plans` — **import 안 함** (Codex Q6). 새 script 는 resolution 절차를 새로 짬.
- 신규 모듈 구성 (W1 확정 예정):
  - `_load_bible_and_evidence()` / `_load_l05_shots()`
  - `compute_source_hashes(bible_path, evidence_path, shots)` (C5)
  - `build_set_topology_lock(bible, source_evidence, source_hashes)` (C5)
  - `resolve_shot_spatial(shot, topology_lock)` — needs_X 분기 포함, evidence_pointers + candidate_interpretations 채움 (C3).
  - `cluster_base_plates(topology_lock, resolutions)` — master/derived 분리, derived 의 inherited/emphasize/may_omit/allow 채움 (C6).
  - `build_gap_ledger(topology_lock, resolutions, design_items, clusters)` — shot-scoped per-shot + grouped (C2).
  - `render_html(...)` — gap-first 레이아웃, blocking_gaps_count first (C10).
  - `write_outputs(...)` — JSON/TSV/HTML.

---

## 5. Proposed W1 dry-run implementation scope (v2 patched)

### 절대 금지 (v1 과 동일)

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

### 산출물 디렉토리 (v1 §5 와 동일 + source_hashes)

`scripts_output/rooftop_spatial_resolution_plan/<run_id>/`

- `topology_lock.json` (source_hashes 포함)
- `shot_resolutions.json` (per-shot, base_plate_binding nullable)
- `shot_resolutions.tsv` — shot/room/anchor/status 1줄
- `base_plate_clusters.json` (master + derived)
- `gap_ledger.json` (per-shot + grouped)
- `gap_ledger.tsv` (gap_id/scope/shot_id/category/summary/rollup_group_id/proposed_resolution)
- `index.html` — gap-first 시각화 (C10)
- `run_meta.json` — 입력/출력/version/hashes

### `index.html` 레이아웃 — blocking_gaps_count first (C10)

```
§0. Top banner (always visible)
     - blocking_gaps_count (해소 필요 gap 총수)
     - shots_resolved_count / 14
     - master_plate_count + derived_plate_count (둘째 지표)
     - 우측 = "open gap ledger" 점프 링크

§1. Gap ledger (open by default)
     - shot-scoped per-shot 표 (rollup_group_id 별 grouping 토글)
     - design/topology grouped 표
     - 각 entry: id / scope / shot_id (해당 시) / summary / proposed_resolution / blocks

§2. Shot resolution status (14 shots, status 별 group)
     - needs_X group 위, resolved group 아래.
     - 각 shot 마다 evidence_pointers + candidate_interpretations 표 (C3).
     - base_plate_binding null 인 shot 은 빨강 배경.

§3. SetTopologyLock
     - nodes 표 (id, kind, active_status, plate_policy, containment, confidence) (C1).
     - edges 표.
     - visibility_from 표.
     - frozen_unknowns + source_hashes 리스트 (C5).

§4. BasePlateCluster
     - cluster 별 master / derived 표.
     - derived 의 inherited_layout_constraints + emphasize / may_omit / allow 표시 (C6).
     - master 없는 cluster 는 invalid 표시 (빨강).

§5. Production design inference (vs 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? (gap_active_minsook_bedroom)
     - bedroom_unresolved 4 shots binding? (gap_room_S12_*/S25_S7)
     - manual_review_needed 4 shots binding? (gap_camera_*)
     - 각 항목에 "decide later" / "decide now (radio button — diagnostic only, write X)" 표시.
```

### W1 keyword conflict policy (C4 = IMPORTANT 2)

**핵심 원칙 (Codex Q8)**: keyword rule 은 "**resolve 가능/불가능 분리 엔진**" 이다. "결정 엔진" 아님. 애매하면 반드시 `needs_manual_*` 로 내린다.

**room_node 결정 규칙 (우선순위 순)**:

1. **direct room keyword** 가 shot_description 또는 scene_summary 에 substring 으로 등장하면 우선 사용.
   - whitelist = `["안방", "수리영의 방", "수리영의_방", "욕실", "거실"]`.
   - "안방" → 민숙의_방_안방.
   - "수리영의 방" / "수리영의_방" → 수리영의_방.
   - "욕실" → 욕실.
   - "거실" → 거실.
2. **다중 direct 충돌**: direct keyword 가 2 개 이상 매칭되면 → `needs_manual_room`. 우선순위로 강제 결정 금지.
3. **수리영 + 민숙/엄마 동시 등장 (direct room keyword 없음)** → 자동 resolve **금지**. `needs_manual_room`.
4. **"엄마" / "민숙" + "침대"** 만 등장 (direct room keyword 없음) → weak evidence 로 evidence_pointers 에 기록 (relation=ambiguous), 그러나 자동으로 안방 active=true 금지. `needs_manual_room` 유지.
5. **"방" 단독** 등장 (direct keyword 없음) → room resolve 에 사용 금지. evidence_pointers 에도 등장 안 함.
6. **substring matching** 은 Korean phrase whitelist 만 허용. **fuzzy / stemming / synonym / 자모 분리 금지**. ex) "수리영방" 은 whitelist 미포함 → 사용 안 함 (W1 에서 whitelist 보완 후보로 GapLedger 에 등재 가능).

**camera_family 결정 규칙 (우선순위 순)**:

1. **direct framing keyword**:
   - "거울" + ("얼굴" 또는 "씻") → bathroom mirror_close.
   - "식탁" + ("앉" 또는 "찻잔" 또는 "옆") → main_room table_close.
   - "wide" 또는 "방 안 전체" 또는 "문턱" → bedroom doorway_wide (room_node 별도 해소 필요).
   - "거실 중앙" 또는 "거실 전체" + "wide" → main_room eye_level_wide.
2. **매칭 0** → camera_family=null, `needs_camera_anchor`.

**design item 흡수 규칙**:

1. design_item.applies_to_nodes ∩ cluster_member.covers_nodes ≠ ∅ → 흡수.
2. design_item.applies_to_nodes 가 SetTopologyLock 의 containment.contained_in 또는 visually_part_of 로 cluster_member.covers_nodes 와 연결 → 흡수.
3. 어디에도 매칭 X → `gap_design_unmatched` (grouped) 에 등재. inactive_design_item 마킹.

### W1 acceptance gate

- 사용자 + Codex 둘 다 확인 후 W2 진입. 자동 진행 X.

---

## 6. Acceptance criteria (v1 §6 + 추가)

1. **shot 결정 가능성**: 14 shots 모두 `resolution_status` ∈ {resolved, needs_manual_room, needs_camera_anchor, needs_both, out_of_scope}. unknown 상태 0.
2. **unresolved 의 evidence 동봉**: needs_manual_room / needs_camera_anchor 인 shot 마다 `evidence_pointers[]` 길이 ≥ 0 (0 도 OK 단 `evidence_pointer_count: 0` 명시) + `candidate_interpretations[]` 길이 ≥ 2 (옵션 A/B 최소).
3. **unmatched design item 처리 후보**: 4 unmatched item (di_002/di_003/di_011/di_013) 마다 (a) topology containment/boundary_opening 으로 흡수 가능 여부, (b) 별도 master 필요 여부 판정 후보가 GapLedger 에 등재.
4. **base plate count 분리**: `master_plate_count` + `derived_shot_plate_count` 별도 표시. 단일 "총 N장" 표시 금지.
5. **HTML usability**: 첫 화면 (스크롤 없이) 안에 `blocking_gaps_count` + `shots_resolved_count/14` 가 보임. base plate count 는 그 다음 (C10).
6. **production 무영향**: 본 plan 의 어떤 항목도 backend production pipeline 의 schema/step/DB 를 즉시 변경하지 않는다.
7. **schema strict typing** (v2 추가): `active_status`, `plate_policy`, `relation`, `strength`, `resolution_status`, `scope`, `category` 가 enum 으로 정의되어 있고 mixed type 0.
8. **shot-scoped GapLedger granularity** (v2 추가): shot scope gap 은 1 entry per shot. shot_ids array 사용 0.
9. **evidence neutrality** (v2 추가): evidence_pointers[] 의 모든 entry 가 `relation ∈ {supports, contradicts, ambiguous}` + 결론 문구 0. 결론은 `candidate_interpretations[]` 에만.
10. **source integrity** (v2 추가): SetTopologyLock.source_hashes 가 채워져 있음. 재현성 검증 가능.

---

## 7. Open questions for Codex re-review

(v1 Q1-Q8 답변은 Codex 응답으로 모두 처리됨. v2 에서 추가로 묻는 항목.)

- **R1**. `active_status` enum 3종 (active / inactive / needs_decision) 과 `plate_policy` enum 5종 (master_candidate / contained_region / boundary_opening / out_of_scope / needs_decision) 의 분리가 적절한가? 둘이 직교인지 의존인지 확인 필요. 예: `plate_policy=needs_decision` 인데 `active_status=active` 가 가능한가? (v2 §2-A 의 민숙방은 둘 다 needs_decision 으로 잡았는데 redundant 한지)
- **R2**. shot-scoped gap_id naming convention `gap_room_<shot_id>` / `gap_camera_<shot_id>` 이 reviewer 가 한 눈에 카테고리 파악하기에 충분한가? 더 좁히는 게 좋은가?
- **R3**. derived 의 `visible_required_items.{emphasize, may_omit, allow}` 3 buckets 가 production i2i prompt 합성과 매핑되는가? 둘 또는 넷이 더 맞는가?
- **R4**. `source_hashes` 의 `shot_snapshot_hash` 를 어떤 canonical dump 로 계산해야 안정적인가? — 후보: (a) `[(shot_id, shot_description, scene_summary, sorted(visible_short_ids))] sorted by shot_id` 의 JSON SHA256, (b) DB row 그대로.
- **R5**. v1 의 evidence_ev_ids 가 v2 의 source_evidence.tsv 와 명시적으로 lookup 가능해야 하는가, 아니면 `ev_id_candidate` 형태로 "후보 id" 정도면 W1 에서 충분한가?
- **R6**. W1 keyword rule 의 whitelist 가 "안방" / "수리영의 방" / "수리영의_방" / "욕실" / "거실" 5개인데, 추가해야 하는 direct keyword 가 있는가? ("주방" 은 plate_policy=contained_region 이라 room_node resolve 용 keyword X 로 결정해 둠 — OK 인지)
- **R7**. W2 (이미지 생성) 진입 조건을 본 plan 에 명시할지, 별도 wave plan 으로 분리할지. v1+v2 는 W1 까지만 명시했음.

---

## 8. Self-review (writing-plans skill 의 self-review checklist)

| 점검 항목 | 결과 |
| --------- | ---- |
| Spec coverage — 사용자 6 섹션 요구 모두 충족 (Current diagnosis / Data contracts / Direction change / Next script / W1 scope / Acceptance) | ✅ §1-6 모두 채움 |
| Placeholder scan — TBD/TODO/"implement later" | ✅ 없음 |
| Type consistency — enum 값이 본문/예시 일치 | ✅ active_status / plate_policy / relation / strength / resolution_status / scope / category 모두 본문 정의 ↔ 예시 일치 |
| Codex NEEDS_REVISION 6 항목 (C1-C11) 모두 반영 | ✅ §0 changelog 에 line range 포함 |
| v1 의 "안방 가능성 강함" / "커튼=수리영 방" 결론 문구 제거 | ✅ §2-B evidence_pointers 는 relation 만, 결론은 candidate_interpretations 로 이동 |

---

## 9. Codex implementation guards for W1 (APPROVED_FOR_W1)

> 2026-05-23 Codex 재리뷰 verdict: **APPROVED_FOR_W1**. 본 §9 는 Codex 가 W1 실행 시 반드시 지키라고 명시한 6 guards + R1-R7 답변 보존본. plan_v2 본문과 충돌 시 §9 우선.

### 9-A. 6 implementation guards

**G1. active_status × plate_policy compatibility matrix (R1 보강)**

W1 코드 / 테스트 단계에서 `validate_node(node)` 가 다음 매트릭스를 강제해야 한다:

| `active_status` | 허용 `plate_policy` 값 |
| --------------- | --------------------- |
| `active`        | `master_candidate` / `contained_region` / `boundary_opening` |
| `inactive`      | `out_of_scope` |
| `needs_decision`| `needs_decision` |

그 외 조합은 invalid → assertion error / 별도 documented exception 으로만 허용. 두 enum 은 직교가 아니라 정의된 의존이 있으므로 schema-level 검증 필수.

**G2. evidence_ev_ids strict-lookup at W1 output (R5 결정)**

- plan 본문의 `evidence_ev_ids` 와 `ev_id_candidate` 는 plan / diagnostic 단계에서만 OK.
- **W1 산출 JSON 의 모든 evidence_ev_ids 는 `source_evidence.tsv` 의 row 와 strict-match 되어야 한다** (source + char_offset 기반 `ev_id`).
- match 실패 시 fabricate 금지. 대신 다음 구조로 emit:
  ```json
  { "ev_id": "<candidate>", "lookup_status": "missing", "lookup_reason": "<why>" }
  ```
- `lookup_status` enum = `resolved` (default) / `missing` / `multiple_match`.

**G3. shot_snapshot_hash canonical dump (R4 결정)**

- 후보 (a) 채택: **canonical sorted JSON SHA256**. raw DB row 사용 금지.
- 입력 fields: `shot_id`, `shot_description`, `scene_summary`, `sorted(visible_short_ids)`.
- 정렬: 전체 list 를 `shot_id` ascending 으로 sort.
- normalize: whitespace (공백/탭/개행) collapse to single space + trim. case 변환 X (Korean 보존).
- JSON dump: `ensure_ascii=False`, `sort_keys=True`, `separators=(",", ":")`.
- SHA256 hex digest.

**G4. Keyword whitelist 보강 (R6 결정)**

- room_node direct keyword whitelist (W1 §5 C4 의 1번 룰 보강):
  - `"안방"` → 민숙의_방_안방.
  - `"수리영의 방"` / `"수리영의_방"` / **`"수리영 방"`** (G4 신규 추가) → 수리영의_방.
  - `"욕실"` → 욕실.
  - `"거실"` → 거실.
- 단독 `"방"` 사용 금지 (변경 없음).
- `"주방"` 은 room_node direct resolve 사용 금지. contained_region 정책이라 별도 처리 — 필요 시 `room_node=거실` + `visible_nodes += ["주방_영역"]` 또는 contained-region signal 로만 emit.

**G5. GapLedger naming convention 유지 (R2 결정)**

- shot scope: `gap_room_<shot_id>` / `gap_camera_<shot_id>` — 현재 plan_v2 §2-D 와 일치, 변경 없음.
- design / topology scope: grouped name (`gap_design_unmatched`, `gap_topology_edge_kitchen`, `gap_active_minsook_bedroom`) — 변경 없음.

**G6. W2 separation (R7 결정)**

- W2 (이미지 생성) 진입 **조건** 만 본 plan §6 acceptance criteria 형태로 명시 OK.
- W2 **구현 세부** 는 별도 wave plan 으로 분리. 본 plan_v2 에서 W2 schema/step/code 가이드 추가 금지.
- W1 → W2 자동 진입 금지. 사용자 + Codex 가 W1 HTML 검토 후 별도 trigger.

### 9-B. R1-R7 답변 보존

| 항목 | 결정 |
| ---- | ---- |
| R1 active_status × plate_policy 직교성 | 분리 유지 OK. 직교 아님 → G1 compatibility matrix 로 강제 |
| R2 gap_id naming | gap_room_<shot_id> / gap_camera_<shot_id> 충분 — 변경 없음 |
| R3 derived buckets emphasize/may_omit/allow | 3 buckets 유지. 4번째 추가 금지. inherited_layout_constraints 는 별도 유지 |
| R4 shot_snapshot_hash | canonical sorted JSON SHA256 (G3 명세) |
| R5 evidence_ev_ids strict lookup | W1 final output strict 필수. plan/diagnostic 만 candidate OK. lookup_status="missing" 구조 사용 (G2) |
| R6 keyword whitelist | "수리영 방" 추가 (G4). "방" 단독 금지 유지. "주방" room-node resolve 제외 OK |
| R7 W2 진입 조건 | 본 plan = gate summary 명시 OK / W2 구현 세부 = 별도 wave plan (G6) |

### 9-C. W0 종료 조건 점검

- plan_v2.md 가 본 §9 까지 포함 → W0 산출물 완성.
- Codex 재리뷰 verdict = APPROVED_FOR_W1.
- W1 진입은 별도 clean session (50% hygiene 후) — 본 §9 가 W1 의 entry checklist 역할.

---

(끝. plan v2 — APPROVED_FOR_W1 with 9.A guards.)
