# G4.5a Spatial Rules Lift Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** v19 system.md 의 3 spatial-rules prose section (Rule F `## 카메라 위치와 frame edge의 물리 일관성` v19:144-176 = 33 lines + Rule G `## fg/bg 인물 분리 시 공유 공간 명시` v19:177-217 = 41 lines + Rule J `## Primary Subject Framing — 한 shot = 한 framing scale` v19:218-267 = 50 lines = 합계 124 lines) 을 `render_strategy.spatial_consistency` 의 **신규 sibling nested dict (Option 3) — 4 top-level sub-keys (`camera_frame_rule` / `fg_bg_shared_anchor_rule` / `primary_framing_rule` / `rationale_summary`)** + extended `render_strategy.constraints` list (G4.4 base 2 → G4.5a 5 strings) 로 lift. v20 system.md 에서 3 prose 삭제 후 ≤ 32-line "Spatial consistency" compact section 으로 교체. detail_steps.py inline prose 삭제 작업 없음 (G4.5a 는 G4.4 Override O-3 같은 inline prose 9 줄 삭제 패턴이 없음 — 잔여 inline prose 는 G4.5b/c 재검토). 5 canary scripts (4 metric + 1 token). ~53 unit tests (table 53 enumerate, no buffer required — RO-12 binding raised by Plan-R3-R-fix-iter-1) + ~18 integration test suite. 4-point version sync (prompt 디렉토리 + detail_steps.py:105 + version_registry.py:34 + version_registry.py:126). single commit + 듀얼 리뷰 3 iter fix loop.

**Architecture:**
- **render_prompt_card.py producer-side**: `build_render_strategy()` (line 379) 안에 신규 nested dict `spatial_consistency` 4 sub-key (3 sub-rule + rationale_summary) assemble + base 2 → 5 strings constraints 확장. 9 module-level constants (RO-11 binding — `_SPATIAL_CAMERA_LOW_TOKENS` / `_SPATIAL_CAMERA_HIGH_TOKENS` / `_SPATIAL_CAMERA_HIP_TOKENS` / `_SPATIAL_FRAME_EDGE_POSITION_TOKENS` / `_SPATIAL_FG_BG_SEPARATION_TOKENS` / `_SPATIAL_INTERACTION_VERBS` / `_SPATIAL_SHARED_ANCHOR_KEYWORDS` / `_SPATIAL_FRAMING_CLOSE_KEYWORDS` (canary 9-entry production-aligned, RO-5) / `_SPATIAL_FRAMING_WIDE_KEYWORDS`). G4.3 의 `_ID_BODY_PART_TRIGGERS` 는 in-module 직접 재사용 (RO-11 binding — `_SPATIAL_BODY_PART_TRIGGERS` 별칭 도입 금지, self-import 금지). G4.4 의 `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` 도 in-module 직접 재사용 (RO-13 — G4.5c rename follow-up). `compute_render_strategy_snapshot_hash(card)` helper 신설 (PR4-B4/PR4-I2 fix — render_strategy 전체 hash, NOT spatial-only). `_assert_render_strategy_shape()` 안에서 nested helper `_assert_spatial_consistency_shape()` 호출, 그 안에서 further nested helper `_assert_primary_framing_rule_shape()` 호출 (RO-9 / PR4-I5 binding — nested call chain). `build_render_prompt_card()` 가 `_card_metadata.lift_status` 에 4 신규 key (`spatial_camera_frame_lifted` / `spatial_fg_bg_shared_anchor_lifted` / `spatial_primary_framing_lifted` / `spatial_constraints_extended`) + `rule_source` 3 신규 key (`camera_frame_rule` / `fg_bg_shared_anchor_rule` / `primary_framing_rule`) 추가.
- **detail_steps.py consumer-side**: line 105 `SCENE_DETAIL_PROMPT_VERSION` v19 → `"20.<timestamp>"` bump. **inline prose 삭제 작업 없음** (PR4-B11 — G4.5a 는 G4.4 Override O-3 같은 inline prose 잔존 패턴 없음). `_card_metadata` strip 로직 변경 0 (G4.2 14d14cb + G4.3 51d6c5e + G4.4 b1c5032 carry).
- **prompt-side**: `prompts/_base/scene_detail/20.<timestamp>/system.md` 신규 — v19 3 prose section 124 lines 삭제 + ≤ 32-line "Spatial consistency" compact section. `detail_schema.json` / `user.md` verbatim copy.
- **canary scripts**: 5 종 (`g4_5a_camera_frame_consistency.py` / `g4_5a_fg_bg_shared_anchor.py` / `g4_5a_primary_framing.py` / `g4_5a_view_mixing_extension.py` / `g4_5a_token_count.py` — PR4-B12 정확 명). 모든 metric script 가 module-level constants import + ±N=20 tokens window (RO-8 same-character-ID detection algorithm) + sentinel pre-pass + pinned-tuple post-loop validation + gate fail → exit 1. **RO-8 STRICT 0** (PR4-B7 — exit 1 on any violation, NOT soft warning). **Override O-17 / O-35 carry — regex brittleness**: LLM-validator 전환 P1 follow-up.
- **tests**: ~53 unit tests (table 53 enumerate, no buffer required — RO-12 binding raised by Plan-R3-R-fix-iter-1) + ~18 integration (PR4-B10 binding — body table enumerate) + alignment 4-point sync 갱신.

**Spec:** `docs/superpowers/specs/2026-05-05-g4.5a-spatial-rules-lift-design.md` (1469 lines, R1+R2 audit complete, 14 RO + G4.2/G4.3/G4.4 carry Override rows). **commit gate** = G4.4 production canary 4 gate (사용자 단계 진행 시) AND G4.5a self-canary 7 gate (R3-Entry-Note carry from G4.3/G4.4).

**Out of scope:**
- Background-binding lift (G4.2) — 이미 완료 (commit `14d14cb`). Rule A/C/E prose / `background_binding` shape / `_card_metadata` strip 로직 변경 0.
- ID-policy lift (G4.3) — 이미 완료 (commit `51d6c5e`). v19 의 `## ID Policy` compact section / `id_policy` 5 sub-fields 변경 없음. **단 G4.3 의 `id_policy.body_part_focus_rule` 는 G4.5a `primary_framing_rule.close_framing_rules.body_part_focus_cross_ref` + `primary_framing_rule.wide_medium_rules.body_part_focus_cross_ref` 와 1:1 paired-string substring 보존만 (RO-6 binding, PR4-B3 — cross-card refs 는 primary_framing_rule 하위에만, NOT camera_frame_rule)**.
- Continuity lift (G4.4) — 이미 완료 (commit `b1c5032`). v19 의 `## Continuity` compact section / `continuity_elements_used` shape 변경 0.
- **Rule B (조명·색조가 신체를 변형하지 않게) lift = G4.5b carry**: v19 line 63-90 prose 28 lines. G4.5a scope 아님.
- **Rule D (큰 prop은 본체가 프레임에 보이게) lift = G4.5b carry**: v19 line 386-408 prose 23 lines.
- **outfit 묘사 룰 lift = G4.5b carry**: v19 line 418-441 prose 24 lines.
- **evidence and inference disclosure lift = G4.5b carry**: v19 line 494-533 prose 40 lines.
- **dense compression / final 250-line target = G4.5c carry**.
- **`_derive_framing_scale()` producer 변경 = G4.5c 또는 follow-up (RO-4 binding)**: G4.5a 는 producer enum (close/medium/insert) 을 변경하지 않음. wide-class shots 는 medium 으로 매핑되어 `wide_medium_rules` 적용. card-only classification.
- **새 top-level field 도입 = 영구 금지** (사용자 binding) — 5-field envelope contract 안 흡수만.
- Producer side 변경 — `_collect_card_inputs()` / `shot_dependency_t2i` / shot_extract / shot_staging upstream step 변경 없음. 그러나 `build_render_strategy()` 내부 로직 변경은 G4.5a 자연 부산물 in-scope.
- `_check_prompts()` / deterministic post-check 변경 없음.
- G3.2 owned sentinel 의 로직 변경 없음.
- `id_policy` / `background_binding` / `continuity_elements_used` / `asset_requirements` field 의 sub-field 또는 constraint list 변경 없음.
- scene_image_pipeline 또는 합성 로직 변경 없음.
- `forward_zoom_targets` shape 변경 없음 (G4.4 Override O-4 carry).
- silent-fallback 잔존 carry (`backend/app/core/version_registry.py:148-155` — `MODULE_VERSIONS.get(..., "0.0.0")` 등) — G4.4 Override O-8 / G4.5a Trap #15 G4.5c 또는 별도 cleanup PR follow-up.
- canary DRY refactor (`scripts/canary/_canary_common.py` helper module) — G4.5c 또는 P1 follow-up.

---

## Round Override (audit-driven, applied before Task body)

> **현황 (2026-05-05)**: spec R1+R2 audit 완료 (14 RO row RO-1 ~ RO-14). plan R3 (Codex) + R4 (Claude) audit 진행 — R4 reject (fundamental contract drift) → plan 전체 rewrite. R5/R6 fix loop 진행 시 plan body row 추가.

### Spec R1+R2 Override carry (본 plan body 에 inline 적용 — RO-7 binding)

| ID | Spec source | plan 영향 |
|---|---|---|
| **RO-1** | R1-I2 + R2-I1 disagreement | Phase 4 Task 4.5 token canary `g4_5a_token_count.py` — **system-only `token_delta = candidate - baseline ≤ -1000`** (1차 estimate-based binding gate). R2 분석 G4.4 estimate→actual realization ≈83% → G4.5a estimate -1665 → actual ~-1380 → 28% margin sufficient. Phase 2 Task 2.3 tiktoken 실측이 더 강한 수치 (e.g. -1300) 로 revise 가능 — Round 2 Override row 로만 변경, **upward revise 는 re-audit 필요**. system+inject 합산 (≤ -800) = secondary report-only metric (canary JSON 보조 기록 / no gate). exit_criteria_threshold field = `-1000` (signed, system-only). Phase 2 / Phase 4 / Phase 6 briefing 모두 통일. |
| **RO-2** | G4.4 Override O-2 carry | Phase 2 Task 2.2 v20 `## Spatial consistency` compact section line count gate — **`wc -l` ≤ 32 lines strict** (binding). 각 bullet 반드시 1 line — 80-col wrap 허용 안 됨 (wrap 줄도 line 1 로 카운트). Phase 5 Task 5.2 integration test §6.2 신규 row `test_v20_spatial_consistency_section_line_count_under_32` — `re.search(r"## Spatial consistency.*?(?=\n## )", system_md, re.DOTALL)` content 추출 후 `len(content.split("\n")) <= 32` 강제 (R4-M4 carry from G4.3 — sed inclusive trap 회피). spec §3.3 본문 enumerate 13 lines — Phase 2 draft `wc -l` 자체 점검 필수. |
| **RO-3** | R1-B1 | Phase 1 Task 1.3 — **Rule F core_principles 4-entry list 추가** (camera_frame_rule required keys 6 → 7). v19 line 170-175 의 4 핵심 원칙 (camera 바닥 룰 / 머리 위 룰 / 허리 룰 / full body shot 룰) 모두 verbatim carry. Phase 5 Task 5.1 unit tests `test_camera_frame_rule_core_principles_count == 4` + 4 substring assertions (`test_camera_frame_rule_core_principles_substring_low_camera` / `_overhead` / `_hip_height` / `_full_body`). spec §4 Lift Mapping Table line 170-175 row: `consistency_check_summary` (single string) 유지 + `core_principles` (4-entry list) 별도 sub-key. required keys 6 → 7. |
| **RO-4** | R1-I1 + R2-B1 | Phase 1 Task 1.3 — **framing_scale enum drift fix — card-only classification 채택**: `_derive_framing_scale()` producer enum 변경 없음 (close/medium/insert 유지 — wide 미생성). `primary_framing_rule.applies_when` 텍스트 = "framing_scale ∈ {close, medium, insert} from `_derive_framing_scale()`. wide-class shots are mapped to medium and use `wide_medium_rules`. The LLM may *additionally* infer wide from prose keywords (`wide shot`/`전경`/...) for stricter checking — these are LLM-side reasoning hints, not the production framing_scale enum." spec §2.2 line 202 (envelope shape) + line 256 (applies_when) + §5.1 line 599 + Glossary `Framing scale keywords` 모두 동기화. Producer 변경 = G4.5c 또는 follow-up. |
| **RO-5** | R2-B2 | Phase 1 Task 1.1 + Phase 4 Task 4.3 — **Korean close-framing keyword conflict — 분리 채택**: card `framing_scale_keywords.close` LLM-facing **11-entry list** (`close-up`/`CU`/`MCU`/`ECU`/`XCU`/`extreme close-up`/`클로즈업`/`손가락이`/`손이`/`눈이`/`얼굴이`) 그대로 carry (LLM reasoning hint). canary `g4_5a_primary_framing.py` 의 close-framing shot scope filter 는 **production `_CLOSE_FRAMING_RE` 9-entry alignment** (`close-up`/`CU`/`MCU`/`ECU`/`XCU`/`extreme close-up`/`medium close-up`/`클로즈업`/`손가락이`) 재사용 — LLM hint vs canary detector 분리 strict. Phase 5 Task 5.1 unit test `test_primary_framing_rule_close_keywords_count == 11` (LLM-facing 11-entry list) + canary smoke fixture `test_canary_close_framing_filter_uses_production_close_framing_re` (9-entry production-aligned). |
| **RO-6** | R2-B3 + Override O-31 carry | Phase 1 Task 1.3 — **Cross-card reference — Wired 채택 (option 1)**: `primary_framing_rule.close_framing_rules.body_part_focus_cross_ref` + `primary_framing_rule.wide_medium_rules.body_part_focus_cross_ref` 둘 다 paired-string substring `"see id_policy.body_part_focus_rule"` 포함. **PR4-B3 fix — cross-card refs 는 `primary_framing_rule` 하위에만, NOT `camera_frame_rule` 하위.** `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` constant 재사용 (rename G4.5c 또는 follow-up — RO-13). Phase 5 Task 5.1 unit tests 2 종 (`test_primary_framing_rule_close_body_part_cross_ref_paired` + `test_primary_framing_rule_wide_medium_body_part_cross_ref_paired`) + Phase 5 Task 5.2 integration test 1 종 (`test_cross_card_paired_substring_in_v20_system_md_and_card_inject` 3-way substring assertion). |
| **RO-7** | R2-I2 | **Override numbering scheme**: §0 Round Override = `RO-N` (RO-1 ~ RO-14, G4.5a Round 1). §12 carry = `O-N` (O-1 ~ O-38 G4.2/G4.3/G4.4 carry). body 본문 references must prefix `RO-` 또는 `O-` to disambiguate. plan body 본문 references — 기존 G4.4 carry "Override O-1 (token gate)" / "Override O-2 (line count)" 등은 G4.4 spec §12 carry 의 동일 의미 row 가리키므로 `O-` prefix 명시. **G4.5a Round 1 결정사항은 RO-N**. 메타룰 §0 추가. |
| **RO-8** | R2-I3 | Phase 4 Task 4.3 — **same-character-ID detection algorithm**: per-shot scan, build dict `{character_id: [token_positions]}` for all C##/C##O## occurrences. For each `body_part_close_up_trigger` (`focus on / close on / tight on / detail on` — G4.3 `_ID_BODY_PART_TRIGGERS` 직접 재사용) token position, find nearest C##/C##O## within ±N=20 tokens — `cid_trigger`. For each `full_body_description_keyword` (`stands / seated / leaning / 전신`) position, find nearest character ID — `cid_body`. If `cid_trigger == cid_body` AND both nearest-character-distance ≤ 20: increment violation count. **gate: STRICT 0** (PR4-B7 — NOT soft warning, exit 1 on any violation). Phase 5 unit test fixture covers 3 cases: (a) same-id co-occurrence → violation, (b) different-id → no violation, (c) > 20 tokens distance → no violation. Phase 4 Task 4.3 implementer follows this algorithm verbatim. |
| **RO-9** | R2-I4 (Q9 commit) | Phase 1 Task 1.4 — **`_assert_primary_framing_rule_shape()` separate further nested helper 채택**: nested call chain `_assert_render_strategy_shape() → _assert_spatial_consistency_shape() → _assert_primary_framing_rule_shape()` (further nested helper, NOT sibling — PR4-I5 disambiguation). `_assert_spatial_consistency_shape()` 가 `primary_framing_rule` sub-key 검증 시 직접 `_assert_primary_framing_rule_shape()` 호출. Phase 5 Task 5.1 unit tests `test_assert_primary_framing_rule_shape_validates_*` 4 rows (framing_scale_keywords / close_framing_rules / wide_medium_rules / cross-ref strings). G4.4 `_assert_ref_usage_constraints_shape` nested helper 패턴 carry. |
| **RO-10** | R2-I5 | Phase 2 Task 2.1 — **Section boundary trailing-blank policy**: 삭제 섹션의 trailing blank 한 개 (line 142 / 217 / 268 = 인접 섹션 사이 trailing blank — Background Binding 끝 / Rule F end / Rule G end / Rule J end 사이 blank line) 정책: 모두 drop. Inserted compact section starts on its own blank line. Net: 인접 섹션 사이 1 blank line 유지 (v19 §1-§2-§3 convention 일관). Phase 2 mechanical 적용. |
| **RO-11** | R2-M1 | Phase 1 Task 1.1 + Phase 4 (canary scripts) — **`_SPATIAL_BODY_PART_TRIGGERS` cross-import naming 정정**: G4.5a 는 G4.3 의 `_ID_BODY_PART_TRIGGERS` 를 직접 재사용 (single source — drift 차단). G4.5a 내부에서 `_SPATIAL_BODY_PART_TRIGGERS` 별칭 도입 금지. **PR4-B9 fix**: `render_prompt_card.py` 안 코드는 self-import 금지 — `_ID_BODY_PART_TRIGGERS` 가 동일 module 에 정의되어 있으므로 직접 reference. canary script 는 `from app.core.steps.render_prompt_card import _ID_BODY_PART_TRIGGERS` 외부 import. 별칭 도입 X. |
| **RO-12** | R1-M1 + R2-M2 + Plan-R3-R-fix-iter-1 PR-fix-iter-1-2 | Phase 5 Task 5.1 — **Unit test count alignment**: ~53 unit tests (table 53 enumerate, no buffer required — RO-12 binding raised by Plan-R3-R-fix-iter-1, supersedes earlier "~38-43" estimate). Body table 53 rows + IC Summary + Wave 4 briefing 모두 통일. spec §10 Q7 / spec §6.1 prose 는 별도 spec back-prop patch 로 G4.5b/c 또는 cleanup PR 에서 동기화 (G4.5a scope 밖). plan body 도 동일 수치 사용. |
| **RO-13** | R2-M3 | Phase 1 Task 1.3 + §12 Override O-31 carry — **`_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` namespace footnote**: G4.5a reuses `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` directly (G4.4 carry, **not renaming** in G4.5a scope). Future cleanup: rename to `_ID_POLICY_BODY_PART_FOCUS_CROSS_REF` in G4.5c or separate cleanup PR (follow-up). Phase 1 maintainer 코멘트 명시. |
| **RO-14** | R2-M4 + Plan-R3-R-fix-iter-1 PR-fix-iter-1-2 | Phase 5 Task 5.4 — **Test count baseline disambiguation**: G4.4 끝 cumulative backend pytest count ≈ 2179 (G4.4 76/76 G4.4-specific + 2103 baseline). G4.5a adds ~53 unit + ~18 integration → ≈2250 expected (raised by Plan-R3-R-fix-iter-1 PR-fix-iter-1-2 — earlier ≈2230 estimate based on ~38-43 unit tests, now ~53 unit tests canonical). Phase 5 Task 5.4 regression gate uses this number, NOT "≥ 337". |
| 메타룰 | (G4.2/G4.3/G4.4 carry) | Round 1 Override 결정은 plan body 보다 우선. body 동기화는 Override 동기화 패치에서 inline 적용. body 와 Override 충돌 시 Override 우선. |

### Plan R3 (Codex) + R4 (Claude) Override (R4 reject → plan 전체 rewrite)

> **상태 (2026-05-05)**: plan drafting iter 1 후 Codex (R3) + Claude code-reviewer (R4) 듀얼 audit 진행. R4 verdict = REJECT (fundamental contract drift between plan body and spec ground-truth — 13 Blocking corrections + key Important fixes). plan 전체 rewrite 결정. 본 row 들은 rewrite 시 적용된 정정사항.

| ID | Source | 결정 (Override) | 적용 위치 |
|---|---|---|---|
| **PR4-B1** | R4 reject — silent fallback ban | **ZERO tolerance for `or [] / or {} / .get(key, default)` patterns** in any code sample (Phase 1-7). Use `_DEFAULT_SENTINEL = object()` pattern OR explicit `if "key" not in d: raise AppError(...)` 패턴. plan rewrite 전체 적용. self-check: `grep` for `or \[\]\|or \{\}\|\.get\([^,]+, *\[\]\)\|\.get\([^,]+, *\{\}\)` — should be 0 in code samples. | 모든 Phase code samples |
| **PR4-B2** | R4 reject — spec §2.2 ground-truth | spec §2.2 가 sub-shape의 AUTHORITATIVE source. **DO NOT invent sub-shape keys**. `render_strategy.spatial_consistency` 4 top-level sub-keys (`camera_frame_rule` / `fg_bg_shared_anchor_rule` / `primary_framing_rule` / `rationale_summary`). `camera_frame_rule` 7 keys (RO-3 binding). `fg_bg_shared_anchor_rule` 6 keys. `primary_framing_rule` 6 keys. `framing_scale_keywords` 3 sub-keys (RO-5 — close 11-entry / wide 5-entry / medium_default). | Phase 1 Task 1.3 (assemble) + Phase 1 Task 1.4 (validator) + Phase 5 Task 5.1 (unit tests) |
| **PR4-B3** | R4 reject — cross-card refs | **cross-card refs 는 `primary_framing_rule.close_framing_rules` + `primary_framing_rule.wide_medium_rules` 하위에만**, NOT `camera_frame_rule` 하위. RO-6 binding. plan body Phase 1 / Phase 5 모든 row 정정. | Phase 1 Task 1.3 + Task 1.4 + Phase 5 Task 5.1 |
| **PR4-B4** | R4 reject — snapshot hash naming | helper name = **`compute_render_strategy_snapshot_hash`** over `card["render_strategy"]` (full render_strategy snapshot — NOT narrower `spatial_consistency` only). G4.4 R3-M2 carry: token canary CP-free (`g4_5a_token_count.py` 는 hash helper 미import). spec §5.2 binding. self-check: grep for `compute_spatial_consistency_snapshot_hash` — should be 0. | Phase 1 Task 1.2 + Phase 4 Task 4.1-4.4 (canary import) |
| **PR4-B5** | R4 reject — 9 module-level constants | spec §7.2 step 5 가 9 `_SPATIAL_*` constants 의 ground-truth source. RO-11 binding (`_SPATIAL_BODY_PART_TRIGGERS` alias 도입 금지 — G4.3 `_ID_BODY_PART_TRIGGERS` 직접 재사용). exact names: `_SPATIAL_CAMERA_LOW_TOKENS` / `_SPATIAL_CAMERA_HIGH_TOKENS` / `_SPATIAL_CAMERA_HIP_TOKENS` / `_SPATIAL_FRAME_EDGE_POSITION_TOKENS` / `_SPATIAL_FG_BG_SEPARATION_TOKENS` / `_SPATIAL_INTERACTION_VERBS` / `_SPATIAL_SHARED_ANCHOR_KEYWORDS` / `_SPATIAL_FRAMING_CLOSE_KEYWORDS` (canary 9-entry production-aligned, RO-5) / `_SPATIAL_FRAMING_WIDE_KEYWORDS`. | Phase 1 Task 1.1 |
| **PR4-B6** | R4 reject — render_strategy carry | spec §2.1 + producer `render_prompt_card.py:379` (`build_render_strategy()`) 의 G4.1+G4.4 carry keys: `mode / primary_subject / framing_scale / moment_lock / camera_direction / lighting_mood / perception_mode / constraints` (8 base keys). **G4.5a adds ONLY `spatial_consistency`** as NEW sibling key. **DO NOT REMOVE** any existing G4.1+G4.4 fields. | Phase 1 Task 1.3 |
| **PR4-B7** | R4 reject — RO-8 STRICT 0 | RO-8 same-character-ID algorithm = **STRICT 0, exit 1 on any violation** (NOT soft warning). per-shot dict `{cid: [token_pos]}` + nearest character ID within ±20 tokens + STRICT 0 gate. | Phase 4 Task 4.3 + Phase 5 unit test fixture |
| **PR4-B8** | R4 reject — numbering canonical | **Single canonical Phase/Task numbering across all sections**: Phase 1 = Wave 1-A (Tasks 1.1-1.6) / Phase 2 = Wave 1-B (Tasks 2.1-2.4 parallel) / Phase 3 = Wave 2 (Tasks 3.1-3.2) / Phase 4 = Wave 3 (Tasks 4.1-4.5) / Phase 5 = Wave 4 (Tasks 5.1-5.4) / Phase 6 = Wave 5 (Tasks 6.1-6.3) / Phase 7 = Wave 6 (Tasks 7.1-7.4). 모든 plan section 동일 mapping 사용. | 모든 Phase + Implementation Checklist Summary + Wave Briefings + Override Table |
| **PR4-B9** | R4 reject — self-import ban | `_ID_BODY_PART_TRIGGERS` and `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` 는 같은 module (`render_prompt_card.py`) 에 정의됨. **DO NOT write `from .render_prompt_card import ...`** (self-import). **DO NOT write `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL: str` (typed-only forward declaration shadows)**. Reference them directly in-module — no import needed. Document with comment: "# G4.3/G4.4 carry — defined earlier in this module, referenced directly." | Phase 1 Task 1.1 + Task 1.3 |
| **PR4-B10** | R4 reject — integration test enumeration | spec §6.2 18 integration tests INCLUDING (Phase 5 Task 5.2 body table 모두 enumerate, NOT just briefing reference): `test_v20_spatial_consistency_section_line_count_under_32` (RO-2) / `test_cross_card_paired_substring_in_v20_system_md_and_card_inject` (RO-6 3-way) / `test_v19_checkpoint_resume_on_v20_escalates_force` (G4.1 carry) / `test_g3_2_sentinel_coexists_with_g4_5a_spatial` / `test_4_way_alignment_post_g4_5a_bump` / `test_envelope_top_level_keys_unchanged_post_g4_5a` (5-field invariant). | Phase 5 Task 5.2 |
| **PR4-B11** | R4 reject — Phase 3 inline prose deletion | G4.5a 는 G4.4 Override O-3 같은 inline prose 9 줄 삭제 패턴이 없음. Phase 3 (Wave 2) 는 **detail_steps.py:105 + version_registry 4-point sync 만**. inline prose 삭제 작업 없음. plan body Phase 3 정정. | Phase 3 |
| **PR4-B12** | R4 reject — canary script naming | 5 canary scripts 정확 명 (per spec §5.1 + Implementation Checklist Summary): `g4_5a_camera_frame_consistency.py` / `g4_5a_fg_bg_shared_anchor.py` / `g4_5a_primary_framing.py` / `g4_5a_view_mixing_extension.py` / `g4_5a_token_count.py`. **DO NOT use `canary_g4_5a_*.py` naming pattern** (rejected invented set in R4 iter 1). | Phase 4 Tasks 4.1-4.5 |
| **PR4-B13** | R4 reject — Phase 6 procedure | Phase 6 baseline+candidate procedure per spec §5.3 — 4-step canary procedure: Step 1 baseline (v19) capture / Step 2 candidate (v20) capture / Step 3 pinning equality check (7 fields) / Step 4 7-gate judgment (4 STRICT 0 + token + line count + pytest). | Phase 6 Tasks 6.1-6.3 |
| **PR4-I1** | R4 — Phase 6 procedure detail | Phase 6 task 명시: Task 6.1 = baseline capture / Task 6.2 = candidate capture / Task 6.3 = pinning equality + 7-gate judgment + 듀얼 리뷰 fix loop. JSON output to `docs/canary/g4_5a_*_baseline_<timestamp>.json` + `docs/canary/g4_5a_*_candidate_<timestamp>.json`. | Phase 6 |
| **PR4-I2** | R4 — snapshot hash | helper = `compute_render_strategy_snapshot_hash(card)` — NOT narrower spatial-only. spec §5.2 binding. | Phase 1 Task 1.2 |
| **PR4-I3** | R4 — test count baseline | RO-14 binding — G4.4 끝 ≈2179 → G4.5a 추가 후 ≈2250 expected. Phase 5 Task 5.4 regression gate. | Phase 5 Task 5.4 |
| **PR4-I4** | R4 — Implementation Checklist Summary canonical | Implementation Checklist Summary single-line per task MUST match body content exactly (no swapping). canonical numbering enforce. | Implementation Checklist Summary |
| **PR4-I5** | R4 — RO-9 nested call chain | RO-9 helper ambiguity resolution — pick one reading: nested call chain `_assert_render_strategy_shape() → _assert_spatial_consistency_shape() → _assert_primary_framing_rule_shape()` (further nested, NOT sibling). spec §2.3 + RO-9 binding. | Phase 1 Task 1.4 |
| **PR4-I6** | R4 — numbering canonical | Numbering canonicalization (R4-B8/B11/I5/I7 review items fix — Trap #18; note: R4-I5/I7 are upstream review items, plan-internal numbering renumbered to PR4-I4/I6 per Plan-R3-R-fix-iter-1 PR-fix-iter-1-5) — Single canonical mapping (PR4-B8). Wave 6 has 4 tasks (NOT 3). | 모든 plan sections |

### Plan-R3-R + R4-R fix-iter-1 Override (R3-R Codex retry NEEDS_REVISION 1B/2I + R4-R Claude retry APPROVED_WITH_REVISIONS 4M)

> **상태 (2026-05-05)**: plan rewrite iter 1 후 Codex (R3-R retry) + Claude (R4-R retry) 재 audit. R3-R verdict = NEEDS_REVISION (1 Blocking + 2 Important). R4-R verdict = APPROVED_WITH_REVISIONS (4 Minor). Combined fix iter 1 적용.

| ID | Source | 결정 (Override) | 적용 위치 |
|---|---|---|---|
| **PR-fix-iter-1-1** | R3-R B1 | Silent fallback `.get(key, 0)` zero-tolerance grep strengthened — broader regex `\.get\([^,]+, *(\[\]|\{\}|0|"|''|None|False)\)` catches list/dict/int/string/None/bool defaults (PR4-B1 supplement). Task 4.2 line 1758 `fg_bg_shared_anchor_missing_per_shot.get(shot_key, 0)` replaced with explicit key check (`if shot_key in dict: ... else: dict[shot_key] = 1`). 추가 grep self-check line `grep -nE '\.get\([^)]*,[^)]*\)' scripts/canary/g4_5a_*.py | grep -v '_DEFAULT_SENTINEL'` — only `_DEFAULT_SENTINEL` allowed as default arg. | Task 4.2 + 3 self-check grep blocks (Verification Checklist + Wave 1-A + Wave 3 briefings) |
| **PR-fix-iter-1-2** | R3-R I1 + R4-R M1 | Unit test count drift fix — 53 enumerate canonical (no buffer required). RO-12 binding raised. body table 53 rows (already enumerate-rich) + IC Summary + Wave 4 briefing 모두 통일. spec §6.1 prose / §10 Q7 별도 spec back-prop patch (G4.5b/c 또는 cleanup PR — G4.5a scope 밖). plan 모든 location 동기화 (RO-12 row + Phase 5 Phase 목표 + Task 5.1 header + table 표제 + 2078 footnote + 2466 verification + 2375 Risk Register + 2706 IC Summary + 2908/2919/2924 Wave 4 briefing). **+71 G4.5a-specific tests** target (~53 unit + ~18 integration), G4.5a 추가 후 ≈2250 expected (RO-14 / PR4-I3 raised by Plan-R3-R-fix-iter-1). | RO-12 + 모든 location |
| **PR-fix-iter-1-3** | R3-R I2 | Canary window wording disambiguation — RO-8 ±20 tokens applies ONLY to `g4_5a_primary_framing.py` (same-character-ID detection algorithm). Other scripts use algorithm-specific windows: camera_frame ±50 chars (G4.4 carry — camera position vs frame edge contradiction); fg_bg_shared_anchor per-shot scope (no proximity); view_mixing_extension ±20 tokens (G4.4 view_mixing carry); token_count CP-free. Phase 4 common prose 5-row enumeration list 추가. | Phase 4 prose lines 1430 + 1445 |
| **PR-fix-iter-1-4** | R4-R M2 | `_assert_render_strategy_shape()` host disambiguation — Option A 채택. Production reality: `assert_card_shape()` (`render_prompt_card.py:1436`) 가 `rs_mode` enum 검증을 inline 으로 line 1519-1527 영역에 둠 — **별도 명명된 `_assert_render_strategy_shape()` helper 가 존재하지 않음**. Plan G4.5a 는 inline 구조 그대로 유지 — `assert_card_shape()` body 안 line 1527 직후 신규 `_assert_spatial_consistency_shape(card)` 호출 inline 삽입. **NO new `_assert_render_strategy_shape()` factor-out** (minimal refactor risk for G4.5a). 대안 Option B (factor-out) reject — G4.5c 또는 별도 cleanup PR. plan body Phase 1 Task 1.4 line 716 disambiguation note 추가. | Phase 1 Task 1.4 |
| **PR-fix-iter-1-5** | R4-R M4 | PR4-I4 numbering gap fix — original PR4-I5/I6/I7 → renumbered PR4-I4/I5/I6 (no gap). 38 plan body references 모두 업데이트. R4 upstream review items (R4-I5/I7) 는 별도 외부 ID — Override row text 에서 disambiguation note 추가. | 모든 PR4-I* references (38 occurrences) |
| **PR-fix-iter-1-6** | R4-R M3 | Spec §7.2 step 5 + §5.4 stale alias-import wording acknowledgment — Spec 본문 의 `_SPATIAL_BODY_PART_TRIGGERS` 10th constant + `as _ID_BODY_PART_TRIGGERS_G4_3_CARRY` alias-import 표현은 stale (RO-11 + PR4-B9 supersede with in-module direct reference, no self-import). Spec back-propagation patch deferred to G4.5b/c 또는 separate spec PR — **NOT in G4.5a scope**. plan body 만 RO-11 + PR4-B9 binding 유효. | spec drift acknowledgment (no plan body code change) |

### 메타룰

- 본 표 가 plan body 보다 우선 적용.
- Body 동기화는 Override 동기화 패치에서 inline 적용 (G4.2 + G4.3 + G4.4 패턴 답습).
- Spec ↔ plan drift 발견 시 **즉시 plan R5 round** 진행.
- 절대 규칙 위반 (LLM 입력 truncation, 시나리오 의존 고유명사, silent fallback, 새 top-level field 도입) 발견 시 BLOCKING 최우선.
- silent fallback 패턴 (`(d or {})` / `(card or {})` / `(pair or {}).get(...)` / `or [] / or {} / .get(key, default)`) 발견 시 BLOCKING. fail-fast (AppError) 강제 (`feedback_no_silent_fallback.md`).
- regex post-processing 의존 발견 시 IMPORTANT (`feedback_no_regex_postprocessing.md`). 의미 기반은 LLM validator 또는 prompt 로.
- **Override numbering convention (RO-7 binding)**: §0 Round Override = `RO-N`. §12 carry = `O-N`. body 본문 references must prefix `RO-` 또는 `O-` to disambiguate. **G4.5a Round 1 결정사항은 RO-N**.

---

## Wave Decomposition Table

> **PR4-I6 binding (canonical numbering)**: Phase 1 = Wave 1-A (Tasks 1.1-1.6) / Phase 2 = Wave 1-B (Tasks 2.1-2.4 parallel) / Phase 3 = Wave 2 (Tasks 3.1-3.2) / Phase 4 = Wave 3 (Tasks 4.1-4.5) / Phase 5 = Wave 4 (Tasks 5.1-5.4) / Phase 6 = Wave 5 (Tasks 6.1-6.3) / Phase 7 = Wave 6 (Tasks 7.1-7.4).

| Wave | inputs | outputs | blocking-on | approx LOC | approx test count |
|---|---|---|---|---|---|
| **Wave 1-A** (Phase 1) | spec §2.2 / §4 / §7.2 step 1-6 / RO-3 / RO-4 / RO-5 / RO-6 / RO-9 / RO-11 / RO-13 + PR4-B2 / PR4-B3 / PR4-B5 / PR4-B6 / PR4-B9 / PR4-I2 / PR4-I5 | `render_prompt_card.py` modified (~340 LOC) — 9 module-level constants (RO-11 binding, no `_SPATIAL_BODY_PART_TRIGGERS` alias) + `compute_render_strategy_snapshot_hash()` (PR4-B4 / PR4-I2) + `build_render_strategy()` 확장 (`spatial_consistency` 4 sub-key + constraints 2→5) + `_assert_spatial_consistency_shape()` nested helper + `_assert_primary_framing_rule_shape()` further nested helper (RO-9 / PR4-I5 nested call chain) + `build_render_prompt_card()` `_card_metadata` 4 신규 lift_status key + 3 신규 rule_source key | 없음 (Wave 1-B 와 병렬) | ~340 | 0 (test 는 Wave 4) |
| **Wave 1-B** (Phase 2) | spec §3.1 / §3.2 / §3.3 / §3.4 / RO-1 / RO-2 / RO-10 + PR4-B11 | `prompts/_base/scene_detail/20.<timestamp>/system.md` (신규) + `detail_schema.json` (verbatim copy) + `user.md` (verbatim copy) — net -111 lines (124 system 삭제 + 13 추가) + Phase 3 inline prose 삭제 작업 없음 (PR4-B11) | 없음 (Wave 1-A 와 병렬) | -111 net (system) | 0 |
| **Wave 2** (Phase 3) | Wave 1-A + Wave 1-B 완료 (timestamp 결정) + PR4-B11 | `detail_steps.py:105` `SCENE_DETAIL_PROMPT_VERSION` v19→`"20.<timestamp>"` bump + `version_registry.py:34` `MODULE_VERSIONS["scene_detail_composer"]` `1.19.0`→`1.20.0` + `version_registry.py:126` `_MODULE_INFO[...]["prompt_dependency"]` `"scene_detail/v19"`→`"scene_detail/v20"` (dict access — G4.3/G4.4 R3R4-B1 carry). **NO inline prose deletion** (PR4-B11). alignment test fixture 갱신 | Wave 1-B (timestamp pin) | ~7 (bump only) | 0 (alignment test 는 Wave 4) |
| **Wave 3** (Phase 4) | spec §5.1 / §5.3 / §5.4 / RO-1 / RO-5 / RO-8 / RO-11 + PR4-B12 / PR4-B7 | 5 canary script (`scripts/canary/g4_5a_camera_frame_consistency.py` / `g4_5a_fg_bg_shared_anchor.py` / `g4_5a_primary_framing.py` / `g4_5a_view_mixing_extension.py` / `g4_5a_token_count.py`) — 4 metric scripts: module constants import + `compute_render_strategy_snapshot_hash` import + sentinel pre-pass + pinned-tuple post-loop + ±20 tokens window (RO-8). 1 token script: CP-free, tiktoken cl100k_base. STRICT 0 gates (RO-8 violation = exit 1, NOT soft warning) | Wave 1-A (constants + hash helper import) | ~750 (5 script × ~150) | 0 (canary 자체 unit test 는 Wave 4 smoke) |
| **Wave 4** (Phase 5) | spec §6.1 / §6.2 / §6.4 / RO-2 / RO-3 / RO-5 / RO-6 / RO-8 / RO-9 / RO-12 / RO-14 + PR4-B10 / PR4-I3 | `backend/tests/unit/test_g4_5a_spatial_lift.py` (~53 unit tests, RO-12 binding — table 53 enumerate, no buffer required, raised by Plan-R3-R-fix-iter-1) + `backend/tests/integration/test_g4_5a_spatial_integration.py` (~18 integration, PR4-B10 binding) + alignment test 갱신 | Wave 1-A + 1-B + 2 (alignment test 의존) | ~950 | +71 (~53 unit + ~18 integration) |
| **Wave 5** (Phase 6) | Wave 1-A~4 완료 + G4.4 production canary 4 gate green (R3-Entry-Note carry) + PR4-B13 / PR4-I1 | baseline canary capture (v19) + candidate canary capture (v20) + 4 metric STRICT 0 + token + line count + pytest = 7 gate 비교 + 듀얼 리뷰 3 iter fix loop | G4.4 production canary (commit gate) | n/a (실행 단계) | 0 (수정만) |
| **Wave 6** (Phase 7) | Wave 5 통과 | single commit (4 영역 + canary 5 + tests 3) + push + Codex/Claude 듀얼 리뷰 R5+R6 + fix iter 1 → 재push | Wave 5 (canary green) | n/a | 0 (수정만) |

총 expected delta: **+~340 producer LOC + -111 prompt LOC (system.md net) + ~7 LOC (version bump only) + ~750 canary LOC + ~1050 test LOC + ~71 신규 test** (~53 unit + ~18 integration — raised by Plan-R3-R-fix-iter-1 PR-fix-iter-1-2).

---

## Phase 1 (Wave 1-A): render_prompt_card.py 변경

**Phase 목표:** 9 module-level constants + in-module reuse `_ID_BODY_PART_TRIGGERS` + `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` (PR4-B9 self-import 금지 — directly reference) + `compute_render_strategy_snapshot_hash()` helper 신설 (PR4-B4 / PR4-I2 — render_strategy 전체 hash). `build_render_strategy()` 안에 `spatial_consistency` 4 sub-key (3 sub-rule + rationale_summary) assemble + base 2 → 5 strings constraints 확장 (PR4-B6 — G4.1+G4.4 carry keys 8 base 유지, NEW sibling 추가만). `_assert_render_strategy_shape()` 안에서 `_assert_spatial_consistency_shape()` nested helper 호출 + `_assert_primary_framing_rule_shape()` further nested helper 호출 (RO-9 / PR4-I5 binding — nested call chain). `build_render_prompt_card()` `_card_metadata.lift_status` 4 신규 key + `rule_source` 3 신규 key 추가.

**Phase 1 expected test pass delta:** 0 (test 는 Wave 4 에서 추가).

**Wave subagent 함정 경고 (G4.1 + G4.2 + G4.3 + G4.4 carry — 19 trap):**
- **절대 금지 (Trap #14)**: `staging_not_applicable=False` / `shot_info=None` 류의 hardcoded empty inputs 도입. fixture default 가 production failure 를 가릴 위험. 본 Wave 구현자 는 helper 함수 / fixture 의 default 인자 추가 시 반드시 `_DEFAULT_SENTINEL = object()` pattern 사용 (None / [] / default 셋 명시 구분). G4.1 R3-B2 / G4.2 plan-R1-I4 / G4.3 plan-R1-I4 / G4.4 carry.
- **절대 금지 (Trap #1)**: `or [] / or {}` silent absorb pattern. None 흡수 → builder fail-fast 무력화 → production drift cascade. G4.2 iter1 C3 / G4.3 carry / Override O-8 / PR4-B1.
- **절대 금지 (Trap #14 carry)**: 신규 `spatial_consistency` 4 sub-key 를 `if camera_direction:` / `if shot_info:` 류 truthy guard 안 배치. **builder-static** — 입력 무관 항상 동일 dict (RO-9 invariant). Test `test_spatial_consistency_4_subfields_independent_of_input` 가 강제 검증.
- **절대 금지 (Trap #5)**: `_MODULE_INFO` attribute access (`info.prompt_dependency`). 반드시 dict access (`info["prompt_dependency"]`) — G4.3/G4.4 R3R4-B1 carry. `prompt_dependency` 값 = `"scene_detail/v20"` (major suffix only, NOT `"scene_detail/20.<timestamp>"`).
- **절대 금지 (Trap #9)**: 시나리오 의존 0 — 작품 고유명사 절대 포함 금지. spec §2.2 의 generic placeholder (`"a hand"` / `"a young woman"` / `"the figure"`) 만 사용.
- **절대 금지 (Trap #18 — PR4-B8/I7)**: Phase / Task 번호 drift trap. plan body Task 순서 vs Implementation Checklist Summary vs Override carry 일치 강제. drift 발생 시 즉시 patch.
- **반드시**: `import hashlib` + `import json` 존재 확인 self-check (G4.1 carry — 미존재 시 add). G4.3 R4-M1 carry. **Trap #4 carry**.
- **반드시 (PR4-B9)**: `_ID_BODY_PART_TRIGGERS` (G4.3, line 95) + `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` (G4.4, line 240) 는 같은 module 에 정의 — 직접 reference (no import). self-import 금지 (`from .render_prompt_card import ...` 절대 금지). typed-only forward declaration 도 금지.

---

### Task 1.1 — module-level constants 도입 (9 신규 + in-module reuse 명시)

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py`
- [ ] **Insert location:** module top — G4.4 9 constants (`_CONTINUITY_GENERIC_PERSON_NOUNS` 등 line 230-300 추정) 직후 일괄 추가
- [ ] **Dependency:** 없음 (G4.4 base 위에서 직접 수정). G4.3 의 `_ID_BODY_PART_TRIGGERS` (line 95) + G4.4 의 `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` (line 240) 는 **in-module direct reference** (PR4-B9 — self-import 금지).
- [ ] **Spec cross-reference:** spec §2.2 / §7.2 step 5 / §9 Glossary / RO-5 / RO-11 / PR4-B5 / PR4-B9
- [ ] **Expected diff size:** ~80 lines added

**변경 내용:**

G4.4 constants 직후 다음 9 constant 일괄 도입:

```python
# G4.5a ground-truth: camera vertical (low / ground / floor) tokens for camera_frame_rule
# detection. canary `g4_5a_camera_frame_consistency.py` ±50 chars window detection source.
# spec §2.2 + §5.1 + §9 Glossary "Camera vertical tokens" single source.
# RO-11 binding — 영어/한국어 mixed substring 패턴 (영어 t2i_prompt 가정 + shot description
# 한국어 가정). 한국어 t2i 도입 시 G4.5c 에서 재검토 (`feedback_t2i_storyboard.md`).
_SPATIAL_CAMERA_LOW_TOKENS: Tuple[str, ...] = (
    "low",
    "ground level",
    "quay level",
    "floor level",
    "낮은",
    "발 높이",
)

# G4.5a ground-truth: camera high (overhead / above / high angle) tokens.
_SPATIAL_CAMERA_HIGH_TOKENS: Tuple[str, ...] = (
    "overhead",
    "from above",
    "high angle",
    "위에서",
    "오버헤드",
)

# G4.5a ground-truth: camera hip-height tokens.
_SPATIAL_CAMERA_HIP_TOKENS: Tuple[str, ...] = (
    "hip height",
    "waist height",
    "허리 높이",
)

# G4.5a ground-truth: frame-edge surface position tokens (vertical 좌표 inside frame).
# camera_frame_rule violation = camera height token + frame-edge position token mismatch.
_SPATIAL_FRAME_EDGE_POSITION_TOKENS: Tuple[str, ...] = (
    "chest",
    "waist",
    "가슴",
    "허리",
    "floor",
    "바닥",
    "ceiling",
    "천장",
)

# G4.5a ground-truth: foreground/background separation tokens for fg_bg_shared_anchor_rule.
_SPATIAL_FG_BG_SEPARATION_TOKENS: Tuple[str, ...] = (
    "foreground",
    "background",
    "rear background",
    "전경",
    "후경",
)

# G4.5a ground-truth: interaction verbs — fg_bg_shared_anchor_rule applies_when trigger.
_SPATIAL_INTERACTION_VERBS: Tuple[str, ...] = (
    "facing",
    "handing",
    "dialogue",
    "hold",
    "across",
    "마주",
    "건네",
)

# G4.5a ground-truth: shared-anchor keywords (recommended_keywords for fg_bg_shared_anchor_rule).
_SPATIAL_SHARED_ANCHOR_KEYWORDS: Tuple[str, ...] = (
    "shared bench",
    "shared table",
    "shared surface",
    "the same",
    "across the",
    "single line of sight",
    "같은 벤치",
    "같은 테이블",
)

# G4.5a ground-truth: close-framing keywords for canary `g4_5a_primary_framing.py`
# **9-entry production-aligned** (RO-5 binding — production `_CLOSE_FRAMING_RE` reuse).
# LLM-facing card `framing_scale_keywords.close` 11-entry list 와 분리 (RO-5 strict).
# canary close-framing shot scope filter 가 본 9-entry 만 사용 — `손이`/`눈이`/`얼굴이` 등
# LLM-facing keyword 는 canary scope filter 에 포함되지 않음.
_SPATIAL_FRAMING_CLOSE_KEYWORDS: Tuple[str, ...] = (
    "close-up",
    "CU",
    "MCU",
    "ECU",
    "XCU",
    "extreme close-up",
    "medium close-up",
    "클로즈업",
    "손가락이",
)

# G4.5a ground-truth: wide-framing keywords (LLM-facing reasoning hint).
# RO-4 binding — production `_derive_framing_scale()` enum 은 close/medium/insert 만,
# wide 미생성. 본 list 는 LLM hint only — wide-class shots 는 medium 으로 매핑되어
# `wide_medium_rules` 적용. card-only classification.
_SPATIAL_FRAMING_WIDE_KEYWORDS: Tuple[str, ...] = (
    "wide shot",
    "establishing",
    "aerial",
    "전경",
    "전신",
)
```

**In-module reuse maintainer 코멘트 (PR4-B9 binding — self-import 금지):**

`build_render_strategy()` body 안 cross-card reference string + body-part trigger 사용 위치 직전에 다음 코멘트 추가:

```python
# G4.3/G4.4 carry — `_ID_BODY_PART_TRIGGERS` (line 95) and
# `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` (line 240) are defined earlier in
# this same module. Reference them directly — NO `from .render_prompt_card import ...`
# (self-import banned, PR4-B9). NO typed-only forward declaration like
# `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL: str` (shadows real value, PR4-B9).
# Rename to `_ID_POLICY_BODY_PART_FOCUS_CROSS_REF` deferred to G4.5c (RO-13).
```

**주의:**
- 9 constant 모두 **tuple of str** (immutable) — list 사용 금지. R2-B4 carry로 unit test가 `len() == N` + `set(...) == EXACT_GROUND_TRUTH_SET` 강제 검증.
- 모든 constant 가 module-level (Class 안에 넣지 말 것) — `from app.core.steps.render_prompt_card import _SPATIAL_CAMERA_LOW_TOKENS` 형태로 canary script 와 unit test 가 직접 import.
- 시나리오 의존 0 (Trap #9): 9 constant 안에 작품 고유명사 (인물 이름, 지명) 절대 포함 금지.
- **`_SPATIAL_BODY_PART_TRIGGERS` 별칭 도입 금지** (RO-11 / PR4-B9). G4.3 `_ID_BODY_PART_TRIGGERS` 직접 재사용 — 동일 module 에 정의되어 있으므로 in-module reference, canary 외부 import.
- **`_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` reuse** (RO-13 / PR4-B9). G4.4 carry, NOT renaming in G4.5a scope. G4.5c rename follow-up.
- 본 step 의 정확한 entry 개수는 R2-B4 carry 로 unit test 가 `len() == N` + `set(...) == EXACT_GROUND_TRUTH_SET` 강제 검증 (Phase 5 Task 5.1).

**Wave subagent fixture trap 회피 (G4.1 carry — Trap #2)**: helper 추가 시 `staging=_DEFAULT_SENTINEL` 류 pattern 사용. constant tuple 자체는 hardcoded literal 이므로 trap 무관 — 하지만 본 constant 를 사용하는 helper 가 default arg 가지면 sentinel pattern 적용.

---

### Task 1.2 — `compute_render_strategy_snapshot_hash()` helper 신설 (PR4-B4 / PR4-I2)

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py`
- [ ] **Insert location:** `compute_continuity_snapshot_hash()` (G4.4 carry, line ~1372) 직후 — hash helper 영역 logical grouping
- [ ] **Dependency:** Task 1.1 (constants 가 본 helper 하부 호출 대상이 아니지만, 동일 모듈에서 logical grouping)
- [ ] **Spec cross-reference:** spec §5.2 / §5.3 / RO-7 / PR4-B4 / PR4-I2
- [ ] **Expected diff size:** ~30 lines added

**변경 내용:**

```python
def compute_render_strategy_snapshot_hash(card: Dict[str, Any]) -> str:
    """G4.5a (PR4-B4 / PR4-I2) — render_strategy 전체 partial canonicalize hash.

    canary pinning block 의 `render_strategy_card_snapshot_hash` 산출용.
    baseline / candidate render_strategy shape 동일 검증. 변동 source 차단.

    산출법:
      sha256(json.dumps(card["render_strategy"], sort_keys=True, ensure_ascii=False))[:16]

    PR4-B4 / PR4-I2 binding 결정 근거: render_strategy 전체 hash (NOT narrower
    spatial_consistency only). G4.5a canary 는 render_strategy 전체 변경에 sensitive
    해야 — spatial_consistency 만 변경되어도 hash drift, base 7 sibling (mode /
    primary_subject / framing_scale / moment_lock / camera_direction / lighting_mood /
    perception_mode / constraints) 변경되어도 hash drift. `_card_metadata` 는 envelope
    sibling 이므로 자연 제외 — 추가 strip 불필요. G4.3 `compute_id_policy_snapshot_hash`
    + G4.4 `compute_continuity_snapshot_hash` 와 동일 패턴 carry (검증 완료).

    fail-fast: card 가 dict 가 아니거나 `render_strategy` key 부재 시 AppError raise
    (silent fallback 금지 — `feedback_no_silent_fallback.md` carry / Override O-8 /
    PR4-B1).
    """
    if not isinstance(card, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"compute_render_strategy_snapshot_hash: card must be dict "
                f"(got {type(card).__name__})"
            ),
        )
    if "render_strategy" not in card:
        raise AppError(
            code="step.contract_violation",
            message="compute_render_strategy_snapshot_hash: card missing 'render_strategy'",
        )
    payload = json.dumps(
        card["render_strategy"],
        sort_keys=True,
        ensure_ascii=False,
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
```

**주의:**
- `json` / `hashlib` 모두 module top 의 import 영역에 이미 존재 (G4.1 carry / G4.3 carry / G4.4 carry) — 신규 import 불필요. **R4-M1 carry**: Wave 1-A 진입 시 self-check (미존재 시 add — 그러나 G4.1~G4.4 가 추가했으므로 정상 존재해야 함).
- 본 helper 는 4 metric canary script (Task 4.1 / 4.2 / 4.3 / 4.4) 모두 import 사용. token script (Task 4.5) 는 CP-free 이므로 미import (PR4-B4 carry).
- silent fallback 금지 (Trap #1 / PR4-B1): `card.get("render_strategy", {})` 패턴 절대 금지 — None / 빈 dict 흡수 차단. 명시 `if "render_strategy" not in card:` 패턴.
- G4.3 의 `compute_id_policy_snapshot_hash` + G4.4 의 `compute_continuity_snapshot_hash` 와 sibling — 동일 모듈에서 세 helper 가 logical grouping (canary pinning 7 field 안 등록).
- self-check (Trap #1 grep): `grep -n "compute_spatial_consistency_snapshot_hash" render_prompt_card.py` — 0 결과 강제 (PR4-B4 binding — narrower naming 금지).

---

### Task 1.3 — `build_render_strategy()` 확장: spatial_consistency 4 sub-key + constraints 2 → 5 strings

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py:379-446`
- [ ] **Dependency:** Task 1.1 (9 constants + in-module reuse)
- [ ] **Spec cross-reference:** spec §2.2 / §3.2 / §4 / §7.2 step 1-2 / RO-3 / RO-4 / RO-5 / RO-6 / RO-13 / PR4-B2 / PR4-B3 / PR4-B6
- [ ] **Expected diff size:** ~230 lines (spatial_consistency 4 sub-key ~180 + constraints 확장 ~30 + branch wiring ~20)

**변경 내용:**

`build_render_strategy()` 의 두 return 문 (`mode='not_applicable'` 분기 + default 분기) 모두에서 `spatial_consistency` 4 sub-key + 5-string constraints 추가. **PR4-B6 carry**: 기존 8 base keys (mode / primary_subject / framing_scale / moment_lock / camera_direction / lighting_mood / perception_mode / constraints) 모두 유지 — `spatial_consistency` 만 NEW sibling.

> **G4.1 carry footnote (silent fallback grep false positive 회피):** 아래 코드 샘플 의 `(shot_info or {}).get(...)` / `(staging or {}).get(...)` / `or ""` 패턴은 **기존 G4.1 base code (line 379-446) 그대로 보존** (PR4-B6 binding — DO NOT REMOVE existing fields). G4.5a 신규 코드는 PR4-B1 silent fallback ban 적용 — 신규 helper (`_build_spatial_consistency_dict()` / `_build_render_strategy_constraints()`) / canary scripts / tests 안에 동일 패턴 도입 절대 금지. 본 plan body 의 silent fallback grep self-check 는 **G4.5a 신규 코드 영역만** 대상 (PR4-B1 ban scope = G4.5a-new only, NOT G4.1 carry baseline). G4.5b/c 에서 G4.1 carry silent fallback 도 cleanup 대상으로 재검토 가능 (Trap #15 / Override O-32 carry).

```python
def build_render_strategy(
    *,
    seg: Dict[str, Any],
    shot_info: Dict[str, Any],
    staging: Optional[Dict[str, Any]],
    perception_mode: Optional[str],
) -> Dict[str, Any]:
    """spec §4.1 — render_strategy field.

    G4.5a (2026-05-05) — PR4-B2 / PR4-B3 / PR4-B6 명명 정정:
      `spatial_consistency` 신규 sibling nested dict 추가 (4 top-level sub-keys:
      camera_frame_rule (7 keys, RO-3 — core_principles 4-entry list 추가) /
      fg_bg_shared_anchor_rule (6 keys) / primary_framing_rule (6 keys + nested
      framing_scale_keywords 3 / close_framing_rules 4 incl body_part_focus_cross_ref
      RO-6 / wide_medium_rules 4 incl body_part_focus_cross_ref RO-6) / rationale_summary).
      cross-card refs 는 primary_framing_rule 하위에만 (PR4-B3 — NOT camera_frame_rule).
      constraints G4.4 base 2 → G4.5a 5 strings.

    R1-I1 (no silent fallback): staging 부재는 두 분기 — (a) shot 이 staging 필수
      인데 부재 → AppError (silent fallback 금지). (b) shot_info 가 명시적으로
      `staging_not_applicable=True` → mode='not_applicable'. 양 분기 모두에서 신규
      `spatial_consistency` 4 sub-key 는 builder-static (입력 무관) — RO-9 invariant.

    Override RO-13 carry: `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` (G4.4 line 240
      defined) 직접 in-module reference — NO self-import (PR4-B9).
    """
    shot_cam = (shot_info or {}).get("camera_direction") or ""
    staging_cam = (staging or {}).get("camera_direction") or ""
    cam_dir = staging_cam or shot_cam
    lighting = (staging or {}).get("lighting_mood") or ""
    pm = perception_mode or RENDER_MODE_DIRECT
    primary = (shot_info or {}).get("primary_subject") or ""

    # G4.5a 신규 nested dict — spatial_consistency 4 sub-key (builder-static).
    # PR4-B6 carry: G4.1+G4.4 base 8 keys 보존, NEW sibling 추가만.
    spatial_consistency = _build_spatial_consistency_dict()

    # G4.5a constraints: G4.4 base 2 → G4.5a 5 strings
    constraints = _build_render_strategy_constraints()

    # R1-I1: staging 부재 분기.
    if staging is None:
        if (shot_info or {}).get("staging_not_applicable") is True:
            # 의도적 not applicable case.
            return {
                "mode": RENDER_MODE_NOT_APPLICABLE,
                "primary_subject": primary,
                "framing_scale": _derive_framing_scale(shot_cam),
                "moment_lock": "single still moment from the selected shot only",
                "camera_direction": "",
                "lighting_mood": "",
                "perception_mode": pm,
                # G4.5a NEW sibling — builder-static (RO-9 invariant)
                "spatial_consistency": spatial_consistency,
                "constraints": constraints,
            }
        # silent fallback 금지 — shot 이 staging 필수인데 부재.
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_render_strategy: staging is required but missing "
                f"(scene={(shot_info or {}).get('scene_index')}, "
                f"shot={(shot_info or {}).get('shot_index')}). "
                "Set shot_info['staging_not_applicable']=True if intentional, "
                "otherwise upstream staging must populate."
            ),
        )

    framing = _derive_framing_scale(cam_dir)
    return {
        "mode": RENDER_MODE_DIRECT,
        "primary_subject": primary,
        "framing_scale": framing,
        "moment_lock": "single still moment from the selected shot only",
        "camera_direction": cam_dir,
        "lighting_mood": lighting,
        "perception_mode": pm,
        # G4.5a NEW sibling — builder-static (RO-9 invariant)
        "spatial_consistency": spatial_consistency,
        "constraints": constraints,
    }


def _build_spatial_consistency_dict() -> Dict[str, Any]:
    """G4.5a — spec §2.2 spatial_consistency 4 sub-key assemble (builder-static).

    PR4-B2 binding: spec §2.2 가 ground-truth source. 4 top-level sub-keys —
    camera_frame_rule (7 keys, RO-3 — core_principles 4-entry list) /
    fg_bg_shared_anchor_rule (6 keys) / primary_framing_rule (6 keys + nested) /
    rationale_summary.

    PR4-B3 binding: cross-card refs (`body_part_focus_cross_ref`) are placed
    inside `primary_framing_rule.close_framing_rules` and
    `primary_framing_rule.wide_medium_rules` — NOT inside `camera_frame_rule`.

    PR4-B9 binding: `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` (G4.4 line 240) is
    referenced directly in-module — NO self-import.
    """
    # G4.5a sub-rule 1: camera_frame_rule (7 required keys, RO-3 binding)
    camera_frame_rule = {
        "applies_when": (
            "camera_direction specifies a vertical height/position "
            "(low / hip-height / overhead / ground level / quay level / "
            "floor level / from above) and the scene description includes "
            "frame-edge surfaces"
        ),
        "consistency_check_summary": (
            "elements described inside the frame must be at vertical/depth "
            "positions consistent with the camera's height — low cameras place "
            "feet/lower-body in the lower frame, overhead cameras place head/"
            "shoulders in upper frame, hip-height cameras place torso in frame "
            "with head/feet implied beyond edges"
        ),
        # RO-3 binding — v19 line 170-175 4 핵심 원칙 verbatim carry. 4-entry list.
        # substring assertions (Phase 5 Task 5.1):
        #   [0] "low camera" / [1] "overhead" / [2] "hip-height" / [3] "full body"
        "core_principles": [
            "low cameras at ground/quay/floor level place feet and lower legs "
            "in the lower frame; chest-height frame-edge surfaces behind the "
            "subject are geometrically forbidden",
            "overhead high angles from above place the top of head and shoulders "
            "in the upper-center frame; floor surfaces visible below feet at the "
            "lower frame edge are forbidden",
            "hip-height side cameras place torso and arms in frame; head and "
            "feet are implied beyond frame edges, not both fully visible together",
            "full body shots require either eye-level or high-angle framing where "
            "head and feet both fit; low cameras + full silhouette in one frame "
            "is forbidden",
        ],
        "forbidden_combinations": [
            "low camera at ground/quay/floor level + frame-edge surface "
            "described at chest/waist height behind the subject",
            "overhead high angle + feet visible at the lower frame edge with "
            "floor visible below them",
            "hip-height side camera + full silhouette including head and feet "
            "visible in same frame",
            "low angle looking up + ceiling and floor both fully visible in same frame",
        ],
        "recommended_phrasings": [
            "low observer at ground level, shoes and lower legs occupying the "
            "lower frame, upper body extending toward the upper edge",
            "overhead high angle from above, the top of head and shoulders fill "
            "the upper-center frame, the floor visible around them",
            "at hip height beside the subject, torso and arms in frame, head and "
            "lower legs implied beyond frame edges",
            "low angle looking up at the figure, ceiling lamps in the upper "
            "frame, the wall behind extending downward",
        ],
        "self_check_steps": [
            "identify the camera position phrasing (height + angle)",
            "list elements described inside the frame (foreground prop, body parts, "
            "background surfaces)",
            "verify each element's vertical/depth placement is geometrically "
            "consistent with that camera position",
            "if mismatch found, adjust frame-edge description to match camera position",
        ],
        "rationale_summary": (
            "T2I 모델은 카메라 위치와 frame 안 요소 vertical 좌표 모순 시 절충 "
            "합성 — 인물이 가구에 박히거나 신체 일부가 잘리거나 prop 이 떠 있는 "
            "부자연스러운 결과"
        ),
    }

    # G4.5a sub-rule 2: fg_bg_shared_anchor_rule (6 required keys)
    fg_bg_shared_anchor_rule = {
        "applies_when": (
            "two characters share an interaction (dialogue / handing object / "
            "facing / co-watching) AND are described as foreground/background "
            "separated AND a shared surface or spatial anchor exists between "
            "them (bench / table / door / room)"
        ),
        "shared_anchor_required": (
            "describe the shared surface or spatial anchor explicitly so both "
            "figures occupy the same physical space"
        ),
        "forbidden_phrasings": [
            "fg/bg separation without naming the shared surface/space",
            "isolated foreground hand reaching with another figure 'in the rear "
            "background' (no shared anchor named)",
            "one figure 'in the foreground' and another 'in the back' without "
            "naming the room/surface they share",
        ],
        "recommended_keywords": [
            "shared bench / shared table / shared surface",
            "the same X they both occupy (X = bench, sofa, table, doorway, room)",
            "across the X from each other",
            "<prop> crosses the air between them (interaction prop visually "
            "links the two)",
            "the <surface> sharing a single line of sight from the camera",
        ],
        "self_check_steps": [
            "do the two characters share a single interaction (dialogue, handing, "
            "facing, co-watching)?",
            "is one foreground and the other background-separated?",
            "is the shared surface/space anchor explicitly named?",
            "if not, add one of the recommended keywords",
        ],
        "rationale_summary": (
            "fg/bg 분리만 묘사하면 모델이 두 인물을 다른 공간에 배치하거나 "
            "인터랙션이 끊긴 어색한 합성 — 공유 anchor 명시가 같은 물리적 "
            "공간 강제"
        ),
    }

    # G4.5a sub-rule 3: primary_framing_rule (6 required keys + 3 nested)
    # RO-4 binding: card framing_scale enum = close/medium/insert (production).
    #   wide-class shots → medium → wide_medium_rules.
    # RO-5 binding: framing_scale_keywords.close = LLM-facing 11-entry list
    #   (separate from canary 9-entry production-aligned).
    # RO-6 / PR4-B3 binding: cross-card refs in close_framing_rules +
    #   wide_medium_rules — NOT camera_frame_rule.
    primary_framing_rule = {
        "applies_when": (
            "framing_scale ∈ {close, medium, insert} from `_derive_framing_scale()` "
            "(render_strategy.framing_scale, production enum). wide-class shots "
            "are mapped to medium and use `wide_medium_rules`. The LLM may "
            "*additionally* infer wide from prose keywords (`wide shot` / `전경` / "
            "`establishing` / `aerial`) for stricter checking — these are LLM-side "
            "reasoning hints, not the production framing_scale enum (RO-4 binding "
            "— card-only classification)."
        ),
        "framing_scale_keywords": {
            "_comment": (
                "LLM-facing reasoning hints — not the production framing_scale "
                "enum. card framing_scale is close/medium/insert from "
                "_derive_framing_scale(); wide is LLM hint only (RO-4 / RO-5 "
                "binding). Canary close-framing shot scope filter uses "
                "production _CLOSE_FRAMING_RE (9-entry alignment) — separate "
                "from this 11-entry LLM list."
            ),
            # RO-5 binding: 11-entry LLM-facing list
            "close": [
                "close-up", "CU", "MCU", "ECU", "XCU", "extreme close-up",
                "클로즈업", "손가락이", "손이", "눈이", "얼굴이",
            ],
            # RO-4 binding: 5-entry LLM-facing wide hint
            "wide": [
                "wide shot", "establishing", "aerial",
                "전경", "전신",
            ],
            "medium_default": (
                "medium / mid / two-shot — anything not matching close/wide keywords"
            ),
        },
        "close_framing_rules": {
            "primary_subject_only_fully_visible": (
                "the named primary subject (hand / face / object) is the only "
                "fully visible entity in the frame"
            ),
            "other_entities_appearance_options": [
                "partial visibility — shoulder slice / arm slice / sleeve / "
                "fingertips entering the frame edge",
                "soft background — out-of-focus silhouette via shallow depth of field",
                "absent — not rendered at all even if listed in visible_entities",
            ],
            "forbidden_combinations": [
                "same character's full body and body-part close-up in the same "
                "frame (view mixing)",
                "two characters both rendered as sharp close-up faces in the same frame",
            ],
            # RO-6 / PR4-B3 / PR4-B9 binding: cross-card paired-string substring.
            # `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` (G4.4 line 240) defined in
            # this same module — referenced directly (NO self-import). RO-13:
            # rename to _ID_POLICY_BODY_PART_FOCUS_CROSS_REF in G4.5c (follow-up).
            "body_part_focus_cross_ref": (
                f"see {_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} (G4.3 sub-field — "
                f"body-part Focus 시 C##O## 금지)"
            ),
        },
        "wide_medium_rules": {
            "primary_and_secondary_both_visible": (
                "primary subject + secondary entities all renderable"
            ),
            "body_part_close_up_forbidden": (
                "body-part close-up of any character is forbidden in wide/medium "
                "framing — close-up belongs in a separate shot"
            ),
            "fg_bg_separation_requires_shared_anchor": (
                "if two characters are rendered fg/bg separated, also apply "
                "fg_bg_shared_anchor_rule"
            ),
            # RO-6 / PR4-B3 / PR4-B9 binding: cross-card paired-string substring.
            "body_part_focus_cross_ref": (
                f"see {_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} (G4.3 sub-field — "
                f"body-part Focus 시 C##O## 금지)"
            ),
        },
        "self_check_steps": [
            "identify framing_scale from camera_direction",
            "if close: verify only primary subject is fully visible; other "
            "entities are partial / soft bg / absent",
            "verify same character's full body + body-part close-up are not combined",
            "if close: verify two faces are not both rendered sharp",
        ],
        "rationale_summary": (
            "한 shot 안 framing scale 가 일관되어야 모델이 단일 카메라 셔터로 해석. "
            "close/wide/medium 혼합은 multi-camera 합성으로 인식되어 인물 분열 / "
            "부위 분리 / 시점 혼합 합성"
        ),
    }

    return {
        "camera_frame_rule": camera_frame_rule,
        "fg_bg_shared_anchor_rule": fg_bg_shared_anchor_rule,
        "primary_framing_rule": primary_framing_rule,
        "rationale_summary": (
            "Rule F/G/J 의 spatial consistency 룰 통합 — camera 위치 vs frame edge "
            "모순, fg/bg 분리 시 공유 anchor 누락, framing scale 혼합. 3 sub-rule "
            "모두 single t2i_prompt = single camera shutter 원칙의 spatial 측면."
        ),
    }


def _build_render_strategy_constraints() -> List[str]:
    """G4.5a — spec §2.2 constraints 5 strings (G4.4 base 2 carry + 3 신규 spatial).

    PR4-B6 carry: G4.4 base 2 strings 보존 — `[0] one prompt captures one still
    moment / [1] do not combine full-body framing and body-part close-up in one
    frame` 그대로. G4.5a 신규 3 strings — camera_frame_rule / fg_bg_shared_anchor_rule
    / primary_framing_rule 의 inline reference.
    """
    return [
        # constraints[0] — G4.4 carry verbatim
        "one prompt captures one still moment",
        # constraints[1] — G4.4 carry verbatim
        "do not combine full-body framing and body-part close-up in one frame",
        # constraints[2] — G4.5a 신규: camera_frame_rule inline
        "camera position and frame-edge elements must be geometrically "
        "consistent — see render_strategy.spatial_consistency.camera_frame_rule",
        # constraints[3] — G4.5a 신규: fg_bg_shared_anchor_rule inline
        "two characters in fg/bg separation must share a named surface/space "
        "anchor — see render_strategy.spatial_consistency.fg_bg_shared_anchor_rule",
        # constraints[4] — G4.5a 신규: primary_framing_rule inline
        "framing_scale binds entity rendering — close = primary subject only "
        "fully visible, wide/medium = no body-part close-up — see "
        "render_strategy.spatial_consistency.primary_framing_rule",
    ]
```

**주의 (Wave 1-A 함정 회피 — G4.1 / G4.2 / G4.3 / G4.4 trap carry):**
- 신규 `spatial_consistency` 4 sub-key 모두 **builder-static** (입력 무관 — `staging=None` + `staging_not_applicable=True` 상태에서도 항상 동일 dict). `if camera_direction:` / `if shot_info:` 류 truthy guard 안 배치 절대 금지 (Trap #14). test `test_spatial_consistency_4_subfields_independent_of_input` 가 강제.
- `_build_spatial_consistency_dict()` 와 `_build_render_strategy_constraints()` private helper 분리 — DRY (양 분기 동일 호출). 단, 양쪽 분기 모두 신규 helper return 호출 (signed gate carry).
- Override RO-6 paired-string substring `"id_policy.body_part_focus_rule"` 는 정확 literal — G4.3 sub-field 명 rename 시 본 plan / spec / canary / unit test 모두 동시 patch (paired drift 차단). G4.4 `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` 직접 in-module reference (PR4-B9 — self-import 금지).
- 한국어 prose (rationale_summary 등) 는 시나리오 의존 0 (Trap #9) — 작품 고유명사 (인물 이름, 지명) 절대 포함 금지.
- `_card_metadata.lift_status.spatial_*_lifted` 4 신규 key 는 `build_render_prompt_card()` 책임 — 본 task 에서는 추가 안 함 (Task 1.5).
- self-check (PR4-B1 / PR4-B3 / PR4-B4 grep): 코드 작성 후 다음 grep 모두 0 결과 강제.
  - `grep -n "or \[\]\|or \{\}" render_prompt_card.py` (Phase 1 신규 코드 영역만)
  - `grep -n "camera_frame_rule.*close_framing_rules\|camera_frame_rule.*wide_medium_rules"` — cross-card refs는 primary_framing_rule 하위에만
  - `grep -n "compute_spatial_consistency_snapshot_hash"` — narrower naming 금지

---

### Task 1.4 — `_assert_spatial_consistency_shape()` + `_assert_primary_framing_rule_shape()` nested helpers (RO-9 / PR4-I5)

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py` — `_assert_continuity_elements_used_shape` (line 1864) 직후 또는 `assert_card_shape` (line 1436) 안 wiring
- [ ] **Dependency:** Task 1.3 (`spatial_consistency` 4 sub-key 가 builder 출력에 존재)
- [ ] **Spec cross-reference:** spec §2.2 (assert_card_shape strict) / §2.3 / §6.1 / RO-9 / PR4-I5 / PR4-B2 / PR4-B3
- [ ] **Expected diff size:** ~150 lines (2 nested helper + assert_card_shape wiring)

**변경 내용:**

**Plan-R3-R-fix-iter-1 PR-fix-iter-1-4 disambiguation (Option A 채택)**: production reality (`render_prompt_card.py:1436` `assert_card_shape`) 가 `rs_mode` 검증을 inline 으로 line 1519-1527 에 둠 — **별도로 명명된 `_assert_render_strategy_shape()` helper 가 존재하지 않음**. Plan G4.5a 는 inline 구조를 그대로 유지 — `assert_card_shape()` body 안 line 1527 의 `rs_mode` 검증 직후에 신규 `_assert_spatial_consistency_shape(card)` 호출 inline 삽입. **NO new `_assert_render_strategy_shape()` factor-out** — minimal refactor risk for G4.5a. (대안 Option B factor-out 은 Override row PR-fix-iter-1-4 에서 reject 명시 — G4.5c 또는 별도 cleanup PR.)

G4.5a `spatial_consistency` 4 sub-key 검증 + nested call chain 추가는 `assert_card_shape()` body 안 inline 호출 형태 + `_assert_spatial_consistency_shape()` / `_assert_primary_framing_rule_shape()` 두 nested helper 신설.

**Nested call chain (RO-9 / PR4-I5 binding — assert_card_shape inline structure preserved per PR-fix-iter-1-4):**
1. `assert_card_shape(card)` — line 1436 producer-side function. line 1519-1527 inline 의 `rs_mode` enum 검증 직후 `_assert_spatial_consistency_shape(card)` 호출 추가. 본 plan body 가 "기존 `_assert_render_strategy_shape()`" 표현 사용 시 = "assert_card_shape() 의 inline rs_mode 검증 영역" 으로 읽음 (Plan-R3-R-fix-iter-1 PR-fix-iter-1-4 disambiguation).
2. `_assert_spatial_consistency_shape(card)` — neue helper. 4 top-level sub-key 검증 + camera_frame_rule 7 keys + fg_bg_shared_anchor_rule 6 keys + 호출 → `_assert_primary_framing_rule_shape(sc["primary_framing_rule"])`
3. `_assert_primary_framing_rule_shape(pf)` — 6 sub-key + nested framing_scale_keywords 3 / close_framing_rules 4 (incl `body_part_focus_cross_ref` paired-string) / wide_medium_rules 4 (incl `body_part_focus_cross_ref` paired-string)

```python
# G4.5a constants — frozenset module-level (single source for assert + tests)
_SPATIAL_CONSISTENCY_REQUIRED_KEYS: FrozenSet[str] = frozenset({
    "camera_frame_rule",
    "fg_bg_shared_anchor_rule",
    "primary_framing_rule",
    "rationale_summary",
})

# RO-3 binding: 7 keys (core_principles 추가)
_SPATIAL_CAMERA_FRAME_RULE_REQUIRED_KEYS: FrozenSet[str] = frozenset({
    "applies_when",
    "consistency_check_summary",
    "core_principles",
    "forbidden_combinations",
    "recommended_phrasings",
    "self_check_steps",
    "rationale_summary",
})

_SPATIAL_FG_BG_SHARED_ANCHOR_RULE_REQUIRED_KEYS: FrozenSet[str] = frozenset({
    "applies_when",
    "shared_anchor_required",
    "forbidden_phrasings",
    "recommended_keywords",
    "self_check_steps",
    "rationale_summary",
})

_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS: FrozenSet[str] = frozenset({
    "applies_when",
    "framing_scale_keywords",
    "close_framing_rules",
    "wide_medium_rules",
    "self_check_steps",
    "rationale_summary",
})

_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS: FrozenSet[str] = frozenset({
    "close", "wide", "medium_default",
})

# RO-6 / PR4-B3 binding: cross-card refs in close_framing_rules + wide_medium_rules
_SPATIAL_CLOSE_FRAMING_RULES_REQUIRED_SUBKEYS: FrozenSet[str] = frozenset({
    "primary_subject_only_fully_visible",
    "other_entities_appearance_options",
    "forbidden_combinations",
    "body_part_focus_cross_ref",  # RO-6 paired-string
})

_SPATIAL_WIDE_MEDIUM_RULES_REQUIRED_SUBKEYS: FrozenSet[str] = frozenset({
    "primary_and_secondary_both_visible",
    "body_part_close_up_forbidden",
    "fg_bg_separation_requires_shared_anchor",
    "body_part_focus_cross_ref",  # RO-6 paired-string
})


def _assert_spatial_consistency_shape(
    sc: Dict[str, Any], where: str
) -> None:
    """G4.5a (RO-9 / PR4-I5 binding) — render_strategy.spatial_consistency
    nested validator helper.

    spec §2.2 가 ground-truth source. 4 top-level sub-key (PR4-B2):
      - camera_frame_rule (7 keys, RO-3)
      - fg_bg_shared_anchor_rule (6 keys)
      - primary_framing_rule (6 keys + nested) → further nested call to
        _assert_primary_framing_rule_shape() (RO-9 / PR4-I5 — nested call chain)
      - rationale_summary (str)

    Override O-30 carry (G4.4): strict required-keys subset — `EXPECTED <= returned`
    (set superset, NOT equality). extra keys 허용 (G4.5b/c forward-compat).
    """
    if not isinstance(sc, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy.spatial_consistency must be "
                f"dict (got {type(sc).__name__}) {where}"
            ),
        )
    sc_returned = set(sc.keys())
    if not (_SPATIAL_CONSISTENCY_REQUIRED_KEYS <= sc_returned):
        missing = sorted(_SPATIAL_CONSISTENCY_REQUIRED_KEYS - sc_returned)
        raise AppError(
            code="step.contract_violation",
            message=(
                f"spatial_consistency missing required keys {missing} "
                f"(have {sorted(sc_returned)}) {where}"
            ),
        )

    # camera_frame_rule (7 keys, RO-3)
    cfr = sc["camera_frame_rule"]
    if not isinstance(cfr, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"spatial_consistency.camera_frame_rule must be dict "
                f"(got {type(cfr).__name__}) {where}"
            ),
        )
    cfr_returned = set(cfr.keys())
    if not (_SPATIAL_CAMERA_FRAME_RULE_REQUIRED_KEYS <= cfr_returned):
        missing = sorted(_SPATIAL_CAMERA_FRAME_RULE_REQUIRED_KEYS - cfr_returned)
        raise AppError(
            code="step.contract_violation",
            message=(
                f"camera_frame_rule missing required keys {missing} "
                f"(have {sorted(cfr_returned)}) {where}"
            ),
        )
    # core_principles (RO-3) — list-len strict 4
    cp = cfr["core_principles"]
    if not isinstance(cp, list):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"camera_frame_rule.core_principles must be list "
                f"(got {type(cp).__name__}) {where}"
            ),
        )
    if len(cp) != 4:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"camera_frame_rule.core_principles must have exactly 4 entries "
                f"(got {len(cp)}) — RO-3 binding (v19 line 170-175 carry) {where}"
            ),
        )

    # fg_bg_shared_anchor_rule (6 keys)
    fb = sc["fg_bg_shared_anchor_rule"]
    if not isinstance(fb, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"spatial_consistency.fg_bg_shared_anchor_rule must be dict "
                f"(got {type(fb).__name__}) {where}"
            ),
        )
    fb_returned = set(fb.keys())
    if not (_SPATIAL_FG_BG_SHARED_ANCHOR_RULE_REQUIRED_KEYS <= fb_returned):
        missing = sorted(_SPATIAL_FG_BG_SHARED_ANCHOR_RULE_REQUIRED_KEYS - fb_returned)
        raise AppError(
            code="step.contract_violation",
            message=(
                f"fg_bg_shared_anchor_rule missing required keys {missing} "
                f"(have {sorted(fb_returned)}) {where}"
            ),
        )

    # primary_framing_rule — further nested helper (RO-9 / PR4-I5 binding)
    _assert_primary_framing_rule_shape(sc["primary_framing_rule"], where)


def _assert_primary_framing_rule_shape(
    pf: Dict[str, Any], where: str
) -> None:
    """G4.5a (RO-9 / PR4-I5 binding) — primary_framing_rule further nested helper.

    nested call chain: `_assert_render_strategy_shape() →
    _assert_spatial_consistency_shape() → _assert_primary_framing_rule_shape()`
    (further nested, NOT sibling — PR4-I5 disambiguation).

    6 sub-key + nested validation:
      - framing_scale_keywords (3 sub-key: close 11-entry RO-5 / wide 5-entry RO-4 /
        medium_default str)
      - close_framing_rules (4 sub-key incl body_part_focus_cross_ref RO-6 paired)
      - wide_medium_rules (4 sub-key incl body_part_focus_cross_ref RO-6 paired)

    PR4-B3 binding: cross-card refs ONLY here (close_framing_rules +
    wide_medium_rules), NOT in camera_frame_rule.
    """
    if not isinstance(pf, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"spatial_consistency.primary_framing_rule must be dict "
                f"(got {type(pf).__name__}) {where}"
            ),
        )
    pf_returned = set(pf.keys())
    if not (_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS <= pf_returned):
        missing = sorted(_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS - pf_returned)
        raise AppError(
            code="step.contract_violation",
            message=(
                f"primary_framing_rule missing required keys {missing} "
                f"(have {sorted(pf_returned)}) {where}"
            ),
        )

    # framing_scale_keywords — 3 sub-key (close 11-entry RO-5 / wide / medium_default)
    fsk = pf["framing_scale_keywords"]
    if not isinstance(fsk, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"primary_framing_rule.framing_scale_keywords must be dict "
                f"(got {type(fsk).__name__}) {where}"
            ),
        )
    fsk_returned = set(fsk.keys())
    if not (_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS <= fsk_returned):
        missing = sorted(_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS - fsk_returned)
        raise AppError(
            code="step.contract_violation",
            message=(
                f"framing_scale_keywords missing required sub-keys {missing} "
                f"(have {sorted(fsk_returned)}) {where}"
            ),
        )
    # close 11-entry list (RO-5 binding — LLM-facing 11-entry list)
    close_kw = fsk["close"]
    if not isinstance(close_kw, list):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"framing_scale_keywords.close must be list "
                f"(got {type(close_kw).__name__}) {where}"
            ),
        )
    if len(close_kw) != 11:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"framing_scale_keywords.close must have exactly 11 entries "
                f"(got {len(close_kw)}) — RO-5 binding (LLM-facing 11-entry list) {where}"
            ),
        )

    # close_framing_rules — 4 sub-key (incl body_part_focus_cross_ref, RO-6 / PR4-B3)
    cfr = pf["close_framing_rules"]
    if not isinstance(cfr, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"primary_framing_rule.close_framing_rules must be dict "
                f"(got {type(cfr).__name__}) {where}"
            ),
        )
    cfr_returned = set(cfr.keys())
    if not (_SPATIAL_CLOSE_FRAMING_RULES_REQUIRED_SUBKEYS <= cfr_returned):
        missing = sorted(_SPATIAL_CLOSE_FRAMING_RULES_REQUIRED_SUBKEYS - cfr_returned)
        raise AppError(
            code="step.contract_violation",
            message=(
                f"close_framing_rules missing required sub-keys {missing} "
                f"(have {sorted(cfr_returned)}) {where}"
            ),
        )
    # body_part_focus_cross_ref paired-string (RO-6 binding)
    cfr_xref = cfr["body_part_focus_cross_ref"]
    if not isinstance(cfr_xref, str):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"close_framing_rules.body_part_focus_cross_ref must be str "
                f"(got {type(cfr_xref).__name__}) {where}"
            ),
        )
    if _CONTINUITY_ID_POLICY_CROSS_REF_LITERAL not in cfr_xref:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"close_framing_rules.body_part_focus_cross_ref must contain "
                f"substring {_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL!r} (RO-6 "
                f"paired-string) {where}"
            ),
        )

    # wide_medium_rules — 4 sub-key (incl body_part_focus_cross_ref, RO-6 / PR4-B3)
    wmr = pf["wide_medium_rules"]
    if not isinstance(wmr, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"primary_framing_rule.wide_medium_rules must be dict "
                f"(got {type(wmr).__name__}) {where}"
            ),
        )
    wmr_returned = set(wmr.keys())
    if not (_SPATIAL_WIDE_MEDIUM_RULES_REQUIRED_SUBKEYS <= wmr_returned):
        missing = sorted(_SPATIAL_WIDE_MEDIUM_RULES_REQUIRED_SUBKEYS - wmr_returned)
        raise AppError(
            code="step.contract_violation",
            message=(
                f"wide_medium_rules missing required sub-keys {missing} "
                f"(have {sorted(wmr_returned)}) {where}"
            ),
        )
    wmr_xref = wmr["body_part_focus_cross_ref"]
    if not isinstance(wmr_xref, str):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"wide_medium_rules.body_part_focus_cross_ref must be str "
                f"(got {type(wmr_xref).__name__}) {where}"
            ),
        )
    if _CONTINUITY_ID_POLICY_CROSS_REF_LITERAL not in wmr_xref:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"wide_medium_rules.body_part_focus_cross_ref must contain "
                f"substring {_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL!r} (RO-6 "
                f"paired-string) {where}"
            ),
        )
```

**`_assert_render_strategy_shape()` wiring (assert_card_shape line 1519-1527 영역):**

기존 `rs_mode = card["render_strategy"].get("mode")` 분기 직후 `spatial_consistency` 필수 검증 추가:

```python
    # G4.5a (RO-9 / PR4-I5 binding) — spatial_consistency 누락 strict
    rs = card["render_strategy"]
    if "spatial_consistency" not in rs:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy missing 'spatial_consistency' "
                f"(G4.5a PR4-B2) {where}"
            ),
        )
    _assert_spatial_consistency_shape(rs["spatial_consistency"], where)
```

**주의:**
- Override O-30 strict superset — `EXPECTED_REQUIRED <= returned_keys`. set equality (`==`) 사용 금지. extra keys 허용 (G4.5b/c forward-compat).
- `frozenset` 사용 — module-level data 가 정의된 후 mutate 방지.
- nested call chain (RO-9 / PR4-I5) 고정: `_assert_render_strategy_shape()` → `_assert_spatial_consistency_shape()` → `_assert_primary_framing_rule_shape()`. **NOT sibling** — `_assert_spatial_consistency_shape()` 가 직접 `_assert_primary_framing_rule_shape()` 호출.
- 한국어 / 영어 cross-ref 문자열 검증 — `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` (G4.4 line 240) substring 포함만 검증 (정확 literal equality 아님 — RO-6 paired-string substring).

---

### Task 1.5 — `build_render_prompt_card()` `_card_metadata` 4 신규 lift_status key + 3 신규 rule_source key

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py` — `build_render_prompt_card()` 위치 (G4.4 carry, line ~1487)
- [ ] **Dependency:** Task 1.3
- [ ] **Spec cross-reference:** spec §2.2 _card_metadata matrix / §7.2 step 4 / G4.2 R1-B1 carry / Override O-9 carry
- [ ] **Expected diff size:** ~15 lines

**변경 내용:**

기존 G4.4 `_card_metadata` assembly (commit `b1c5032` 도입 — `lift_status` 11 key + `rule_source` 10 key) 에 G4.5a 신규 4 lift_status key + 3 rule_source key 추가.

```python
# G4.5a 신규 4 키 (Override O-9 정확 명 carry — abbrev 금지)
card["_card_metadata"] = {
    "lift_status": {
        # G4.2 carry (commit 14d14cb)
        "rule_a_lifted": True,
        "rule_c_lifted": True,
        "rule_e_lifted": rule_e_lifted,  # G4.2 carry — bg_mode 분기 표현식 그대로
        # G4.3 carry (commit 51d6c5e)
        "rule_h_lifted": True,
        "id_policy_composite_lifted": True,
        "id_policy_close_framing_face_lifted": True,
        "id_policy_reproduction_surface_lifted": True,
        # G4.4 carry (commit b1c5032)
        "continuity_cross_shot_id_substitution_lifted": True,
        "continuity_ref_usage_constraints_lifted": True,
        "continuity_view_consistency_lifted": True,
        "continuity_constraints_extended": True,
        # G4.5a 신규 4 키 (정확 명 — abbrev 금지, drift 시 G4.5 lift_status enumerate test fail)
        "spatial_camera_frame_lifted": True,
        "spatial_fg_bg_shared_anchor_lifted": True,
        "spatial_primary_framing_lifted": True,
        "spatial_constraints_extended": True,
    },
    "rule_source": {
        # G4.2 carry
        "camera_rule": "A",
        "owned_rule": "C",
        "close_skip_rule": "E",
        # G4.3 carry
        "demographic_rule": "H",
        "composite_id_rule": "C## 사용 규칙 — 참조 이미지 연동 (v17:32-64)",
        "close_framing_face_rule": "극단 클로즈업 표현 (v17:66-85)",
        "reproduction_surface_rule": "사진·포스터·화면·거울 속 인물 규칙 (v17:133-146)",
        # G4.4 carry
        "cross_shot_id_substitution_rule": "교차 샷 고정 요소 통합 규칙 (v18:109-120)",
        "ref_usage_rule": "앞쪽 참조 샷 처리 — ref_usage 유형별 지시 (v18:122-156)",
        "view_consistency_rule": "절대 규칙 — 단일 시점 (1인칭/3인칭 혼합 금지) (v18:308-326)",
        # G4.5a 신규 3 키
        "camera_frame_rule": "카메라 위치와 frame edge의 물리 일관성 (Rule F) (v19:144-176)",
        "fg_bg_shared_anchor_rule": "fg/bg 인물 분리 시 공유 공간 명시 (Rule G) (v19:177-217)",
        "primary_framing_rule": "Primary Subject Framing — 한 shot = 한 framing scale (Rule J) (v19:218-267)",
    },
}
```

**주의 (G4.2 + G4.3 + G4.4 carry):**
- `_card_metadata` 는 `canonicalize_render_prompt_card()` 가 top-level pop (G4.1/G4.2/G4.3/G4.4 carry) — hash 영향 0. test `test_card_metadata_excluded_from_hash_after_g4_5a_keys_added` 가 강제.
- `_card_metadata` 는 inject 직전 `detail_steps.py` 에서 strip — LLM 입력 영향 0 (G4.2 carry).
- 4 신규 lift_status key 는 **모든 mode 에서 `True`** (spatial_consistency 는 ref_usage / mode 무관 동일 적용 — spec §2.2 matrix 단일 row).
- `rule_source` 의 v19 line range 표기는 hardcoded — 시나리오 의존 0 (Trap #9). 작품 고유명사 없음.
- **정확 명 사용** — abbrev 형태 (`spatial_camera_lifted` / `spatial_fg_bg_lifted`) 절대 금지. drift 시 G4.5 lift_status enumerate test fail.

---

### Task 1.6 — docstring 갱신

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py`
- [ ] **Dependency:** Task 1.1 / 1.2 / 1.3 / 1.4 / 1.5
- [ ] **Spec cross-reference:** spec §7.2 step 1-6
- [ ] **Expected diff size:** ~25 lines (docstring only)

**변경 내용:**

1. Module docstring 상단 `G4.1/G4.2/G4.3/G4.4 RenderPromptCard helper` → `G4.1/G4.2/G4.3/G4.4/G4.5a RenderPromptCard helper`.
2. `build_render_strategy()` docstring 에 G4.5a 변경 기록 추가:
   ```
   G4.5a (2026-05-05): spatial_consistency 신규 sibling nested dict 추가
   (4 sub-key — camera_frame_rule (7 keys, RO-3) / fg_bg_shared_anchor_rule
   (6 keys) / primary_framing_rule (6 keys + nested) / rationale_summary).
   constraints 2 → 5 strings 확장 (G4.4 carry 2 + 3 신규 spatial inline).
   PR4-B2 binding: spec §2.2 ground-truth source. PR4-B3 binding: cross-card
   refs in primary_framing_rule only (NOT camera_frame_rule). PR4-B6 binding:
   G4.1+G4.4 base 8 keys 보존, NEW sibling 추가만. _card_metadata.lift_status
   4 신규 key (spatial_*_lifted) + rule_source 3 신규 key 는 build_render_prompt_card
   wrapper 책임 (Task 1.5).
   ```
3. `_assert_spatial_consistency_shape()` docstring 에 G4.5a 4 sub-key strict 검증 + RO-9 nested call chain 명시.
4. `_assert_primary_framing_rule_shape()` docstring 에 RO-6 / PR4-B3 paired-string substring 검증 명시.
5. `compute_render_strategy_snapshot_hash()` docstring 에 PR4-B4 / PR4-I2 결정 근거 명시.

---


## Phase 2 (Wave 1-B parallel): v20 prompt 신규 디렉토리

**Phase 목표:** `prompts/_base/scene_detail/20.<timestamp>/` 신규 디렉토리 생성. v19 system.md 복사 후 3 spatial-rules prose section (Rule F 33L + Rule G 41L + Rule J 50L = 124 lines) 삭제 + ≤ 32-line "Spatial consistency" compact section 신설. `detail_schema.json` / `user.md` 는 v19 verbatim copy. tiktoken cl100k_base 실측으로 §3.4 estimate vs 실측 비교 + RO-1 token gate threshold 보정 (Wave 5 fix loop 진입 전).

**Phase 2 expected test pass delta:** 0 (test 는 Phase 5 에서).

**중요 — 덮어쓰기 금지:** CLAUDE.md `feedback_prompt_versioning.md` 규칙 (Trap #10). `19.<timestamp>/system.md` 수정 금지. 반드시 새 버전 디렉토리 (`20.<timestamp>/`) 생성.

**Wave 1-B → Wave 2 timestamp lockstep**: scratch file `.wave_1b_timestamp.txt` 에 timestamp 기록 후 Wave 2 dispatcher 가 read (G4.3 R4-I3 / G4.4 carry). alignment test fail = lockstep 누락 신호.

---

### Task 2.1 — v20 디렉토리 생성 + system.md 3 prose 삭제

- [ ] **File:** `prompts/_base/scene_detail/20.<timestamp>/system.md` (신규 생성)
- [ ] **Dependency:** 없음 (Wave 1-A 와 병렬 가능)
- [ ] **Spec cross-reference:** spec §3.1 / §3.2 / RO-10
- [ ] **Expected diff size:** -124 lines (3 prose 삭제) — RO-2 line count 확인은 Task 2.2 에서

**변경 내용:**

1. **타임스탬프 결정**: 작업 시점의 `YYYYMMDDHHmm` 형식 (예: `20.202605051600`). Wave 2 의 detail_steps.py / version_registry.py 와 lockstep 동기화. **scratch file `.wave_1b_timestamp.txt` 에 timestamp 기록**.
2. **디렉토리 복사**:
   ```bash
   cp -r prompts/_base/scene_detail/19.202605050814 prompts/_base/scene_detail/20.<timestamp>
   ```
3. **v20 system.md 에서 다음 3 sections 삭제** (v19 line 기준 — RO-10 inclusive 행):

| section | v19 lines (inclusive) | 라인수 | 삭제 이유 |
|---|---|---|---|
| `## 카메라 위치와 frame edge의 물리 일관성 (Rule F) ★ 신규` | 144-176 | 33 | card `render_strategy.spatial_consistency.camera_frame_rule` (7 required keys, RO-3) + constraints[2] 가 동일 데이터 carry |
| `## fg/bg 인물 분리 시 공유 공간 명시 (Rule G) ★ 신규` | 177-217 | 41 | card `render_strategy.spatial_consistency.fg_bg_shared_anchor_rule` (6 required keys) + constraints[3] 가 동일 데이터 carry |
| `## Primary Subject Framing — 한 shot = 한 framing scale (Rule J) ★ 신규` | 218-267 | 50 | card `render_strategy.spatial_consistency.primary_framing_rule` (6 required keys + nested 3 sub-rule) + constraints[4] 가 동일 데이터 carry |

총 삭제: 33 + 41 + 50 = **124 lines** (system.md). detail_steps.py inline prose 삭제 작업 없음 (PR4-B11 — G4.5a 는 G4.4 Override O-3 같은 inline prose 잔존 패턴 없음).

**Blank lines preservation (RO-10 binding)**: blank lines 142 / 217 / 268 = 인접 섹션 사이 trailing blank — Background Binding 끝 / Rule F end / Rule G end / Rule J end 사이 blank line 모두 drop. Inserted compact section starts on its own blank line. Net: 인접 섹션 사이 1 blank line 유지 (v19 §1-§2-§3 convention 일관).

**삭제 자동화 검증 (`grep -n` post-delete check):**
```bash
grep -n "## 카메라 위치와 frame edge의 물리 일관성" prompts/_base/scene_detail/20.<timestamp>/system.md
# → (no output)
grep -n "## fg/bg 인물 분리 시 공유 공간 명시" prompts/_base/scene_detail/20.<timestamp>/system.md
# → (no output)
grep -n "## Primary Subject Framing" prompts/_base/scene_detail/20.<timestamp>/system.md
# → (no output)
```

**보존 검증** (G4.2 / G4.3 / G4.4 영역 변경 없음):
```bash
grep -n "## ID Policy" prompts/_base/scene_detail/20.<timestamp>/system.md
# → <line>:## ID Policy ...  (G4.3 carry 보존)
grep -n "## Background Binding" prompts/_base/scene_detail/20.<timestamp>/system.md
# → <line>:## Background Binding ...  (G4.2 carry 보존)
grep -n "## Continuity" prompts/_base/scene_detail/20.<timestamp>/system.md
# → <line>:## Continuity ...  (G4.4 carry 보존)
```

---

### Task 2.2 — v20 system.md ≤ 32-line "Spatial consistency" compact section 삽입

- [ ] **File:** `prompts/_base/scene_detail/20.<timestamp>/system.md`
- [ ] **Dependency:** Task 2.1 완료
- [ ] **Spec cross-reference:** spec §3.3 / RO-2 / RO-6 (paired-string substring)
- [ ] **Expected diff size:** +13 lines (spec §3.3 enumerate, gate ≤ 32 strict)

**변경 내용:**

삭제된 `## 카메라 위치와 frame edge의 물리 일관성 (Rule F)` (line 144) 위치에 다음 ≤ 32-line compact section 삽입 (`## fg/bg 인물 분리 시 공유 공간 명시 (Rule G)` / `## Primary Subject Framing — 한 shot = 한 framing scale (Rule J)` 3 section 의 위치 모두 본 단일 section 으로 통합):

```markdown
## Spatial consistency (camera frame + fg/bg shared anchor + primary framing)

`[RenderPromptCard v1]` 의 `render_strategy.spatial_consistency` 가 이 shot 의 spatial 룰을 결정한다 (card 가 primary contract — 충돌 시 card 우선).

**핵심 원칙:**

- `camera_frame_rule.applies_when` 조건 (camera_direction 의 vertical 위치 + frame-edge surface 묘사) 만족 시 `forbidden_combinations` (낮은 카메라 + 가슴 높이 surface, overhead + 발 아래 바닥 등) 회피, `recommended_phrasings` 사용. `core_principles` 4 항 (낮은 카메라 / overhead / 허리 / full body) 으로 카메라 위치와 frame 안 요소 좌표 자연스럽게 작성.
- `fg_bg_shared_anchor_rule.applies_when` 조건 (두 인물 인터랙션 + fg/bg 분리 + 공유 surface/공간) 만족 시 `recommended_keywords` (`shared bench` / `the same X they both occupy` / `<prop> crosses the air between them` 류) 로 공유 anchor 명시. anchor 누락 시 모델이 두 인물을 다른 공간에 배치.
- `primary_framing_rule.framing_scale_keywords` (LLM hint) 로 close/wide/medium 판정 보조. card framing_scale 은 close/medium/insert (production enum, RO-4). close 이면 `close_framing_rules` 적용 — primary subject 만 fully visible, 다른 entity 는 partial/soft bg/absent. wide/medium 이면 `wide_medium_rules` 적용 — body-part close-up 금지 (close 는 별도 shot). 같은 인물 전신 + 부위 close-up 한 프레임 결합 금지 — see id_policy.body_part_focus_rule.
- fg/bg 분리 시 framing scale 무관 `fg_bg_shared_anchor_rule` 동시 적용.
- 카메라 1 위치 = 한 셔터 1/1000 초 = 한 framing scale. spatial mismatch 는 multi-camera 합성으로 모델이 해석해 인물 분열 / 부위 분리 / 좌표 모순 합성.

**충돌 해결**: 본 섹션 prose 는 reasoning summary; 구체 token list (forbidden_combinations / recommended_keywords / framing_scale_keywords / core_principles) 는 모두 `render_strategy.spatial_consistency` sub-field 가 single source. prose 가 sub-field 와 충돌하면 sub-field 우선.
```

**주의:**
- 정확히 **13 lines** spec §3.3 enumerate (heading 1 + blank 1 + intro 1 + blank 1 + "**핵심 원칙:**" 1 + blank 1 + 5 bullet × 1 line + blank 1 + "**충돌 해결**" 1).
- **RO-2 binding gate `wc -l` ≤ 32 lines strict** — wrap 줄도 line 1 로 카운트. 80-col wrap 발생 시 violation. Phase 2 draft 작성 시 자체 점검 필수 (Trap #7).
- ✓/✗ 예시 inline 미포함 (G4.4 Override O-15 carry — G4.3/G4.4 baseline 통과 패턴 carry, canary STRICT 0 검증이 LLM 행동 보증).
- **RO-6 cross-card paired-string substring** `"see id_policy.body_part_focus_rule"` 1+ 등장 (3번째 bullet 안 — 통합 테스트 §6.2 `test_cross_card_paired_substring_in_v20_system_md_and_card_inject` 강제, 3-way assertion).
- perception_mode 우선 명시는 G4.3 ID Policy 영역 — G4.5a 변경 0.

**삽입 후 검증 (R4-M4 carry from G4.3 — sed inclusive trap 회피):**
```bash
grep -n "## Spatial consistency" prompts/_base/scene_detail/20.<timestamp>/system.md
# → <line>:## Spatial consistency (camera frame + fg/bg shared anchor + primary framing)
```

```python
# RO-2 line count gate
import re
with open("prompts/_base/scene_detail/20.<timestamp>/system.md") as f:
    text = f.read()
m = re.search(r"## Spatial consistency.*?(?=\n## )", text, re.DOTALL)
if m is None:
    raise AssertionError("## Spatial consistency section not found")
content_lines = m.group().split("\n")
if len(content_lines) > 32:
    raise AssertionError(f"expected ≤ 32 lines, got {len(content_lines)}")
```

---

### Task 2.3 — `detail_schema.json` / `user.md` verbatim copy + 자체 line count + tiktoken 실측

- [ ] **File:** `prompts/_base/scene_detail/20.<timestamp>/detail_schema.json` + `user.md` (verbatim copy)
- [ ] **Dependency:** Task 2.1 / 2.2 완료
- [ ] **Spec cross-reference:** spec §7.2 step 6-8 / RO-1
- [ ] **Expected diff size:** 0 (verbatim) + tiktoken 실측 결과 별도 캡쳐

**변경 내용:**

1. **verbatim copy** (cp_r 시 이미 복사됨 — 변경 없음 확인):
   ```bash
   diff prompts/_base/scene_detail/19.202605050814/detail_schema.json prompts/_base/scene_detail/20.<timestamp>/detail_schema.json
   # → (no output — 동일)

   diff prompts/_base/scene_detail/19.202605050814/user.md prompts/_base/scene_detail/20.<timestamp>/user.md
   # → (no output — 동일)
   ```

2. **자체 line count + tiktoken 실측 (RO-1 binding)**:
   ```bash
   wc -l prompts/_base/scene_detail/19.202605050814/system.md
   # → 534 (baseline)

   wc -l prompts/_base/scene_detail/20.<timestamp>/system.md
   # → 534 - 124 + 13 = 423 부근 (3 prose 삭제 + Spatial consistency compact)

   python -c "
   import tiktoken
   enc = tiktoken.get_encoding('cl100k_base')
   with open('prompts/_base/scene_detail/19.202605050814/system.md') as f:
       v19_tokens = len(enc.encode(f.read()))
   with open('prompts/_base/scene_detail/20.<timestamp>/system.md') as f:
       v20_tokens = len(enc.encode(f.read()))
   print(f'v19: {v19_tokens} tokens')
   print(f'v20: {v20_tokens} tokens')
   print(f'token_delta: {v20_tokens - v19_tokens}')
   print(f'gate -1000 pass: {v20_tokens - v19_tokens <= -1000}')
   "
   ```

3. **gate margin 실측 후 estimate 보정 (RO-1)**: estimate `-1665` 와 actual delta 비교 → spec §3.4 inline note 갱신 (선택 — Wave 5 fix loop 진입 시). Round 2 Override row 로 downward (e.g. -1300) revise 가능, **upward revise (e.g. -700) 는 re-audit 필요**.

---

### Task 2.4 — Wave 1-B → Wave 2 timestamp lockstep 기록

- [ ] **File:** `.wave_1b_timestamp.txt` (scratch — gitignored)
- [ ] **Dependency:** Task 2.1 완료
- [ ] **Spec cross-reference:** PR4-I6 / G4.3 R4-I3 / G4.4 carry
- [ ] **Expected diff size:** 1 line (timestamp value)

**변경 내용:**

```bash
echo "20.<timestamp>" > .wave_1b_timestamp.txt
```

Wave 2 dispatcher (Task 3.1 + 3.2) 가 본 file 을 read 후 detail_steps.py:105 + version_registry.py:34 + version_registry.py:126 + alignment test fixture 4-point sync 적용. lockstep drift 시 alignment test fail.

**주의:**
- `.wave_1b_timestamp.txt` 는 .gitignore 에 추가 (git status `?? .wave_1b_timestamp.txt` 확인) — single commit 시점에 삭제 또는 commit 제외.
- timestamp 형식 `YYYYMMDDHHmm` (12자리) — `feedback_version_format.md` carry.

---

## Phase 3 (Wave 2): detail_steps.py + version_registry 4-point sync

**Phase 목표:** `SCENE_DETAIL_PROMPT_VERSION` v19 → `"20.<timestamp>"` bump (디렉토리 매칭). `MODULE_VERSIONS["scene_detail_composer"]` `1.19.0` → `1.20.0`. `_MODULE_INFO["scene_detail_composer"]["prompt_dependency"]` `"scene_detail/v19"` → `"scene_detail/v20"` (dict access — major suffix only, G4.3/G4.4 R3R4-B1 carry — Trap #5). **NO inline prose deletion** (PR4-B11 — G4.5a 는 G4.4 Override O-3 같은 inline prose 잔존 패턴 없음). alignment test 가 4-point sync 강제 검증.

**Phase 3 expected test pass delta:** 0 (alignment test pass 는 Wave 4 에서 검증).

---

### Task 3.1 — `SCENE_DETAIL_PROMPT_VERSION` 상수 bump

- [ ] **File:** `backend/app/core/steps/detail_steps.py:105`
- [ ] **Dependency:** Wave 1-B Task 2.1 완료 (v20 `<timestamp>` 결정 *후*) + scratch `.wave_1b_timestamp.txt` read
- [ ] **Spec cross-reference:** spec §6.4 4-point sync / G4.3 plan-R1-I3 / G4.4 carry
- [ ] **Expected diff size:** ~1 line

**변경 내용:**

line 105 의 정확한 변경:
```python
# Before (G4.4):
SCENE_DETAIL_PROMPT_VERSION = "19.202605050814"  # G4.4: Continuity lift — 3 prose sections (cross-shot subst./ref_usage/single-view) → continuity_elements_used 3 new sibling policy dicts + 7-string constraints. v19 system.md = v18 - 66 lines + ≤ 32-line Continuity compact section. detail_steps.py:1555-1563 inline prose 9 줄 별도 삭제 (Override O-3).

# After (G4.5a):
SCENE_DETAIL_PROMPT_VERSION = "20.<timestamp>"  # G4.5a: Spatial rules lift — 3 prose sections (Rule F/G/J = 124 lines) → render_strategy.spatial_consistency 4 sub-key + constraints 2→5 strings. v20 system.md = v19 - 124 lines + ≤ 32-line Spatial consistency compact section. detail_steps.py inline prose 삭제 작업 없음 (PR4-B11).
```

`<timestamp>` 는 Phase 2 Task 2.1 에서 결정된 실제 `YYYYMMDDHHmm` 값 — Wave 1-B / Wave 2 lockstep (`.wave_1b_timestamp.txt` read).

line 713 의 사용처 (`"prompt_version": SCENE_DETAIL_PROMPT_VERSION`) 는 자동 반영 — 별도 변경 불필요. 본 변경은 cp `config_hash` 자동 변경 → resume 시 v19 cp 가 force escalate path 로 진입 (G4.1 R1-B1 / G4.2 / G4.3 / G4.4 carry pattern).

**주의 (G4.2 / G4.3 / G4.4 plan carry — SCHEMA alignment, PR4-B11):**
- `SCENE_DETAIL_SCHEMA_VERSION` (= 7) 은 **변경 없음**. cp shape 변경 아님 (render_strategy.spatial_consistency 신규 sibling 추가는 cp 안 dict key 추가만).
- `backend/app/core/step_manifest.py:632` 의 `scene_detail.schema_version = 7` 도 변경 없음.
- `CARD_SCHEMA_VERSION` (= 1) 은 변경 없음 — render_strategy.spatial_consistency 신규 sub-field 는 envelope 외 dict 확장이며 동일.
- **PR4-B11 binding**: G4.5a 는 G4.4 Override O-3 같은 inline prose 9 줄 삭제 패턴이 없음. line 1555-1563 은 G4.4 b1c5032 에서 이미 삭제 — 재삭제 작업 없음.

---

### Task 3.2 — `version_registry.py` 4-point sync 동시 변경

- [ ] **File:** `backend/app/core/version_registry.py:34` + `:126`
- [ ] **Dependency:** Task 3.1 완료
- [ ] **Spec cross-reference:** spec §6.4 / G4.3/G4.4 R3R4-B1 carry / Trap #5
- [ ] **Expected diff size:** ~6 lines

**변경 내용:**

```python
# backend/app/core/version_registry.py:34 — MODULE_VERSIONS dict
# Before (G4.4):
MODULE_VERSIONS = {
    ...
    "scene_detail_composer": "1.19.0",  # G4.4 continuity lift
    ...
}

# After (G4.5a):
MODULE_VERSIONS = {
    ...
    "scene_detail_composer": "1.20.0",  # G4.5a spatial rules lift (3 prose 124L → spatial_consistency 4 sub-key + 5 constraints)
    ...
}
```

```python
# backend/app/core/version_registry.py:125-128 — _MODULE_INFO dict entry
# (G4.3/G4.4 R3R4-B1 carry: 실제 shape 는 dict, NOT ModuleInfo 객체. value 도 nested dict.)
# Before (G4.4):
"scene_detail_composer": {
    "prompt_dependency": "scene_detail/v19",
    "updated_at": "2026-05-05",
},

# After (G4.5a):
"scene_detail_composer": {
    "prompt_dependency": "scene_detail/v20",  # major suffix only — NOT "scene_detail/20.<timestamp>" (Trap #5)
    "updated_at": "2026-05-05",
},
```

**주의 (G4.3/G4.4 R3R4-B1 carry — Trap #5):**
- `_MODULE_INFO` 는 plain dict 리터럴 (`backend/app/core/version_registry.py:43-145`). `ModuleInfo(...)` constructor 는 존재하지 않음 — attribute access (`.prompt_dependency`) 사용 시 NameError. 반드시 dict access (`["prompt_dependency"]`) 사용.
- `prompt_dependency` 값 = `"scene_detail/v20"` (major suffix only). 기존 alignment test 가 `pdep.endswith(f"v{latest_major}")` 검증하므로 timestamp 형식 (`"scene_detail/20.<timestamp>"`) 사용 시 alignment fail. `version_registry.py:106 "shot_extract/v11"` / `:110 "shot_validator/v3"` 등 기존 entry 모두 major-only suffix.
- 본 변경 누락 시 `test_scene_detail_spatial_alignment.py` alignment test 4-point sync row 가 fail.
- Task 5.3 alignment test 가 본 변경 검증 (Wave 4 에서).
- `SCENE_DETAIL_PROMPT_VERSION` (Task 3.1) 의 timestamp 부분과 본 `prompt_dependency` 값은 **별도 source** — 전자는 `"20.<timestamp>"` (디렉토리 매칭), 후자는 `"scene_detail/v20"` (major suffix). 혼동 금지.
- silent fallback 제거 (Trap #13 carry — G4.3 R5-I2 / G4.4 carry): alignment test 의 `MODULE_VERSIONS.get(..., "")` / `_MODULE_INFO.get(..., {})` 패턴 절대 금지 — 명시적 키 존재 + 타입 검증.

---


## Phase 4 (Wave 3): canary scripts 5종

**Phase 목표:** `scripts/canary/` 에 5개 측정 스크립트 작성 (4 metric + 1 token). 4 metric scripts (Task 4.1-4.4) 는 `compute_render_strategy_snapshot_hash` import 후 CP-pinning JSON 에 hash 기록. **Task 4.5 token script 는 CP-free** — system.md 토큰만 측정하므로 hash helper 미import (PR4-B4 carry). 각 스크립트는 module-level constants import + sentinel pre-pass + pinned-tuple post-loop validation + algorithm-specific proximity window + gate fail → exit 1 (PR4-B7 — STRICT 0).

**Algorithm-specific proximity window (Plan-R3-R-fix-iter-1 PR-fix-iter-1-3 disambiguation — RO-8 ±20 tokens applies ONLY to primary_framing.py same-character-ID detection):**
- `g4_5a_primary_framing.py`: **±20 tokens** (RO-8 same-character-ID nearest-character)
- `g4_5a_camera_frame_consistency.py`: **±50 chars** (G4.4 carry — camera position vs frame edge contradiction)
- `g4_5a_fg_bg_shared_anchor.py`: **per-shot scope** (no proximity window — shared anchor presence check across the whole shot prompt)
- `g4_5a_view_mixing_extension.py`: **±20 tokens** (G4.4 view_mixing carry)
- `g4_5a_token_count.py`: **CP-free** (no proximity)

**스크립트 5종 (PR4-B12 정확 명):**
- Task 4.1 `g4_5a_camera_frame_consistency.py` — camera vertical token shots only, frame-edge surface mismatch detection (Rule F violations, ALL shot scope)
- Task 4.2 `g4_5a_fg_bg_shared_anchor.py` — fg/bg + interaction shots only, shared anchor missing detection (Rule G violations, fg/bg interaction shot scope)
- Task 4.3 `g4_5a_primary_framing.py` — close-framing shot scope (production `_CLOSE_FRAMING_RE` 9-entry alignment, RO-5), RO-8 same-character-ID detection algorithm (Rule J violations)
- Task 4.4 `g4_5a_view_mixing_extension.py` — G4.4 view_mixing carry baseline 회귀 0 + Rule J extension (PR4-B12)
- Task 4.5 `g4_5a_token_count.py` — system.md tiktoken cl100k_base v19 vs v20 delta + system+inject 합산 (RO-1 secondary report-only). CP-free, signed gate ≤ -1000.

**공통 패턴 (G4.2 + G4.3 + G4.4 carry — silent-absorb 차단, Trap #1 / PR4-B1):**
- **CP shape**: `scene["render_prompt_card"]["render_strategy"]` (NOT 평면 `scene["render_strategy"]`). G4.2 함정 carry — `detail_steps.py:2161` authoritative (G4.3 R3-M1 / G4.4 carry). **Trap #3**.
- **silent absorb 금지** (Trap #1 / PR4-B1): `or [] / or {} / .get(key, default)` 패턴 제거. `data.scenes` / `t2i_variations` / `t2i_prompt` 누락은 `measurement_failures` list 로 기록 후 exit 1.
- **pinned tuple seen_in_cp post-loop validation**: pinning 의 `(si, shi)` 가 CP 에 실제 존재했는지 enumerate 후 검증. 없으면 false STRICT pass 차단 (Trap #4).
- **t2i_variations + per-variation t2i_prompt validation**: variation 누락 / variation 내부 t2i_prompt 누락 모두 measurement_failures.
- **module-level constants import**: 각 metric script 가 `from app.core.steps.render_prompt_card import _SPATIAL_CAMERA_LOW_TOKENS, _SPATIAL_FRAME_EDGE_POSITION_TOKENS, _SPATIAL_FG_BG_SEPARATION_TOKENS, _SPATIAL_INTERACTION_VERBS, _SPATIAL_SHARED_ANCHOR_KEYWORDS, _SPATIAL_FRAMING_CLOSE_KEYWORDS, _ID_BODY_PART_TRIGGERS, compute_render_strategy_snapshot_hash` (Trap #8).
- **algorithm-specific proximity window (Plan-R3-R-fix-iter-1 PR-fix-iter-1-3 — RO-8 ±20 tokens applies ONLY to `g4_5a_primary_framing.py` same-character-ID detection algorithm; `g4_5a_camera_frame_consistency.py` uses ±50 chars per G4.4 carry; `g4_5a_fg_bg_shared_anchor.py` uses per-shot scope; `g4_5a_view_mixing_extension.py` uses ±20 tokens; `g4_5a_token_count.py` is CP-free)**. Sentence regex 사용 금지. `feedback_no_regex_postprocessing.md` carry.
- **STRICT 0 gate (PR4-B7)**: RO-8 violation = exit 1 on any violation. NOT soft warning. baseline 비0 이어도 candidate 0 강제 (G4.2 R1-I4 / G4.3/G4.4 carry / Override O-16).
- **Override O-17 / O-35 carry — regex brittleness**: regex 1차 detector. baseline 비0 시 P1 follow-up 으로 LLM-validator (gpt-5.4-mini judge) 전환 검토. brittle pattern: `g4_5a_camera_frame_consistency.py` (camera height token list) + `g4_5a_fg_bg_shared_anchor.py` (anchor keyword list). false-positive 발견 시 LLM-validator 전환.

**공통 preflight helper (G4.3 R3R4-B2 / G4.4 carry — 4 metric script 모두 적용):**

```python
# 모든 4 metric script 가 metric 합산 전 본 helper 호출. silent absorb 차단 (Trap #1 / PR4-B1).

def _load_scenes_or_fail(cp: dict, measurement_failures: list) -> list | None:
    # silent-fallback ban (Trap #1, PR4-B1) — explicit key existence + type validation.
    if "data" not in cp:
        measurement_failures.append("cp.data key missing")
        return None
    data = cp["data"]
    if not isinstance(data, dict):
        measurement_failures.append("cp.data not dict")
        return None
    if "scenes" not in data:
        measurement_failures.append("cp.data.scenes key missing")
        return None
    scenes = data["scenes"]
    if not isinstance(scenes, list):
        measurement_failures.append("cp.data.scenes not list")
        return None
    return scenes


def _validate_scene_preflight(
    scene: dict, si: int, shi: int, measurement_failures: list
) -> list | None:
    # silent-fallback ban (Trap #1, PR4-B1) — explicit key existence + type validation.
    if "render_prompt_card" not in scene:
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card key missing"
        )
        return None
    rpc = scene["render_prompt_card"]
    if not isinstance(rpc, dict):
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card not dict"
        )
        return None
    if "render_strategy" not in rpc:
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card.render_strategy key missing"
        )
        return None
    rs = rpc["render_strategy"]
    if not isinstance(rs, dict):
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card.render_strategy not dict"
        )
        return None
    if "spatial_consistency" not in rs:
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card.render_strategy.spatial_consistency key missing"
        )
        return None
    # snapshot hash 산출 — 실패 시 measurement_failures (silent skip 금지)
    try:
        compute_render_strategy_snapshot_hash(rpc)
    except Exception as e:
        measurement_failures.append(
            f"s{si}_sh{shi}: render_strategy hash failed: {e}"
        )
        return None
    # silent-fallback ban (Trap #1, PR4-B1) — explicit key existence + type +
    # non-empty validation (missing OR empty must fail, never silently absorbed).
    if "t2i_variations" not in scene:
        measurement_failures.append(
            f"s{si}_sh{shi}: t2i_variations key missing"
        )
        return None
    variations = scene["t2i_variations"]
    if not isinstance(variations, list):
        measurement_failures.append(
            f"s{si}_sh{shi}: t2i_variations not list"
        )
        return None
    if not variations:
        measurement_failures.append(
            f"s{si}_sh{shi}: t2i_variations empty"
        )
        return None
    return variations
```

**주의 (G4.3 R3R4-B2 / G4.4 carry + PR4-B1 strict):**
- silent-absorb 패턴 (`scene.get("t2i_variations", [])` / `rs.get("spatial_consistency", {})` 등) **절대 금지** — Trap #1 / PR4-B1 violation. `_validate_scene_preflight()` helper + per-Task explicit key existence + type + (where applicable) non-empty validation 으로 fail-fast.
- `t2i_variations` 누락 OR 빈 list 모두 `measurement_failures` 누적 (R3-B1 carry — empty must fail).
- token script (Task 4.5) 는 CP 가 아니라 prompt 파일을 읽으므로 본 helper 미적용 (PR4-B4 carry — CP-free script). prompt 파일 존재 / version 매칭 / pinning validation 별도 적용. `compute_render_strategy_snapshot_hash` 미import.
- gate fail 시 모든 4 metric script + token script 명시적 `sys.exit(1)`.
- 모든 5 canary script 의 출력 JSON schema 에 `measurement_failures` field (배열) 필수 — silent absorb 차단.
- canary DRY refactor (`scripts/canary/_canary_common.py` helper module 신설) = G4.5c 또는 P1 follow-up.

---

### Task 4.1 — `g4_5a_camera_frame_consistency.py` (camera vertical shot only)

- [ ] **File:** `scripts/canary/g4_5a_camera_frame_consistency.py` (신규 생성)
- [ ] **Dependency:** Task 1.1 (`_SPATIAL_CAMERA_LOW_TOKENS` / `_SPATIAL_CAMERA_HIGH_TOKENS` / `_SPATIAL_CAMERA_HIP_TOKENS` / `_SPATIAL_FRAME_EDGE_POSITION_TOKENS` 사용) + Task 1.2 (`compute_render_strategy_snapshot_hash`)
- [ ] **Spec cross-reference:** spec §5.1 (camera-frame row) / §5.3 / §5.4 / RO-1 / PR4-B12
- [ ] **Expected diff size:** ~150 lines (신규)

**스크립트 역할:**

주어진 PID + scene_index_list 의 scene_detail CP 를 읽어, **camera_direction 안 vertical 위치 token 1+ 존재 shot 만** scan, camera height token + frame-edge position token 동시 등장 + **모순 패턴** (low + chest height / overhead + floor below feet 등) 검출.

**핵심 로직:**
```python
import json
import re
import sys
from pathlib import Path

from app.core.steps.render_prompt_card import (
    _SPATIAL_CAMERA_LOW_TOKENS,
    _SPATIAL_CAMERA_HIGH_TOKENS,
    _SPATIAL_CAMERA_HIP_TOKENS,
    _SPATIAL_FRAME_EDGE_POSITION_TOKENS,
    compute_render_strategy_snapshot_hash,
)

WINDOW_CHARS = 50  # ±50 chars window for proximity (sentence regex 금지)


def _has_any_token(text: str, tokens: tuple) -> bool:
    """case-insensitive substring presence check."""
    text_lower = text.lower()
    return any(t.lower() in text_lower for t in tokens)


measurement_failures: list = []
total_shots_with_camera_vertical = 0
camera_frame_violation_count = 0
camera_frame_violation_per_shot: dict = {}
seen_in_cp: set = set()

cp = json.loads(cp_path.read_text())
scenes = _load_scenes_or_fail(cp, measurement_failures)
if scenes is None:
    print(json.dumps({"measurement_failures": measurement_failures}))
    sys.exit(1)

for scene in scenes:
    if "scene_index" not in scene or "_shot_index" not in scene:
        measurement_failures.append("scene missing scene_index/_shot_index")
        continue
    si = scene["scene_index"]
    shi = scene["_shot_index"]
    if (si, shi) not in pinning_scene_set:
        continue
    seen_in_cp.add((si, shi))
    variations = _validate_scene_preflight(scene, si, shi, measurement_failures)
    if variations is None:
        continue
    rpc = scene["render_prompt_card"]
    rs = rpc["render_strategy"]
    if "camera_direction" not in rs:
        measurement_failures.append(
            f"s{si}_sh{shi}: render_strategy.camera_direction key missing"
        )
        continue
    cam_dir = rs["camera_direction"]
    if not isinstance(cam_dir, str):
        measurement_failures.append(
            f"s{si}_sh{shi}: render_strategy.camera_direction not str"
        )
        continue
    # camera vertical token 1+ 존재 shot 만 분모 (R2-B4 carry)
    has_vert = (
        _has_any_token(cam_dir, _SPATIAL_CAMERA_LOW_TOKENS)
        or _has_any_token(cam_dir, _SPATIAL_CAMERA_HIGH_TOKENS)
        or _has_any_token(cam_dir, _SPATIAL_CAMERA_HIP_TOKENS)
    )
    if not has_vert:
        continue
    total_shots_with_camera_vertical += 1
    shot_count = 0
    for vi, var in enumerate(variations):
        if "t2i_prompt" not in var:
            measurement_failures.append(
                f"s{si}_sh{shi}_v{vi}: t2i_prompt key missing"
            )
            continue
        prompt = var["t2i_prompt"]
        if not isinstance(prompt, str):
            measurement_failures.append(
                f"s{si}_sh{shi}_v{vi}: t2i_prompt not str"
            )
            continue
        prompt_lower = prompt.lower()
        # 모순 패턴 검출:
        # (a) low camera + chest/waist height frame-edge surface
        # (b) overhead + floor below feet
        # (c) hip-height + full silhouette (head + feet 동시)
        if (
            _has_any_token(cam_dir, _SPATIAL_CAMERA_LOW_TOKENS)
            and any(t.lower() in prompt_lower for t in ("chest", "waist", "가슴", "허리"))
        ):
            shot_count += 1
        if (
            _has_any_token(cam_dir, _SPATIAL_CAMERA_HIGH_TOKENS)
            and any(t.lower() in prompt_lower for t in ("floor below", "바닥 아래"))
        ):
            shot_count += 1
        # ... 추가 패턴 (hip + full silhouette) ...
    camera_frame_violation_count += shot_count
    if shot_count > 0:
        camera_frame_violation_per_shot[f"{si}_{shi}"] = shot_count

# pinned tuple seen_in_cp post-loop validation (Trap #4)
missing_in_cp = pinning_scene_set - seen_in_cp
if missing_in_cp:
    measurement_failures.append(
        f"pinning scene_set NOT in CP: {sorted(missing_in_cp)}"
    )

if measurement_failures:
    print(json.dumps({"measurement_failures": measurement_failures}))
    sys.exit(1)

# STRICT 0 gate (PR4-B7)
if camera_frame_violation_count > 0:
    print(json.dumps({
        "metric": "camera_frame_violation_count",
        "candidate": camera_frame_violation_count,
        "gate": "STRICT 0",
        "violations_per_shot": camera_frame_violation_per_shot,
    }))
    sys.exit(1)
```

**출력 JSON schema:**
```json
{
  "timestamp": "ISO8601",
  "prompt_version": "19.202605050814",
  "pinning": {
    "pid": "...",
    "scene_index_list": [],
    "shot_index_list_per_scene": {},
    "model_routing": "gemini-3.1-pro-preview",
    "prompt_source_mode": "file",
    "card_commit_hash": "b1c5032",
    "render_strategy_card_snapshot_hash": "..."
  },
  "scene_set": [
    {"scene_index": 3, "shot_index": 1, "has_camera_vertical_token": true, "has_fg_bg_pattern": false, "has_close_framing": false}
  ],
  "metrics": {
    "total_shots_with_camera_vertical": 0,
    "camera_frame_violation_count": 0,
    "camera_frame_violation_per_shot": {}
  },
  "measurement_failures": [],
  "measurement_scripts": {
    "camera_frame_consistency": "scripts/canary/g4_5a_camera_frame_consistency.py (camera vertical token shots only)"
  }
}
```

**exit criteria threshold (spec §5.1 strict — PR4-B7):**
- **STRICT 0**: `candidate.metrics.camera_frame_violation_count == 0` (baseline 비0 이어도 candidate 0 강제, exit 1 on any violation).
- baseline 비0 이면 P1 follow-up 등록 (G4.5a scope 밖, 별도 추적).

---

### Task 4.2 — `g4_5a_fg_bg_shared_anchor.py` (fg/bg shot only)

- [ ] **File:** `scripts/canary/g4_5a_fg_bg_shared_anchor.py` (신규 생성)
- [ ] **Dependency:** Task 1.1 (`_SPATIAL_FG_BG_SEPARATION_TOKENS` / `_SPATIAL_INTERACTION_VERBS` / `_SPATIAL_SHARED_ANCHOR_KEYWORDS`) + Task 1.2
- [ ] **Spec cross-reference:** spec §5.1 (fg-bg row) / §5.3 / PR4-B12
- [ ] **Expected diff size:** ~155 lines (신규)

**스크립트 역할:**

두 인물 ID (C##/C##O##) 1+ + fg/bg separation token 동시 등장 + 인터랙션 동사 동시 등장 shot 만 scan. `_SPATIAL_SHARED_ANCHOR_KEYWORDS` 부재 시 violation.

**핵심 로직:**
```python
import re
from app.core.steps.render_prompt_card import (
    _SPATIAL_FG_BG_SEPARATION_TOKENS,
    _SPATIAL_INTERACTION_VERBS,
    _SPATIAL_SHARED_ANCHOR_KEYWORDS,
    compute_render_strategy_snapshot_hash,
)

ID_REGEX = re.compile(r"\bC\d{2}(?:O\d{2})?\b")

# 분모: 두 인물 ID + fg/bg + 인터랙션 동시 등장 shot
for var in variations:
    if "t2i_prompt" not in var:
        measurement_failures.append(f"...t2i_prompt key missing")
        continue
    prompt = var["t2i_prompt"]
    if not isinstance(prompt, str):
        measurement_failures.append(f"...t2i_prompt not str")
        continue
    char_ids = set(ID_REGEX.findall(prompt))
    if len(char_ids) < 2:
        continue  # 두 인물 ID 1+ 부재
    if not _has_any_token(prompt, _SPATIAL_FG_BG_SEPARATION_TOKENS):
        continue
    if not _has_any_token(prompt, _SPATIAL_INTERACTION_VERBS):
        continue
    total_shots_with_fg_bg_pattern += 1
    # violation: shared anchor keyword 부재
    if not _has_any_token(prompt, _SPATIAL_SHARED_ANCHOR_KEYWORDS):
        fg_bg_shared_anchor_missing_count += 1
        shot_key = f"{si}_{shi}"
        # PR4-B1 / Plan-R3-R-fix-iter-1 PR-fix-iter-1-1: explicit key check, NOT silent fallback `.get(key, 0)`.
        if shot_key in fg_bg_shared_anchor_missing_per_shot:
            fg_bg_shared_anchor_missing_per_shot[shot_key] += 1
        else:
            fg_bg_shared_anchor_missing_per_shot[shot_key] = 1  # explicit init, not silent fallback
```

**exit criteria threshold (PR4-B7):**
- **STRICT 0**: `candidate.metrics.fg_bg_shared_anchor_missing_count == 0` (exit 1 on any violation).

---

### Task 4.3 — `g4_5a_primary_framing.py` (close-framing shot scope, RO-8 STRICT 0)

- [ ] **File:** `scripts/canary/g4_5a_primary_framing.py` (신규 생성)
- [ ] **Dependency:** Task 1.1 (`_SPATIAL_FRAMING_CLOSE_KEYWORDS` 9-entry production-aligned RO-5 + `_ID_BODY_PART_TRIGGERS` G4.3 carry RO-11) + Task 1.2
- [ ] **Spec cross-reference:** spec §5.1 (primary framing row) / §5.3 / RO-5 / RO-8 / RO-11 / PR4-B7 / PR4-B12
- [ ] **Expected diff size:** ~180 lines (신규 — RO-8 algorithm 구현)

**스크립트 역할:**

close-framing shot scope filter = production `_CLOSE_FRAMING_RE` **9-entry alignment** 재사용 (RO-5 binding — LLM-facing 11-entry list 와 분리). close framing keyword 등장 shot 만 scan, **RO-8 same-character-ID detection algorithm** 적용 + 두 인물 face close-up 동시 등장 + wide/medium framing shot 의 body-part close-up trigger 등장 검출.

**핵심 로직 — RO-8 algorithm:**
```python
import re
from app.core.steps.render_prompt_card import (
    _SPATIAL_FRAMING_CLOSE_KEYWORDS,  # 9-entry production-aligned (RO-5)
    _ID_BODY_PART_TRIGGERS,  # G4.3 carry — direct external import (RO-11)
    compute_render_strategy_snapshot_hash,
)

ID_REGEX = re.compile(r"\bC\d{2}(?:O\d{2})?\b")
WINDOW_TOKENS = 20  # ±20 tokens (RO-8 binding)
FULL_BODY_KEYWORDS = ("stands", "seated", "leaning", "전신")


def _tokenize(text: str) -> list[str]:
    """simple whitespace tokenizer for RO-8 algorithm."""
    return text.split()


def _detect_same_character_id_violations(prompt: str) -> int:
    """RO-8 same-character-ID detection algorithm (PR4-B7 STRICT 0).

    per-shot scan, build dict {character_id: [token_positions]} for all
    C##/C##O## occurrences. For each body_part_close_up_trigger token position,
    find nearest C##/C##O## within ±N=20 tokens — cid_trigger. For each
    full_body_description_keyword position, find nearest character ID — cid_body.
    If cid_trigger == cid_body AND both nearest-character-distance ≤ 20:
    increment violation count.
    """
    tokens = _tokenize(prompt)
    # build dict {cid: [token_pos]}
    cid_positions: dict[str, list[int]] = {}
    for pos, tok in enumerate(tokens):
        m = ID_REGEX.search(tok)
        if m is not None:
            cid = m.group()
            cid_positions.setdefault(cid, []).append(pos)

    if not cid_positions:
        return 0

    def _nearest_cid(target_pos: int) -> tuple[str | None, int]:
        """return (cid, distance) for nearest cid within ±20 tokens."""
        best_cid = None
        best_dist = WINDOW_TOKENS + 1
        for cid, positions in cid_positions.items():
            for cp in positions:
                d = abs(cp - target_pos)
                if d <= WINDOW_TOKENS and d < best_dist:
                    best_cid = cid
                    best_dist = d
        return best_cid, best_dist

    violations = 0
    for pos, tok in enumerate(tokens):
        # body-part close-up trigger detection
        # _ID_BODY_PART_TRIGGERS = ("focus on", "close on", "tight on", "detail on")
        # multi-word triggers — check token + next token
        for trigger in _ID_BODY_PART_TRIGGERS:
            tlen = len(trigger.split())
            if pos + tlen <= len(tokens):
                window_text = " ".join(tokens[pos:pos + tlen]).lower()
                if window_text == trigger.lower():
                    cid_trigger, dist_trigger = _nearest_cid(pos)
                    if cid_trigger is None:
                        continue
                    # find full_body keyword in same shot, get nearest cid
                    for fb_pos, fb_tok in enumerate(tokens):
                        if any(kw in fb_tok.lower() for kw in FULL_BODY_KEYWORDS):
                            cid_body, dist_body = _nearest_cid(fb_pos)
                            if (
                                cid_body is not None
                                and cid_trigger == cid_body
                                and dist_trigger <= WINDOW_TOKENS
                                and dist_body <= WINDOW_TOKENS
                            ):
                                violations += 1
                                break
    return violations


# scope filter: close-framing shot only (RO-5 — production 9-entry alignment)
close_kw_lower = tuple(kw.lower() for kw in _SPATIAL_FRAMING_CLOSE_KEYWORDS)

for var in variations:
    if "t2i_prompt" not in var:
        measurement_failures.append(f"...t2i_prompt key missing")
        continue
    prompt = var["t2i_prompt"]
    if not isinstance(prompt, str):
        measurement_failures.append(f"...t2i_prompt not str")
        continue
    prompt_lower = prompt.lower()
    has_close_framing = any(kw in prompt_lower for kw in close_kw_lower)
    if not has_close_framing:
        continue  # close-framing shot scope filter — out of scope
    total_shots_with_close_framing += 1
    # RO-8 same-character-ID detection
    violations = _detect_same_character_id_violations(prompt)
    primary_framing_violation_count += violations
    if violations > 0:
        primary_framing_violation_per_shot[f"{si}_{shi}"] = violations

# STRICT 0 gate (PR4-B7 — RO-8 binding)
if primary_framing_violation_count > 0:
    print(json.dumps({
        "metric": "primary_framing_violation_count",
        "candidate": primary_framing_violation_count,
        "gate": "STRICT 0",
        "violations_per_shot": primary_framing_violation_per_shot,
    }))
    sys.exit(1)
```

**주의 (RO-5 + RO-8 + RO-11 + PR4-B7 binding):**
- close-framing shot scope filter 는 **production `_SPATIAL_FRAMING_CLOSE_KEYWORDS` 9-entry alignment** 재사용 (RO-5). LLM-facing 11-entry list 와 분리 strict — `손이`/`눈이`/`얼굴이` 등은 canary scope filter 에 포함 안 됨.
- `_ID_BODY_PART_TRIGGERS` 는 G4.3 carry — `from app.core.steps.render_prompt_card import _ID_BODY_PART_TRIGGERS` 외부 import (RO-11 binding — 별칭 도입 금지, in-module reference 는 producer code 측만).
- RO-8 algorithm 정확 구현 — `_detect_same_character_id_violations()` 안 ±20 tokens distance + `cid_trigger == cid_body` + 둘 다 distance ≤ 20.
- **PR4-B7 STRICT 0** — violation 1 이라도 발생 시 exit 1, NOT soft warning.
- canary smoke fixture (Phase 5 Task 5.1 `test_canary_close_framing_filter_uses_production_close_framing_re`) 가 9-entry alignment 검증.

**exit criteria threshold (PR4-B7 binding):**
- **STRICT 0**: `candidate.metrics.primary_framing_violation_count == 0` (exit 1 on any violation).

---

### Task 4.4 — `g4_5a_view_mixing_extension.py` (G4.4 carry baseline 회귀 0 + Rule J extension)

- [ ] **File:** `scripts/canary/g4_5a_view_mixing_extension.py` (신규 생성)
- [ ] **Dependency:** G4.4 `g4_4_view_mixing.py` (b1c5032 carry) + Task 1.2
- [ ] **Spec cross-reference:** spec §5.1 (view-mixing extension row) / §5.3 / PR4-B12
- [ ] **Expected diff size:** ~140 lines (신규)

**스크립트 역할:**

G4.4 view_mixing carry baseline 회귀 0 검증 + Rule J extension. G4.4 의 view_mixing 패턴 (full-body description + body-part close-up trigger 동시 등장) 이 G4.5a v20 에서도 STRICT 0 유지 검증. Rule J extension (close framing 시 두 인물 face close-up 동시 등장) 추가 검출.

**핵심 로직:**
```python
# G4.4 carry pattern — baseline 회귀 0
g4_4_view_mixing_count = _detect_g4_4_view_mixing(prompt)

# G4.5a 신규 extension — Rule J: 두 인물 face close-up 동시 등장
two_face_close_up_count = _detect_two_face_close_up(prompt)

view_mixing_extension_count = g4_4_view_mixing_count + two_face_close_up_count
```

**exit criteria threshold (PR4-B7 binding):**
- **STRICT 0**: `candidate.metrics.view_mixing_extension_count == 0` (G4.4 baseline 회귀 0 + Rule J extension 0).

---

### Task 4.5 — `g4_5a_token_count.py` (CP-free, system.md tiktoken cl100k_base)

- [ ] **File:** `scripts/canary/g4_5a_token_count.py` (신규 생성)
- [ ] **Dependency:** 없음 (CP-free, PR4-B4 carry)
- [ ] **Spec cross-reference:** spec §5.1 (token row) / §5.3 / RO-1 / PR4-B12
- [ ] **Expected diff size:** ~140 lines (신규)

**스크립트 역할:**

`tiktoken cl100k_base` 로 v20 system.md 실측 vs v19 baseline 실측 + system+inject 합산 측정 (RO-1 secondary report-only metric). **CP-free** — `compute_render_strategy_snapshot_hash` 미import (PR4-B4 carry). CLI args = pinning 7 fields (G4.2 plan-R2-I4 carry).

**핵심 로직:**
```python
import tiktoken
import json
import sys
from pathlib import Path


def main(baseline_prompt_path: str, candidate_prompt_path: str) -> int:
    enc = tiktoken.get_encoding("cl100k_base")

    if not Path(baseline_prompt_path).exists():
        print(json.dumps({
            "measurement_failures": [f"baseline prompt not found: {baseline_prompt_path}"]
        }))
        return 1
    if not Path(candidate_prompt_path).exists():
        print(json.dumps({
            "measurement_failures": [f"candidate prompt not found: {candidate_prompt_path}"]
        }))
        return 1

    baseline_text = Path(baseline_prompt_path).read_text()
    candidate_text = Path(candidate_prompt_path).read_text()

    baseline_tokens = len(enc.encode(baseline_text))
    candidate_tokens = len(enc.encode(candidate_text))
    token_delta = candidate_tokens - baseline_tokens

    output = {
        "baseline_prompt_version": Path(baseline_prompt_path).parent.name,
        "candidate_prompt_version": Path(candidate_prompt_path).parent.name,
        "baseline_tokens": baseline_tokens,
        "candidate_tokens": candidate_tokens,
        "token_delta": token_delta,
        "gate_threshold": -1000,  # RO-1 binding
        "gate_pass": token_delta <= -1000,
    }
    print(json.dumps(output, indent=2))

    # signed gate (RO-1 / Trap #6 carry — token_delta = candidate - baseline ≤ -1000)
    if token_delta > -1000:
        print(f"GATE FAIL: token_delta={token_delta} > -1000", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1], sys.argv[2]))
```

**exit criteria threshold (RO-1 binding):**
- **system-only `token_delta = candidate - baseline ≤ -1000`** (1차 estimate-based binding gate, signed convention Trap #6).
- system+inject 합산 (≤ -800) = secondary report-only metric (canary JSON 보조 기록 / no gate).

---


## Phase 5 (Wave 4): tests

**Phase 목표:** ~53 unit tests (RO-12 binding — table 53 enumerate, no buffer required — raised by Plan-R3-R-fix-iter-1 PR-fix-iter-1-2 supersedes earlier "~38-43" estimate) + ~18 integration tests (PR4-B10 binding — body table enumerate, NOT just briefing reference) + alignment 4-point sync 갱신.

**Phase 5 expected test pass delta:** **+~71 신규 G4.5a-specific tests** (~53 unit + ~18 integration), regression 0. G4.4 끝 cumulative ≈ 2179 → G4.5a 추가 후 ≈ 2250 expected (RO-14 / PR4-I3 binding — raised by Plan-R3-R-fix-iter-1 PR-fix-iter-1-2; earlier ≈2230 estimate based on ~38-43 unit tests, now ~53 unit tests canonical).

**Wave subagent fixture trap 회피 (G4.1 + G4.3 R4-M6 + G4.4 carry — Trap #2 / #14)**:
- `_DEFAULT_SENTINEL = object()` fixture pattern (모든 helper / 인자 default 에 적용).
- `_make_full_card()` helper 가 항상 `spatial_consistency` 4 sub-key + 3 sub-rule 채움 (hardcoded `[]` / 누락 fixture 절대 금지).
- 신규 `spatial_consistency` 4 sub-key 는 builder-static 검증 — `if camera_direction:` 또는 `if shot_info:` 류 truthy guard 안 배치 절대 금지.

---

### Task 5.1 — Unit tests (~53 tests, RO-12 binding raised)

- [ ] **File:** `backend/tests/unit/test_g4_5a_spatial_lift.py` (신규 생성)
- [ ] **Dependency:** Phase 1 완료 (Task 1.1-1.6)
- [ ] **Spec cross-reference:** spec §6.1 / RO-3 / RO-5 / RO-6 / RO-8 / RO-9 / RO-12 / PR4-B2 / PR4-B3 / PR4-B7 / PR4-B9 / PR4-I5
- [ ] **Expected diff size:** ~700 lines (~53 tests, RO-12 raised by Plan-R3-R-fix-iter-1)

**Unit test enumeration (~53 tests, RO-12 binding — table 53 enumerate, no buffer required):**

| # | test name | 설명 | assert |
|---|---|---|---|
| 1 | `test_render_strategy_has_spatial_consistency` | `build_render_strategy()` 반환에 `spatial_consistency` dict 존재 + 4 required keys | dict + 4 key assert |
| 2 | `test_spatial_consistency_required_keys_superset` | `spatial_consistency` 의 4 required key superset (camera_frame_rule / fg_bg_shared_anchor_rule / primary_framing_rule / rationale_summary) | `assert set(returned_keys) >= EXPECTED_SET` |
| 3 | `test_camera_frame_rule_required_keys_superset` | `camera_frame_rule` 7 required key (RO-3 binding — applies_when / consistency_check_summary / core_principles / forbidden_combinations / recommended_phrasings / self_check_steps / rationale_summary) superset | superset assert |
| 4 | `test_camera_frame_rule_core_principles_count` | `core_principles` list-len == 4 (RO-3 binding — v19 line 170-175 carry) | len assert |
| 5 | `test_camera_frame_rule_core_principles_substring_low_camera` | `core_principles[0]` 안 `"low camera"` substring 존재 | substring assert |
| 6 | `test_camera_frame_rule_core_principles_substring_overhead` | `core_principles[1]` 안 `"overhead"` substring 존재 | substring assert |
| 7 | `test_camera_frame_rule_core_principles_substring_hip_height` | `core_principles[2]` 안 `"hip-height"` substring 존재 | substring assert |
| 8 | `test_camera_frame_rule_core_principles_substring_full_body` | `core_principles[3]` 안 `"full body"` substring 존재 | substring assert |
| 9 | `test_camera_frame_rule_forbidden_combinations_count` | `forbidden_combinations` list-len == 4 | len assert |
| 10 | `test_camera_frame_rule_recommended_phrasings_count` | `recommended_phrasings` list-len == 4 | len assert |
| 11 | `test_camera_frame_rule_self_check_steps_count` | `self_check_steps` list-len == 4 | len assert |
| 12 | `test_fg_bg_shared_anchor_rule_required_keys_superset` | `fg_bg_shared_anchor_rule` 6 required key superset | superset assert |
| 13 | `test_fg_bg_shared_anchor_rule_forbidden_phrasings_count` | `forbidden_phrasings` list-len == 3 | len assert |
| 14 | `test_fg_bg_shared_anchor_rule_recommended_keywords_count` | `recommended_keywords` list-len == 5 | len assert |
| 15 | `test_fg_bg_shared_anchor_rule_self_check_steps_count` | `self_check_steps` list-len == 4 | len assert |
| 16 | `test_primary_framing_rule_required_keys_superset` | `primary_framing_rule` 6 required key superset | superset assert |
| 17 | `test_primary_framing_rule_framing_scale_keywords_required_subkeys` | `framing_scale_keywords` 3 required sub-key (close / wide / medium_default) superset | superset assert |
| 18 | `test_primary_framing_rule_close_keywords_count` | `framing_scale_keywords.close` list-len == 11 (RO-5 binding — LLM-facing 11-entry list) | len assert |
| 19 | `test_primary_framing_rule_wide_keywords_count` | `framing_scale_keywords.wide` list-len == 5 | len assert |
| 20 | `test_primary_framing_rule_close_framing_rules_required_subkeys` | `close_framing_rules` 4 required sub-key (RO-6 binding — primary_subject_only_fully_visible / other_entities_appearance_options / forbidden_combinations / `body_part_focus_cross_ref`) superset | superset assert |
| 21 | `test_primary_framing_rule_close_framing_other_entities_options_count` | `close_framing_rules.other_entities_appearance_options` list-len == 3 | len assert |
| 22 | `test_primary_framing_rule_close_framing_forbidden_combinations_count` | `close_framing_rules.forbidden_combinations` list-len == 2 | len assert |
| 23 | `test_primary_framing_rule_close_body_part_cross_ref_paired` | `close_framing_rules.body_part_focus_cross_ref` (string) paired-substring `"see id_policy.body_part_focus_rule"` 포함 (RO-6 binding) | substring assert |
| 24 | `test_primary_framing_rule_wide_medium_rules_required_subkeys` | `wide_medium_rules` 4 required sub-key (RO-6 binding) superset | superset assert |
| 25 | `test_primary_framing_rule_wide_medium_body_part_cross_ref_paired` | `wide_medium_rules.body_part_focus_cross_ref` paired-substring `"see id_policy.body_part_focus_rule"` 포함 (RO-6 binding) | substring assert |
| 26 | `test_primary_framing_rule_self_check_steps_count` | `self_check_steps` list-len == 4 | len assert |
| 27 | `test_render_strategy_constraints_grew_to_five_strings` | G4.4 base 2 strings → G4.5a 5 strings (1 still moment + 1 view-mix + 1 camera frame + 1 fg/bg anchor + 1 framing scale) | `len(constraints) == 5` |
| 28 | `test_render_strategy_constraints_string_1_still_moment_carry` | constraints[0] == G4.4 carry verbatim string | string equality |
| 29 | `test_render_strategy_constraints_string_2_view_mix_carry` | constraints[1] == G4.4 carry verbatim string | string equality |
| 30 | `test_render_strategy_constraints_string_3_camera_frame_inline` | constraints[2] 안 `"camera position and frame-edge"` + `"render_strategy.spatial_consistency.camera_frame_rule"` substring | 2 substring assert |
| 31 | `test_render_strategy_constraints_string_4_fg_bg_anchor_inline` | constraints[3] 안 `"fg/bg separation"` + `"render_strategy.spatial_consistency.fg_bg_shared_anchor_rule"` substring | 2 substring assert |
| 32 | `test_render_strategy_constraints_string_5_framing_scale_inline` | constraints[4] 안 `"framing_scale binds entity rendering"` + `"render_strategy.spatial_consistency.primary_framing_rule"` substring | 2 substring assert |
| 33 | `test_spatial_consistency_4_subfields_independent_of_input` | hardcoded `staging=None` (with `staging_not_applicable=True`) 입력에도 4 sub-field 모두 builder-static 출력 (Trap #14 — Wave subagent hardcoded `[]` fixture trap 회피, G4.1+G4.3 R4-M6 carry) | 모든 sub-field deep equality vs ground-truth |
| 34 | `test_card_metadata_lift_status_includes_spatial_keys` | `_card_metadata.lift_status` 에 4 신규 key (`spatial_camera_frame_lifted` / `spatial_fg_bg_shared_anchor_lifted` / `spatial_primary_framing_lifted` / `spatial_constraints_extended`) 모두 존재 | 4 key assert |
| 35 | `test_card_metadata_rule_source_includes_spatial_keys` | `_card_metadata.rule_source` 에 3 신규 key (`camera_frame_rule` / `fg_bg_shared_anchor_rule` / `primary_framing_rule`) 존재 | 3 key assert |
| 36 | `test_card_metadata_excluded_from_hash_after_g4_5a_keys_added` | G4.2 R1-B1 + G4.3/G4.4 carry — 신규 4 lift_status key 추가에도 hash 변경 0 (`_card_metadata` strip) | hash_before == hash_after |
| 37 | `test_card_hash_drifts_on_spatial_consistency_change` | `render_strategy.spatial_consistency.camera_frame_rule.forbidden_combinations[0]` 문자열 1 char 변경 → hash drift | hash_A != hash_B |
| 38 | `test_card_hash_stable_on_spatial_lift_status_only_change` | `_card_metadata.lift_status.spatial_camera_frame_lifted` 만 toggle → hash 동일 | hash_A == hash_B |
| 39 | `test_assert_render_strategy_shape_validates_spatial_consistency_shape` | `_assert_render_strategy_shape()` 가 `spatial_consistency` 누락 시 AppError | pytest.raises |
| 40 | `test_assert_spatial_consistency_shape_validates_camera_frame_rule_shape` | nested helper 가 `camera_frame_rule` 누락 또는 7 sub-key (incl core_principles, RO-3) 미충족 시 AppError | pytest.raises |
| 41 | `test_assert_spatial_consistency_shape_validates_fg_bg_shared_anchor_rule_shape` | nested helper 가 `fg_bg_shared_anchor_rule` 누락 또는 6 sub-key 미충족 시 AppError | pytest.raises |
| 42 | `test_assert_spatial_consistency_shape_validates_primary_framing_rule_shape` | nested helper 가 `primary_framing_rule` 누락 또는 6 sub-key 미충족 시 AppError | pytest.raises |
| 43 | `test_assert_primary_framing_rule_shape_validates_framing_scale_keywords` | further nested helper (RO-9 / PR4-I5 binding — nested call chain) 가 `framing_scale_keywords` 3 sub-key (close/wide/medium_default) 미충족 시 AppError | pytest.raises |
| 44 | `test_assert_primary_framing_rule_shape_validates_close_framing_rules` | further nested helper 가 `close_framing_rules` 4 sub-key (incl body_part_focus_cross_ref, RO-6) 미충족 시 AppError | pytest.raises |
| 45 | `test_assert_primary_framing_rule_shape_validates_wide_medium_rules` | further nested helper 가 `wide_medium_rules` 4 sub-key (incl body_part_focus_cross_ref, RO-6) 미충족 시 AppError | pytest.raises |
| 46 | `test_assert_primary_framing_rule_shape_validates_cross_ref_strings` | further nested helper 가 close + wide_medium body_part_focus_cross_ref 둘 다 paired-substring 누락 시 AppError | pytest.raises |
| 47 | `test_g4_5a_canary_camera_frame_violation_filter_module_constants` | canary fixture 가 module-level `_SPATIAL_CAMERA_LOW_TOKENS` 와 `_SPATIAL_FRAME_EDGE_POSITION_TOKENS` 동시 등장 패턴 정확히 검출 (false-positive 0) | filter behavior assert |
| 48 | `test_g4_5a_canary_fg_bg_anchor_filter_module_constants` | canary fixture 가 fg/bg + 인터랙션 + shared anchor 부재 패턴 정확 검출 (false-positive 0) | filter behavior assert |
| 49 | `test_g4_5a_canary_primary_framing_filter_close_keywords_RO8` | RO-8 same-character-ID detection algorithm fixture 3 cases — (a) same-id co-occurrence → violation, (b) different-id → no violation, (c) > 20 tokens distance → no violation. PR4-B7 STRICT 0. | filter behavior + RO-8 algorithm assert |
| 50 | `test_canary_close_framing_filter_uses_production_close_framing_re` | RO-5 binding — `g4_5a_primary_framing.py` 의 close-framing shot scope filter 가 production `_SPATIAL_FRAMING_CLOSE_KEYWORDS` 9-entry alignment 재사용. fixture: `손이`/`눈이`/`얼굴이` 등 LLM-facing keyword 가 canary scope filter 에 포함 안 됨 (production scope filter 정확) | filter import + 9-entry alignment assert |
| 51 | `test_render_strategy_camera_direction_none_raises` | `staging=None` + `shot_info` 에 `staging_not_applicable=False` (default) → AppError (G4.1 R1-I1 carry — silent fallback ban) | pytest.raises |
| 52 | `test_render_strategy_camera_direction_empty_string_valid_with_static_subfields` | `staging` 의 `camera_direction=""` (explicit empty) → 4 sub-field 모두 present (builder-static 보장) | shape valid |
| 53 | `test_render_strategy_view_consistency_mixing_forbidden_is_bool_true` | (G4.4 carry regression) view_consistency 의 `mixing_forbidden` 이 `True` (bool, NOT bool int subclass) | identity + type assert (G4.4 carry — G4.5a 변경 0 검증) |

총 unit tests: **~53 enumerate** (table 53 enumerate, no buffer required — RO-12 binding raised by Plan-R3-R-fix-iter-1 PR-fix-iter-1-2). 본 표 는 53 row 모두 enumerate (38 base + canary smoke + alignment regression + RO-9 nested helper 4 + RO-8 algorithm fixture 3 + bool int subclass carry 1 + 추가 검증 row) — body table 이 already enumerate-rich, buffer 불필요. **PR4-I3 binding**: G4.4 76 → G4.5a +71 ≈ 147 G4.5a-specific tests target (~53 unit + ~18 integration).

**Wave subagent 함정 회피 (Trap #2 / #14)**:
- 모든 fixture default arg 에 `_DEFAULT_SENTINEL = object()` 사용. None / [] / default 셋 명시 구분.
- `_make_full_card()` helper 가 항상 spatial_consistency 4 sub-key 채움 — hardcoded `{}` / 누락 fixture 절대 금지.
- 신규 sub-field 가 builder-static — `if shot_info:` truthy guard 안 배치 절대 금지 (test 33 강제).

---

### Task 5.2 — Integration tests (~18 tests, PR4-B10 binding)

- [ ] **File:** `backend/tests/integration/test_g4_5a_spatial_integration.py` (신규 생성)
- [ ] **Dependency:** Phase 1 / 2 / 3 완료
- [ ] **Spec cross-reference:** spec §6.2 / RO-2 / RO-6 / PR4-B10
- [ ] **Expected diff size:** ~400 lines (~18 tests)

**Integration test enumeration (~18 tests, PR4-B10 binding — body table enumerate, NOT just briefing reference):**

| # | test name | 설명 | assert |
|---|---|---|---|
| 1 | `test_v20_system_prompt_missing_camera_frame_section` | v20 system.md 에 `"## 카메라 위치와 frame edge의 물리 일관성 (Rule F)"` heading 없음 | heading not present |
| 2 | `test_v20_system_prompt_missing_fg_bg_separation_section` | v20 system.md 에 `"## fg/bg 인물 분리 시 공유 공간 명시 (Rule G)"` heading 없음 | heading not present |
| 3 | `test_v20_system_prompt_missing_primary_subject_framing_section` | v20 system.md 에 `"## Primary Subject Framing — 한 shot = 한 framing scale (Rule J)"` heading 없음 | heading not present |
| 4 | `test_v20_system_prompt_has_spatial_consistency_section` | v20 system.md 에 `"## Spatial consistency"` heading 존재 | heading present |
| 5 | `test_v20_system_prompt_keeps_id_policy_section` | v20 system.md 에 G4.3 의 `"## ID Policy"` heading 여전히 존재 | heading present |
| 6 | `test_v20_system_prompt_keeps_background_binding_section` | v20 system.md 에 G4.2 의 `"## Background Binding"` heading 여전히 존재 | heading present |
| 7 | `test_v20_system_prompt_keeps_continuity_section` | v20 system.md 에 G4.4 의 `"## Continuity"` heading 여전히 존재 | heading present |
| 8 | `test_v20_prompt_token_reduction_vs_v19` | v20 system.md 줄 수 < v19 baseline 534 lines - 32 (compact section gate RO-2) - 보수 buffer 5 = **497 lines 미만** (G4.4 O-14 carry derivation) | `len(v20_lines) < 497` |
| 9 | `test_v20_spatial_consistency_section_line_count_under_32` | RO-2 binding — v20 system.md 의 `## Spatial consistency` heading 부터 다음 `##` heading 사이 lines `wc -l` ≤ 32 (bullet 1 line 강제, wrap 줄도 카운트) | `assert spatial_section_line_count <= 32` |
| 10 | `test_spatial_consistency_compact_section_references_card_path` | v20 system.md 의 Spatial consistency compact section 안 `"render_strategy.spatial_consistency"` substring 1+ 등장 | substring present |
| 11 | `test_cross_card_paired_substring_in_v20_system_md_and_card_inject` | RO-6 binding — `"see id_policy.body_part_focus_rule"` paired substring 이 (a) v20 system.md "Spatial consistency" compact section 안 1+ 등장 + (b) card inject `close_framing_rules.body_part_focus_cross_ref` + (c) `wide_medium_rules.body_part_focus_cross_ref` 모두 동일 substring 포함 — 3-way assertion (G4.4 `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` reuse, Override O-31 carry) | 3-way substring assertion |
| 12 | `test_render_strategy_card_carries_spatial_consistency` | `_collect_card_inputs()` + `build_render_prompt_card()` + inject 경로 통해 user_prompt 에 `render_strategy.spatial_consistency` dict 포함 확인 | JSON parse → dict valid |
| 13 | `test_camera_frame_rule_card_inject_carries_forbidden_combinations` | inject 된 user_prompt 에 `camera_frame_rule.forbidden_combinations` 4 entry 모두 substring 존재 | 4 substring in injected JSON |
| 14 | `test_fg_bg_shared_anchor_rule_card_inject_carries_keywords` | inject 된 user_prompt 에 `fg_bg_shared_anchor_rule.recommended_keywords` 5 entry 모두 substring 존재 | 5 substring in injected JSON |
| 15 | `test_primary_framing_rule_card_inject_carries_close_keywords` | inject 된 user_prompt 에 `primary_framing_rule.framing_scale_keywords.close` 11 entry 모두 substring 존재 (RO-5 LLM-facing 11-entry) | 11 substring in injected JSON |
| 16 | `test_card_hash_drift_on_spatial_consistency_change_in_verify` | spatial_consistency.camera_frame_rule.forbidden_combinations 변경 후 `verify_completion()` 실행 → hash drift detected → status partial | verify marks partial |
| 17 | `test_v19_checkpoint_resume_on_v20_escalates_force` | G4.1 carry — v19 cp (schema_version=7) 를 v20 consumer 로 resume → `_config_hash` mismatch → `mode="force"` escalate | step_runner.escalate observed |
| 18 | `test_g3_2_sentinel_coexists_with_g4_5a_spatial` | 임의 shot → card `render_strategy.spatial_consistency` 신규 4 sub-key + variation-level `owned_validation` sentinel + `background_binding.constraints` + `id_policy` 4 신규 sub-field + `continuity_elements_used` 3 sibling policy dicts 모두 CP 에 존재 | five areas present in CP dict |
| 19 | `test_4_way_alignment_post_g4_5a_bump` | alignment test runner — v20 prompt dir + detail_steps.py:105 + version_registry.py:34 + version_registry.py:126 4 곳 lockstep 갱신 검증 | 4 locations match |
| 20 | `test_envelope_top_level_keys_unchanged_post_g4_5a` | 5-field invariant — envelope top-level 7 fields (`schema_version` / `shot_key` / 5 semantic) 변경 0 (사용자 binding invariant — 새 top-level field 도입 절대 금지) | envelope keys equality |

총 integration tests: **~18-20** (PR4-B10 binding — body table enumerate, NOT just briefing reference). RO-2 line count test + RO-6 cross-card paired test + G4.1 carry resume escalation + G3.2 sentinel coexist + 5-field invariant 모두 본 표 enumerate.

**`test_v19_checkpoint_resume_on_v20_escalates_force` (G4.2 + G4.3 + G4.4 carry)**: G4.4 의 v18→v19 escalation test 와 같은 escalate path 가 v19→v20 에 자동 적용됨을 검증. `SCENE_DETAIL_PROMPT_VERSION` 변경이 `_config_hash` 에 reflect 되므로 step_runner 자동 force escalate.

---

### Task 5.3 — Alignment test (4-way sync)

- [ ] **File:** `backend/tests/prompts/test_scene_detail_spatial_alignment.py` (신규 생성)
- [ ] **Dependency:** Phase 2 / 3 완료
- [ ] **Spec cross-reference:** spec §6.4 / Trap #11 / Trap #13
- [ ] **Expected diff size:** ~150 lines (4 alignment tests)

**Alignment test enumeration (4 tests):**

```python
def test_scene_detail_prompt_version_constant_matches_latest():
    """SCENE_DETAIL_PROMPT_VERSION 가 v20 latest 와 일치 (G4.5a 가 v19 → v20 bump)."""
    from backend.app.core.steps.detail_steps import SCENE_DETAIL_PROMPT_VERSION
    # silent fallback 제거 (Trap #13 / G4.3 R5-I2 carry) — 명시적 키 존재 + 타입 검증
    assert isinstance(SCENE_DETAIL_PROMPT_VERSION, str)
    assert SCENE_DETAIL_PROMPT_VERSION.startswith("20.")


def test_scene_detail_schema_version_synced_with_manifest():
    """SCENE_DETAIL_SCHEMA_VERSION 유지 (= 7) — render_strategy.spatial_consistency
    신규 sub-field 는 cp shape 변경 아님 (G4.2 R1-I6 carry)."""
    from backend.app.core.steps.detail_steps import SCENE_DETAIL_SCHEMA_VERSION
    assert SCENE_DETAIL_SCHEMA_VERSION == 7


def test_scene_detail_version_registry_aligned():
    """version_registry._MODULE_INFO['scene_detail_composer'].prompt_dependency 가
    'scene_detail/v20' 로 갱신 (G4.3/G4.4 R3R4-B1 carry: dict access NOT attribute,
    major suffix only — Trap #5)."""
    from backend.app.core.version_registry import _MODULE_INFO
    # silent fallback 제거 (Trap #13) — 명시적 키 존재
    assert "scene_detail_composer" in _MODULE_INFO
    info = _MODULE_INFO["scene_detail_composer"]
    assert isinstance(info, dict)
    assert "prompt_dependency" in info
    pdep = info["prompt_dependency"]
    assert pdep == "scene_detail/v20"  # major suffix only — NOT "scene_detail/20.<timestamp>"


def test_scene_detail_module_versions_aligned():
    """MODULE_VERSIONS["scene_detail_composer"] == "1.20.0" 검증 (silent fallback ban
    — MODULE_VERSIONS.get(..., "") 패턴 금지, 명시적 키 존재 + 타입 검증, G4.3/G4.4
    R5-I2 carry)."""
    from backend.app.core.version_registry import MODULE_VERSIONS
    assert "scene_detail_composer" in MODULE_VERSIONS
    version = MODULE_VERSIONS["scene_detail_composer"]
    assert isinstance(version, str)
    assert version == "1.20.0"
```

**4-way sync constraint (G4.3/G4.4 R3-I2 carry — Trap #11)**: v20 push 시 다음 4 곳 lockstep 갱신 필수:
1. `prompts/_base/scene_detail/20.<timestamp>/system.md` 디렉토리 생성
2. `backend/app/core/steps/detail_steps.py:105` — `SCENE_DETAIL_PROMPT_VERSION = "20.<timestamp>"`
3. `backend/app/core/version_registry.py:34` — `MODULE_VERSIONS["scene_detail_composer"]` minor bump (1.19.0 → 1.20.0)
4. `backend/app/core/version_registry.py:126` — `_MODULE_INFO["scene_detail_composer"]["prompt_dependency"] = "scene_detail/v20"` (major suffix only — Trap #5)

---

### Task 5.4 — Regression suite (RO-14 / PR4-I3 binding — ≈ 2250 expected)

- [ ] **File:** 기존 backend/tests/ 전체
- [ ] **Dependency:** Phase 1-5 모든 task 완료
- [ ] **Spec cross-reference:** spec §6.3 / RO-14 / PR4-I3
- [ ] **Expected diff size:** 0 (실행만)

**변경 내용:**

```bash
pytest backend/tests/ -x --timeout=120
```

기준: G4.4 baseline (≈2179) 대비 0 regressions. G4.5a test 추가 후 total count 증가만 허용. **RO-14 / PR4-I3 binding (test count baseline disambiguation, raised by Plan-R3-R-fix-iter-1 PR-fix-iter-1-2)**: G4.4 끝 cumulative backend pytest count ≈ 2179 (G4.4 76/76 G4.4-specific + 2103 baseline). G4.5a adds ~53 unit + ~18 integration → **≈2250 expected**. (G4.4 시 76/76 G4.4-specific 그대로 유지 — §7.1 carry 변경 없음.)

G3.2 sentinel test suite (`test_g3_2_*.py`) + G4.1 (`test_g4_1_*.py`) + G4.2 (`test_g4_2_*.py`) + G4.3 (`test_g4_3_*.py`) + G4.4 (`test_g4_4_*.py`) 가 G4.5a 이후에도 모두 pass 해야 한다 (render_strategy 외 영역 변경 없음, 그리고 render_strategy 의 G4.1 base 8 sibling shape 도 변경 없음 — `spatial_consistency` 신규만, PR4-B6 binding).

---

## Phase 6 (Wave 5): production canary execution (사용자 단계)

**Phase 목표:** Wave 5 production canary execution. baseline (v19) capture + candidate (v20) capture + 3 metric STRICT 0 + token + line count + pytest = 7 gate 비교 (PR4-B13 binding — 4-step canary procedure per spec §5.3). 사용자 PID 결정 + scene_set 선정 + canary 실행.

**Phase 6 expected test pass delta:** 0 (실행 단계).

**4-step canary procedure (PR4-B13 / PR4-I1 binding — spec §5.3):**
- Step 1: baseline (v19) capture — run all 5 canary scripts, output JSON to `docs/canary/g4_5a_*_baseline_<timestamp>.json`
- Step 2: candidate (v20) capture — same scripts, `docs/canary/g4_5a_*_candidate_<timestamp>.json`
- Step 3: pinning equality check (7 fields: `pid` / `scene_index_list` / `shot_index_list_per_scene` / `model_routing` / `prompt_source_mode` / `card_commit_hash` / `render_strategy_card_snapshot_hash`)
- Step 4: 7-gate judgment (4 STRICT 0 metric + token gate ≤ -1000 + line count ≤ 32 + pytest green)

---

### Task 6.1 — Baseline canary capture (v19)

- [ ] **File:** `docs/canary/g4_5a_*_baseline_<timestamp>.json` (신규 생성)
- [ ] **Dependency:** Phase 1-5 완료 + 사용자 PID 결정 (G4.2/G4.3/G4.4 canary 와 동일 권장)
- [ ] **Spec cross-reference:** spec §5.2 / §5.3 / PR4-B13 / PR4-I1
- [ ] **Expected diff size:** N/A (canary 실행)

**변경 내용:**

```bash
# 1. canary script 가 위 pinning field 모두 capture 후 baseline JSON 작성
# 2. v19 (G4.4) 로 force scene_detail 실행 — 명시된 scene/shot 만 force
# 3. 측정: 4 metric per shot + tiktoken cl100k_base system.md 실측 token count
# 4. 저장: docs/canary/g4_5a_*_baseline_<timestamp>.json (5 script per output)

python scripts/canary/g4_5a_camera_frame_consistency.py \
    --pid <PID> \
    --scene-set <scene_set_json> \
    --output docs/canary/g4_5a_camera_frame_baseline_<ts>.json

python scripts/canary/g4_5a_fg_bg_shared_anchor.py ...
python scripts/canary/g4_5a_primary_framing.py ...
python scripts/canary/g4_5a_view_mixing_extension.py ...
python scripts/canary/g4_5a_token_count.py \
    prompts/_base/scene_detail/19.202605050814/system.md \
    prompts/_base/scene_detail/20.<timestamp>/system.md \
    > docs/canary/g4_5a_token_baseline_<ts>.json
```

**Baseline scene set 요건 (R1+R2 audit 시 정확 수치 정정):**
- camera_direction 안 vertical 위치 token 1+ 존재 shot 최소 5개 (camera-frame metric 분모 보장)
- 두 인물 ID + fg/bg separation token 동시 등장 shot 최소 3개 (fg-bg shared-anchor metric 분모 보장)
- close framing keyword 등장 shot 최소 5개 (primary framing metric 분모 보장)
- 모두 `scene_index_list` / `shot_index_list_per_scene` 에 명시.

candidate 측정도 동일 pinning. 한 field 라도 다르면 비교 reject — baseline 재측정.

---

### Task 6.2 — Candidate canary capture (v20)

- [ ] **File:** `docs/canary/g4_5a_*_candidate_<timestamp>.json` (신규 생성)
- [ ] **Dependency:** Task 6.1 완료 + Phase 2 v20 prompt 활성화 (Phase 3 SCENE_DETAIL_PROMPT_VERSION bump 후)
- [ ] **Spec cross-reference:** spec §5.2 / §5.3 / PR4-B13 / PR4-I1
- [ ] **Expected diff size:** N/A (canary 실행)

**변경 내용:**

```bash
# 새 v20 prompt 적용 + 동일 PID 동일 scene_set force 재실행
# capture → docs/canary/g4_5a_*_candidate_<timestamp>.json
# 동일 JSON schema (`prompt_version: "20.<timestamp>"` 로 변경).

python scripts/canary/g4_5a_camera_frame_consistency.py \
    --pid <PID> \
    --scene-set <scene_set_json> \
    --output docs/canary/g4_5a_camera_frame_candidate_<ts>.json
# ... (4 metric scripts 모두 실행)
python scripts/canary/g4_5a_token_count.py \
    prompts/_base/scene_detail/19.202605050814/system.md \
    prompts/_base/scene_detail/20.<timestamp>/system.md \
    > docs/canary/g4_5a_token_candidate_<ts>.json
```

candidate 의 pinning block 7 field 모두 baseline 과 동일 (Step 3 검증 대상).

---

### Task 6.3 — Pinning equality + 7-gate judgment + 듀얼 리뷰 fix loop

- [ ] **File:** N/A (판정 단계)
- [ ] **Dependency:** Task 6.1 + 6.2 완료
- [ ] **Spec cross-reference:** spec §5.3 Step 3 / Step 4 / PR4-B13 / PR4-I1
- [ ] **Expected diff size:** N/A (판정 + fix loop)

**Step 3 — Pinning equality check (7 fields):**

선행 조건: `pinning` block 7 field 모두 baseline == candidate 일치 확인 (dict equality 한 번. 불일치 시 재측정).

```python
import json
baseline = json.loads(Path("docs/canary/g4_5a_camera_frame_baseline_<ts>.json").read_text())
candidate = json.loads(Path("docs/canary/g4_5a_camera_frame_candidate_<ts>.json").read_text())
assert baseline["pinning"] == candidate["pinning"], "pinning drift — baseline 재측정 필수"
```

**Step 4 — 7-gate judgment (PR4-B13 binding):**

| Gate | metric | threshold | source |
|---|---|---|---|
| 1 | `camera_frame_violation_count` | **STRICT 0** | candidate (PR4-B7) |
| 2 | `fg_bg_shared_anchor_missing_count` | **STRICT 0** | candidate (PR4-B7) |
| 3 | `primary_framing_violation_count` | **STRICT 0** (RO-8 algorithm) | candidate (PR4-B7) |
| 4 | `view_mixing_extension_count` | **STRICT 0** (G4.4 carry baseline 회귀 0 + Rule J extension) | candidate |
| 5 | `system_prompt_token_delta` | **`token_delta = candidate - baseline ≤ -1000`** | system-only (RO-1) |
| 6 | `v20_spatial_consistency_section_line_count` | **`wc -l` ≤ 32 lines strict** | RO-2 |
| 7 | pytest G4.5a test suite | **green, 0 regressions** | Phase 5 Task 5.4 (RO-14 ≈ 2250 expected) |

**판정:**
- 모두 통과 → G4.5a lift 적용 확정, 3 spatial-rules prose v20 삭제 확정 → Phase 7 진입.
- 실패 항목 있음 → constraints sub-field 또는 compact section 문구 수정 후 재canary (3 iter fix loop, G4.2/G4.3/G4.4 carry).
- canary 가 mix 되면 다음 phase (G4.5b) 와 분리 상태 유지 (`_card_metadata.lift_status` 확인으로 구별 가능).

**듀얼 리뷰 3 iter fix loop:**
- Iter 1: Codex (R5) post-impl review — Blocking / Important / Minor 발견 시 fix.
- Iter 2: Claude (R6) post-impl review — 동일.
- Iter 3: 양쪽 결과 통합 + 추가 발견 사항 fix.

---


## Phase 7 (Wave 6): single commit + push + R5/R6 듀얼 리뷰 + fix loop

**Phase 목표:** 4 영역 (render_prompt_card.py + detail_steps.py + version_registry.py + prompts/_base/scene_detail/20.../) + canary scripts 5종 + tests 3 file (unit + integration + alignment) = single commit + push. Codex (R5) + Claude (R6) 듀얼 리뷰 → fix iter 1 → 재push (G4.2/G4.3/G4.4 패턴 — 3 iter dual review fix loop).

**Phase 7 expected test pass delta:** 0 (commit + 수정만).

---

### Task 7.1 — Single commit assemble + push

- [ ] **File:** N/A (git commit + push)
- [ ] **Dependency:** Phase 1-6 모두 완료 + canary 7 gate green
- [ ] **Spec cross-reference:** spec §7.2 step 27 / Override O-19 carry (commit target)
- [ ] **Expected diff size:** N/A (git operation)

**변경 내용:**

```bash
# 변경 파일 모두 stage
git add backend/app/core/steps/render_prompt_card.py
git add backend/app/core/steps/detail_steps.py
git add backend/app/core/version_registry.py
git add prompts/_base/scene_detail/20.<timestamp>/
git add scripts/canary/g4_5a_camera_frame_consistency.py
git add scripts/canary/g4_5a_fg_bg_shared_anchor.py
git add scripts/canary/g4_5a_primary_framing.py
git add scripts/canary/g4_5a_view_mixing_extension.py
git add scripts/canary/g4_5a_token_count.py
git add backend/tests/unit/test_g4_5a_spatial_lift.py
git add backend/tests/integration/test_g4_5a_spatial_integration.py
git add backend/tests/prompts/test_scene_detail_spatial_alignment.py
# spec / plan 도 같이 push (G4.2/G4.3/G4.4 패턴 carry)
git add docs/superpowers/specs/2026-05-05-g4.5a-spatial-rules-lift-design.md
git add docs/superpowers/plans/2026-05-05-g4.5a-spatial-rules-lift-implementation.md

git commit -m "$(cat <<'EOF'
feat(g4.5a): spatial-rules lift — 3 prose 124 lines → render_strategy.spatial_consistency 4 sub-key + v20 prompt

- v19 system.md Rule F (33L) + Rule G (41L) + Rule J (50L) = 124 lines lift
- render_strategy.spatial_consistency 신규 sibling nested dict
  (camera_frame_rule 7 keys / fg_bg_shared_anchor_rule 6 keys /
   primary_framing_rule 6 keys + nested / rationale_summary)
- constraints 2 → 5 strings (G4.4 carry 2 + 3 신규 spatial inline)
- v20 system.md = v19 - 124 lines + ≤ 32-line "Spatial consistency"
  compact section
- 9 module-level constants + nested validator helpers
  (_assert_spatial_consistency_shape + _assert_primary_framing_rule_shape)
- 5 canary scripts (4 metric STRICT 0 + 1 token CP-free signed gate ≤ -1000)
- ~53 unit + ~18 integration + 4 alignment tests (RO-12 raised by Plan-R3-R-fix-iter-1)
- 4-point version sync (prompt dir + detail_steps.py:105 +
  version_registry.py:34 + version_registry.py:126)

Spec: docs/superpowers/specs/2026-05-05-g4.5a-spatial-rules-lift-design.md
Plan: docs/superpowers/plans/2026-05-05-g4.5a-spatial-rules-lift-implementation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

git push origin main
```

**주의 (Trap #19 — Plan agent 출력 truncation):**
- 큰 plan (2500+ lines) 작성 시 agent 출력이 60KB persisted-output cap 에 truncate. 회수: SendMessage 로 missing prefix/suffix 회수 + 병합. 또는 task 분할.

---

### Task 7.2 — Codex (R5) post-impl review

- [ ] **File:** N/A (Codex CLI 실행)
- [ ] **Dependency:** Task 7.1 push 완료
- [ ] **Spec cross-reference:** G4.4 R5 carry / Override O-36 carry / Trap #17
- [ ] **Expected diff size:** N/A (review)

**변경 내용:**

```bash
# Codex review (G4.4 R5 carry pattern — Codex CLI)
# Trap #17 (G4.4 carry): R3 Codex CLI 두 번 BLOCKED 였음. G4.5a R5 도 동일 위험 — fast manual fallback 준비.
# Override O-36 carry: tighter dimension scope (8-10 file reads cap) +
# explicit "emit final block then STOP" 지시.
```

R5 Codex review verdict 분류:
- `APPROVED`: 즉시 push 마무리.
- `APPROVED_WITH_REVISIONS`: Important / Minor row 적용 후 재push (Task 7.4).
- `NEEDS_REVISION`: Blocking row 적용 후 재push (Task 7.4).

---

### Task 7.3 — Claude (R6) post-impl review

- [ ] **File:** N/A (Claude code-reviewer 실행)
- [ ] **Dependency:** Task 7.1 push 완료 (Task 7.2 와 병렬 가능)
- [ ] **Spec cross-reference:** G4.4 R6 carry
- [ ] **Expected diff size:** N/A (review)

**변경 내용:**

Claude code-reviewer 실행 — `Skill code-review` 또는 `Skill code-review:code-review` 사용. R6 verdict 분류 동일 (APPROVED / APPROVED_WITH_REVISIONS / NEEDS_REVISION).

---

### Task 7.4 — Fix iter 1 + 재push

- [ ] **File:** R5/R6 결과에 따른 변경 파일
- [ ] **Dependency:** Task 7.2 + 7.3 완료
- [ ] **Spec cross-reference:** G4.4 fix iter 1 carry
- [ ] **Expected diff size:** ~50-100 lines (R5/R6 발견 사항 fix)

**변경 내용:**

R5 + R6 발견 사항 통합 fix:
- Blocking 우선 적용 (silent fallback / 시나리오 의존 / 새 top-level field 도입 등 절대 규칙 위반).
- Important 적용.
- Minor 시간 허용 시 적용.

```bash
git add <변경 파일들>
git commit -m "$(cat <<'EOF'
fix(g4.5a): R5/R6 dual review iter 1 — <Blocking N + Important M + Minor K> 적용

- <fix 내용 요약>

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"

git push origin main
```

**주의 (G4.4 R3/R5 carry — Trap #17):**
- Codex CLI BLOCKED 시 fast manual fallback — tighter dimension scope (8-10 file reads cap) + explicit "emit final block then STOP" 지시. 그래도 fail 시 manual fallback 빠르게 전환.

---

## Verification Checklist (commit 전)

- [ ] `pytest backend/tests/ -x --timeout=120` — 0 failures, 0 regressions, total ≈ 2250 (RO-14 / PR4-I3 binding).
- [ ] `pytest backend/tests/unit/test_g4_5a_spatial_lift.py -v` — ~53 tests green (RO-12 binding raised by Plan-R3-R-fix-iter-1).
- [ ] `pytest backend/tests/integration/test_g4_5a_spatial_integration.py -v` — ~18 tests green (PR4-B10 binding).
- [ ] `pytest backend/tests/prompts/test_scene_detail_spatial_alignment.py -v` — 4 tests green (Trap #11 / #13).
- [ ] `python scripts/canary/g4_5a_camera_frame_consistency.py --baseline ... --candidate ...` — STRICT 0 (PR4-B7).
- [ ] `python scripts/canary/g4_5a_fg_bg_shared_anchor.py --baseline ... --candidate ...` — STRICT 0 (PR4-B7).
- [ ] `python scripts/canary/g4_5a_primary_framing.py --baseline ... --candidate ...` — STRICT 0 (RO-8 algorithm + PR4-B7).
- [ ] `python scripts/canary/g4_5a_view_mixing_extension.py --baseline ... --candidate ...` — STRICT 0 (G4.4 carry baseline 회귀 0 + Rule J extension).
- [ ] `python scripts/canary/g4_5a_token_count.py prompts/_base/scene_detail/19.../system.md prompts/_base/scene_detail/20.<ts>/system.md` — `token_delta ≤ -1000` (RO-1 binding, signed gate Trap #6).
- [ ] `wc -l prompts/_base/scene_detail/20.<ts>/system.md` — ≤ 423 부근 (v19 534 - 111 ≈ 423).
- [ ] v20 `## Spatial consistency` section line count ≤ 32 (RO-2 binding, Trap #7).
- [ ] `grep -n "## 카메라 위치와 frame edge" prompts/_base/scene_detail/20.<ts>/system.md` — no output (Rule F 삭제).
- [ ] `grep -n "## fg/bg 인물 분리" prompts/_base/scene_detail/20.<ts>/system.md` — no output (Rule G 삭제).
- [ ] `grep -n "## Primary Subject Framing" prompts/_base/scene_detail/20.<ts>/system.md` — no output (Rule J 삭제).
- [ ] `grep -n "## ID Policy" prompts/_base/scene_detail/20.<ts>/system.md` — present (G4.3 carry 보존).
- [ ] `grep -n "## Background Binding" prompts/_base/scene_detail/20.<ts>/system.md` — present (G4.2 carry 보존).
- [ ] `grep -n "## Continuity" prompts/_base/scene_detail/20.<ts>/system.md` — present (G4.4 carry 보존).
- [ ] `grep -n "## Spatial consistency" prompts/_base/scene_detail/20.<ts>/system.md` — present (G4.5a 신규).
- [ ] `grep -n "see id_policy.body_part_focus_rule" prompts/_base/scene_detail/20.<ts>/system.md` — 1+ 등장 (RO-6 paired-string).
- [ ] silent fallback grep (PR4-B1 / PR-fix-iter-1-1 strengthened): `grep -nE 'or \[\]|or \{\}|\.get\([^,]+, *(\[\]|\{\}|0|"|'"'"''"'"'|None|False)\)' backend/app/core/steps/render_prompt_card.py | grep "G4.5a\|spatial"` — 0 결과 (Phase 1 신규 코드 영역). Catches list/dict/int/string/None/bool defaults — only `_DEFAULT_SENTINEL` allowed as default arg.
- [ ] silent fallback grep canary (PR4-B1 / PR-fix-iter-1-1 supplement): `grep -nE '\.get\([^)]*,[^)]*\)' scripts/canary/g4_5a_*.py | grep -v '_DEFAULT_SENTINEL'` — 0 결과 (only `_DEFAULT_SENTINEL` allowed as default).
- [ ] cross-card refs grep (PR4-B3): `grep -n "camera_frame_rule.*close_framing_rules\|camera_frame_rule.*wide_medium_rules" backend/app/core/steps/render_prompt_card.py` — 0 결과.
- [ ] snapshot hash naming grep (PR4-B4): `grep -rn "compute_spatial_consistency_snapshot_hash"` — 0 결과.
- [ ] canary script naming check (PR4-B12): `ls scripts/canary/g4_5a_*.py` — 정확 5 file (camera_frame_consistency / fg_bg_shared_anchor / primary_framing / view_mixing_extension / token_count). NO `canary_g4_5a_*.py` naming.
- [ ] 4-point sync check: `grep -n "20.<timestamp>" backend/app/core/steps/detail_steps.py:105 + version_registry.py:34 + version_registry.py:126` 모두 일치 + `prompts/_base/scene_detail/20.<timestamp>/` 디렉토리 존재.
- [ ] `_card_metadata.lift_status` 4 신규 key 정확 명 (`spatial_camera_frame_lifted` / `spatial_fg_bg_shared_anchor_lifted` / `spatial_primary_framing_lifted` / `spatial_constraints_extended`) — abbrev 형태 사용 안 함 (Trap #14 carry).
- [ ] envelope top-level keys 변경 0 (5-field invariant — `schema_version` / `shot_key` / `render_strategy` / `id_policy` / `background_binding` / `continuity_elements_used` / `asset_requirements`, integration test `test_envelope_top_level_keys_unchanged_post_g4_5a` 강제).
- [ ] 시나리오 의존 0 (Trap #9): plan body / spec / 신규 코드 / unit test / canary 어디에도 작품 고유명사 없음.
- [ ] CLAUDE.md `feedback_no_silent_fallback.md` / `feedback_no_regex_postprocessing.md` / `feedback_t2i_storyboard.md` / `feedback_prompt_versioning.md` 모두 carry.

---

## Risk Register / Rollback

### G4.5a-unique risks

| risk | mitigation | measurable threshold |
|---|---|---|
| **Rule F/G/J 가 visual quality critical (★ 신규 표시) — lift 후 LLM 행동 변화** | 3 metric STRICT 0 + Phase 6 production canary 사용자 단계 (G4.4 carry) + R1+R2 audit 시 ground-truth list 정확 검증 + body-part trigger reuse from G4.3 (RO-11 / PR4-B9) | 4 candidate count == 0 STRICT (PR4-B7) |
| 5-field contract 안정성 — render_strategy 비대 (G4.5b 추가 lift 시 더 비대) | **Option 3 채택 — nested dict `spatial_consistency`** 로 render_strategy top 비대 방지 (G4.4 view_consistency 패턴 carry). G4.5b spatial 외 lift 는 다른 nested dict 또는 다른 field (id_policy 등) 으로 분산 | render_strategy top-level keys count ≤ 9 (mode + 7 sibling + spatial_consistency + constraints) |
| Rule G 매핑 분기 — render_strategy vs background_binding 의 semantic stretch | Option 3 안 통합 결정 (Rule G 가 "두 인물 spatial relationship" 이므로 background 보다 spatial 의미 강함, R1+R2 final) | mapping decision recorded in §2.2 |
| Token gate -1000+ 까지 줄지 검증 필요 (RO-1 binding) | Phase 2 Task 2.3 실측 + behavior canary 동시. 1차 estimate -1665 system-only / -1075 합산 (§3.4). R2 분석: G4.4 estimate→actual realization ≈83% → G4.5a estimate -1665 → actual ~-1380 → **28% margin over -1000 gate, sufficient** | Phase 2 실측 ≤ -1000 system-only |
| **Rule F core_principles 누락 (RO-3 binding)** | v19 line 170-175 의 4 핵심 원칙 (낮은 카메라 / overhead / 허리 / full body) 이 `consistency_check_summary` single string 에 묻혀 LLM 이 sub-rule 으로 따로 인지 못 할 위험 → `core_principles` (4-entry list) 별도 sub-key 로 lift. unit test 4 entries count + 4 substring assertions. required keys 6 → 7. | unit test 5 종 + integration test (camera_frame_rule.core_principles 4-entry inject 검증) |
| **framing_scale enum drift (RO-4 binding)** | `_derive_framing_scale()` producer enum 은 close/medium/insert (production) — wide-class shots 는 medium 으로 매핑. card-only classification 채택 — LLM 은 prose keyword 로 wide 추론 가능하나 reasoning hint only. spec §2.2 line 202 + applies_when + producer matrix + Glossary 모두 동기화 — producer 변경 = G4.5c/follow-up. | producer enum 변경 0 in G4.5a + spec 5 곳 동기화 |
| **Korean close-framing keyword conflict (RO-5 binding)** | card `framing_scale_keywords.close` LLM-facing 11-entry list (`손이`/`눈이`/`얼굴이` 포함) 그대로 carry — LLM reasoning hint. canary `g4_5a_primary_framing.py` close-framing shot scope filter 는 production `_SPATIAL_FRAMING_CLOSE_KEYWORDS` 9-entry alignment 재사용 — LLM hint vs canary detector 분리 strict. | unit test 2 종 + canary 9-entry alignment fixture |
| **Cross-card reference (RO-6 / PR4-B3 binding)** | `primary_framing_rule.close_framing_rules.body_part_focus_cross_ref` + `primary_framing_rule.wide_medium_rules.body_part_focus_cross_ref` 둘 다 paired-string `"see id_policy.body_part_focus_rule"` 포함. **PR4-B3 — cross-card refs 는 `primary_framing_rule` 하위에만, NOT `camera_frame_rule`**. `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` constant 재사용 (G4.4 carry, rename = G4.5c follow-up — RO-13 / PR4-B9 in-module reference). 2 unit tests (close + wide_medium body_part cross_ref paired) + 1 integration test (3-way substring assertion). | unit test 2 종 + integration test 1 종 |
| **same-character-ID detection algorithm (RO-8 / PR4-B7 binding)** | `g4_5a_primary_framing.py` canary 의 algorithm 명시 — per-shot scan, `{character_id: [token_positions]}` dict, body-part trigger 와 full-body keyword 의 nearest character ID 가 동일 + 둘 다 ±20 tokens 이내 → violation. fixture 3 cases. **STRICT 0** (PR4-B7 — exit 1 on any violation, NOT soft warning). Phase 4 Task 4.3 implementer follows verbatim. | unit test fixture 3 cases + canary 구현 algorithm 일치 |
| **`_assert_primary_framing_rule_shape()` separate further nested helper (RO-9 / PR4-I5 binding)** | nested 깊이 3 levels (rule → close_framing_rules → 4 sub-key) 검증을 `_assert_spatial_consistency_shape()` 안 inline 하면 가독성/유지보수 hurt. nested call chain `_assert_render_strategy_shape() → _assert_spatial_consistency_shape() → _assert_primary_framing_rule_shape()` (further nested, NOT sibling — PR4-I5 disambiguation). | further nested helper 도입 + 4 unit tests |
| **R4 reject contract drift (PR4-B1~13)** | plan body iter 1 의 fundamental contract drift (silent fallback 패턴 / spec §2.2 sub-shape 위반 / cross-card refs 위치 / hash naming / module-level constants names / G4.1+G4.4 carry keys 제거 / RO-8 soft warning / numbering drift / self-import / integration test 미enumerate / Phase 3 inline prose 잘못 가정 / canary script naming / Phase 6 procedure 누락 / nested call chain ambiguity) 발견 → plan 전체 rewrite. 본 plan rewrite 가 13 BLOCKING 정정. | self-check grep 4종 (silent fallback / cross-card / hash naming / canary naming) 모두 0 결과 |
| Staged execution coordination — G4.5a 결과가 G4.5b architecture 결정에 영향 | G4.5a spec §7.3 / §7.4 에 G4.5b/c 진입 조건 + scope 명시. spatial_consistency nested dict 패턴 검증 후 G4.5b 의 Rule B/D 매핑 (id_policy 분산 vs 다른 nested dict) 결정. | G4.5a complete 후 G4.5b spec drafting 시 carry traps 19개 + G4.5a-unique pattern 1개 추가 |
| `_SPATIAL_CAMERA_LOW_TOKENS` / `_SPATIAL_FRAME_EDGE_POSITION_TOKENS` ground-truth list 가 actual production prompt 의 vertical 표현 다양성을 cover 하지 않음 | R1+R2 audit 에서 ground-truth list 확정 + Rule F line 148-153 actual prose 의 표현 카탈로그 + Phase 6 production canary 에서 baseline scan time 에 missed phrase 자동 발견 시 list 확장 (P1 follow-up 별도 추적) | actual production canary baseline 에 등장한 vertical token ≥ 90% list cover |
| `_SPATIAL_SHARED_ANCHOR_KEYWORDS` 가 너무 narrow 해 자연스러운 shared anchor 표현 (e.g. `"on the same wooden bench"`) false-negative | R1+R2 audit 에서 keyword pattern 정정. `the same` substring + 가구/공간 명사 (bench/table/sofa/door/room) co-occurrence 도 anchor 로 인정. | unit test fixture 가 `"on the same wooden bench"` 류 anchor 인정 검증 |
| primary framing canary 가 wide framing 의 자연스러운 부분 묘사 (`"hand resting on the table"`) false-positive | regex 가 close framing keyword + same-character ID + body-part close-up trigger 셋 동시 등장 시에만 trigger. 단순 wide 의 hand 언급은 trigger 아님. R1+R2 audit 에서 regex 정정 | unit test fixture 가 wide framing 자연스러운 부분 묘사 false-positive 0 검증 |
| `_card_metadata.lift_status` 신규 4 key 가 hash 에 의도치 않게 포함되어 hash 가 매 build 마다 변경 | G4.2 R1-B1 + G4.3/G4.4 carry: `_card_metadata` 전체 가 `canonicalize_render_prompt_card()` 에서 strip. unit test `test_card_metadata_excluded_from_hash_after_g4_5a_keys_added` 가 강제 | `_card_metadata` not in canonicalized output (post G4.5a key 추가) |
| canary PID 의 camera vertical / fg-bg / close framing shot 부족 시 metric 분모 0 | Phase 6 Task 6.1 baseline scene set 선정 시 camera vertical shot 최소 5 + fg-bg 최소 3 + close framing 최소 5 보장 (§5.2) | scene_set in canary JSON ≥ 5+3+5 분리 |
| canary baseline camera-frame / fg-bg / primary-framing count 가 비0 인 경우 | strict: candidate == 0 강제. baseline 비0 이면 P1 follow-up 으로 별 추적 (G4.5a scope 밖이지만 production 결함 시그널) | candidate count == 0 mandatory; baseline 비0 면 별 issue 등록 |
| Wave subagent fixture 가 신규 nested `spatial_consistency` (4 sub-key + 3 sub-rule × 6 sub-sub-key) 누락한 partial card 사용 → production drift cascade (Trap #14) | `_DEFAULT_SENTINEL = object()` fixture pattern + `_make_full_card()` helper 가 항상 spatial_consistency 4 sub-key + 3 sub-rule 채움 + `_assert_render_strategy_shape()` strict + `_assert_spatial_consistency_shape()` nested helper (G4.4 R2-I5 carry, superset) | unit test `test_assert_*_shape` 7종 |
| nested dict 깊이 (3 sub-rule × 6 sub-sub-key, primary_framing_rule 안 추가 nested 3 sub-key × 3 sub-sub-sub-key) 가 `_assert_spatial_consistency_shape()` 검증 누락 위험 | nested validator helper `_assert_spatial_consistency_shape()` 신설 + `_assert_primary_framing_rule_shape()` further nested helper (G4.4 `_assert_ref_usage_constraints_shape()` 패턴 carry) + 7 unit test (`test_assert_*_shape`) | nested shape AppError on missing sub-key |
| **regex brittleness on 4 spatial canaries** (Override O-17 / O-35 carry) | regex 는 1차 detector 만, baseline 비0 시 P1 follow-up 으로 LLM-validator (gpt-5.4-mini judge) 전환 검토. 의미 판단은 LLM-first (`feedback_no_regex_postprocessing.md`) | false-positive 발견 시 LLM-validator follow-up issue 등록 |
| **사용자 binding invariant 위반 — G4.5a 의 어떤 path 든 신규 top-level field 도입 위험** | spec §1.1 footnote + R1+R2 audit BLOCKING 최우선 + plan drafting 시 Wave 1-A producer 변경이 envelope 7-field 만 변경하는지 강제 검증 | `assert_card_shape()` envelope 7 field 동일 + integration test `test_envelope_top_level_keys_unchanged_post_g4_5a` |

### G4.1+G4.2+G4.3+G4.4 carry / inherited (19 traps)

| inherited risk | carry source | G4.5a enforcement |
|---|---|---|
| **Trap #1** silent fallback `or [] / or {} / .get(key, default)` | G4.2 iter1 C3 / G4.3 R5-B1 / G4.4 carry / Override O-8 / PR4-B1 | G4.5a canary scripts 5종 + builder 모두 동일 pattern carry. 신규 producer/canary 코드 review 시 grep 검사 (Verification Checklist 참조) |
| **Trap #2** `_DEFAULT_SENTINEL = object()` fixture pattern 필수 | G4.1 R3-B2 / G4.2 plan-R1-I4 / G4.3 plan-R1-I4 / G4.4 carry / Override O-2 | G4.5a fixture (None / [] / default 셋 명시 구분) — Phase 5 Task 5.1 강제 |
| **Trap #3** canary CP shape `scene["render_prompt_card"]["render_strategy"]` (NOT 평면) | G4.2 함정 + G4.3 `_validate_scene_preflight()` helper + G4.4 carry | G4.5a 4 metric scripts 모두 동일 helper 사용 — Phase 4 공통 preflight |
| **Trap #4** pinned tuple seen_in_cp post-loop validation + sentinel pre-pass + gate fail → exit 1 | G4.2 iter2 B1 + G4.3/G4.4 carry / Override O-3 / O-4 | G4.5a canary scripts 4 metric 동일 pattern — Phase 4 |
| **Trap #5** `_MODULE_INFO` dict access (NOT attribute) + `prompt_dependency = "scene_detail/v20"` (major suffix only) | G4.3/G4.4 R3R4-B1 / Override O-23 | G4.5a alignment test 동일 — Phase 5 Task 5.3 |
| **Trap #6** token gate signed convention `token_delta = candidate - baseline`, gate `<= -<value>` | G4.3/G4.4 R4-B4 / Override O-25 | Phase 4 Task 4.5 token gate 수치 RO-1 binding `≤ -1000` |
| **Trap #7** compact section line count 정확 (multi-line wrap trap) — bullet 1줄 강제 | G4.3/G4.4 R3R4-B3 / Override O-29 / RO-2 | Phase 2 Task 2.2 compact section 13 lines target, draft 시 line count 자체 점검 |
| **Trap #8** module-level constants single source — builder + canary + unit test 동일 import. drift 차단 | G4.3/G4.4 R1-I2/R2-I6 / Override O-20 | Phase 1 Task 1.1 + Phase 4 canary import + Phase 5 Task 5.1 unit test |
| **Trap #9** 시나리오 의존 0 — 작품 고유명사 절대 금지. 예시 prose 도 generic Asian man / a young woman / a hand 등 | CLAUDE.md 절대 규칙 + `feedback_no_scenario_names.md` | spec §2.2 / §3.3 / §6.1 / §9 모든 예시 generic placeholder 사용 |
| **Trap #10** prompt versioning — NEVER overwrite v19, 새 dir `prompts/_base/scene_detail/20.<TIMESTAMP>/` | CLAUDE.md `feedback_prompt_versioning.md` | Phase 2 Task 2.1-2.4 |
| **Trap #11** 4-point sync lockstep — prompt dir + detail_steps.py:105 + version_registry.py:34 + version_registry.py:126 | G4.3/G4.4 R4-I6 / Override O-21 | Phase 3 + alignment test |
| **Trap #12** `_card_metadata.rule_source` 키 명세 — namespace 분리되어 top-level field 와 동명 OK | G4.3 R5-I1 + G4.4 carry / Override O-27 | Phase 1 Task 1.5 namespace 일관 |
| **Trap #13** alignment test silent fallback 제거 — `MODULE_VERSIONS.get(..., "")` / `_MODULE_INFO.get(..., {})` 금지 — 명시적 키 존재 + 타입 검증 | G4.3 R5-I2 + G4.4 carry / Override O-28 | Phase 5 Task 5.3 4 alignment test |
| **Trap #14** Wave subagent hardcoded `[]` fixture trap — 신규 sub-field 는 builder-static, `if visible_entities:` truthy guard 안 배치 절대 금지 | G4.1 origin / G4.3 R4-M6 + G4.4 carry / Override O-26 | Phase 5 Task 5.1 test 33 (`test_spatial_consistency_4_subfields_independent_of_input`) 강제 |
| **Trap #15** silent-fallback 잔존 carry (G4.4 Override O-8) | `backend/app/core/version_registry.py:148-155` 의 `MODULE_VERSIONS.get(..., "0.0.0")` / `_MODULE_INFO.get(..., {})` 잔존 = G4.1 inheritance, G4.5a scope 외 | G4.5c 또는 별도 cleanup PR carry follow-up. G4.5a 신규 코드 (Wave 1-A producer + canary + tests) 는 새로운 silent-fallback 도입 절대 금지 |
| **Trap #16** `forward_zoom_targets` G4.4 lift coverage 0 (G4.4 Override O-4 Option A) | G4.4 carry — G4.5b/c 에서 재검토 | G4.5a scope 아님 |
| **Trap #17** R3 Codex CLI 두 번 BLOCKED carry (G4.4 진단) | G4.4 audit 단계 R*-Codex BLOCKED 시 fast manual fallback 전환 — tighter dimension scope (8-10 file reads cap) + explicit "emit final block then STOP" 지시 / Override O-36 | G4.5a R*-audit 진행 시 동일 패턴 carry |
| **Trap #18** Phase 4 / Phase 2 Task 번호 drift trap (G4.4 R4-B1/B2) | plan body Task 순서 vs Implementation Checklist Summary vs Override carry 불일치 위험. plan 작성 시 numbering canonical 일치 강제 — drift 발생 시 즉시 patch / Override O-37 / PR4-B8 / PR4-I6 | G4.5a plan rewrite 시 동일 검증 — 본 plan canonical numbering enforce |
| **Trap #19** Plan agent 출력 truncation trap (G4.4 진단) | 큰 plan (2500+ lines) 작성 시 agent 출력이 60KB persisted-output cap 에 truncate. 회수: SendMessage 로 missing prefix/suffix 회수 + 병합. 또는 task 분할 / Override O-38 | G4.5a plan rewrite 시 동일 회수 패턴 carry — 본 plan 직접 file write |

### Rollback

만약 Phase 6 production canary 가 fail 또는 R5/R6 critical Blocking 발견 시 rollback 절차:

```bash
# Rollback to G4.4 (b1c5032 main HEAD)
git revert HEAD
# 또는 specific files revert
git revert -- backend/app/core/steps/render_prompt_card.py \
              backend/app/core/steps/detail_steps.py \
              backend/app/core/version_registry.py
# v20 prompt directory 는 deprecated 마킹 + sealed (NEVER 삭제 — feedback_prompt_versioning.md)
```

v19 cp 가 V20 환경에서 force escalate 되므로 rollback 후 자동 force scene_detail 재실행 (G4.1 carry).

---

## Override Table (G4.2 + G4.3 + G4.4 carry + G4.5a-new RO + Plan-R4-rewrite)

본 표는 G4.2 + G4.3 + G4.4 audit 결과 중 G4.5a 에 동일 적용해야 하는 결정사항. 추가 G4.5a-specific row 는 R1+R2 audit 에서 발견 시 §0 Round Override 표에 추가.

### G4.2 carry (Override O-1 ~ O-19)

| ID | Source | Override | Why |
|----|-------|----------|-----|
| O-1 | G4.2 R2-B4 | builder 의 None vs [] fail-fast (`build_render_strategy()` `staging=None` + `staging_not_applicable=False` → AppError) | silent fallback ban (`feedback_no_silent_fallback.md`) |
| O-2 | G4.2 R1-I12 / G4.3 R3-B2 fixture trap | `_DEFAULT_SENTINEL = object()` fixture pattern (test 측) | fixture default vs None vs [] 구분 — production drift 차단 |
| O-3 | G4.2 R2-I7 / iter2 B1 | canary pinned-tuple seen_in_cp post-loop 검증 | false STRICT pass 차단 — empty `data.scenes` / pinning 1:1 false-pass |
| O-4 | G4.2 iter2 B2 | `t2i_variations` 누락 silent absorb 차단 — `t2i_variations=[]` / missing → measurement_failures | gate fail 누락 차단 |
| O-5 | G4.2 iter3 B3 | variation 내부 `t2i_prompt` 누락 silent absorb 차단 — `var.get("t2i_prompt") or ""` 패턴 금지 | detail_schema.json variation.required = t2i_prompt — strict |
| O-6 | G4.2 iter1 C3 / G4.3 R5-B1 / G4.4 carry | `or [] / or {}` 패턴 전반 차단 — `(d or {})` / `(card or {})` / `(pair or {}).get(...)` 모두 fail-fast (AppError) 강제 | shots / scenes 흡수 → `total_shots=0` false-pass |
| O-7 | G4.2 iter1 C2 | gate fail 시 exit 1 강제 (return 0 금지) | CI 인지 가능 |
| O-8 | G4.2 iter1 I1 | producer-side `_g41_bg_owned = ... or []` 패턴 금지 — None 흡수 차단, builder fail-fast 보장 | builder fail-fast 무력화 차단 |
| O-9 | G4.2 iter1 I2 | hash 회귀 test gap 방지 — `_card_metadata` 변경 / `render_strategy.spatial_consistency` 변경 drift test 의무 | hash drift undetected 차단 |
| O-10 | G4.2 R1-B1 | `_card_metadata` envelope-sibling top-level only — nested 구조 도입 금지. `canonicalize_render_prompt_card()` top-level pop. | hash 격리 contract |
| O-11 | G4.2 R2-I5 | `_card_metadata` free-form (extra-key 허용, schema 검증 없음). assert_card_shape envelope 7 field 만 strict. | 미래 G4.x 신규 sub-key 추가 시 validator 변경 0 |
| O-12 | G4.2 R2-I4 | `detail_steps.py` inject 직전 `_card_metadata` strip 보존 | LLM 입력 token 격리 |
| O-13 | G4.2 R1-I6 | `CARD_SCHEMA_VERSION` 유지 (= 1) — render_strategy.spatial_consistency 신규 sub-field 는 envelope shape 변경 아님. `SCENE_DETAIL_SCHEMA_VERSION` 유지 (= 7) — cp shape 변경 아님. | backward compat |
| O-14 | G4.2 R2-B6 | canary JSON `pid` 가 `pinning` block 안 (top-level redundant 제거) | pinning dict equality 한 번 |
| O-15 | G4.2 R1-M1 | token measurement = `tiktoken cl100k_base` 실측 (line × 15 estimate 는 spec 참조용) | exit criteria 실측 기준 |
| O-16 | G4.2 R1-I4 / R2-B5 | strict 0 metric 은 baseline 비0 이어도 candidate 0 강제 | baseline 비0 → P1 follow-up 별 추적 |
| O-17 | G4.2 plan-R1-I4 | unit test fixture `_make_full_card()` 가 `_DEFAULT_SENTINEL = object()` 적용 — None / [] / default 구분 | G4.1 R3-B2 fixture trap 회피 |
| O-18 | G4.2 plan-R2-I5 | canary owned/sentinel scripts 가 sentinel 합산 전 `assert_owned_sentinel_shape()` 호출 (G4.5a 의 metric scripts 도 sentinel 검증 시 동일 pattern) | silent 0 false-pass 차단 |
| O-19 | G4.2 plan-R2-I6 | commit target 에 `version_registry.py` (4-way sync 일부) 의무 포함 | alignment test green 유지 |

### G4.3 carry (Override O-20 ~ O-28)

| ID | Source | Override | Why |
|----|-------|----------|-----|
| O-20 | G4.3 R3-B1 ground-truth | ground-truth lists single source — Glossary 항목이 §2.2 / §3.3 / §6.1 unit test / canary detector 모두 reference 하는 single source | drift 차단 |
| O-21 | G4.3 R3-I2 4-way sync | v20 push lockstep 4 곳 (prompt 디렉토리 + detail_steps.py:105 + version_registry.py:34 + version_registry.py:126) — alignment test 가 강제 검증 | 4-way drift 차단 |
| O-22 | G4.3 R3-I3 entry gate split | spec drafting / plan / Wave 1-5 = canary 무관 진행 가능. **commit gate** = G4.4 production canary 4 gate AND G4.5a self-canary 7 gate 둘 다 필수. | 작업 차단 회피 + canary 결과의 commit-gate 역할 명확화 |
| O-23 | G4.3 R3R4-B1 | `_MODULE_INFO` dict access (NOT attribute) + `prompt_dependency` 값 = `"scene_detail/v20"` (major suffix only) | alignment test silent fallback 차단 |
| O-24 | G4.3 R3R4-B3 | compact section line count 정확 (multi-line wrap trap) — bullet 1줄 강제 | token estimate 보정 |
| O-25 | G4.3 R4-B4 | token gate signed convention `token_delta = candidate - baseline`, gate `<= -<value>` | 부호 혼동 차단 |
| O-26 | G4.3 R4-M6 / R5-I3 / G4.4 carry | Wave subagent hardcoded `[]` fixture trap — 신규 sub-field 는 builder-static, `if visible_entities:` 또는 `if shot_info:` truthy guard 안 배치 절대 금지 | production force-loop trap 차단 |
| O-27 | G4.3 R5-I1 / G4.4 carry | `_card_metadata.rule_source` 키 명세 — namespace 분리되어 top-level field 와 동명 OK | drift 차단 |
| O-28 | G4.3 R5-I2 / G4.4 carry | alignment test silent fallback 제거 — `MODULE_VERSIONS.get(..., "")` / `_MODULE_INFO.get(..., {})` 금지 — 명시적 키 존재 + 타입 검증 | silent 0 false-pass 차단 |

### G4.4 carry (Override O-29 ~ O-38)

| ID | Source | Override | Why |
|----|-------|----------|-----|
| O-29 | G4.4 Override O-2 | compact section line count gate `wc -l` ≤ 32 strict (binding) | token estimate 보정 + LLM 가독성 |
| O-30 | G4.4 Override O-5 | strict required-keys subset (set superset 검증) | G4.5b/c forward-compat |
| O-31 | G4.4 Override O-7 | cross-card reference paired-string substring (G4.5a 에서는 G4.3 body_part_focus_rule 와의 cross-ref). **RO-13 binding (G4.5a)**: G4.5a reuses `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` directly (G4.4 carry, **not renaming** in G4.5a scope). Future cleanup: rename to `_ID_POLICY_BODY_PART_FOCUS_CROSS_REF` in G4.5c or separate cleanup PR (follow-up). | drift 차단 |
| O-32 | G4.4 Override O-8 | silent-fallback 잔존 carry follow-up (G4.5c 또는 별도 PR) | G4.5a 신규 코드 silent-fallback 도입 절대 금지 |
| O-33 | G4.4 Override O-11 | canary multi-ref filter 패턴 (G4.5a 의 multi-rule shot — camera vertical + close framing co-presence 시 metric 분리 처리) | metric overlap 차단 |
| O-34 | G4.4 Override O-12 | `compute_render_strategy_snapshot_hash` 동작 명시 (PR4-B4 / PR4-I2 carry — render_strategy 전체 hash, NOT spatial-only) | pinning hash 격리 |
| O-35 | G4.4 Override O-17 | regex view-mixing canary brittleness footnote — LLM-validator 전환 follow-up | 의미 판단 LLM-first |
| O-36 | G4.4 진단 carry | R3 Codex CLI BLOCKED 시 fast manual fallback — tighter dimension scope (8-10 file reads cap) + explicit "emit final block then STOP" 지시 | audit 단계 차단 회피 |
| O-37 | G4.4 R4-B1/B2 | Phase / Task 번호 drift trap — plan body Task 순서 vs Implementation Checklist Summary vs Override carry 불일치 즉시 patch | numbering canonical 일치 강제 |
| O-38 | G4.4 진단 carry | Plan agent 출력 truncation trap — 큰 plan (2500+ lines) 작성 시 SendMessage 회수 + 병합 또는 task 분할 | 60KB persisted-output cap 회피 |

### G4.5a-new Round 1 (RO-1 ~ RO-14)

| RO-ID | Source | Override summary | Why |
|---|---|---|---|
| RO-1 | R1-I2 + R2-I1 | Token gate ≤ -1000 system-only binding (28% margin) | RO-1 binding |
| RO-2 | G4.4 O-2 carry | v20 `## Spatial consistency` ≤ 32 lines strict | LLM 가독성 + token estimate 보정 |
| RO-3 | R1-B1 | Rule F core_principles 4-entry list (required keys 6 → 7) | v19 line 170-175 4 핵심 원칙 carry |
| RO-4 | R1-I1 + R2-B1 | framing_scale enum 정정 — card-only classification (close/medium/insert producer enum) | producer 변경 0 in G4.5a |
| RO-5 | R2-B2 | Korean close-framing keyword 분리 — card 11-entry LLM hint vs canary 9-entry production filter | LLM reasoning vs detector 분리 |
| RO-6 | R2-B3 + O-31 carry | Cross-card reference paired-string in close + wide_medium body_part_focus_cross_ref | drift 차단 + ID-policy 연결 |
| RO-7 | R2-I2 | Override numbering — RO-N (Round 1) vs O-N (§12 carry) | 명확 disambiguation |
| RO-8 | R2-I3 | same-character-ID detection algorithm — per-shot dict + ±20 tokens, STRICT 0 (PR4-B7) | canary 정확도 |
| RO-9 | R2-I4 (Q9) | `_assert_primary_framing_rule_shape()` separate further nested helper (nested call chain, NOT sibling) | nested 깊이 3 levels validator |
| RO-10 | R2-I5 | Section boundary trailing-blank policy — line 142/217/268 drop | v19 convention 일관 |
| RO-11 | R2-M1 | `_SPATIAL_BODY_PART_TRIGGERS` 별칭 도입 금지 — G4.3 alias-import (canary 외부) + in-module direct (producer) | drift 차단 (single source) |
| RO-12 | R1-M1 + R2-M2 + Plan-R3-R-fix-iter-1 | Unit test count alignment ~53 (table 53 enumerate, no buffer required — raised by Plan-R3-R-fix-iter-1 PR-fix-iter-1-2) | enumerate vs prose 일치 |
| RO-13 | R2-M3 | `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` rename follow-up (G4.5c) | namespace cleanup |
| RO-14 | R2-M4 | Test count baseline disambiguation — G4.4 ≈2179 → G4.5a ≈2250 | 명확 baseline |

### Plan-R4-rewrite (PR4-B1 ~ PR4-I6)

| ID | Source | Override summary | Why |
|---|---|---|---|
| PR4-B1 | R4 reject | silent fallback ban — ZERO tolerance for `or [] / or {} / .get(key, default)` | code samples zero violation |
| PR4-B2 | R4 reject | spec §2.2 sub-shape ground-truth — DO NOT invent keys | contract drift 차단 |
| PR4-B3 | R4 reject | cross-card refs in primary_framing_rule only (NOT camera_frame_rule) | RO-6 정확 위치 |
| PR4-B4 | R4 reject | `compute_render_strategy_snapshot_hash` 정확 명 (NOT spatial-only narrower) | PR4-B4 binding |
| PR4-B5 | R4 reject | 9 module-level constants exact names (per spec §7.2 step 5) | ground-truth source |
| PR4-B6 | R4 reject | render_strategy carry G4.1+G4.4 base 8 keys 보존, NEW sibling 추가만 | DO NOT REMOVE existing fields |
| PR4-B7 | R4 reject | RO-8 STRICT 0 (NOT soft warning, exit 1 on any violation) | PR4-B7 binding |
| PR4-B8 | R4 reject | numbering canonical Phase/Task across all sections | Trap #18 |
| PR4-B9 | R4 reject | self-import ban (in-module direct reference) + typed-only forward declaration ban | shadowing 방지 |
| PR4-B10 | R4 reject | integration test enumeration in Phase 5 Task 5.2 body table | NOT just briefing reference |
| PR4-B11 | R4 reject | Phase 3 NO inline prose deletion (G4.5a 는 G4.4 Override O-3 같은 패턴 없음) | scope 정정 |
| PR4-B12 | R4 reject | canary script naming — `g4_5a_*.py` 정확 명 (NOT `canary_g4_5a_*.py`) | PR4-B12 binding |
| PR4-B13 | R4 reject | Phase 6 baseline+candidate procedure (4-step per spec §5.3) | procedure detail |
| PR4-I1 | R4 important | Phase 6 task 명시 (6.1 baseline / 6.2 candidate / 6.3 judgment+fix loop) | task split |
| PR4-I2 | R4 important | snapshot hash naming `compute_render_strategy_snapshot_hash` | spec §5.2 binding |
| PR4-I3 | R4 important | test count baseline G4.4 ≈2179 → G4.5a ≈2250 | RO-14 carry |
| PR4-I4 | R4 important | Implementation Checklist Summary canonical (single-line per task) | Trap #18 carry |
| PR4-I5 | R4 important | RO-9 nested call chain (further nested, NOT sibling) | helper ambiguity 해소 |
| PR4-I6 | R4 important | numbering canonical (Wave 6 has 4 tasks, NOT 3) | PR4-B8 detail |

### G4.5a-binding (사용자 directive)

| ID | Source | Override | Why |
|---|---|---|---|
| **G4.5a-binding** | (사용자 directive) | **새 top-level field 도입 절대 금지** — 5-field envelope contract (`render_strategy` / `id_policy` / `background_binding` / `continuity_elements_used` / `asset_requirements`) 그대로 유지. G4.5a Wave 1-A producer 변경 시 envelope 7-field 만 변경 강제 검증. R1+R2 audit BLOCKING 최우선. | 사용자 binding invariant — G4.5a/b/c 모든 단계에서 적용 |
| **G4.5a-binding** | (사용자 directive) | **Sub-shape 선택 = Option 3 (nested dict `render_strategy.spatial_consistency`)**. G4.4 `view_consistency` 패턴 carry. | render_strategy top 비대 방지 + 의미 그룹화 |

---

## Implementation Checklist Summary (Phase by Phase)

> **PR4-I4 binding**: Implementation Checklist Summary single-line per task MUST match body content exactly (no swapping). canonical numbering enforce. **PR4-B8 / PR4-I6 binding**: Phase 1 (6 tasks) / Phase 2 (4 tasks) / Phase 3 (2 tasks) / Phase 4 (5 tasks) / Phase 5 (4 tasks) / Phase 6 (3 tasks) / Phase 7 (4 tasks).

### Phase 1 (Wave 1-A): render_prompt_card.py (6 tasks)
- [ ] 1.1 — module-level constants 도입 (9 신규 `_SPATIAL_*` + in-module reuse `_ID_BODY_PART_TRIGGERS` G4.3 + `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` G4.4 — PR4-B5 / PR4-B9 / RO-11)
- [ ] 1.2 — `compute_render_strategy_snapshot_hash()` helper 신설 (PR4-B4 / PR4-I2 — render_strategy 전체 hash, NOT spatial-only)
- [ ] 1.3 — `build_render_strategy()` 확장: spatial_consistency 4 sub-key (RO-3 / RO-4 / RO-5 / RO-6 / PR4-B2 / PR4-B3 / PR4-B6 / RO-13) + constraints 2 → 5 strings
- [ ] 1.4 — `_assert_spatial_consistency_shape()` + `_assert_primary_framing_rule_shape()` nested helpers (RO-9 / PR4-I5 nested call chain — NOT sibling) + assert_card_shape wiring
- [ ] 1.5 — `build_render_prompt_card()` `_card_metadata` 4 신규 lift_status key + 3 신규 rule_source key (Override O-9 carry 정확 명)
- [ ] 1.6 — docstring 갱신 (G4.5a 변경 기록 + RO-3 / RO-6 / RO-9 / PR4-B3 / PR4-B4)

### Phase 2 (Wave 1-B parallel): v20 prompt 신규 디렉토리 (4 tasks)
- [ ] 2.1 — v20 디렉토리 생성 + system.md 3 prose 삭제 (Rule F 33L + Rule G 41L + Rule J 50L = 124 lines, RO-10 trailing-blank policy)
- [ ] 2.2 — v20 system.md ≤ 32-line "Spatial consistency" compact section 삽입 (RO-2 binding gate, RO-6 cross-card paired substring 1+ 등장)
- [ ] 2.3 — `detail_schema.json` / `user.md` verbatim copy + 자체 line count + tiktoken cl100k_base 실측 (RO-1 token gate margin 검증)
- [ ] 2.4 — Wave 1-B → Wave 2 timestamp lockstep 기록 (`.wave_1b_timestamp.txt`)

### Phase 3 (Wave 2): detail_steps.py + version_registry 4-point sync (2 tasks — PR4-B11 NO inline prose deletion)
- [ ] 3.1 — `SCENE_DETAIL_PROMPT_VERSION` 상수 bump (v19→`"20.<timestamp>"`)
- [ ] 3.2 — `version_registry.py` 4-point sync (line 34 `1.19.0`→`1.20.0` + line 126 `"scene_detail/v19"`→`"scene_detail/v20"` major suffix only, Trap #5 dict access)

### Phase 4 (Wave 3): canary scripts 5종 (5 tasks — PR4-B12 정확 명)
- [ ] 4.1 — `g4_5a_camera_frame_consistency.py` (Rule F violations, ALL shot scope, STRICT 0 PR4-B7)
- [ ] 4.2 — `g4_5a_fg_bg_shared_anchor.py` (Rule G violations, fg/bg interaction shot scope, STRICT 0)
- [ ] 4.3 — `g4_5a_primary_framing.py` (Rule J violations, close-framing shot scope RO-5 9-entry production-aligned, RO-8 same-character-ID detection algorithm STRICT 0 PR4-B7)
- [ ] 4.4 — `g4_5a_view_mixing_extension.py` (G4.4 carry baseline 회귀 0 + Rule J extension, STRICT 0)
- [ ] 4.5 — `g4_5a_token_count.py` (CP-free, tiktoken cl100k_base, signed gate ≤ -1000 RO-1)

### Phase 5 (Wave 4): tests (4 tasks)
- [ ] 5.1 — Unit tests `test_g4_5a_spatial_lift.py` (~53 tests, RO-12 binding raised — table 53 enumerate, no buffer required, raised by Plan-R3-R-fix-iter-1)
- [ ] 5.2 — Integration tests `test_g4_5a_spatial_integration.py` (~18 tests, PR4-B10 binding — body table enumerate including RO-2 line count + RO-6 cross-card 3-way + G4.1 carry resume escalation + G3.2 sentinel coexist + 5-field invariant)
- [ ] 5.3 — Alignment test `test_scene_detail_spatial_alignment.py` (4 tests — 4-way sync, Trap #11 / #13)
- [ ] 5.4 — Regression suite (≈ 2250 expected, RO-14 / PR4-I3 binding)

### Phase 6 (Wave 5): production canary execution (3 tasks — PR4-B13 4-step procedure)
- [ ] 6.1 — Baseline canary capture (v19) → `docs/canary/g4_5a_*_baseline_<ts>.json`
- [ ] 6.2 — Candidate canary capture (v20) → `docs/canary/g4_5a_*_candidate_<ts>.json`
- [ ] 6.3 — Pinning equality (7 fields) + 7-gate judgment (4 STRICT 0 + token + line count + pytest) + 듀얼 리뷰 3 iter fix loop

### Phase 7 (Wave 6): single commit + push + R5/R6 듀얼 리뷰 + fix loop (4 tasks — PR4-I6 NOT 3)
- [ ] 7.1 — Single commit assemble + push (4 영역 + canary 5 + tests 3 + spec/plan)
- [ ] 7.2 — Codex (R5) post-impl review (Trap #17 carry — Codex CLI BLOCKED 위험)
- [ ] 7.3 — Claude (R6) post-impl review
- [ ] 7.4 — Fix iter 1 + 재push (R5 + R6 발견 사항 통합 fix)

---


## Wave Briefing Templates (7 Waves, self-contained)

> **PR4-B8 / PR4-I6 binding (canonical numbering)**: Wave 1-A = Phase 1 (Tasks 1.1-1.6) / Wave 1-B = Phase 2 (Tasks 2.1-2.4 parallel) / Wave 2 = Phase 3 (Tasks 3.1-3.2) / Wave 3 = Phase 4 (Tasks 4.1-4.5) / Wave 4 = Phase 5 (Tasks 5.1-5.4) / Wave 5 = Phase 6 (Tasks 6.1-6.3) / Wave 6 = Phase 7 (Tasks 7.1-7.4).

### Wave 1-A Briefing — render_prompt_card.py producer 변경

**역할:** Phase 1 의 6 tasks 모두 구현. `render_prompt_card.py` 안 module-level constants + helper + builder 확장 + nested validator helpers + `_card_metadata` 갱신 + docstring.

**Inputs:**
- spec §2.2 (sub-shape ground-truth, PR4-B2 binding)
- spec §4 (Lift Mapping Table)
- spec §7.2 step 1-6 (Wave 1-A 작업 순서)
- Override RO-3 / RO-4 / RO-5 / RO-6 / RO-9 / RO-11 / RO-13
- Plan-R4-rewrite PR4-B2 / PR4-B3 / PR4-B5 / PR4-B6 / PR4-B9 / PR4-I2 / PR4-I5
- 기존 `render_prompt_card.py` (G4.4 carry, b1c5032 base)

**Outputs:**
- `backend/app/core/steps/render_prompt_card.py` modified (~340 LOC):
  - 9 module-level `_SPATIAL_*` constants (RO-11 binding, no `_SPATIAL_BODY_PART_TRIGGERS` alias)
  - `compute_render_strategy_snapshot_hash()` helper (PR4-B4 / PR4-I2 — render_strategy 전체 hash)
  - `build_render_strategy()` 확장 (`spatial_consistency` 4 sub-key + constraints 2→5)
  - `_assert_spatial_consistency_shape()` nested helper
  - `_assert_primary_framing_rule_shape()` further nested helper (RO-9 / PR4-I5 — nested call chain)
  - `assert_card_shape()` `_assert_render_strategy_shape()` wiring
  - `build_render_prompt_card()` `_card_metadata` 4 신규 lift_status key + 3 신규 rule_source key
  - docstring 갱신 (G4.5a 변경 기록)

**Blocking-on:** 없음 (Wave 1-B 와 병렬).

**함정 회피 (19 trap carry):**
- Trap #1 / PR4-B1: silent fallback ban — `or [] / or {} / .get(key, default)` 절대 금지. self-check grep 0 결과.
- Trap #2: `_DEFAULT_SENTINEL = object()` fixture pattern (helper default arg).
- Trap #14: 신규 sub-field 는 builder-static — `if shot_info:` truthy guard 안 배치 절대 금지.
- Trap #5: `_MODULE_INFO` dict access (NOT attribute).
- Trap #9: 시나리오 의존 0 — generic placeholder 만 사용.
- PR4-B3: cross-card refs 는 `primary_framing_rule.close_framing_rules` + `wide_medium_rules` 하위에만, NOT `camera_frame_rule`. self-check grep 0 결과.
- PR4-B4: `compute_render_strategy_snapshot_hash` (NOT spatial-only narrower). self-check grep 0 결과.
- PR4-B6: G4.1+G4.4 base 8 keys 모두 보존 — `spatial_consistency` 만 NEW sibling.
- PR4-B9: in-module direct reference (NO self-import, NO typed-only forward declaration).
- RO-9 / PR4-I5: nested call chain `_assert_render_strategy_shape() → _assert_spatial_consistency_shape() → _assert_primary_framing_rule_shape()` (further nested, NOT sibling).

**검증 (Wave 1-A 완료 시):**
- `grep -nE 'or \[\]|or \{\}|\.get\([^,]+, *(\[\]|\{\}|0|"|'"'"''"'"'|None|False)\)' backend/app/core/steps/render_prompt_card.py | grep "G4.5a\|spatial"` → 0 결과 (PR-fix-iter-1-1 strengthened — int/string/None/bool defaults 추가)
- `grep -n "camera_frame_rule.*close_framing_rules\|camera_frame_rule.*wide_medium_rules"` → 0 결과
- `grep -rn "compute_spatial_consistency_snapshot_hash"` → 0 결과
- `grep -n "from .render_prompt_card import\|from app.core.steps.render_prompt_card import _ID_BODY_PART_TRIGGERS" backend/app/core/steps/render_prompt_card.py` → 0 결과 (in-module — self-import 금지)
- 9 `_SPATIAL_*` constants 모두 정의 + tuple type
- `compute_render_strategy_snapshot_hash` defined
- `_assert_spatial_consistency_shape` + `_assert_primary_framing_rule_shape` defined

---

### Wave 1-B Briefing — v20 prompt 신규 디렉토리

**역할:** Phase 2 의 4 tasks 모두 구현. `prompts/_base/scene_detail/20.<timestamp>/` 신규 디렉토리 + system.md 3 prose 삭제 + ≤ 32-line compact section 삽입 + tiktoken 실측 + timestamp lockstep 기록.

**Inputs:**
- spec §3.1 (대상 버전)
- spec §3.2 (삭제 대상 lines)
- spec §3.3 (대체 section)
- spec §3.4 (Token 감소 추정)
- Override RO-1 (token gate ≤ -1000) / RO-2 (line count ≤ 32) / RO-10 (trailing-blank policy)
- Plan-R4-rewrite PR4-B11 (NO inline prose deletion)
- 기존 v19 system.md (`prompts/_base/scene_detail/19.202605050814/system.md`, 534 lines)

**Outputs:**
- `prompts/_base/scene_detail/20.<timestamp>/` 신규 디렉토리:
  - `system.md` (-124 lines + 13 lines = -111 net)
  - `detail_schema.json` (verbatim copy from v19)
  - `user.md` (verbatim copy from v19)
- `.wave_1b_timestamp.txt` scratch file (gitignored, Wave 2 lockstep)
- tiktoken cl100k_base 실측 결과 + RO-1 token gate margin 검증

**Blocking-on:** 없음 (Wave 1-A 와 병렬).

**함정 회피:**
- Trap #10: prompt versioning — NEVER overwrite v19, 새 dir `20.<TIMESTAMP>/`.
- Trap #7: compact section line count 정확 (multi-line wrap trap) — bullet 1줄 강제, 80-col wrap 발생 시 violation. RO-2 binding gate ≤ 32 lines strict.
- Trap #9: 시나리오 의존 0 — compact section 안 작품 고유명사 절대 포함 금지.
- RO-6: cross-card paired substring `"see id_policy.body_part_focus_rule"` 1+ 등장 (3-way assertion 강제).
- RO-10: trailing blank line 142/217/268 모두 drop, 인접 섹션 사이 1 blank line 유지.

**검증 (Wave 1-B 완료 시):**
- `wc -l prompts/_base/scene_detail/20.<ts>/system.md` ≈ 423 (v19 534 - 111).
- v20 system.md 안 `## Spatial consistency` heading 부터 다음 `##` heading 사이 ≤ 32 lines.
- `grep -n "## 카메라 위치와 frame edge" prompts/_base/scene_detail/20.<ts>/system.md` → no output (Rule F 삭제).
- `grep -n "## fg/bg 인물 분리" prompts/_base/scene_detail/20.<ts>/system.md` → no output (Rule G 삭제).
- `grep -n "## Primary Subject Framing" prompts/_base/scene_detail/20.<ts>/system.md` → no output (Rule J 삭제).
- `grep -n "## Spatial consistency" prompts/_base/scene_detail/20.<ts>/system.md` → present (G4.5a 신규).
- `grep -n "see id_policy.body_part_focus_rule" prompts/_base/scene_detail/20.<ts>/system.md` → 1+ 등장.
- `diff` 으로 detail_schema.json + user.md 가 v19 verbatim copy.
- tiktoken `token_delta ≤ -1000` (RO-1 binding).

---

### Wave 2 Briefing — detail_steps.py + version_registry 4-point sync

**역할:** Phase 3 의 2 tasks 모두 구현. `SCENE_DETAIL_PROMPT_VERSION` v19→`"20.<timestamp>"` bump + `version_registry.py:34` `1.19.0`→`1.20.0` + `version_registry.py:126` `"scene_detail/v19"`→`"scene_detail/v20"`. **PR4-B11 binding — NO inline prose deletion**.

**Inputs:**
- Wave 1-A + 1-B 완료 (timestamp 결정 + scratch `.wave_1b_timestamp.txt`)
- spec §6.4 (4-way sync)
- Plan-R4-rewrite PR4-B11 (G4.5a 는 G4.4 Override O-3 같은 inline prose 잔존 패턴 없음)
- G4.3/G4.4 R3R4-B1 carry (dict access NOT attribute, major suffix only)

**Outputs:**
- `backend/app/core/steps/detail_steps.py:105` — `SCENE_DETAIL_PROMPT_VERSION = "20.<timestamp>"`
- `backend/app/core/version_registry.py:34` — `MODULE_VERSIONS["scene_detail_composer"] = "1.20.0"`
- `backend/app/core/version_registry.py:126` — `_MODULE_INFO["scene_detail_composer"]["prompt_dependency"] = "scene_detail/v20"`
- alignment test fixture (Wave 4 에서 검증)

**Blocking-on:** Wave 1-B (timestamp pin) — `.wave_1b_timestamp.txt` read.

**함정 회피:**
- Trap #5: `_MODULE_INFO` dict access (NOT attribute). `prompt_dependency` 값 = `"scene_detail/v20"` (major suffix only, NOT `"scene_detail/20.<timestamp>"`).
- Trap #11: 4-point sync lockstep 누락 시 alignment test fail.
- Trap #13: alignment test silent fallback 제거 — `MODULE_VERSIONS.get(..., "")` 패턴 금지.
- PR4-B11: G4.5a 는 G4.4 Override O-3 같은 inline prose 9 줄 삭제 패턴이 없음. detail_steps.py:1555-1563 은 G4.4 b1c5032 에서 이미 삭제 완료. 재삭제 작업 절대 금지.

**검증 (Wave 2 완료 시):**
- `grep -n "SCENE_DETAIL_PROMPT_VERSION = \"20\." backend/app/core/steps/detail_steps.py` → line 105 매칭.
- `grep -n "scene_detail_composer.*1\\.20\\.0" backend/app/core/version_registry.py` → line 34 매칭.
- `grep -n "scene_detail/v20" backend/app/core/version_registry.py` → line 126 매칭.
- `SCENE_DETAIL_SCHEMA_VERSION` 변경 0 (= 7).
- `CARD_SCHEMA_VERSION` 변경 0 (= 1).

---

### Wave 3 Briefing — canary scripts 5종

**역할:** Phase 4 의 5 tasks 모두 구현. `scripts/canary/` 에 5 canary scripts (4 metric + 1 token).

**Inputs:**
- spec §5.1 (Exit criteria)
- spec §5.3 (Canary 절차)
- spec §5.4 (canary script 공통 패턴)
- Override RO-1 (token gate) / RO-5 (close-framing 9-entry production-aligned) / RO-8 (same-character-ID detection algorithm STRICT 0) / RO-11 (`_ID_BODY_PART_TRIGGERS` reuse)
- Plan-R4-rewrite PR4-B7 (STRICT 0) / PR4-B12 (canary script 정확 명) / PR4-B4 (token script CP-free, hash helper 미import)
- Wave 1-A 완료 (constants + hash helper import)

**Outputs:**
- `scripts/canary/g4_5a_camera_frame_consistency.py` (~150 LOC, Rule F)
- `scripts/canary/g4_5a_fg_bg_shared_anchor.py` (~155 LOC, Rule G)
- `scripts/canary/g4_5a_primary_framing.py` (~180 LOC, Rule J + RO-8 algorithm)
- `scripts/canary/g4_5a_view_mixing_extension.py` (~140 LOC, G4.4 carry baseline 회귀 0 + Rule J extension)
- `scripts/canary/g4_5a_token_count.py` (~140 LOC, CP-free, signed gate)

**Blocking-on:** Wave 1-A (constants + hash helper import).

**함정 회피:**
- Trap #1 / PR4-B1: silent fallback ban — `or [] / or {} / .get(key, default)` 절대 금지. `_validate_scene_preflight()` helper + per-Task explicit key existence + type validation.
- Trap #3: CP shape `scene["render_prompt_card"]["render_strategy"]` (NOT 평면).
- Trap #4: pinned tuple seen_in_cp post-loop validation + sentinel pre-pass + gate fail → exit 1.
- Trap #8: module-level constants single source — `from app.core.steps.render_prompt_card import _SPATIAL_*` 외부 import.
- RO-8 / PR4-B7: STRICT 0 (NOT soft warning, exit 1 on any violation).
- RO-5: close-framing scope filter = `_SPATIAL_FRAMING_CLOSE_KEYWORDS` 9-entry production-aligned (LLM-facing 11-entry list 와 분리).
- RO-11: `_ID_BODY_PART_TRIGGERS` G4.3 carry 외부 import — 별칭 도입 금지.
- PR4-B12: 정확 5 canary script 명 — `g4_5a_camera_frame_consistency.py` / `g4_5a_fg_bg_shared_anchor.py` / `g4_5a_primary_framing.py` / `g4_5a_view_mixing_extension.py` / `g4_5a_token_count.py`. `canary_g4_5a_*.py` 패턴 절대 금지.
- PR4-B4: token script (Task 4.5) CP-free — `compute_render_strategy_snapshot_hash` 미import.

**검증 (Wave 3 완료 시):**
- `ls scripts/canary/g4_5a_*.py` → 정확 5 file (PR4-B12 정확 명).
- `grep -rnE 'or \[\]|or \{\}|\.get\([^,]+, *(\[\]|\{\}|0|"|'"'"''"'"'|None|False)\)' scripts/canary/g4_5a_*.py` → 0 결과 (Trap #1 / PR4-B1 / PR-fix-iter-1-1 strengthened — int/string/None/bool defaults 추가).
- `grep -rnE '\.get\([^)]*,[^)]*\)' scripts/canary/g4_5a_*.py | grep -v '_DEFAULT_SENTINEL'` → 0 결과 (PR-fix-iter-1-1 supplement — only `_DEFAULT_SENTINEL` allowed as default).
- `grep -n "compute_render_strategy_snapshot_hash" scripts/canary/g4_5a_token_count.py` → 0 결과 (PR4-B4 — token script CP-free).
- `grep -n "from app.core.steps.render_prompt_card import _ID_BODY_PART_TRIGGERS" scripts/canary/g4_5a_primary_framing.py` → present (RO-11).
- 4 metric scripts 모두 `compute_render_strategy_snapshot_hash` import.
- 4 metric scripts 모두 `_validate_scene_preflight` helper 호출.

---

### Wave 4 Briefing — tests

**역할:** Phase 5 의 4 tasks 모두 구현. unit + integration + alignment + regression suite.

**Inputs:**
- spec §6.1 (Unit tests)
- spec §6.2 (Integration tests)
- spec §6.4 (alignment test 4-way sync)
- Override RO-2 / RO-3 / RO-5 / RO-6 / RO-8 / RO-9 / RO-12 (test count alignment) / RO-14 (test count baseline)
- Plan-R4-rewrite PR4-B7 / PR4-B10 (integration test enumeration in body table) / PR4-I3
- Wave 1-A + 1-B + 2 완료 (alignment test 의존)

**Outputs:**
- `backend/tests/unit/test_g4_5a_spatial_lift.py` (~700 LOC, ~53 tests RO-12 binding raised by Plan-R3-R-fix-iter-1)
- `backend/tests/integration/test_g4_5a_spatial_integration.py` (~400 LOC, ~18 tests PR4-B10 binding)
- `backend/tests/prompts/test_scene_detail_spatial_alignment.py` (~150 LOC, 4 alignment tests)
- 기존 backend/tests/ 회귀 검증 (≈2250 expected, RO-14 / PR4-I3 binding)

**Blocking-on:** Wave 1-A + 1-B + 2 (alignment test 의존).

**함정 회피:**
- Trap #2: `_DEFAULT_SENTINEL = object()` fixture pattern (helper default arg). `_make_full_card()` helper 가 항상 spatial_consistency 4 sub-key 채움.
- Trap #14: 신규 sub-field 는 builder-static — test 33 (`test_spatial_consistency_4_subfields_independent_of_input`) 강제.
- Trap #11 / #13: alignment test silent fallback 제거 + 4-way sync 강제.
- RO-12: ~53 unit tests (table 53 enumerate, no buffer required — raised by Plan-R3-R-fix-iter-1).
- PR4-B10: integration test 18 tests body table enumerate (NOT just briefing reference).
- PR4-I3: regression gate ≈ 2250 expected (G4.4 ≈ 2179 + ~71 G4.5a — raised by Plan-R3-R-fix-iter-1 PR-fix-iter-1-2).

**검증 (Wave 4 완료 시):**
- `pytest backend/tests/unit/test_g4_5a_spatial_lift.py -v` → ~53 tests green (RO-12 raised by Plan-R3-R-fix-iter-1).
- `pytest backend/tests/integration/test_g4_5a_spatial_integration.py -v` → ~18 tests green.
- `pytest backend/tests/prompts/test_scene_detail_spatial_alignment.py -v` → 4 tests green.
- `pytest backend/tests/ -x --timeout=120` → 0 failures, total ≈ 2250.

---

### Wave 5 Briefing — production canary execution (사용자 단계)

**역할:** Phase 6 의 3 tasks 모두 구현. baseline (v19) capture + candidate (v20) capture + 7-gate judgment + 듀얼 리뷰 fix loop.

**Inputs:**
- spec §5.1 (Exit criteria)
- spec §5.2 (Baseline 측정 대상 PID / scene set)
- spec §5.3 (Canary 절차 4-step)
- Override RO-1 / RO-2 / RO-8 (STRICT 0) / RO-14 (test count baseline)
- Plan-R4-rewrite PR4-B13 (4-step procedure) / PR4-I1 (task 명시)
- Wave 1-A~4 완료 (canary scripts + tests + 4-point sync)
- G4.4 production canary 4 gate green (R3-Entry-Note carry — commit gate)

**Outputs:**
- `docs/canary/g4_5a_*_baseline_<timestamp>.json` (5 script per output)
- `docs/canary/g4_5a_*_candidate_<timestamp>.json` (5 script per output)
- 7-gate judgment 결과 (4 STRICT 0 + token + line count + pytest)
- fix iter 결과 (3 iter dual review)

**Blocking-on:** G4.4 production canary 4 gate green (사용자 단계).

**함정 회피:**
- PR4-B13: 4-step procedure 정확 (baseline / candidate / pinning equality / 7-gate judgment).
- PR4-I1: task split — Task 6.1 baseline / Task 6.2 candidate / Task 6.3 pinning + judgment + fix loop.
- Trap #4: pinned tuple seen_in_cp post-loop validation 누락 시 false STRICT pass 차단.
- Override O-16: strict 0 metric 은 baseline 비0 이어도 candidate 0 강제. baseline 비0 이면 P1 follow-up.

**검증 (Wave 5 완료 시):**
- baseline.pinning == candidate.pinning (7 fields equality).
- 4 STRICT 0 metric 모두 candidate == 0.
- token_delta ≤ -1000 (RO-1 binding).
- v20 spatial section line count ≤ 32 (RO-2 binding).
- pytest green ≈ 2250 (RO-14 / PR4-I3 binding).

---

### Wave 6 Briefing — single commit + push + R5/R6 듀얼 리뷰 + fix loop

**역할:** Phase 7 의 4 tasks 모두 구현 (PR4-I6 binding — 4 tasks NOT 3). single commit + push + Codex (R5) + Claude (R6) + fix iter 1.

**Inputs:**
- Wave 5 통과 (canary green)
- G4.4 carry pattern (3 iter dual review fix loop)
- Override O-19 carry (commit target — version_registry.py 의무 포함)
- Override O-36 (Codex CLI BLOCKED 시 fast manual fallback)
- Override O-38 (Plan agent 출력 truncation trap)
- Plan-R4-rewrite PR4-I6 (Wave 6 has 4 tasks NOT 3)

**Outputs:**
- single commit (4 영역 + canary 5 + tests 3 + spec/plan)
- R5 review verdict + R6 review verdict
- fix iter 1 commit + push (R5 + R6 발견 사항 통합)

**Blocking-on:** Wave 5 (canary green).

**함정 회피:**
- Trap #17: R3 Codex CLI 두 번 BLOCKED carry — fast manual fallback 전환 (tighter dimension scope 8-10 file reads cap + explicit "emit final block then STOP" 지시).
- Trap #19 / Override O-38: Plan agent 출력 truncation trap — 60KB persisted-output cap. SendMessage 회수 + 병합.
- Override O-19: commit target 에 `version_registry.py` (4-way sync 일부) 의무 포함.

**검증 (Wave 6 완료 시):**
- single commit 후 main HEAD = G4.5a commit hash.
- R5 + R6 verdict 모두 APPROVED 또는 APPROVED_WITH_REVISIONS.
- fix iter 1 후 재push.
- main HEAD 에 G4.5a 변경 모두 적용.

---


## Critical Details

### File paths (absolute)

- **Producer:** `/Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/steps/render_prompt_card.py`
  - `build_render_strategy()` line 379-446 (G4.5a 확장 대상)
  - `_assert_render_strategy_shape()` line 1519-1565 영역 (G4.5a wiring)
  - `compute_id_policy_snapshot_hash` line ~1324 (G4.3 carry)
  - `compute_continuity_snapshot_hash` line ~1372 (G4.4 carry, sibling 위치)
  - `_ID_BODY_PART_TRIGGERS` line 95 (G4.3, in-module reuse — RO-11 / PR4-B9)
  - `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` line 240 (G4.4, in-module reuse — RO-13 / PR4-B9)
  - `canonicalize_render_prompt_card` line 1243 (G4.1 carry, `_card_metadata` strip)
  - `_HASH_EXCLUDED_TOP_KEYS` line 318 (G4.1 carry)
  - `_VALID_RENDER_MODES` line ~317 (G4.1 carry)
- **Consumer:** `/Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/steps/detail_steps.py`
  - `SCENE_DETAIL_PROMPT_VERSION` line 105 (Wave 2 bump 대상)
  - `SCENE_DETAIL_SCHEMA_VERSION` (변경 없음, = 7)
- **Version registry:** `/Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/version_registry.py`
  - `MODULE_VERSIONS` dict line 34 (Wave 2 bump 대상)
  - `_MODULE_INFO` dict line 126 영역 (Wave 2 bump 대상)
- **Prompt:** `/Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/`
  - v19: `19.202605050814/system.md` (sealed — 변경 절대 금지)
  - v20: `20.<timestamp>/` (Phase 2 신규)
- **Canary scripts:** `/Users/manta/Documents/Projects/TheRoad-I1/scripts/canary/`
  - `g4_5a_camera_frame_consistency.py` / `g4_5a_fg_bg_shared_anchor.py` / `g4_5a_primary_framing.py` / `g4_5a_view_mixing_extension.py` / `g4_5a_token_count.py`
- **Tests:** `/Users/manta/Documents/Projects/TheRoad-I1/backend/tests/`
  - `unit/test_g4_5a_spatial_lift.py`
  - `integration/test_g4_5a_spatial_integration.py`
  - `prompts/test_scene_detail_spatial_alignment.py`
- **Canary outputs:** `/Users/manta/Documents/Projects/TheRoad-I1/docs/canary/`
  - `g4_5a_*_baseline_<timestamp>.json` (5 script per timestamp)
  - `g4_5a_*_candidate_<timestamp>.json` (5 script per timestamp)
- **Spec:** `/Users/manta/Documents/Projects/TheRoad-I1/docs/superpowers/specs/2026-05-05-g4.5a-spatial-rules-lift-design.md`
- **Plan:** `/Users/manta/Documents/Projects/TheRoad-I1/docs/superpowers/plans/2026-05-05-g4.5a-spatial-rules-lift-implementation.md`

### Numeric gates (explicit)

- Token gate (RO-1 binding): `token_delta = candidate - baseline ≤ -1000` system-only (signed convention Trap #6).
- Line count gate (RO-2 binding): `wc -l` ≤ 32 lines strict for v20 `## Spatial consistency` compact section.
- camera_frame_violation_count: STRICT 0 (PR4-B7).
- fg_bg_shared_anchor_missing_count: STRICT 0 (PR4-B7).
- primary_framing_violation_count: STRICT 0 (RO-8 algorithm + PR4-B7).
- view_mixing_extension_count: STRICT 0 (G4.4 carry baseline 회귀 0 + Rule J extension).
- pytest regression gate: 0 failures, total ≈ 2250 (RO-14 / PR4-I3 binding).
- camera_frame_rule.core_principles list-len: == 4 (RO-3 binding).
- camera_frame_rule.forbidden_combinations list-len: == 4.
- camera_frame_rule.recommended_phrasings list-len: == 4.
- camera_frame_rule.self_check_steps list-len: == 4.
- fg_bg_shared_anchor_rule.forbidden_phrasings list-len: == 3.
- fg_bg_shared_anchor_rule.recommended_keywords list-len: == 5.
- fg_bg_shared_anchor_rule.self_check_steps list-len: == 4.
- primary_framing_rule.framing_scale_keywords.close list-len: == 11 (RO-5 LLM-facing 11-entry).
- primary_framing_rule.framing_scale_keywords.wide list-len: == 5.
- primary_framing_rule.close_framing_rules.other_entities_appearance_options list-len: == 3.
- primary_framing_rule.close_framing_rules.forbidden_combinations list-len: == 2.
- primary_framing_rule.self_check_steps list-len: == 4.
- render_strategy.constraints list-len: == 5 (G4.4 base 2 carry + 3 신규 spatial).
- canary close-framing scope filter alignment: 9-entry production-aligned (RO-5).
- RO-8 same-character-ID detection ±N=20 tokens.

### Key invariants (사용자 binding)

- **5-field envelope contract 변경 0** — `schema_version` / `shot_key` / `render_strategy` / `id_policy` / `background_binding` / `continuity_elements_used` / `asset_requirements` 모두 G4.5a 가 변경하지 않음.
- **`render_strategy` G4.1+G4.4 base 8 keys 보존** (PR4-B6) — `mode / primary_subject / framing_scale / moment_lock / camera_direction / lighting_mood / perception_mode / constraints`. G4.5a adds ONLY `spatial_consistency` as NEW sibling.
- **새 top-level field 도입 절대 금지** (사용자 directive).
- **producer `_derive_framing_scale()` 변경 0** (RO-4 — close/medium/insert enum 유지).
- **`_card_metadata` namespace 분리** (Override O-10 / O-27) — top-level field 와 동명 OK, hash 영향 0, LLM 입력 영향 0.

---

## Data Flow

### Producer → consumer wire

```
shot_dependency_t2i (upstream)
  ↓ (writes shot_dependency CP)
detail_steps._collect_card_inputs()
  ↓ (reads shot_dependency CP + shot_staging CP + visible_entities)
render_prompt_card.build_render_prompt_card()
  ├─ build_render_strategy()
  │  ├─ _build_spatial_consistency_dict()  ← Wave 1-A new
  │  │  ├─ camera_frame_rule (7 keys, RO-3)
  │  │  ├─ fg_bg_shared_anchor_rule (6 keys)
  │  │  └─ primary_framing_rule (6 keys + nested)
  │  │     ├─ framing_scale_keywords (close 11 / wide 5 / medium_default)
  │  │     ├─ close_framing_rules (4 keys incl body_part_focus_cross_ref RO-6)
  │  │     └─ wide_medium_rules (4 keys incl body_part_focus_cross_ref RO-6)
  │  └─ _build_render_strategy_constraints() (5 strings, G4.4 carry 2 + 3 신규)
  ├─ build_id_policy() (G4.3 carry — 변경 0)
  ├─ build_background_binding() (G4.2 carry — 변경 0)
  ├─ build_continuity_elements_used() (G4.4 carry — 변경 0)
  ├─ build_asset_requirements() (G3.2 carry — 변경 0)
  └─ _card_metadata assembly (Wave 1-A new keys)
     ├─ lift_status: 4 신규 spatial_*_lifted keys
     └─ rule_source: 3 신규 keys (camera_frame_rule / fg_bg_shared_anchor_rule / primary_framing_rule)
  ↓ (returns 5-field envelope card)
assert_card_shape() → _assert_render_strategy_shape()
  └─ _assert_spatial_consistency_shape() ← Wave 1-A new
     └─ _assert_primary_framing_rule_shape() ← Wave 1-A new (RO-9 nested call chain)
  ↓ (validates shape strict)
canonicalize_render_prompt_card()
  ↓ (strips _card_metadata + render_prompt_card_hash from hash payload)
detail_steps inject path
  ├─ _card_metadata strip (G4.2 carry — line 1937-1942)
  └─ JSON prepend to user_prompt (line ~1500-1610)
  ↓
LLM (gemini-3.1-pro-preview) reads:
  ├─ system.md (v20 — Spatial consistency compact section + RO-6 cross-card refs)
  └─ user_prompt (RenderPromptCard JSON — render_strategy.spatial_consistency)
  ↓ (generates t2i_prompt)
scene_detail CP (data.scenes[].t2i_variations[].t2i_prompt)
  ↓
canary scripts (4 metric):
  ├─ g4_5a_camera_frame_consistency.py — `_SPATIAL_CAMERA_*_TOKENS` + `_SPATIAL_FRAME_EDGE_POSITION_TOKENS` proximity
  ├─ g4_5a_fg_bg_shared_anchor.py — `_SPATIAL_FG_BG_SEPARATION_TOKENS` + `_SPATIAL_INTERACTION_VERBS` + `_SPATIAL_SHARED_ANCHOR_KEYWORDS`
  ├─ g4_5a_primary_framing.py — `_SPATIAL_FRAMING_CLOSE_KEYWORDS` (RO-5 9-entry production-aligned) + RO-8 same-character-ID detection (±20 tokens, `_ID_BODY_PART_TRIGGERS` G4.3 carry)
  └─ g4_5a_view_mixing_extension.py — G4.4 view_mixing carry baseline 회귀 0 + Rule J extension
  ↓ (STRICT 0 — PR4-B7)
canary token script (CP-free):
  └─ g4_5a_token_count.py — tiktoken cl100k_base v19 vs v20 system.md (signed gate ≤ -1000, RO-1)
  ↓
Phase 6 7-gate judgment:
  - 4 STRICT 0 metric
  - token gate ≤ -1000 (RO-1)
  - v20 line count ≤ 32 (RO-2)
  - pytest green ≈ 2250 (RO-14 / PR4-I3)
```

### `_card_metadata` namespace separation

```
Card envelope 7 fields (assert_card_shape strict):
  schema_version
  shot_key
  render_strategy
  id_policy
  background_binding
  continuity_elements_used
  asset_requirements

Sibling envelope key (free-form, hash-excluded):
  _card_metadata
    lift_status:
      G4.2 carry: rule_a_lifted / rule_c_lifted / rule_e_lifted
      G4.3 carry: rule_h_lifted / id_policy_composite_lifted /
                  id_policy_close_framing_face_lifted /
                  id_policy_reproduction_surface_lifted
      G4.4 carry: continuity_cross_shot_id_substitution_lifted /
                  continuity_ref_usage_constraints_lifted /
                  continuity_view_consistency_lifted /
                  continuity_constraints_extended
      G4.5a 신규: spatial_camera_frame_lifted /
                  spatial_fg_bg_shared_anchor_lifted /
                  spatial_primary_framing_lifted /
                  spatial_constraints_extended
    rule_source:
      G4.2 carry: camera_rule / owned_rule / close_skip_rule
      G4.3 carry: demographic_rule / composite_id_rule /
                  close_framing_face_rule / reproduction_surface_rule
      G4.4 carry: cross_shot_id_substitution_rule / ref_usage_rule /
                  view_consistency_rule
      G4.5a 신규: camera_frame_rule / fg_bg_shared_anchor_rule /
                  primary_framing_rule

  render_prompt_card_hash (sibling, format-checked)
```

`_card_metadata` 와 `render_strategy.spatial_consistency.camera_frame_rule` / `fg_bg_shared_anchor_rule` / `primary_framing_rule` 의 명명이 겹치는 것은 **namespace 분리 OK** (Override O-27). top-level field (envelope 7 fields) 와 `_card_metadata.rule_source` 는 namespace 분리되어 있어 동명 가능.

---

## Spec ↔ plan drift quick-check

본 plan 가 spec §2.2 (sub-shape ground-truth) 와 일치하는지 확인:

| spec §2.2 contract | plan body 위치 | self-check |
|---|---|---|
| `render_strategy.spatial_consistency` 4 top-level sub-keys (`camera_frame_rule` / `fg_bg_shared_anchor_rule` / `primary_framing_rule` / `rationale_summary`) | Phase 1 Task 1.3 `_build_spatial_consistency_dict()` return | `grep -n "camera_frame_rule\|fg_bg_shared_anchor_rule\|primary_framing_rule\|rationale_summary" plan.md` 모두 등장 |
| `camera_frame_rule` 7 required keys (RO-3 — `core_principles` 추가) | Phase 1 Task 1.3 `camera_frame_rule = {...}` + Phase 1 Task 1.4 `_SPATIAL_CAMERA_FRAME_RULE_REQUIRED_KEYS` frozenset | 7 keys enumeration |
| `fg_bg_shared_anchor_rule` 6 required keys | Phase 1 Task 1.3 `fg_bg_shared_anchor_rule = {...}` + Phase 1 Task 1.4 `_SPATIAL_FG_BG_SHARED_ANCHOR_RULE_REQUIRED_KEYS` | 6 keys |
| `primary_framing_rule` 6 required keys | Phase 1 Task 1.3 `primary_framing_rule = {...}` + Phase 1 Task 1.4 `_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS` | 6 keys |
| `framing_scale_keywords` 3 sub-keys (close 11-entry RO-5 / wide 5-entry / medium_default) | Phase 1 Task 1.3 + Phase 1 Task 1.4 `_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS` | 3 sub-keys |
| `close_framing_rules` 4 sub-keys (incl `body_part_focus_cross_ref` RO-6) | Phase 1 Task 1.3 + Phase 1 Task 1.4 `_SPATIAL_CLOSE_FRAMING_RULES_REQUIRED_SUBKEYS` | 4 sub-keys |
| `wide_medium_rules` 4 sub-keys (incl `body_part_focus_cross_ref` RO-6) | Phase 1 Task 1.3 + Phase 1 Task 1.4 `_SPATIAL_WIDE_MEDIUM_RULES_REQUIRED_SUBKEYS` | 4 sub-keys |
| **cross-card refs in `primary_framing_rule` only, NOT `camera_frame_rule`** (PR4-B3) | Phase 1 Task 1.3 — body_part_focus_cross_ref 만 close_framing_rules + wide_medium_rules 안 | grep `camera_frame_rule.*close_framing_rules\|camera_frame_rule.*wide_medium_rules` → 0 |
| `core_principles[0..3]` substrings: `"low camera"` / `"overhead"` / `"hip-height"` / `"full body"` (RO-3) | Phase 1 Task 1.3 core_principles list 4-entry | substring assertions × 4 |
| `compute_render_strategy_snapshot_hash` (PR4-B4 / PR4-I2) — render_strategy 전체 hash, NOT spatial-only | Phase 1 Task 1.2 helper definition | grep `compute_spatial_consistency_snapshot_hash` → 0 |
| 9 module-level constants names (PR4-B5) | Phase 1 Task 1.1 9 `_SPATIAL_*` constants | 9 names exact match |
| RO-9 nested call chain (PR4-I5) — `_assert_render_strategy_shape() → _assert_spatial_consistency_shape() → _assert_primary_framing_rule_shape()` (further nested, NOT sibling) | Phase 1 Task 1.4 nested helpers | call chain inspection |
| RO-8 STRICT 0 (PR4-B7) — exit 1 on any violation, NOT soft warning | Phase 4 Task 4.3 implementation + Phase 5 unit test fixture 3 cases | exit 1 enforcement |
| 5 canary script names (PR4-B12) | Phase 4 Task 4.1-4.5 file names | `ls scripts/canary/g4_5a_*.py` → 5 file 정확 명 |
| Phase 3 NO inline prose deletion (PR4-B11) | Phase 3 Task 3.1 + 3.2 (bump only, no inline prose deletion) | Phase 3 task count = 2 |
| Phase 6 4-step procedure (PR4-B13 / PR4-I1) — baseline / candidate / pinning equality / 7-gate judgment | Phase 6 Task 6.1 / 6.2 / 6.3 | task split 정확 |
| 18 integration tests in body table (PR4-B10) | Phase 5 Task 5.2 body table | 18 tests enumerated |
| canonical numbering (PR4-B8 / PR4-I6) — Wave 6 has 4 tasks NOT 3 | Phase 7 Task 7.1-7.4 | task count = 4 |
| envelope 5-field invariant (사용자 binding) | Phase 5 Task 5.2 test 20 (`test_envelope_top_level_keys_unchanged_post_g4_5a`) | 7 envelope keys equality |

**Drift detection process**: plan body 변경 시 본 표 의 self-check 항목 즉시 검증. drift 발견 시 **즉시 plan R5 round** 진행.

---

## Final notes (R5/R6 audit 진행 전)

본 plan 은 R4 reject 후 plan 전체 rewrite. R4 reject 사유 (13 BLOCKING + key Important):

1. **PR4-B1**: silent fallback 패턴 violation
2. **PR4-B2**: spec §2.2 sub-shape contract drift
3. **PR4-B3**: cross-card refs 위치 오류 (camera_frame_rule 아래 잘못 배치)
4. **PR4-B4**: hash naming narrower (`compute_spatial_consistency_snapshot_hash` 잘못 명명)
5. **PR4-B5**: module-level constants 이름 drift
6. **PR4-B6**: G4.1+G4.4 carry keys 제거 위험
7. **PR4-B7**: RO-8 soft warning (STRICT 0 위반)
8. **PR4-B8**: numbering canonical drift
9. **PR4-B9**: self-import + typed-only forward declaration
10. **PR4-B10**: integration test 미enumerate (briefing reference 만)
11. **PR4-B11**: Phase 3 inline prose 잘못 가정 (G4.4 Override O-3 carry 없음)
12. **PR4-B12**: canary script naming `canary_g4_5a_*.py` 잘못
13. **PR4-B13**: Phase 6 procedure 누락

본 rewrite 가 13 BLOCKING 모두 정정. R5 (Codex) + R6 (Claude) post-impl review 진행 시 추가 발견 사항 fix iter 1 적용.

**G4.5a 다음 단계 (G4.5b → G4.5c)**:
- G4.5b: Rule B (line 63-90 조명·색조 28L) + Rule D (line 386-408 큰 prop 23L) + outfit 묘사 (line 418-441 24L) + evidence and inference disclosure (line 494-533 40L) lift. 5-field envelope contract 안 흡수 유지 (사용자 binding invariant).
- G4.5c: dense compression / final 250-line target / 잔여 reasoning prose consolidation.

---

> **Plan rewrite 완료 — 2026-05-05**
> spec: 1469 lines (`docs/superpowers/specs/2026-05-05-g4.5a-spatial-rules-lift-design.md`)
> plan: ~3000 lines (본 파일)
> 19 trap carry (G4.1+G4.2+G4.3+G4.4) + 13 PR4-B* + 7 PR4-I* + 14 RO + G4.2-G4.4 carry O-1~O-38
> R3+R4 plan audit 진행 — R5+R6 post-impl audit 후 fix loop iter 1
> commit gate = G4.4 production canary 4 gate AND G4.5a self-canary 7 gate

