# G4.3 ID-Policy 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:** v17 system.md 의 4 ID-policy prose section (`## C## 사용 규칙` 33L + `## 극단 클로즈업 표현` 20L + `## 사진·포스터·화면·거울 속 인물 규칙` 14L + `## entity ID 인물의 demographic descriptor 명시 (Rule H)` 42L = 109 lines) 을 `id_policy` 의 5 신규 sub-field (`face_identifiability_rule` + `body_part_focus_rule` + `close_framing_face_phrasing` + `reproduction_surface_rule` + `demographic_descriptor_policy`) + 6-string `constraints` 로 lift. system.md 에서 4 prose 삭제 후 30-line "ID Policy" compact section 으로 교체. v17 line 395 의 ID 룰 인용 1 line 인플레이스 교체. 5 canary scripts (4 metric + token) 작성. 36 unit + 12 integration test suite. 4-way 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_id_policy()` 5 신규 sub-field assemble + base 3 → 6 strings constraints 확장. 6 module-level constants (`_ID_BODY_PART_TRIGGERS` / `_ID_CLOSE_FACE_FORBIDDEN_PHRASES` / `_ID_CLOSE_FACE_RECOMMENDED_PHRASINGS` / `_ID_REPRODUCTION_SURFACES` / `_ID_ETHNICITY_COMPONENTS` / `_ID_AGE_BANDS`) + `_ID_BODY_PART_FOCUS_APPLIES_TO` literal. `_assert_id_policy_shape()` 에 5 sub-field strict 검증 추가. `compute_id_policy_snapshot_hash(card)` helper 신설. `build_render_prompt_card()` 가 `_card_metadata.lift_status` 에 4 신규 key + `rule_source` 4 신규 key 추가.
- **detail_steps.py consumer-side**: 변경 0 — G4.2 `_card_metadata` strip (commit `14d14cb`) 그대로 carry. line 105 `SCENE_DETAIL_PROMPT_VERSION` v17 → v18 bump.
- **prompt-side**: `prompts/_base/scene_detail/18.<timestamp>/system.md` 신규 — v17 4 prose section 109 lines 삭제 + 30-line "ID Policy" compact section + line 395 1-line 인플레이스 교체. `detail_schema.json` / `user.md` verbatim copy.
- **canary scripts**: 5 종 (`g4_3_body_part_focus.py` / `g4_3_close_framing_face_forbidden.py` / `g4_3_reproduction_surface.py` / `g4_3_demographic_descriptor_present.py` / `g4_3_token_count.py`). 모든 script 가 module-level constants import + ±50 chars window proximity (sentence regex 금지) + sentinel pre-pass + pinned-tuple post-loop validation + gate fail → exit 1.
- **tests**: 36 unit + 12 integration + alignment 4-way sync 갱신.

**Spec:** `docs/superpowers/specs/2026-05-04-g4.3-id-policy-lift-design.md` (1037 lines, R1+R2 audit complete, 21 Override rows). G4.3 entry condition split: spec / plan / Wave 1-4 = G4.2 production canary 무관 진행 가능. **commit gate** = G4.2 production canary 6 gate 필수 (R3-I3).

**Out of scope:**
- Continuity lift (G4.4) — fixed_elements / previous_shot_refs / forward_zoom_targets prose 변경 없음.
- Prompt slimming (G4.5) — heading count enforcement / single 1-paragraph rule 등.
- Background-binding lift (G4.2) — 이미 완료 (commit `14d14cb`). Rule A/C/E prose / `background_binding` shape / `_card_metadata` strip 로직 변경 0.
- perception_mode redesign — 현재 G4.1 carry 1-row branch 유지.
- v17 line 390-407 `## 절대 규칙 — 단일 시점` 섹션 자체 — G4.4 scope. G4.3 는 line 395 1 line 인플레이스 교체만 (R1R2-Q1).
- Producer-side scenario-domain 변경 (`entity_extractor` / `outlook_*` step / chain_bg loader) 없음.
- `_check_prompts()` / deterministic post-check 변경 없음.
- G3.2 owned sentinel 로직 변경 없음.
- `id_policy.allowed_base_entity_ids` / `allowed_outlook_pairs` shape 변경 없음 (G4.1 carry).

---

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

> **현황 (2026-05-04)**: spec R1+R2 audit 완료 (21 row). plan R3 (Codex) + R4 (Claude) audit 미진행. Wave 5 fix loop 진행 시 plan R5 round 추가.

### Spec R1+R2 Override carry (본 plan body 에 inline 적용)

| ID | Spec source | plan 영향 |
|---|---|---|
| **R1R2-B1** | spec R1-B1 | Task 1.1.A — `build_id_policy()` 에 신규 sub-field `face_identifiability_rule` 추가 (use_entity_id_when / common_noun_required_when / id_use_summary / rationale_summary 4 key dict). Task 1.3 — `_assert_id_policy_shape()` 가 본 sub-field strict 검증. Task 4.1 — alignment table row. Task 5.1 unit test 4종 추가 (`test_id_policy_face_identifiability_*`). canary detector 신설 보류 (prose 직접 검출 없음 — v18 prompt 삭제 + 단위 test 만으로 lift 완료 판정). |
| **R1R2-B2** | spec R1-B2 | Task 1.1.B — `_ID_BODY_PART_TRIGGERS = ("focus on", "close on", "tight on", "detail on")` 4 trigger module-level constant 도입. Task 4.1 canary regex `r"\b(?:" + "\|".join(map(re.escape, _ID_BODY_PART_TRIGGERS)) + r")\s+C\d{2}(?:O\d{2})?'s\s+\w+"` (IGNORECASE) — 4 trigger alternation. canary scope = **모든 shot** (`focus on` pattern 자체가 의도된 close-up 만 trigger). |
| **R1R2-B3** | spec R1-B3 + R2-B5 | Task 4.4 — `g4_3_demographic_descriptor_present.py` denominator = **first-appearance per `(scene_index, shot_index, variation_index, id)` tuple**. id regex `r"\bC\d{2}(?:O\d{2})?\b"` (bare `C##` OR composite `C##O##`). numerator = first-appearance 가 ±50 chars window 안에 ethnicity OR age_band entry 1+ 결합. **sentence boundary regex 사용 금지** (LLM punctuation 가변 — `feedback_no_regex_postprocessing.md` carry). metric 명: `demographic_descriptor_total_first_id_appearances` / `demographic_descriptor_present_first_appearances` / `demographic_descriptor_present_ratio`. |
| **R2-B1** | spec R2-B1 | Wave 1-B 진입 시 109 lines 정확 (33+20+14+42) 확인 — spec stale 표기 `약 ~140 lines` 대신 사용. Task 3.1 line 삭제 검증에서 구체 라인 범위 4 set 사용. |
| **R2-B3** | spec R2-B3 + R1-I2 | Task 1.3 — `_assert_id_policy_shape()` 5 신규 sub-field 의 sub-field shape contract 명시: `face_identifiability_rule.required_keys = ("use_entity_id_when", "common_noun_required_when", "id_use_summary", "rationale_summary")` / `body_part_focus_rule.required_keys = ("trigger_phrases", "applies_to", "id_use")` / `close_framing_face_phrasing.required_keys = ("forbidden_phrases", "recommended_phrasings", "rationale_summary")` / `reproduction_surface_rule.required_keys = ("applies_to_surfaces", "id_use", "rationale_summary")` / `demographic_descriptor_policy.required_keys = ("required_on_first_appearance", "applies_to_id_forms", "components")` (`components.required_keys = ("ethnicity", "gender", "age_band")` — role_hint_from_outfit / format_template_a/b / source_priority / scenario_dependency_ban / regional_consistency 는 optional/free-form). |
| **R2-B4** | spec R2-B4 | Task 5.1 unit test ground-truth 검증 시 `assert set(returned) == EXACT_GROUND_TRUTH_SET and len(returned) == N` 강제 (substring 검증 금지). N: body-part triggers = 4, close-framing forbidden = 6, close-framing recommended = 4, reproduction surfaces = 9, ethnicity = 10, age_band = 6. |
| **R1R2-Q1** | spec R1-I5 + R2-B2 | Task 3.1 추가 step — v18 system.md 에서 v17 line 395 `"C## 사용 금지 — 얼굴 참조 이미지가 강제 주입"` 부분을 `"id_policy.body_part_focus_rule 참조 — body-part Focus 시 C##O## 금지 (얼굴 ref 합성 차단)"` 로 1 line 인플레이스 교체. v17 line 390-407 단일 시점 섹션 자체는 G4.4 scope — body 보존. Task 5.2 integration test 가 v18 system.md 에 `"얼굴 참조 이미지가 강제 주입"` substring 부재 + `"id_policy.body_part_focus_rule"` substring 존재 검증. |
| **R2-I2 / Q5** | spec R2-I2 | Task 1.4 — `compute_id_policy_snapshot_hash(card: dict) -> str` helper 신설 (`render_prompt_card.py`). 산출법: `sha256(json.dumps(card["id_policy"], sort_keys=True, ensure_ascii=False))[:16]`. 5 canary scripts + Wave 3 모두 동일 helper import. pinning block field `id_policy_card_snapshot_hash` 산출에 사용. |
| **R1-I1** | spec R1-I1 | Task 1.1.A — ethnicity ground-truth = **10 entry**: `("Asian", "East Asian", "South Asian", "Southeast Asian", "Black", "Middle Eastern", "Hispanic", "Latina", "Latino", "Caucasian")` (Latina+Latino split 후). Task 4.4 / 5.1 / 9 Glossary 모두 10 entry 기준 통일. |
| **R1-I2 / R2-I6** | spec R1-I2 + R2-I6 | Task 1.1.B — `render_prompt_card.py` top 에 6 module-level constant 도입: `_ID_BODY_PART_TRIGGERS` (4) / `_ID_CLOSE_FACE_FORBIDDEN_PHRASES` (6) / `_ID_CLOSE_FACE_RECOMMENDED_PHRASINGS` (4) / `_ID_REPRODUCTION_SURFACES` (9) / `_ID_ETHNICITY_COMPONENTS` (10) / `_ID_AGE_BANDS` (6) + `_ID_BODY_PART_FOCUS_APPLIES_TO` 1 string literal. 단위 test + canary detector 가 동일 import. 기존 `_VALID_RENDER_MODES` (line 56-60) / `_FIXED_ELEMENT_REQUIRED_KEYS` (line 893-897) 패턴 답습. |
| **R1-I3 / R2-I3** | spec R1-I3 + R2-I3 | Task 3.1 compact section line count = **30** (예상치 25 → 정정). 30 lines 초과 금지 — Wave 1-B draft 작성 시 자체 line count 자체 점검. Task 4.5 token gate `≥ -1050` (estimate -1065 대비 15 token slack). |
| **R1-I4 / R2-I5** | spec R1-I4 + R2-I5 | Task 1.1.A — perception_mode (reflection / mirror / through_device / projection) constraint 가 reproduction_surface_rule 와 의도적 overlap (defense in depth). Task 5.1 unit test 추가 `test_id_policy_perception_mode_reflection_does_not_contradict_reproduction_surface()`. v18 compact section 에 "perception_mode constraint 가 mirror/projection shot 에서 우선" 명시 (Task 3.1 §3.3 carry). |
| **R2-I4** | spec R2-I4 | Task 5.1 unit test — `test_id_policy_visible_entities_empty_list_valid` 강화: `assert "face_identifiability_rule" in result and "body_part_focus_rule" in result and "close_framing_face_phrasing" in result and "reproduction_surface_rule" in result and "demographic_descriptor_policy" in result` — 5 builder-static sub-field 가 빈 입력에서도 항상 present. |
| **R2-I7** | spec R2-I7 | Task 5.4 alignment test 갱신 — `test_scene_detail_version_registry_aligned` 가 `MODULE_VERSIONS["scene_detail_composer"] == "1.18.0"` row + `_MODULE_INFO["scene_detail_composer"]["prompt_dependency"] == "scene_detail/v18"` row 검증 (R3R4-B1 carry: dict access). 4-point drift 차단. |
| **R1-M1** | spec R1-M1 | Task 5.1 unit test — `_ID_CLOSE_FACE_FORBIDDEN_PHRASES` 6번째 entry `"his face fills the frame"` 는 v17 prose 직접 등장 없음 (masculine symmetry — defensive). 본 entry 도 6 set 일부로 set equality 검증. §9 Glossary 에 `[v17-derived (symmetry)]` 라벨. |
| **R2-M2** | spec R2-M2 | Task 5.1 hash drift test (`test_id_policy_constraints_hash_drifts_on_content_change`) — `constraints[N]` 인덱스 의존 금지. content selector: `next(c for c in constraints if "face filling" in c)`. |
| **R2-M5** | spec R2-M5 | spec body 와 plan body 분리 명시: spec §11 = G4.3 R1/R2 audit findings (Round Override 동기화 history). spec §12 = G4.2 carry (immutable). plan R3+ audit 발견 사항 → plan R3/R4 row 추가. |
| **R2-M6** | spec R2-M6 | Task 6.1 baseline scene set 요건 — composite-ID 등장 shot 5 → first-appearance 발생 횟수 최소 10 으로 변경. baseline `demographic_descriptor_present_ratio` 측정 안정성. |
| **R2-M3** | spec R2-M3 | Task 3.1 §3.3 compact section draft — `✓/✗` 예시 미포함 (token gate 15 token slack tight). reasoning prose 만 carry. |
| **R2-M4** | spec R2-M4 | Wave 1-B 끝 Task 3.4 — v18 system.md draft 1차 tiktoken cl100k_base 실측 → estimate 보정 → Wave 4-5 진입 전 estimate vs gate margin 재검증. |
| **R2-I1** | spec R2-I1 | Task 1.1.A — `body_part_focus_rule.applies_to` ground-truth 문자열 = `"any body part (face or non-face) when the framing is dominated by that part"` (module-level constant `_ID_BODY_PART_FOCUS_APPLIES_TO`). Task 5.1 — `test_id_policy_body_part_focus_applies_to_exact()` 강제 동등 비교. |

### Plan R3 (Codex) + R4 (Claude) Override — 2026-05-04 듀얼 audit 결과

R3 verdict: `NEEDS_REVISION` (3B + 3I + 3M). R4 verdict: `APPROVED_WITH_REVISIONS` (4B + 6I + 6M).

| ID | Source | 결정 (Override) | 적용 위치 |
|---|---|---|---|
| **R3R4-B1** | R3-B1 + R4-B1 + R4-B2 | `_MODULE_INFO` 실제 shape = **dict** (NOT `ModuleInfo` 객체). plan body Phase 3 Task 3.2 의 attribute access (`.prompt_dependency`) 대신 dict access (`["prompt_dependency"]`). `prompt_dependency` 값 = `"scene_detail/v18"` (major suffix only, NOT `"scene_detail/18.<timestamp>"`). 기존 alignment test 가 `pdep.endswith(f"v{latest_major}")` 검증 — `"v18"` 형식 필수. 변경 위치: Wave decomposition table row 78, Phase 3 Task 3.2, Override R2-I7 row, Verification Checklist Wave 2, Task 5.4 alignment test rewrite, Wave 5 briefing, Override Table G4.3-N5. | Phase 3 Task 3.2 + Verification + Task 5.4 |
| **R3R4-B2** | R3-B2 | canary preflight 공통 패턴을 Phase 4 common pattern 에 명시 — `_load_scenes_or_fail()` + `_validate_scene_preflight()` helper 4 metric script 모두 적용. CP shape `scene["render_prompt_card"]["id_policy"]` validation + `compute_id_policy_snapshot_hash()` 호출 + `t2i_variations` list type check 모두 measurement_failures pre-pass. Task 4.4 line 1298 의 `variations = scene.get("t2i_variations", [])` 패턴 (silent absorb 위반) 제거. | Phase 4 common pattern + 4 script |
| **R3R4-B3** | R3-B3 | compact section line count 정확 30 — drafted block 31 lines (perception_mode bullet 2 줄 split) → 1 줄로 압축: `- "perception_mode" 가 mirror / reflection / through_device / projection 이면 reproduction_surface_rule 와 별도로 perception_mode constraint 가 우선 적용.` | Phase 2 Task 2.2 |
| **R4-B3 (6th phrase)** | R4-B3 | 6th forbidden phrase `"his face fills the frame"` 처리 명확화: `_ID_CLOSE_FACE_FORBIDDEN_PHRASES` 6 entry 모두 `close_framing_face_phrasing.forbidden_phrases` 에 포함 (set equality 검증). v18 compact section + constraints[3] inline list 는 5 entry 만 (token cost 이유). canary `g4_3_close_framing_face_forbidden.py` 가 6 entry 모두 검출 — 구조 field (LLM 에 inject 됨) 가 instruction source 로 충분. test 11 (set 6) + test 29 ([:5] inline) 분리 의도 명시. | Phase 4 Task 4.2 + Phase 5 Task 5.1 |
| **R4-B4** | R4-B4 | token gate 부호 통일 — 모든 위치에서 `token_delta = candidate - baseline` 사용, gate 조건 = `token_delta <= -1050`. Verification Checklist line 1944, Task 6.3 line 1793, Wave 5 briefing line 2299, Wave 1-B briefing line 2192 모두 통일. exit_criteria_threshold field = `-1050` (signed). | Phase 6 Task 6.3 + Verification + briefings |
| **R4-I1** | R4-I1 | unit test count 일관화 — Task 5.1 의 50 enumerated row → 36 mandatory test 로 collapse via parametrize. Wave 4 briefing 에 명시 "50 enumerated rows; collapse to ~36 via parametrize where ground-truth iteration shape allows; minimum 36 distinct test_ functions". Wave decomposition / Implementation Checklist Summary 모두 36 으로 통일. | Phase 5 Task 5.1 + briefings |
| **R4-I2** | R4-I2 | constraints[3] hash drift test selector strengthen — `next(c for c in constraints if "face filling the entire frame" in c)` (verbatim 6th-phrase prefix). Override R2-M2 row + Test 47 모두 갱신. | Phase 5 Task 5.1 Test 47 |
| **R4-I3** | R4-I3 | Wave 1-B → Wave 2 timestamp lockstep — Wave 1-B briefing 에 "scratch file `.wave_1b_timestamp.txt` 에 timestamp 기록 후 Wave 2 dispatcher 가 read" 명시. alignment test fail = lockstep 누락 신호. | Wave 1-B briefing |
| **R4-I4** | R4-I4 | hash invariance test 추가 — Test 46.b `test_card_hash_invariant_to_card_metadata_lift_status_dynamic_keys`: `compute_card_hash(card_close_skip)` == `compute_card_hash(card_off)` when only `_card_metadata.lift_status` differs. G4.2 carry guarantee 가 G4.3 에서 무력화되지 않도록 explicit 검증. | Phase 5 Task 5.1 |
| **R4-I5** | R4-I5 | `body_part_focus_rule.common_noun_examples_generic` 의 required vs optional 모순 해결 — Task 1.4 의 `required_keys` 에 추가 (`("trigger_phrases", "applies_to", "id_use", "common_noun_examples_generic")`). 본 entry 가 항상 4 element 보장. test 9 (`len == 4`) 도 항상 적용. | Phase 1 Task 1.4 + Phase 5 Task 5.1 test 9 |
| **R4-I6** | R4-I6 | "4-way sync" → "4-point sync" rename + 명시화 (4 = v18 prompt dir / `detail_steps.py:105` / `version_registry.py:34 MODULE_VERSIONS` / `version_registry.py:126 _MODULE_INFO[...]["prompt_dependency"]`). 2 of 4 가 같은 file 안 — 이를 명시. Verification Checklist Wave 2 (line 1947-1950) 4 점 enumerate. Override O-19 wording 갱신. | Verification Checklist + Override |
| **R3-M1** | R3-M1 | Phase 4 common pattern (line 937) `detail_steps.py:2124` 인용 정정 → `detail_steps.py:2161` (실제 `result["render_prompt_card"]` 저장 위치, 기존 line 2124 는 outfit-assignment comment 무관). | Phase 4 common pattern |
| **R3-M2** | R3-M2 | "Spec ↔ plan drift quick-check" 표 line 인용 갱신 — spec Round Override 실제 lines 21-91 (52 아님) + Override Table 매핑 spec §10 → §12 (G4.2 carry table). | Spec ↔ plan drift quick-check |
| **R3-M3** | R3-M3 | Risk Register Rollback 명세 — `git revert HEAD` 가 prompt directory artifact 도 삭제함 명시 (commit 이 새 dir 추가했으므로). artifact 보존 목적이면 별도 cherry-pick 필요. | Risk Register |
| **R4-M1** | R4-M1 | Wave 1-A briefing 진입 시 `import hashlib` + `import json` 존재 확인 self-check 추가. (G4.1 carry 가정 — 미존재 시 add). | Wave 1-A briefing |
| **R4-M2** | R4-M2 | Wave 4 briefing pytest selector — `g3_2 or g4_1 or g4_2` 외에 alignment test 명시 path `backend/tests/prompts/test_scene_detail_id_policy_alignment.py` 추가. | Wave 4 briefing |
| **R4-M3** | R4-M3 | Task 4.4 ratio gate 0/0 floor 방어 — `total_first_id_appearances < 10` (R2-M6 minimum) 시 measurement_failures (silent 0.0 금지). | Phase 4 Task 4.4 |
| **R4-M4** | R4-M4 | compact section line count 검증 — sed `,/^## /` inclusive 의 trap 회피. integration test 6 가 `re.search(r"## ID Policy.*?(?=\n## )", system_md, re.DOTALL)` content 만 추출 후 `len(content.split("\n")) == 30` 강제. | Phase 5 Task 5.2 test 6 |
| **R4-M5** | R4-M5 | Task 4.3 reproduction surface canary deduplication — 한 id 가 ±50 chars window 안 surface 2+ 와 매칭 시 violation 1 회만 카운트 (id_token + id_position[0] 기반 dedup). | Phase 4 Task 4.3 |
| **R4-M6** | R4-M6 | Wave 1-A briefing — 5 신규 sub-field 는 builder-static, `if visible_entities:` 같은 truthy guard 안 배치 절대 금지. Override O-1 / Test 37 (visible_entities=[] valid + 5 sub-field 모두 present) 무력화 차단. | Wave 1-A briefing |

### 메타룰

- 본 표 가 plan body 보다 우선 적용.
- Body 동기화는 Override 동기화 패치에서 inline 적용 (G4.2 패턴 답습).
- Spec ↔ plan drift 발견 시 **즉시 plan R5 round** 진행.
- 절대 규칙 위반 (LLM 입력 truncation, 시나리오 의존 고유명사, silent fallback) 발견 시 BLOCKING 최우선.

---

## Wave Decomposition Table

| Wave | inputs | outputs | blocking-on | approx LOC | approx test count |
|---|---|---|---|---|---|
| **Wave 1-A** | spec §2.2 / §4 / §7.2 step 1 / Override R1-I1 / R1-I2 / R1R2-B1 / R1R2-B2 / R2-B3 / R2-I1 / R2-I2 | `render_prompt_card.py` modified (~280 LOC) — 7 module constants + `compute_id_policy_snapshot_hash()` + `build_id_policy()` 확장 + `_assert_id_policy_shape()` 5 sub-field 검증 + `build_render_prompt_card()` `_card_metadata` 4 신규 key | 없음 (Wave 1-B 와 병렬) | ~280 | 0 (test 는 Wave 4) |
| **Wave 1-B** | spec §3.1 / §3.2 / §3.3 / §3.4 / Override R1R2-Q1 / R2-M3 / R2-M4 | `prompts/_base/scene_detail/18.<timestamp>/system.md` (신규) + `detail_schema.json` (verbatim copy) + `user.md` (verbatim copy) — net -79 lines (109 삭제 + 30 추가) + line 395 인플레이스 교체 | 없음 (Wave 1-A 와 병렬) | -79 net | 0 |
| **Wave 2** | Wave 1-A + Wave 1-B 완료 (timestamp 결정) | `detail_steps.py:105` `SCENE_DETAIL_PROMPT_VERSION` v17→`"18.<timestamp>"` bump + `version_registry.py:34` `MODULE_VERSIONS["scene_detail_composer"]` `1.17.0`→`1.18.0` + `version_registry.py:126` `_MODULE_INFO[...]["prompt_dependency"]` `"scene_detail/v17"`→`"scene_detail/v18"` (dict access — R3R4-B1). alignment test fixture 갱신 | Wave 1-B (timestamp pin) | ~6 | 0 (alignment test 는 Wave 4) |
| **Wave 3** | spec §5.3 / §5.1 / Override R1-I2 / R1R2-B2 / R1R2-B3 / R2-I2 | 5 canary script (`scripts/canary/g4_3_*.py`) — 모든 script 가 module constants import + sentinel pre-pass + pinned-tuple post-loop + ±50 chars window | Wave 1-A (constants import) | ~600 (5 script × ~120) | 0 (canary 자체 unit test 는 smoke 만 — Wave 4) |
| **Wave 4** | spec §6.1 / §6.2 / §6.4 / Override R1R2-B1 / R2-B3 / R2-B4 / R2-I4 / R2-I7 / R2-M2 / R2-I1 | `backend/tests/unit/test_g4_3_id_policy_lift.py` (~36 unit) + `backend/tests/integration/test_g4_3_id_policy_integration.py` (~12 integration) + alignment test 갱신 | Wave 1-A + 1-B + 2 (alignment test 의존) | ~700 | +48 (36 unit + 12 integration) |
| **Wave 5** | Wave 1-A~4 완료 + G4.2 production canary 6 gate green | baseline canary capture + candidate canary capture + 5 metric 비교 + 듀얼 리뷰 3 iter fix + single commit + push | G4.2 production canary (commit gate) | n/a (실행 단계) | 0 (수정만) |

총 expected delta: **+~280 producer LOC + -79 prompt LOC + ~600 canary LOC + ~700 test LOC + 48 신규 test**.

---

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

**Phase 목표:** 6 module-level constant + 1 string literal 추가. `build_id_policy()` 에 5 신규 sub-field assemble 로직 + 6-string constraints 확장. `_assert_id_policy_shape()` 5 sub-field strict 검증. `compute_id_policy_snapshot_hash()` helper 신설. `build_render_prompt_card()` `_card_metadata` 4 신규 key 추가.

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

**Wave subagent 함정 경고 (G4.1 + G4.2 carry):**
- **절대 금지**: `visible_entities=[]` / `outlook_pairs=[]` 류의 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 carry.
- **절대 금지**: `or [] / or {}` silent absorb pattern. None 흡수 → builder fail-fast 무력화 → production drift cascade. G4.2 iter1 C3 / O-6 carry.

---

### Task 1.1 — module-level constants 도입

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py`
- [ ] **Insert location:** module top (기존 `_VALID_RENDER_MODES` 56-60 / `_FIXED_ELEMENT_REQUIRED_KEYS` 893-897 패턴 답습 — 새 constant 는 56-60 다음 줄에 일괄 추가)
- [ ] **Dependency:** 없음 (G4.2 base 위에서 직접 수정)
- [ ] **Spec cross-reference:** spec §2.2 / §9 Glossary R3-B1 / R3-B2 / R3-B3 / Override R1-I1 / R1-I2 / R2-I6 / R2-I1 / R1-M1
- [ ] **Expected diff size:** ~50 lines added

**변경 내용:**

`render_prompt_card.py:60` (`_VALID_RENDER_MODES` 직후) 에 다음 7 constant 일괄 도입:

```python
# G4.3 R3-B1 ground-truth: body-part Focus trigger phrases (4).
# canary `g4_3_body_part_focus.py` regex alternation source. 본 tuple 이 single source —
# 본문 §2.2 / §3.3 / §6.1 unit test / canary detector 모두 본 list reference.
_ID_BODY_PART_TRIGGERS: Tuple[str, ...] = (
    "focus on",
    "close on",
    "tight on",
    "detail on",
)

# G4.3 R2-I1 ground-truth: body_part_focus_rule.applies_to literal.
# `test_id_policy_body_part_focus_applies_to_exact()` 강제 동등 비교 source.
_ID_BODY_PART_FOCUS_APPLIES_TO: str = (
    "any body part (face or non-face) when the framing is dominated by that part"
)

# G4.3 R3-B1 ground-truth: close-framing face forbidden phrasings (6).
# 6번째 entry `"his face fills the frame"` 는 v17 prose 직접 미등장 (masculine symmetry — defensive).
# canary `g4_3_close_framing_face_forbidden.py` 검출 source.
_ID_CLOSE_FACE_FORBIDDEN_PHRASES: Tuple[str, ...] = (
    "face filling the entire frame",
    "face filling nearly the entire frame",
    "her face fills the frame",
    "his face fills the frame",
    "shoulders only entering the frame",
    "head snapped sideways",
)

# G4.3 close-framing face recommended phrasings (4).
_ID_CLOSE_FACE_RECOMMENDED_PHRASINGS: Tuple[str, ...] = (
    "tight framing on her face, upper body in frame",
    "close on his expression, shoulder visible at the edge",
    "head-and-shoulder composition, face occupying the center-right",
    "face in profile at the left, neck and upper chest visible",
)

# G4.3 R3-B2 ground-truth: reproduction surface keywords (9).
# canary `g4_3_reproduction_surface.py` ±50 chars window proximity source.
_ID_REPRODUCTION_SURFACES: Tuple[str, ...] = (
    "photograph",
    "poster",
    "painting",
    "portrait",
    "monitor",
    "TV",
    "mirror",
    "window reflection",
    "projection",
)

# G4.3 R3-B3 ground-truth: ethnicity components (10 — Latina+Latino split per R1-I1).
# 한 작품 내 region 표기는 일관 — 작품 단위 고정.
# canary `g4_3_demographic_descriptor_present.py` ±50 chars window 결합 source.
_ID_ETHNICITY_COMPONENTS: Tuple[str, ...] = (
    "Asian",
    "East Asian",
    "South Asian",
    "Southeast Asian",
    "Black",
    "Middle Eastern",
    "Hispanic",
    "Latina",
    "Latino",
    "Caucasian",
)

# G4.3 age band components (6).
_ID_AGE_BANDS: Tuple[str, ...] = (
    "in his/her 20s",
    "in his/her 30s",
    "in his/her 40s",
    "young",
    "middle-aged",
    "elderly",
)
```

**주의:**
- 7 constant 모두 **tuple of str** (immutable) — list 사용 금지.
- 모든 constant 가 module-level (Class 안에 넣지 말 것) — `from app.core.steps.render_prompt_card import _ID_BODY_PART_TRIGGERS` 형태로 canary script 와 unit test 가 직접 import.
- 시나리오 의존 0: 7 constant 안에 작품 고유명사 (인물 이름, 지명) 절대 포함 금지.
- 본 step 의 정확한 entry 개수는 R2-B4 carry 로 unit test 가 `len() == N` + `set(...) == EXACT_GROUND_TRUTH_SET` 강제 검증.

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

---

### Task 1.2 — `compute_id_policy_snapshot_hash()` helper 신설

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py`
- [ ] **Insert location:** `compute_card_hash()` 직전 / 직후 (canonicalize_render_prompt_card 헬퍼들 인접 위치 — 기존 hash helpers 와 같은 영역)
- [ ] **Dependency:** Task 1.1 (constants 가 본 helper 하부 호출 대상이 아니지만, 동일 모듈에서 logical grouping)
- [ ] **Spec cross-reference:** spec §5.2 / §5.3 / Override R2-I2 / Q5 RESOLVED
- [ ] **Expected diff size:** ~25 lines added

**변경 내용:**

```python
def compute_id_policy_snapshot_hash(card: Dict[str, Any]) -> str:
    """G4.3 R2-I2 carry — id_policy partial canonicalize hash.

    canary pinning block 7 field 중 `id_policy_card_snapshot_hash` 산출용.
    baseline / candidate id_policy shape 동일 검증. 변동 source 차단.

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

    R2-I2 결정 근거: G4.2 의 `compute_card_hash()` 가 envelope 전체 hash —
    G4.3 canary 는 id_policy 변경에만 sensitive 해야 하므로 partial hash 별도.

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

**주의:**
- `json` / `hashlib` 모두 module top 의 import 영역에 이미 존재 (G4.1 carry) — 신규 import 불필요.
- 본 helper 는 5 canary script 모두 import 사용. canary 추가 시 동일 helper 재사용.
- silent fallback 금지: `card.get("id_policy", {})` 패턴 절대 금지 — None / 빈 dict 흡수 차단.

---

### Task 1.3 — `build_id_policy()` 확장: 5 신규 sub-field assemble

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py:217-275`
- [ ] **Dependency:** Task 1.1 (7 constants 사용)
- [ ] **Spec cross-reference:** spec §2.2 / §7.2 step 1 / Override R1R2-B1 / R1R2-B2 / R2-B3 / R2-I1 / R2-I4 / R1-I4 / R2-I5 / R1-I1
- [ ] **Expected diff size:** ~140 lines (constraints 3 → 6 + 5 신규 sub-field)

**변경 내용:**

`build_id_policy()` 의 현재 return 직전에 다음 dict 5개 assemble 추가. constraints list 도 base 3 → 6 strings 으로 확장.

```python
def build_id_policy(
    *,
    visible_entities: List[str],
    outlook_pairs: List[Dict[str, str]],
    perception_mode: Optional[str],
) -> Dict[str, Any]:
    """spec §4.2 — id_policy field.

    G4.3 (2026-05-04):
      - face_identifiability_rule (R1R2-B1) 신규 sub-field — face identifiable
        gates / OTS-no-face exception.
      - body_part_focus_rule (R1R2-B2) 신규 sub-field — 4 trigger phrases.
      - close_framing_face_phrasing (R1R2-B1 카운터파트) 신규 sub-field — 6
        forbidden + 4 recommended.
      - reproduction_surface_rule 신규 sub-field — 9 surface keywords.
      - demographic_descriptor_policy 신규 sub-field — composite ID 첫 등장 시
        1-2 token mandatory + 10 ethnicity components + 6 age bands.
      - constraints G4.1 base 3 → G4.3 6 strings.

    G4.1 R2-B4 carry: visible_entities / outlook_pairs 가 None 이면 producer
    missing 으로 간주 → AppError. 명시적 [] 만 valid.
    """
    if visible_entities is None:
        raise AppError(...)  # 기존 G4.1 코드 그대로 보존
    if outlook_pairs is None:
        raise AppError(...)  # 기존 G4.1 코드 그대로 보존

    # G4.3 신규 sub-field 1: face_identifiability_rule (R1R2-B1)
    face_identifiability_rule = {
        "use_entity_id_when": [
            "face identifiable: front, profile, three-quarter, or eyes closed",
            "OTS framing with any visible face",
        ],
        "common_noun_required_when": [
            "back to camera with no visible face",
            "silhouette or blurred outline only",
            "OTS with only back of head/shoulder, face entirely hidden",
        ],
        "id_use_summary": (
            "C##/C##O## allowed only when face is identifiable AND framing is "
            "not dominated by an isolated body part"
        ),
        "rationale_summary": (
            "C## 사용 시 얼굴 ref 가 자동 결합 — 얼굴이 안 보이는 인물에 C## 부착하면 "
            "ref 와 본문 묘사가 충돌"
        ),
    }

    # G4.3 신규 sub-field 2: body_part_focus_rule (R1R2-B2)
    body_part_focus_rule = {
        "trigger_phrases": list(_ID_BODY_PART_TRIGGERS),  # 4 entries
        "applies_to": _ID_BODY_PART_FOCUS_APPLIES_TO,
        "id_use": "forbidden — use a common noun + demographic descriptor instead",
        "common_noun_examples_generic": [
            "a trembling hand",
            "an exposed forearm",
            "the furrowed brow of a figure in shadow",
            "the clenched jaw of a figure in profile",
        ],
    }

    # G4.3 신규 sub-field 3: close_framing_face_phrasing
    close_framing_face_phrasing = {
        "forbidden_phrases": list(_ID_CLOSE_FACE_FORBIDDEN_PHRASES),  # 6 entries
        "recommended_phrasings": list(_ID_CLOSE_FACE_RECOMMENDED_PHRASINGS),  # 4 entries
        "rationale_summary": (
            "T2I 모델은 얼굴이 프레임 전체를 가득 채우는 묘사를 받으면 몸과 분리된 "
            "얼굴만 공중에 떠 있는 합성을 낸다 — 어깨/상반신 일부를 프레임 안에 유지"
        ),
    }

    # G4.3 신규 sub-field 4: reproduction_surface_rule
    reproduction_surface_rule = {
        "applies_to_surfaces": list(_ID_REPRODUCTION_SURFACES),  # 9 entries
        "id_use": "forbidden — common noun + demographic descriptor only",
        "rationale_summary": (
            "C##O## 은 얼굴 reference 이미지를 원본 해상도로 inject 하므로 사진/화면 "
            "속 얼굴이 사진 밖 실물 크기로 합성됨"
        ),
    }

    # G4.3 신규 sub-field 5: demographic_descriptor_policy (Rule H lift)
    demographic_descriptor_policy = {
        "required_on_first_appearance": True,
        "applies_to_id_forms": ["C##", "C##O##"],
        "token_count_range": [1, 2],
        "components": {
            "ethnicity": list(_ID_ETHNICITY_COMPONENTS),  # 10 entries (R1-I1)
            "gender": ["man", "woman", "figure"],
            "age_band": list(_ID_AGE_BANDS),  # 6 entries
            "role_hint_from_outfit": [
                "in worker uniform",
                "in fisher workwear",
                "in detective coat",
                "in business suit",
            ],
        },
        "format_template_a": "C##O## in <옷 1-3 단어>, <demographic descriptor>",
        "format_template_b": "C##O##, <demographic descriptor>, <자세 표현>",
        "source_priority": [
            "visual_world_rules.region",
            "visual_world_rules.era",
            "shot context outfit hint",
        ],
        "scenario_dependency_ban": (
            "no work-specific proper nouns (character names, place names) — "
            "common nouns only"
        ),
        "regional_consistency": (
            "한 작품 내 region 표기는 일관 — 선택된 세분 ethnicity (East Asian / "
            "Southeast Asian / South Asian / Caucasian / Black / Hispanic / Latina|o / "
            "Middle Eastern) 를 작품 단위 고정"
        ),
    }

    # G4.3 constraints: G4.1 base 3 → G4.3 6 strings
    constraints = [
        # constraints[0] — composite IDs use
        "visible real people with outfit binding use C##O## composite IDs",
        # constraints[1] — reproduction surface (9 surfaces enumerated inline)
        (
            "do not use C##O## for a reproduced face inside a photograph, poster, "
            "painting, portrait, monitor, TV, mirror, window reflection, or projection "
            "— use a common noun + demographic descriptor instead"
        ),
        # constraints[2] — body-part focus (4 triggers enumerated inline)
        (
            "do not use C##O## for isolated body-part focus (face or non-face) — "
            "when the framing is dominated by a single body part triggered by "
            "'focus on / close on / tight on / detail on' phrasing, use a common noun "
            "(hand / eye / brow / jaw / etc.)"
        ),
        # constraints[3] — close-framing face forbidden (selected 5 of 6 inline)
        (
            "for close-up of a face, do not use 'face filling the entire frame' / "
            "'face filling nearly the entire frame' / 'her face fills the frame' / "
            "'shoulders only entering the frame' / 'head snapped sideways' — keep "
            "upper body or shoulder edge visible in the frame "
            "('tight framing on her face, upper body in frame')"
        ),
        # constraints[4] — demographic first-appearance
        (
            "when introducing a C##O## (or C##) for the first time in this t2i_prompt, "
            "append a 1-2 token demographic descriptor (ethnicity + age band, or "
            "ethnicity + role hint) consistent with visual_world_rules.region — never "
            "use scenario-specific proper nouns"
        ),
        # constraints[5] — format templates
        (
            "use the format `C##O## in <옷 1-3 단어>, <demographic descriptor>` or "
            "`C##O##, <demographic descriptor>, <자세 표현>` — main characters with "
            "face refs may include this descriptor too (image inject takes precedence)"
        ),
    ]

    # G4.1 carry: perception_mode reflection branch — base 6 + 1 = 7 (R1-I4 / R2-I5
    # carry — overlap with reproduction_surface_rule for defense in depth)
    if (perception_mode or "").lower() in (
        "reflection", "mirror", "through_device", "projection",
    ):
        constraints.append(
            "this shot's perception_mode involves a reflective/projected surface — "
            "all reproduced faces use common nouns even if the source identity is known"
        )

    return {
        "allowed_base_entity_ids": list(visible_entities),  # G4.1 carry
        "allowed_outlook_pairs": list(outlook_pairs),       # G4.1 carry
        "must_use_composite_character_ids": True,           # G4.1 carry
        "common_noun_required_when": [                       # G4.1 carry
            "body-part close-up",
            "photo/poster/screen/mirror/reflection/projection depiction",
            "unregistered extra",
        ],
        "demographic_fallback_required": True,               # G4.1 carry

        # G4.3 신규 5 sub-field
        "face_identifiability_rule": face_identifiability_rule,
        "body_part_focus_rule": body_part_focus_rule,
        "close_framing_face_phrasing": close_framing_face_phrasing,
        "reproduction_surface_rule": reproduction_surface_rule,
        "demographic_descriptor_policy": demographic_descriptor_policy,

        # G4.3 확장 constraints (6 base + 0~1 perception_mode)
        "constraints": constraints,
    }
```

**주의 (Wave 1-A 함정 회피 — G4.1 trap carry):**
- `list(...)` wrapping 필수 — tuple → list 변환으로 JSON serializable 보장 + caller 가 mutate 해도 module constant 영향 없음.
- 신규 5 sub-field 모두 builder-static (입력 무관 — visible_entities=[] / [] 상태에서도 항상 동일 dict). R2-I4 carry — 빈 입력에서도 5 sub-field present.
- perception_mode reflection branch 는 base 6 위에 추가되는 7번째 constraint — base 6 의 일부가 아님 (R1-I4 carry — defense in depth).
- 신규 5 sub-field 안 한국어 prose (rationale_summary 등) 는 시나리오 의존 0 — 작품 고유명사 (인물 이름, 지명) 절대 포함 금지.

---

### Task 1.4 — `_assert_id_policy_shape()` 5 sub-field strict 검증 추가

- [ ] **File:** `backend/app/core/steps/render_prompt_card.py:844-872`
- [ ] **Dependency:** Task 1.3 (5 신규 sub-field 가 build_id_policy 출력에 존재)
- [ ] **Spec cross-reference:** spec §2.2 (assert_card_shape strict) / Override R2-B3 / R1-I2
- [ ] **Expected diff size:** ~80 lines (5 sub-field 검증 추가)

**변경 내용:**

기존 `_assert_id_policy_shape()` (line 844-872) 가 `allowed_base_entity_ids` / `allowed_outlook_pairs` 만 검증 — G4.3 5 sub-field 검증 추가.

각 sub-field 의 required_keys (R2-B3 carry):

```python
def _assert_id_policy_shape(ip: Dict[str, Any], where: str) -> None:
    """G4.1 carry: allowed_base_entity_ids (list[str]) +
    allowed_outlook_pairs (list[dict] with character_id+outlook_id).

    G4.3 (R2-B3 carry): 5 신규 sub-field strict 검증 추가.
    각 sub-field 의 required_keys 는 본 함수에서 single source.
    """
    # G4.1 carry — allowed_base_entity_ids / allowed_outlook_pairs 검증
    if "allowed_base_entity_ids" not in ip:
        raise AppError(...)  # 기존 코드 그대로
    _assert_str_list(...)
    if "allowed_outlook_pairs" not in ip:
        raise AppError(...)
    _assert_dict_list_with_keys(...)

    # G4.3 신규 5 sub-field 검증
    _ID_POLICY_SUB_FIELD_REQUIRED_KEYS = {
        "face_identifiability_rule": (
            "use_entity_id_when",
            "common_noun_required_when",
            "id_use_summary",
            "rationale_summary",
        ),
        "body_part_focus_rule": (
            "trigger_phrases",
            "applies_to",
            "id_use",
            "common_noun_examples_generic",  # R4-I5 carry — required (test 9 len == 4 항상 적용)
        ),
        "close_framing_face_phrasing": (
            "forbidden_phrases",
            "recommended_phrasings",
            "rationale_summary",
        ),
        "reproduction_surface_rule": (
            "applies_to_surfaces",
            "id_use",
            "rationale_summary",
        ),
        "demographic_descriptor_policy": (
            "required_on_first_appearance",
            "applies_to_id_forms",
            "components",
        ),
    }
    for sub_field_name, required_keys in _ID_POLICY_SUB_FIELD_REQUIRED_KEYS.items():
        if sub_field_name not in ip:
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.id_policy missing "
                    f"{sub_field_name!r} (G4.3 R2-B3) {where}"
                ),
            )
        sub_field = ip[sub_field_name]
        if not isinstance(sub_field, dict):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.id_policy.{sub_field_name} must be dict "
                    f"(got {type(sub_field).__name__}) {where}"
                ),
            )
        for k in required_keys:
            if k not in sub_field:
                raise AppError(
                    code="step.contract_violation",
                    message=(
                        f"render_prompt_card.id_policy.{sub_field_name} missing "
                        f"required key {k!r} (have keys "
                        f"{sorted(sub_field.keys())}) {where}"
                    ),
                )

    # demographic_descriptor_policy.components 의 ethnicity / gender / age_band 검증
    if "demographic_descriptor_policy" in ip:
        ddp = ip["demographic_descriptor_policy"]
        if "components" in ddp:
            comp = ddp["components"]
            if not isinstance(comp, dict):
                raise AppError(
                    code="step.contract_violation",
                    message=(
                        "demographic_descriptor_policy.components must be dict "
                        f"(got {type(comp).__name__}) {where}"
                    ),
                )
            for comp_key in ("ethnicity", "gender", "age_band"):
                if comp_key not in comp:
                    raise AppError(
                        code="step.contract_violation",
                        message=(
                            f"demographic_descriptor_policy.components missing "
                            f"{comp_key!r} {where}"
                        ),
                    )
                _assert_str_list(
                    comp[comp_key],
                    f"demographic_descriptor_policy.components.{comp_key}",
                    where,
                )
```

**주의:**
- `role_hint_from_outfit` / `format_template_a` / `format_template_b` / `source_priority` / `scenario_dependency_ban` / `regional_consistency` 는 optional/free-form — strict 검증에 포함 X (R2-B3 결정).
- `body_part_focus_rule.common_noun_examples_generic` 는 **required** (R4-I5 carry) — `body_part_focus_rule.required_keys = ("trigger_phrases", "applies_to", "id_use", "common_noun_examples_generic")` 4 key. 본 entry 4 element 항상 보장 (test 9 `len == 4`). spec Round Override `R2-B3` row 의 3 key 표기는 R4-I5 patch 후 4 key 로 통일.
- `face_identifiability_rule` 의 `common_noun_required_when` 은 G4.1 의 base id_policy `common_noun_required_when` (root level) 과 다른 sub-field 안 nested. 이름 충돌 없음 — 본 sub-field 안 검증만.

---

### Task 1.5 — `build_render_prompt_card()` `_card_metadata` 4 신규 key 추가

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

**변경 내용:**

기존 G4.2 `_card_metadata` assembly (commit `14d14cb` 도입 — `lift_status` 3 key + `rule_source` 3 key) 에 G4.3 신규 4 key 추가.

```python
# G4.3 R3-B1 carry: id_policy lift 완료 — 4 prose section 모두 card 로 lift
card["_card_metadata"] = {
    "lift_status": {
        # G4.2 carry (commit 14d14cb)
        "rule_a_lifted": True,
        "rule_c_lifted": True,
        "rule_e_lifted": _is_close_skip_or_off_or_not_applicable_else_false,
        # G4.3 신규
        "rule_h_lifted": True,
        "id_policy_composite_lifted": True,
        "id_policy_close_framing_face_lifted": True,
        "id_policy_reproduction_surface_lifted": True,
    },
    "rule_source": {
        # G4.2 carry
        "camera_rule": "A",
        "owned_rule": "C",
        "close_skip_rule": "E",
        # G4.3 신규
        "demographic_rule": "H",
        "composite_id_rule": "C## 사용 규칙 — 참조 이미지 연동 (v17:32-64)",
        "close_framing_face_rule": "극단 클로즈업 표현 (v17:66-85)",
        "reproduction_surface_rule": "사진·포스터·화면·거울 속 인물 규칙 (v17:133-146)",
    },
}
```

**주의 (G4.2 carry):**
- `_card_metadata` 는 `canonicalize_render_prompt_card()` 가 top-level pop (G4.1/G4.2 carry) — hash 영향 0.
- `_card_metadata` 는 inject 직전 `detail_steps.py:1937-1942` 에서 strip — LLM 입력 영향 0.
- 4 신규 key 는 모든 perception_mode 에서 `True` (id_policy 는 perception 무관 동일 적용 — spec §2.2 matrix).
- `rule_source` 의 v17 line range 표기는 hardcoded — 시나리오 의존 0 (작품 고유명사 없음).

---

### 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 7
- [ ] **Expected diff size:** ~20 lines (docstring only)

**변경 내용:**

1. Module docstring 상단 `G4.1/G4.2 RenderPromptCard helper` → `G4.1/G4.2/G4.3 RenderPromptCard helper`.
2. `build_id_policy()` docstring 에 G4.3 변경 기록 추가:
   ```
   G4.3 (2026-05-04): 5 신규 builder-static sub-field 추가 (face_identifiability_rule
   / body_part_focus_rule / close_framing_face_phrasing / reproduction_surface_rule
   / demographic_descriptor_policy). constraints 3 → 6 strings 확장. ground-truth
   data 는 module-level constants (R1-I2 / R2-I6 carry). _card_metadata.lift_status
   에 4 신규 key (build_render_prompt_card wrapper 책임).
   ```
3. `_assert_id_policy_shape()` docstring 에 G4.3 5 sub-field strict 검증 명시.
4. `compute_id_policy_snapshot_hash()` docstring 에 R2-I2 결정 근거 명시.

---

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

**Phase 목표:** `prompts/_base/scene_detail/18.<timestamp>/` 신규 디렉토리 생성. v17 system.md 복사 후 4 ID-policy prose section 109 lines 삭제 + 30-line "ID Policy" compact section 신설 + line 395 1-line 인플레이스 교체. `detail_schema.json` / `user.md` 는 v17 verbatim copy.

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

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

---

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

- [ ] **File:** `prompts/_base/scene_detail/18.<timestamp>/system.md` (신규 생성)
- [ ] **Dependency:** 없음 (Wave 1-A 와 병렬 가능)
- [ ] **Spec cross-reference:** spec §3.1 / §3.2 / §3.3 / §3.4 / Override R1R2-Q1 / R2-B1 / R2-M3 / R2-M4
- [ ] **Expected diff size:** -109 lines (4 prose 삭제) + +30 lines (compact section) + line 395 1-line 인플레이스 교체 = net -79 lines

**변경 내용:**

1. **타임스탬프 결정**: 작업 시점의 `YYYYMMDDHHmm` 형식 (예: `18.202605041800`). Wave 2 의 detail_steps.py / version_registry.py 와 lockstep 동기화.
2. **디렉토리 복사**:
   ```bash
   cp -r prompts/_base/scene_detail/17.202605042018 prompts/_base/scene_detail/18.<timestamp>
   ```
3. **v18 system.md 에서 다음 4 sections 삭제** (v17 line 기준 — R2-B1 carry 정확 109 lines):

| section | v17 lines | 라인수 | 삭제 이유 |
|---|---|---|---|
| `## C## 사용 규칙 — 참조 이미지 연동` | 32-64 | 33 | card `face_identifiability_rule` + `body_part_focus_rule` + constraints 가 동일 데이터 carry |
| `## 극단 클로즈업 표현 — 몸과 분리된 얼굴 방지` | 66-85 | 20 | card `close_framing_face_phrasing` 가 동일 데이터 carry |
| `## 사진·포스터·화면·거울 속 인물 규칙` | 133-146 | 14 | card `reproduction_surface_rule` + constraints 가 동일 데이터 carry |
| `## entity ID 인물의 demographic descriptor 명시 (Rule H) ★ 신규` | 341-382 | 42 | card `demographic_descriptor_policy` 가 동일 데이터 carry |

총 삭제: 33 + 20 + 14 + 42 = **109 lines**.

**삭제 자동화 검증 (`grep -n` post-delete check):**
```bash
grep -n "C## 사용 규칙 — 참조 이미지 연동" prompts/_base/scene_detail/18.<timestamp>/system.md
# → (no output)
grep -n "극단 클로즈업 표현 — 몸과 분리된 얼굴 방지" prompts/_base/scene_detail/18.<timestamp>/system.md
# → (no output)
grep -n "사진·포스터·화면·거울 속 인물 규칙" prompts/_base/scene_detail/18.<timestamp>/system.md
# → (no output)
grep -n "entity ID 인물의 demographic descriptor 명시" prompts/_base/scene_detail/18.<timestamp>/system.md
# → (no output)
```

---

### Task 2.2 — v18 system.md 30-line "ID Policy" compact section 삽입

- [ ] **File:** `prompts/_base/scene_detail/18.<timestamp>/system.md`
- [ ] **Dependency:** Task 2.1 완료
- [ ] **Spec cross-reference:** spec §3.3 / Override R1-I3 / R2-I3 / R2-M3
- [ ] **Expected diff size:** +30 lines

**변경 내용:**

삭제된 `## C## 사용 규칙` (line 32) 위치에 다음 30-line compact section 삽입 (`## 극단 클로즈업` / `## 사진·포스터·화면·거울` / `## entity ID 인물의 demographic descriptor` 4 section 의 위치 모두 본 단일 section 으로 통합):

```markdown
## ID Policy (C##/C##O## composite + body-part / reproduction / demographic — Rule H)

`[RenderPromptCard v1]` 의 `id_policy` field 가 이 shot 의 character ID 사용
정책을 결정한다 (card 가 primary contract — 이 섹션 prose 와 충돌 시 card 우선).

**핵심 원칙:**

- `allowed_base_entity_ids` / `allowed_outlook_pairs` 안의 ID 만 사용 가능.
  그 외 인물은 보통명사 + demographic descriptor 로 묘사.
- `body_part_focus_rule.trigger_phrases` (`focus on / close on / tight on /
  detail on` + 신체부위) 패턴이 등장하면 **얼굴·비얼굴 가리지 않고 C##O##
  사용 금지** — 공통명사 + demographic descriptor 로 대체. 이유: 얼굴 ref
  가 신체부위 위에 겹쳐 그려지거나 몸 분리 합성.
- 얼굴 close-up 시 `close_framing_face_phrasing.forbidden_phrases` 표현 금지
  (`face filling the entire frame` 류) — `recommended_phrasings`
  (head-and-shoulder 구도) 권장. 이유: T2I 모델이 frame 가득 채운 얼굴을
  몸 분리 합성으로 처리.
- `reproduction_surface_rule.applies_to_surfaces` (사진·포스터·모니터·거울·
  반사·투영 등) 안의 인물은 **C##O## 절대 금지** — 보통명사로만. 이유: 얼굴
  ref 가 원본 해상도로 inject 되어 surface 위에 실물 크기 합성.
- `demographic_descriptor_policy.required_on_first_appearance=true` 일 때
  composite/base ID 첫 등장에 ethnicity + age (또는 + role hint) 1-2 token
  부착. format: `C##O## in <옷 1-3 단어>, <demographic descriptor>` 또는
  `C##O##, <demographic descriptor>, <자세 표현>`. region 은 visual_world_rules
  기반 — 작품 고유명사 (인물 이름·지명) 사용 금지.
- `perception_mode` 가 mirror / reflection / through_device / projection 이면 `reproduction_surface_rule` 와 별도로 perception_mode constraint 가 우선 적용.

**충돌 해결**: 본 섹션의 prose 는 reasoning summary 이며, 구체 token list (forbidden
phrases / surfaces / ethnicity options) 는 모두 `id_policy` sub-field 에서 가져온다.
prose 가 sub-field 와 충돌하면 sub-field 우선.
```

**주의:**
- 정확히 **30 lines** (R1-I3 / R2-I3 carry — 30 초과 금지).
- ✓/✗ 예시 inline 미포함 (R2-M3 결정 — token gate 15 token slack tight 유지).
- perception_mode 우선 명시 (R1-I4 / R2-I5 carry — defense in depth).

**삽입 후 검증 (R4-M4 carry — sed inclusive trap 회피):**
```bash
grep -n "## ID Policy" prompts/_base/scene_detail/18.<timestamp>/system.md
# → <line>:## ID Policy (C##/C##O## composite + body-part / reproduction / demographic — Rule H)
```

```python
# 정확히 30 lines 검증 (next ## heading 제외)
import re
with open("prompts/_base/scene_detail/18.<timestamp>/system.md") as f:
    text = f.read()
m = re.search(r"## ID Policy.*?(?=\n## )", text, re.DOTALL)
assert m is not None, "## ID Policy section not found"
content_lines = m.group().split("\n")
assert len(content_lines) == 30, f"expected 30 lines, got {len(content_lines)}"
```

---

### Task 2.3 — v17 line 395 인플레이스 교체 (R1R2-Q1)

- [ ] **File:** `prompts/_base/scene_detail/18.<timestamp>/system.md`
- [ ] **Dependency:** Task 2.1 완료 (디렉토리 존재)
- [ ] **Spec cross-reference:** spec §3.2 R1R2-Q1 결정
- [ ] **Expected diff size:** 1 line replaced

**변경 내용:**

v17 line 395 의 ID 룰 인용 1 line 인플레이스 교체:

| 시점 | 텍스트 |
|---|---|
| **Before (v17)** | `... C## 사용 금지 — 얼굴 참조 이미지가 강제 주입 ...` |
| **After (v18)** | `... id_policy.body_part_focus_rule 참조 — body-part Focus 시 C##O## 금지 (얼굴 ref 합성 차단) ...` |

v17 line 390-407 단일 시점 섹션 자체는 G4.4 scope — body 보존. 본 task 는 line 395 한 줄만 교체.

**교체 후 검증 (Wave 4 integration test 가 강제 검증):**
```bash
# v17-style substring 부재 확인
grep -n "얼굴 참조 이미지가 강제 주입" prompts/_base/scene_detail/18.<timestamp>/system.md
# → (no output)

# v18-style substring 존재 확인
grep -n "id_policy.body_part_focus_rule" prompts/_base/scene_detail/18.<timestamp>/system.md
# → <line_number>:... id_policy.body_part_focus_rule 참조 ...
```

---

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

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

**변경 내용:**

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

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

2. **자체 line count + tiktoken 실측 (R2-M4 carry)**:
   ```bash
   wc -l prompts/_base/scene_detail/17.202605042018/system.md
   # → <baseline_line_count>

   wc -l prompts/_base/scene_detail/18.<timestamp>/system.md
   # → <baseline - 79> 부근 (109 삭제 + 30 추가 + 1 line 인플레이스 교체)

   python -c "
   import tiktoken
   enc = tiktoken.get_encoding('cl100k_base')
   with open('prompts/_base/scene_detail/17.202605042018/system.md') as f:
       v17_tokens = len(enc.encode(f.read()))
   with open('prompts/_base/scene_detail/18.<timestamp>/system.md') as f:
       v18_tokens = len(enc.encode(f.read()))
   print(f'v17: {v17_tokens} tokens')
   print(f'v18: {v18_tokens} tokens')
   print(f'delta: {v18_tokens - v17_tokens}')
   print(f'gate -1050 pass: {v18_tokens - v17_tokens <= -1050}')
   "
   ```

3. **gate margin 실측 후 estimate 보정**: estimate `-1065` 와 actual delta 비교 → spec §3.4 inline note 갱신 (선택 — Wave 5 fix loop 진입 시).

---

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

**Phase 목표:** `SCENE_DETAIL_PROMPT_VERSION` v17 → `"18.<timestamp>"` bump (디렉토리 매칭). `MODULE_VERSIONS["scene_detail_composer"]` `1.17.0` → `1.18.0`. `_MODULE_INFO["scene_detail_composer"]["prompt_dependency"]` `"scene_detail/v17"` → `"scene_detail/v18"` (dict access — major suffix only, R3R4-B1). 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 완료 (v18 `<timestamp>` 결정 *후*)
- [ ] **Spec cross-reference:** spec §6.4 4-way sync / G4.2 plan-R1-I3 / Override R2-I7
- [ ] **Expected diff size:** ~1 line

**변경 내용:**

line 105 의 정확한 변경:
```python
# Before (G4.2):
SCENE_DETAIL_PROMPT_VERSION = "17.202605042018"  # G4.2: Rule A/C/E lift to background_binding.constraints — Rule prose 삭제 + Background Binding compact section.

# After (G4.3):
SCENE_DETAIL_PROMPT_VERSION = "18.<timestamp>"  # G4.3: ID-policy lift — 4 prose sections (C##/close-framing/reproduction/Rule H) → id_policy.{face_identifiability_rule, body_part_focus_rule, close_framing_face_phrasing, reproduction_surface_rule, demographic_descriptor_policy} + 6-string constraints. v18 system.md = v17 - 109 lines + 30-line compact section + line 395 인플레이스.
```

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

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

**주의 (G4.2 plan-R1-I6 carry — SCHEMA alignment):**
- `SCENE_DETAIL_SCHEMA_VERSION` (= 7) 은 **변경 없음**. cp shape 변경 아님 (id_policy sub-field 추가는 cp 안 dict key 추가만).
- `backend/app/core/step_manifest.py:632` 의 `scene_detail.schema_version = 7` 도 변경 없음.
- `CARD_SCHEMA_VERSION` (= 1) 은 변경 없음 — id_policy 신규 sub-field 는 envelope 외 dict 확장이며 5 → 7 → 동일.

---

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

- [ ] **File:** `backend/app/core/version_registry.py:34` + `:126`
- [ ] **Dependency:** Task 3.1 완료
- [ ] **Spec cross-reference:** spec §6.4 / G4.2 plan-R2-I6 / Override R2-I7
- [ ] **Expected diff size:** ~6 lines

**변경 내용:**

```python
# backend/app/core/version_registry.py:34 — MODULE_VERSIONS dict
# Before (G4.2):
MODULE_VERSIONS = {
    ...
    "scene_detail_composer": "1.17.0",  # G4.2 background-binding lift
    ...
}

# After (G4.3):
MODULE_VERSIONS = {
    ...
    "scene_detail_composer": "1.18.0",  # G4.3 ID-policy lift (4 prose → id_policy sub-fields + 6 constraints)
    ...
}
```

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

# After (G4.3):
"scene_detail_composer": {
    "prompt_dependency": "scene_detail/v18",  # major suffix only — NOT "scene_detail/18.<timestamp>"
    "updated_at": "2026-05-04",  # 또는 신규 timestamp
},
```

**주의 (R3R4-B1 carry):**
- `_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/v18"` (major suffix only). 기존 alignment test 가 `pdep.endswith(f"v{latest_major}")` 검증하므로 timestamp 형식 (`"scene_detail/18.<timestamp>"`) 사용 시 alignment fail. `version_registry.py:106 "shot_extract/v11"` / `:110 "shot_validator/v3"` 등 기존 entry 모두 major-only suffix.
- 본 변경 누락 시 `test_scene_detail_id_policy_alignment.py` alignment test 4-point sync row 가 fail.
- Task 5.4 alignment test 가 본 변경 검증 (Wave 4 에서).
- `SCENE_DETAIL_PROMPT_VERSION` (Task 3.1) 의 timestamp 부분과 본 `prompt_dependency` 값은 **별도 source** — 전자는 `"18.<timestamp>"` (디렉토리 매칭), 후자는 `"scene_detail/v18"` (major suffix). 혼동 금지.

---

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

**Phase 목표:** `scripts/canary/` 에 5개 측정 스크립트 작성 (4 metric + 1 token). 각 스크립트는 module-level constants import + sentinel pre-pass + pinned-tuple post-loop validation + ±50 chars window proximity (sentence regex 금지) + gate fail → exit 1.

**스크립트 5종**:
- Task 4.1 `g4_3_body_part_focus.py` — 모든 shot scope, 4 trigger alternation
- Task 4.2 `g4_3_close_framing_face_forbidden.py` — close-framing shot only, 6 forbidden
- Task 4.3 `g4_3_reproduction_surface.py` — 모든 shot scope, 9 surface ±50 chars window with C##O##
- Task 4.4 `g4_3_demographic_descriptor_present.py` — composite-ID 등장 shot only, first-appearance per `(scene, shot, variation, id)` tuple ratio
- Task 4.5 `g4_3_token_count.py` — system.md tiktoken cl100k_base v17 vs v18 delta

**공통 패턴 (G4.2 carry — silent-absorb 차단)**:
- **CP shape**: `scene["render_prompt_card"]["id_policy"]` (NOT 평면 `scene["id_policy"]`). G4.2 함정 carry — `detail_steps.py:2161` authoritative (R3-M1 carry).
- **silent absorb 금지**: `or [] / or {}` 패턴 제거. `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 차단.
- **t2i_variations + per-variation t2i_prompt validation**: variation 누락 / variation 내부 t2i_prompt 누락 모두 measurement_failures.
- **module-level constants import**: 각 script 가 `from app.core.steps.render_prompt_card import _ID_BODY_PART_TRIGGERS, _ID_CLOSE_FACE_FORBIDDEN_PHRASES, _ID_REPRODUCTION_SURFACES, _ID_ETHNICITY_COMPONENTS, _ID_AGE_BANDS, compute_id_policy_snapshot_hash`.
- **±50 chars window proximity (sentence regex 사용 금지)** — `feedback_no_regex_postprocessing.md` carry. id 등장 위치를 기준으로 [start-50, end+50] window 안에서 keyword/component substring presence 검사.

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

```python
# 모든 4 metric script 가 metric 합산 전 본 helper 호출. silent absorb 차단.

def _load_scenes_or_fail(cp: dict, measurement_failures: list) -> list | None:
    data = cp.get("data")
    if not isinstance(data, dict):
        measurement_failures.append("data missing or not dict")
        return None
    scenes = data.get("scenes")
    if not isinstance(scenes, list):
        measurement_failures.append("data.scenes missing or not list")
        return None
    return scenes


def _validate_scene_preflight(
    scene: dict, si: int, shi: int, measurement_failures: list
) -> list | None:
    rpc = scene.get("render_prompt_card")
    if not isinstance(rpc, dict):
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card missing or not dict"
        )
        return None
    id_policy = rpc.get("id_policy")
    if not isinstance(id_policy, dict):
        measurement_failures.append(
            f"s{si}_sh{shi}: render_prompt_card.id_policy missing or not dict"
        )
        return None
    # snapshot hash 산출 — 실패 시 measurement_failures (silent skip 금지)
    try:
        compute_id_policy_snapshot_hash(rpc)
    except Exception as e:
        measurement_failures.append(f"s{si}_sh{shi}: id_policy hash failed: {e}")
        return None
    variations = scene.get("t2i_variations")
    if not isinstance(variations, list):
        measurement_failures.append(
            f"s{si}_sh{shi}: t2i_variations missing or not list"
        )
        return None
    return variations


# script 본문 — 사용 예시:
# scenes = _load_scenes_or_fail(cp, measurement_failures)
# if scenes is None:
#     sys.exit(1)
# for si, scene in enumerate(scenes):
#     variations = _validate_scene_preflight(scene, si, shi, measurement_failures)
#     if variations is None:
#         continue  # measurement_failures 가 이미 기록됨
#     for vi, var in enumerate(variations):
#         prompt = var.get("t2i_prompt")
#         if not isinstance(prompt, str):
#             measurement_failures.append(f"s{si}_sh{shi}_v{vi}: t2i_prompt missing")
#             continue
#         # ... metric 측정 ...
# if measurement_failures:
#     print(json.dumps({"measurement_failures": measurement_failures}))
#     sys.exit(1)
```

**주의 (R3R4-B2 carry):**
- Task 4.4 의 `variations = scene.get("t2i_variations", [])` 같은 silent-absorb 패턴 제거 — `_validate_scene_preflight()` helper 가 fail-fast.
- token script (Task 4.5) 는 CP 가 아니라 prompt 파일을 읽으므로 본 helper 미적용 — 대신 prompt 파일 존재 / version 매칭 / pinning validation 별도 적용.
- gate fail 시 모든 4 metric script + token script 명시적 `sys.exit(1)` (Override O-7).

---

### Task 4.1 — `g4_3_body_part_focus.py` (모든 shot scope)

- [ ] **File:** `scripts/canary/g4_3_body_part_focus.py` (신규 생성)
- [ ] **Dependency:** Task 1.1 (constants 사용)
- [ ] **Spec cross-reference:** spec §5.1 (body-part focus row) / §5.3 / Override R1R2-B2
- [ ] **Expected diff size:** ~140 lines (신규)

**스크립트 역할:**

주어진 PID + scene_index_list 의 scene_detail CP 를 읽어, **모든 shot 의 `t2i_variations[].t2i_prompt`** 에서 body-part focus pattern (`focus on / close on / tight on / detail on` + `C##O##` + body part) 검출.

**regex (R1R2-B2 carry — 4 trigger alternation):**
```python
import re
from app.core.steps.render_prompt_card import (
    _ID_BODY_PART_TRIGGERS,
    compute_id_policy_snapshot_hash,
)

# alternation pattern — 4 trigger 모두 match
_TRIGGER_ALT = "|".join(re.escape(t) for t in _ID_BODY_PART_TRIGGERS)
BODY_PART_FOCUS_PATTERN = re.compile(
    r"\b(?:" + _TRIGGER_ALT + r")\s+C\d{2}(?:O\d{2})?'s\s+\w+",
    re.IGNORECASE,
)
```

**pinning block (G4.2 R1-I5, R2-B6 carry, G4.3 추가 `id_policy_card_snapshot_hash`):**
```python
pinning = {
    "pid": pid,
    "scene_index_list": scene_index_list,
    "shot_index_list_per_scene": shot_index_list_per_scene,
    "model_routing": model_routing,
    "prompt_source_mode": prompt_source_mode,
    "card_commit_hash": card_commit_hash,
    "id_policy_card_snapshot_hash": id_policy_card_snapshot_hash,
}
```

**핵심 로직 (silent-absorb 차단 패턴):**
```python
measurement_failures: list = []
total_shots = 0
body_part_focus_count = 0
body_part_focus_per_shot = {}
seen_in_cp: set = set()

cp = json.loads(cp_path.read_text())
scenes_in_cp = cp.get("data", {}).get("scenes")
if not isinstance(scenes_in_cp, list):
    measurement_failures.append("data.scenes missing or not list")
    print(json.dumps({"measurement_failures": measurement_failures, ...}))
    sys.exit(1)

for scene in scenes_in_cp:
    si = scene.get("scene_index")
    shi = scene.get("_shot_index")
    if (si, shi) not in pinning_scene_set:
        continue
    seen_in_cp.add((si, shi))
    variations = scene.get("t2i_variations")
    if not isinstance(variations, list):
        measurement_failures.append(f"s{si}_sh{shi}: t2i_variations missing")
        continue
    total_shots += 1
    shot_count = 0
    for var in variations:
        prompt = var.get("t2i_prompt")
        if not isinstance(prompt, str):
            measurement_failures.append(
                f"s{si}_sh{shi}: variation t2i_prompt missing"
            )
            continue
        matches = BODY_PART_FOCUS_PATTERN.findall(prompt)
        shot_count += len(matches)
    body_part_focus_count += shot_count
    body_part_focus_per_shot[f"{si}_{shi}"] = shot_count

# pinned tuple seen_in_cp post-loop validation (false STRICT pass 차단)
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)}"
    )

# gate fail → exit 1 (CI 인지 가능)
if measurement_failures:
    print(json.dumps({"measurement_failures": measurement_failures, ...}))
    sys.exit(1)
```

**출력 JSON schema:**
```json
{
  "timestamp": "ISO8601",
  "prompt_version": "17.202605042018",  // baseline / candidate 별
  "pinning": {
    "pid": "...",
    "scene_index_list": [],
    "shot_index_list_per_scene": {},
    "model_routing": "...",
    "prompt_source_mode": "file",
    "card_commit_hash": "...",
    "id_policy_card_snapshot_hash": "..."
  },
  "scene_set": [
    {"scene_index": 3, "shot_index": 1, "is_close_framing": false, "has_composite_id": true, "has_reproduction_surface": false}
  ],
  "metrics": {
    "total_shots": 0,
    "body_part_focus_pattern_count": 0,
    "body_part_focus_per_shot": {},
    "trigger_phrases_used": ["focus on", "close on", "tight on", "detail on"]
  },
  "measurement_failures": [],
  "measurement_scripts": {
    "body_part_focus": "scripts/canary/g4_3_body_part_focus.py (all shots — 4 trigger alternation R1R2-B2)"
  }
}
```

**exit criteria threshold (R1R2-B2 / R3-I1 strict):**
- **STRICT 0**: `candidate.metrics.body_part_focus_pattern_count == 0` (baseline 비0 이어도 candidate 0 강제).
- baseline 비0 이면 P1 follow-up 등록 (G4.3 scope 밖, 별도 추적).

**Wave subagent 함정 회피**:
- **절대 금지**: `prompt or ""` / `variations or []` / `scenes or []` silent absorb pattern.
- **반드시**: `measurement_failures` 누적 후 exit 1 — silent 0 false-pass 차단.
- module-level constants 직접 import — script 안에 hardcoded 4 trigger literal 작성 금지 (drift 차단).

---

### Task 4.2 — `g4_3_close_framing_face_forbidden.py` (close-framing shot only)

- [ ] **File:** `scripts/canary/g4_3_close_framing_face_forbidden.py` (신규 생성)
- [ ] **Dependency:** Task 1.1
- [ ] **Spec cross-reference:** spec §5.1 (close-framing face row) / §5.3 / Override R1-M1
- [ ] **Expected diff size:** ~135 lines (신규)

**스크립트 역할:**

close-framing shot 만 scan. 6 forbidden phrasing 검출.

**module constants import + 검출:**
```python
from app.core.steps.render_prompt_card import (
    _ID_CLOSE_FACE_FORBIDDEN_PHRASES,
    compute_id_policy_snapshot_hash,
)

# literal substring 검출 — regex 사용 안 함 (단순 substring case-insensitive)
def find_forbidden_phrases(prompt: str) -> list:
    """6 forbidden phrasing 중 prompt 안 등장 list 반환."""
    found = []
    prompt_lower = prompt.lower()
    for phrase in _ID_CLOSE_FACE_FORBIDDEN_PHRASES:
        if phrase.lower() in prompt_lower:
            found.append(phrase)
    return found
```

**close framing 판별:**
- CP 의 `background_binding.close_framing_skips_background_ref == true` 또는 `background_binding.mode == "skipped_close_framing"` 인 shot 만 측정.
- close framing 아닌 shot 은 측정 대상 X (face-filling-frame phrasing 자체 자연 미발생 — R1R2-B2 / R3-I1 scope).

**출력 JSON schema (다른 script 와 pinning 동일 — `is_close_framing` field 포함):**
```json
{
  "timestamp": "ISO8601",
  "prompt_version": "...",
  "pinning": {...},
  "scene_set": [
    {"scene_index": 3, "shot_index": 1, "is_close_framing": true, "has_composite_id": true, "has_reproduction_surface": false}
  ],
  "metrics": {
    "total_shots": 0,
    "close_framing_shots": 0,
    "non_close_framing_shots": 0,
    "close_framing_face_forbidden_count": 0,
    "close_framing_face_forbidden_per_shot": {},
    "forbidden_phrases_detected": []
  },
  "measurement_failures": [],
  "measurement_scripts": {
    "close_framing_face_forbidden": "scripts/canary/g4_3_close_framing_face_forbidden.py (close-framing shots only — R3-I1)"
  }
}
```

**exit criteria threshold (R3-I1 strict):**
- **STRICT 0**: `candidate.metrics.close_framing_face_forbidden_count == 0`.

---

### Task 4.3 — `g4_3_reproduction_surface.py` (모든 shot scope)

- [ ] **File:** `scripts/canary/g4_3_reproduction_surface.py` (신규 생성)
- [ ] **Dependency:** Task 1.1
- [ ] **Spec cross-reference:** spec §5.1 (reproduction surface row) / Override R1R2-B3 / R3-I1
- [ ] **Expected diff size:** ~150 lines (신규)

**스크립트 역할:**

모든 shot scope (R3-I1). 9 surface keyword 와 인접 (±50 chars window) C##O## composite 검출.

**module constants + ±50 chars window logic:**
```python
import re
from app.core.steps.render_prompt_card import (
    _ID_REPRODUCTION_SURFACES,
    compute_id_policy_snapshot_hash,
)

ID_REGEX = re.compile(r"\bC\d{2}O\d{2}\b")  # composite IDs only (C##O##)
WINDOW_CHARS = 50  # ±50 chars (sentence boundary regex 사용 금지 — feedback_no_regex_postprocessing.md)


def find_reproduction_violations(prompt: str) -> list:
    """C##O## composite 가 9 surface keyword 의 ±50 chars window 안 등장 시 violation."""
    violations = []
    surface_positions = []
    prompt_lower = prompt.lower()
    for surface in _ID_REPRODUCTION_SURFACES:
        s_lower = surface.lower()
        start = 0
        while True:
            idx = prompt_lower.find(s_lower, start)
            if idx == -1:
                break
            surface_positions.append((idx, idx + len(s_lower), surface))
            start = idx + 1
    for match in ID_REGEX.finditer(prompt):
        id_start = match.start()
        id_end = match.end()
        id_token = match.group()
        for s_start, s_end, surface in surface_positions:
            # ±50 chars window proximity check
            window_start = min(id_start, s_start) - WINDOW_CHARS
            window_end = max(id_end, s_end) + WINDOW_CHARS
            # id 와 surface 가 서로 ±50 chars 안인지
            if (
                (s_start <= id_end + WINDOW_CHARS and s_end >= id_start - WINDOW_CHARS)
            ):
                violations.append({
                    "id_token": id_token,
                    "surface": surface,
                    "id_position": [id_start, id_end],
                    "surface_position": [s_start, s_end],
                })
    return violations
```

**중요 — sentence boundary regex 사용 금지 (R1R2-B3 carry):**
- LLM 의 punctuation 이 가변적 — sentence split (`. ` / `\n\n`) 으로 결합 검사 시 false-negative 발생.
- ±50 chars window 는 character-level proximity — proximity 기반 결합 판정.

**출력 JSON schema (`has_reproduction_surface` field 포함):**
```json
{
  "timestamp": "ISO8601",
  "prompt_version": "...",
  "pinning": {...},
  "scene_set": [...],
  "metrics": {
    "total_shots": 0,
    "shots_with_reproduction_surface": 0,
    "reproduction_surface_composite_id_count": 0,
    "reproduction_surface_composite_id_per_shot": {},
    "violations_detail": []
  },
  "measurement_failures": [],
  "measurement_scripts": {
    "reproduction_surface": "scripts/canary/g4_3_reproduction_surface.py (all shots, ±50 chars window — R1R2-B3 / R3-I1)"
  }
}
```

**exit criteria threshold (R3-I1 strict):**
- **STRICT 0**: `candidate.metrics.reproduction_surface_composite_id_count == 0`.

---

### Task 4.4 — `g4_3_demographic_descriptor_present.py` (composite-ID 등장 shot only)

- [ ] **File:** `scripts/canary/g4_3_demographic_descriptor_present.py` (신규 생성)
- [ ] **Dependency:** Task 1.1
- [ ] **Spec cross-reference:** spec §5.1 (demographic ratio row) / Override R1R2-B3 / R1-I1 / R3-I1
- [ ] **Expected diff size:** ~170 lines (신규)

**스크립트 역할:**

composite-ID 등장 shot 만 measurement scope (R3-I1 분모). first-appearance per `(scene_index, shot_index, variation_index, id_token)` tuple.

**핵심 로직 (R1R2-B3 carry — 4-tuple denominator):**

```python
import re
from app.core.steps.render_prompt_card import (
    _ID_ETHNICITY_COMPONENTS,
    _ID_AGE_BANDS,
    compute_id_policy_snapshot_hash,
)

# bare C## OR composite C##O## (4-tuple distinct id 처리)
ID_REGEX = re.compile(r"\bC\d{2}(?:O\d{2})?\b")
WINDOW_CHARS = 50


def first_appearance_descriptor_present(prompt: str) -> tuple:
    """returns (total_first_appearances_dict, descriptor_present_count_dict).

    각 (id_token) → first-appearance position. window 안에 ethnicity OR age_band 결합 시 numerator++.
    """
    seen_ids: dict = {}  # id_token → first_position
    descriptor_present: set = set()  # id_token set with descriptor present
    prompt_lower = prompt.lower()

    for match in ID_REGEX.finditer(prompt):
        id_token = match.group()
        if id_token in seen_ids:
            continue  # not first appearance
        seen_ids[id_token] = match.start()
        # ±50 chars window
        win_start = max(0, match.start() - WINDOW_CHARS)
        win_end = min(len(prompt), match.end() + WINDOW_CHARS)
        window = prompt_lower[win_start:win_end]

        # ethnicity OR age_band component 1+ 결합 검사
        for ethnicity in _ID_ETHNICITY_COMPONENTS:
            if ethnicity.lower() in window:
                descriptor_present.add(id_token)
                break
        else:
            for age in _ID_AGE_BANDS:
                if age.lower() in window:
                    descriptor_present.add(id_token)
                    break
    return seen_ids, descriptor_present
```

**핵심 4-tuple denominator (R1R2-B3):**

```python
total_first_id_appearances = 0
descriptor_present_first_appearances = 0

for scene in scenes_in_cp:
    si = scene["scene_index"]
    shi = scene["_shot_index"]
    if (si, shi) not in pinning_scene_set:
        continue
    variations = scene.get("t2i_variations", [])  # silent absorb 금지 — measurement_failures 처리
    for vi, var in enumerate(variations):
        prompt = var.get("t2i_prompt")
        if not isinstance(prompt, str):
            measurement_failures.append(f"s{si}_sh{shi}_v{vi}: t2i_prompt missing")
            continue
        seen, descriptor_present = first_appearance_descriptor_present(prompt)
        # 4-tuple denominator: (si, shi, vi, id_token)
        total_first_id_appearances += len(seen)
        descriptor_present_first_appearances += len(descriptor_present)

ratio = (
    descriptor_present_first_appearances / total_first_id_appearances
    if total_first_id_appearances > 0
    else 0.0
)
```

**중요 — sentence boundary regex 사용 금지 (R1R2-B3 carry):**
- ±50 chars window — char-level proximity.
- 분모 = first-appearance per `(si, shi, vi, id_token)` tuple — 변동 측정 안정성 (R2-M6 carry).

**출력 JSON schema:**
```json
{
  "timestamp": "ISO8601",
  "prompt_version": "...",
  "pinning": {...},
  "scene_set": [...],
  "metrics": {
    "total_shots": 0,
    "shots_with_composite_id": 0,
    "demographic_descriptor_total_first_id_appearances": 0,
    "demographic_descriptor_present_first_appearances": 0,
    "demographic_descriptor_present_ratio": 0.0
  },
  "measurement_failures": [],
  "measurement_scripts": {
    "demographic_descriptor_present": "scripts/canary/g4_3_demographic_descriptor_present.py (composite-ID shots only, 4-tuple denominator — R1R2-B3)"
  }
}
```

**exit criteria threshold (R1R2-B3):**
- `candidate.metrics.demographic_descriptor_present_ratio >= baseline.metrics.demographic_descriptor_present_ratio` (degradation 차단).

---

### Task 4.5 — `g4_3_token_count.py` (tiktoken cl100k_base)

- [ ] **File:** `scripts/canary/g4_3_token_count.py` (신규 생성)
- [ ] **Dependency:** Task 2.1 / 2.2 (v18 prompt 존재해야 candidate 측정)
- [ ] **Spec cross-reference:** spec §5.1 token row / Override R1-M1 / R2-I3
- [ ] **Expected diff size:** ~90 lines (신규)

**스크립트 역할:**

`prompts/_base/scene_detail/<version>/system.md` 를 `tiktoken cl100k_base` 로 인코딩, token 수 실측. v17 vs v18 delta 계산. exit criteria `≥ -1050 tokens` (R1-I3 / R2-I3) 판정.

**출력 JSON schema (G4.2 plan-R2-I4 carry — pinning 7 field 포함):**
```json
{
  "timestamp": "ISO8601",
  "pinning": {
    "pid": "...",
    "scene_index_list": [],
    "shot_index_list_per_scene": {},
    "model_routing": "...",
    "prompt_source_mode": "file",
    "card_commit_hash": "...",
    "id_policy_card_snapshot_hash": "..."
  },
  "baseline_prompt_version": "17.202605042018",
  "candidate_prompt_version": "18.<timestamp>",
  "baseline_token_count": 0,
  "candidate_token_count": 0,
  "token_delta": 0,
  "exit_criteria_threshold": -1050,
  "exit_criteria_pass": false,
  "measurement_tool": "tiktoken cl100k_base"
}
```

**CLI args:**
```bash
python scripts/canary/g4_3_token_count.py \
  --baseline-version 17.202605042018 \
  --candidate-version 18.<timestamp> \
  --pid <PID> \
  --scene-index-list "[3,7,12,18,24]" \
  --shot-index-list-per-scene '{"3":[1,2],"7":[4],"12":[4],"18":[3],"24":[1,5]}' \
  --model-routing gemini-3.1-pro-preview \
  --prompt-source-mode file \
  --card-commit-hash 14d14cb \
  --id-policy-card-snapshot-hash <hash> \
  --output docs/canary/g4_3_token_count_$(date +%Y%m%d%H%M).json
```

또는 `--config <json-file>` 로 한 번에 받음 (G4.2 carry — Task 4.x 4 metric script JSON 자체를 input 으로).

**exit criteria threshold:**
- `token_delta = candidate_token_count - baseline_token_count` (음수 expected).
- `token_delta <= -1050` → pass.

---

## Phase 5 (Wave 4): unit + integration + alignment tests

**Phase 목표:** `backend/tests/unit/test_g4_3_id_policy_lift.py` (~36 unit) + `backend/tests/integration/test_g4_3_id_policy_integration.py` (~12 integration) + alignment test 4-way sync 갱신. G4.2 baseline 대비 0 regressions.

**Phase 5 expected total test count delta:** **+48 minimum** (36 unit + 12 integration). regression suite 가 +48 위에 alignment test pass 도 보존.

**Wave subagent 함정 경고 (G4.1 / G4.2 carry):**
- **절대 금지**: fixture 의 hardcoded empty inputs (`bg_owned=[]`, `visible_entities=[]`) 도입 — production drift cascade. 반드시 `_DEFAULT_SENTINEL = object()` pattern + `is _DEFAULT_SENTINEL` 비교.
- **반드시**: 각 unit test 가 실제 `build_id_policy()` / `build_render_prompt_card()` 를 call (mock 금지). hardcoded result dict 사용 금지.

---

### Task 5.1 — unit tests (~36 tests)

- [ ] **File:** `backend/tests/unit/test_g4_3_id_policy_lift.py` (신규 생성)
- [ ] **Dependency:** Phase 1 완료 (Task 1.1 ~ 1.5)
- [ ] **Spec cross-reference:** spec §6.1 / Override R1R2-B1 / R2-B3 / R2-B4 / R2-I4 / R2-M2 / R2-I1 / R1-M1
- [ ] **Expected diff size:** ~580 lines (신규)
- [ ] **Expected test count:** **~36** (R2-B4 ground-truth 검증 + 5 sub-field shape + perception_mode + hash + alignment 4-way fixture)

**test list (spec §6.1 + Override carry — content selector pattern, set equality):**

| # | test name | assert |
|---|---|---|
| 1 | `test_id_policy_has_face_identifiability_rule` | `face_identifiability_rule` dict 존재 + 4 required key 모두 present |
| 2 | `test_id_policy_face_identifiability_use_entity_id_when_exact` | `set(use_entity_id_when) == {"face identifiable: front, profile, three-quarter, or eyes closed", "OTS framing with any visible face"} and len == 2` |
| 3 | `test_id_policy_face_identifiability_common_noun_required_when_exact` | `set(common_noun_required_when) == 3 ground-truth set and len == 3` |
| 4 | `test_id_policy_face_identifiability_id_use_summary_substring` | `id_use_summary` 에 `"identifiable AND framing is not dominated"` substring |
| 5 | `test_id_policy_face_identifiability_applies_to_default_branch` | perception_mode None 에서도 face_identifiability_rule present (R2-I4) |
| 6 | `test_id_policy_has_body_part_focus_rule` | `body_part_focus_rule` dict + 3 required key + `id_use="forbidden ..."` |
| 7 | `test_id_policy_body_part_focus_trigger_phrases_exact` | `set(trigger_phrases) == set(_ID_BODY_PART_TRIGGERS) and len(trigger_phrases) == 4` (R2-B4) |
| 8 | `test_id_policy_body_part_focus_applies_to_exact` | `applies_to == _ID_BODY_PART_FOCUS_APPLIES_TO` (R2-I1 — 동등 비교) |
| 9 | `test_id_policy_body_part_focus_common_noun_examples_len` | `len(common_noun_examples_generic) == 4` |
| 10 | `test_id_policy_has_close_framing_face_phrasing` | `close_framing_face_phrasing` dict + 3 required key |
| 11 | `test_id_policy_close_framing_face_forbidden_phrases_exact` | `set(forbidden_phrases) == set(_ID_CLOSE_FACE_FORBIDDEN_PHRASES) and len == 6` (R2-B4) |
| 12 | `test_id_policy_close_framing_face_recommended_phrasings_exact` | `set(recommended_phrasings) == set(_ID_CLOSE_FACE_RECOMMENDED_PHRASINGS) and len == 4` (R2-B4) |
| 13 | `test_id_policy_close_framing_face_rationale_summary_substring` | `rationale_summary` 에 `"몸과 분리된 얼굴"` substring |
| 14 | `test_id_policy_has_reproduction_surface_rule` | `reproduction_surface_rule` dict + 3 required key + `id_use="forbidden ..."` |
| 15 | `test_id_policy_reproduction_surface_list_exact` | `set(applies_to_surfaces) == set(_ID_REPRODUCTION_SURFACES) and len == 9` (R2-B4) |
| 16 | `test_id_policy_reproduction_surface_rationale_summary_substring` | `rationale_summary` 에 `"실물 크기로 합성"` substring |
| 17 | `test_id_policy_has_demographic_descriptor_policy` | `demographic_descriptor_policy` dict + `required_on_first_appearance is True` + `token_count_range == [1,2]` + `applies_to_id_forms == ["C##","C##O##"]` |
| 18 | `test_id_policy_demographic_components_ethnicity_exact` | `set(components.ethnicity) == set(_ID_ETHNICITY_COMPONENTS) and len == 10` (R1-I1, R2-B4) |
| 19 | `test_id_policy_demographic_components_age_band_exact` | `set(components.age_band) == set(_ID_AGE_BANDS) and len == 6` (R2-B4) |
| 20 | `test_id_policy_demographic_components_gender_exact` | `set(components.gender) == {"man", "woman", "figure"} and len == 3` |
| 21 | `test_id_policy_demographic_components_role_hint_len_4` | `len(components.role_hint_from_outfit) == 4` |
| 22 | `test_id_policy_demographic_format_template_a_substring` | `format_template_a` 에 `"<옷 1-3 단어>"` + `"<demographic descriptor>"` substring |
| 23 | `test_id_policy_demographic_format_template_b_substring` | `format_template_b` 에 `"<자세 표현>"` substring |
| 24 | `test_id_policy_demographic_no_scenario_proper_nouns` | scenario_dependency_ban 에 `"no work-specific proper nouns"` substring + ethnicity entries 안 시나리오 고유명사 없음 (작품명/인물명 list scan) |
| 25 | `test_id_policy_constraints_grew_to_six_strings` | base 6 (perception_mode None) — `len(constraints) == 6` |
| 26 | `test_id_policy_constraints_string_1_composite_id_use` | `"C##O## composite IDs"` in constraints[0] |
| 27 | `test_id_policy_constraints_string_2_reproduction_surface_listed` | constraints[1] 에 9 surface keyword 모두 inline (R2-B4 — `for s in _ID_REPRODUCTION_SURFACES: assert s in constraints[1]`) |
| 28 | `test_id_policy_constraints_string_3_body_part_focus_trigger` | constraints[2] 에 `"focus on / close on / tight on / detail on"` substring + `"common noun"` substring |
| 29 | `test_id_policy_constraints_string_4_close_framing_face_forbidden_listed_5_inline` | constraints[3] 에 **5 inline forbidden phrasing** (R2-B4 + R4-B3 carry — `for p in _ID_CLOSE_FACE_FORBIDDEN_PHRASES[:5]: assert p in constraints[3]`; 6번째 `his face fills the frame` 는 token cost 이유로 inline 미포함, 단 `close_framing_face_phrasing.forbidden_phrases` 구조 field 가 6 모두 carry — test 11 이 set equality 6 검증). LLM 에 inject 되는 구조 field 가 instruction source 로 충분 — masculine symmetry defensive R1-M1 carry. |
| 30 | `test_id_policy_constraints_string_5_demographic_first_appearance` | constraints[4] 에 `"first time"` + `"1-2 token demographic descriptor"` substring |
| 31 | `test_id_policy_constraints_string_6_format_template` | constraints[5] 에 `"in <옷"` 또는 `"<demographic descriptor>"` placeholder substring |
| 32 | `test_id_policy_perception_mode_reflection_adds_constraint` | `perception_mode="reflection"` → `len(constraints) == 7` |
| 33 | `test_id_policy_perception_mode_default_no_extra` | `perception_mode=None` → `len(constraints) == 6` |
| 34 | `test_id_policy_perception_mode_reflection_does_not_contradict_reproduction_surface` | reflection branch constraint string 와 reproduction_surface_rule.applies_to_surfaces 가 일관 (defense in depth — R1-I4 / R2-I5) |
| 35 | `test_id_policy_visible_entities_none_raises` | `visible_entities=None` → `pytest.raises(AppError)` (G4.1 / G4.2 silent-absorb ban carry, O-1) |
| 36 | `test_id_policy_outlook_pairs_none_raises` | `outlook_pairs=None` → `pytest.raises(AppError)` (O-1) |
| 37 | `test_id_policy_visible_entities_empty_list_valid_with_5_subfields` | `visible_entities=[]` + `outlook_pairs=[]` → no raise + `face_identifiability_rule in result and body_part_focus_rule in result and close_framing_face_phrasing in result and reproduction_surface_rule in result and demographic_descriptor_policy in result` (R2-I4) |
| 38 | `test_assert_id_policy_shape_validates_face_identifiability_rule` | `_assert_id_policy_shape()` 가 `face_identifiability_rule` 누락 시 `pytest.raises(AppError)` (R2-B3) |
| 39 | `test_assert_id_policy_shape_validates_body_part_focus_rule` | `body_part_focus_rule` 누락 → AppError (R2-B3) |
| 40 | `test_assert_id_policy_shape_validates_close_framing_face_phrasing` | `close_framing_face_phrasing` 누락 → AppError (R2-B3) |
| 41 | `test_assert_id_policy_shape_validates_reproduction_surface_rule` | `reproduction_surface_rule` 누락 → AppError (R2-B3) |
| 42 | `test_assert_id_policy_shape_validates_demographic_descriptor_policy` | `demographic_descriptor_policy` 누락 → AppError (R2-B3) |
| 43 | `test_assert_id_policy_shape_validates_demographic_components_keys` | `components.ethnicity` 누락 → AppError (R2-B3 — components.required_keys) |
| 44 | `test_card_metadata_lift_status_includes_g4_3_keys` | `_card_metadata.lift_status` 에 `rule_h_lifted=True` + `id_policy_composite_lifted=True` + `id_policy_close_framing_face_lifted=True` + `id_policy_reproduction_surface_lifted=True` 4 key 모두 present |
| 45 | `test_card_metadata_rule_source_includes_g4_3_keys` | `_card_metadata.rule_source` 에 `demographic_rule="H"` + `composite_id_rule` + `close_framing_face_rule` + `reproduction_surface_rule` 4 key present |
| 46 | `test_card_metadata_excluded_from_hash_after_g4_3_keys_added` | G4.2 R1-B1 carry — 4 신규 lift_status key 추가 후에도 `compute_card_hash()` 결과 변경 0 |
| 46.b | `test_card_hash_invariant_to_card_metadata_lift_status_dynamic_keys` | R4-I4 carry — `_card_metadata.lift_status` 의 dynamic boolean (e.g. `rule_e_lifted` close_skip vs off) 변경에도 `compute_card_hash()` 결과 동일. Wave 1-A 가 `_card_metadata` strip 위치를 잘못 옮기거나 G4.3 신규 key 가 envelope 외부에 누출되면 본 test fail. |
| 47 | `test_card_hash_drifts_on_id_policy_constraint_change` | `id_policy.constraints` 안 content selector (`next(c for c in constraints if "face filling the entire frame" in c)` — R4-I2 carry, verbatim 6th-phrase prefix specific 매칭) 수정 → hash drift detected (R2-M2 + R4-I2 — index 의존 금지 + selector specific) |
| 48 | `test_compute_id_policy_snapshot_hash_deterministic` | 동일 card → 동일 hash (16 char hex) |
| 49 | `test_compute_id_policy_snapshot_hash_drifts_on_id_policy_change` | id_policy 안 1 char 수정 → hash 다름 |
| 50 | `test_compute_id_policy_snapshot_hash_card_missing_id_policy_raises` | card 에 `id_policy` key 없으면 `pytest.raises(AppError)` (silent fallback ban) |

총 unit tests: **~36 minimum** (위 50 row 중 일부 통합 가능 — 최소 36 보장). 최소 보장:
- 5 sub-field 각 4-7 test = 20-30
- shape validator 5 sub-field test = 5
- perception_mode 3 test = 3
- card_metadata 3 test = 3
- hash 3-4 test = 3-4
- 입력 validation (None / [] / 5 subfield present) 3 test = 3

= 36+ minimum.

**fixture helper (G4.2 plan-R1-I4 carry — `_DEFAULT_SENTINEL` pattern):**

```python
import pytest
from app.core.steps.render_prompt_card import (
    build_id_policy,
    build_render_prompt_card,
    compute_card_hash,
    compute_id_policy_snapshot_hash,
    _ID_BODY_PART_TRIGGERS,
    _ID_BODY_PART_FOCUS_APPLIES_TO,
    _ID_CLOSE_FACE_FORBIDDEN_PHRASES,
    _ID_CLOSE_FACE_RECOMMENDED_PHRASINGS,
    _ID_REPRODUCTION_SURFACES,
    _ID_ETHNICITY_COMPONENTS,
    _ID_AGE_BANDS,
)

# G4.1 R3-B2 / G4.2 plan-R1-I4 carry — `or []` silent absorb 차단
_DEFAULT_SENTINEL = object()


def _make_id_policy(
    visible_entities=_DEFAULT_SENTINEL,
    outlook_pairs=_DEFAULT_SENTINEL,
    perception_mode=None,
):
    """build_id_policy() wrapper. None / [] / default 명시 구분."""
    if visible_entities is _DEFAULT_SENTINEL:
        visible_entities = ["C01", "C02"]
    if outlook_pairs is _DEFAULT_SENTINEL:
        outlook_pairs = [
            {"character_id": "C01", "outlook_id": "O01"},
            {"character_id": "C02", "outlook_id": "O02"},
        ]
    return build_id_policy(
        visible_entities=visible_entities,
        outlook_pairs=outlook_pairs,
        perception_mode=perception_mode,
    )


def _make_full_card(
    visible_entities=_DEFAULT_SENTINEL,
    outlook_pairs=_DEFAULT_SENTINEL,
    perception_mode=None,
    bg_id="cb_test_001",
    bg_owned=_DEFAULT_SENTINEL,
    bg_camera_meta=_DEFAULT_SENTINEL,
    is_close_framing=False,
    background_mode_on=True,
):
    """build_render_prompt_card() entire call — caller 가 explicit 인자 전달.

    Sentinel 의도 (G4.1 함정 1 carry):
      - default value 가 사용자 의도가 아닐 때 → caller 가 explicit 인자 전달
      - `visible_entities=None` → builder 가 None 처리 (AppError raise)
      - `visible_entities=[]` → builder 가 빈 list 로 처리 (5 sub-field 모두 present)
      - `visible_entities=_DEFAULT_SENTINEL` (default) → fixture 가 ["C01","C02"] 채움
    """
    if visible_entities is _DEFAULT_SENTINEL:
        visible_entities = ["C01", "C02"]
    if outlook_pairs is _DEFAULT_SENTINEL:
        outlook_pairs = [
            {"character_id": "C01", "outlook_id": "O01"},
            {"character_id": "C02", "outlook_id": "O02"},
        ]
    if bg_owned is _DEFAULT_SENTINEL:
        bg_owned = ["door", "window"]
    if bg_camera_meta is _DEFAULT_SENTINEL:
        bg_camera_meta = {
            "camera_position": "south",
            "camera_height": "eye-level",
            "lens_hint": "35mm",
            "framing_notes": "wide",
        }
    # ... 나머지 inputs 구성 — build_render_prompt_card 전체 call path
    return build_render_prompt_card(...)
```

**중요 (Wave subagent trap 차단):**
- **절대 금지** 패턴: `visible_entities or []`, `visible_entities if visible_entities else []`. silent absorb 패턴은 production 에서 false drift cascade.
- **반드시** 사용: `_DEFAULT_SENTINEL` object pattern. `is _DEFAULT_SENTINEL` 비교.
- 모든 None / [] / default 셋이 명시적 구분되어야 unit test 가 production behavior 와 일치.
- **R2-B4 carry**: ground-truth 검증 시 `set equality + len equality` — substring 검증 절대 금지 (drift 차단).

---

### Task 5.2 — integration tests (~12 tests)

- [ ] **File:** `backend/tests/integration/test_g4_3_id_policy_integration.py` (신규 생성)
- [ ] **Dependency:** Phase 1 + Phase 2 + Phase 3 완료
- [ ] **Spec cross-reference:** spec §6.2 / Override R1R2-Q1
- [ ] **Expected diff size:** ~370 lines (신규)
- [ ] **Expected test count:** **12**

**test list (spec §6.2 base 12):**

| # | test name | assert |
|---|---|---|
| 1 | `test_v18_system_prompt_missing_c_use_section` | v18 system.md 에 `"## C## 사용 규칙 — 참조 이미지 연동"` heading not present |
| 2 | `test_v18_system_prompt_missing_close_face_section` | v18 system.md 에 `"## 극단 클로즈업 표현"` heading not present |
| 3 | `test_v18_system_prompt_missing_reproduction_surface_section` | v18 system.md 에 `"## 사진·포스터·화면·거울 속 인물 규칙"` heading not present |
| 4 | `test_v18_system_prompt_missing_rule_h_section` | v18 system.md 에 `"## entity ID 인물의 demographic descriptor"` heading not present |
| 5 | `test_v18_system_prompt_has_id_policy_section` | v18 system.md 에 `"## ID Policy"` heading present |
| 6 | `test_v18_system_prompt_compact_section_line_count_30` | "## ID Policy" heading 부터 next "## " heading 직전까지 = 30 lines (R1-I3 / R2-I3) |
| 7 | `test_v18_system_prompt_token_reduction_vs_v17` | tiktoken cl100k_base 실측 v18 < v17 - 1050 |
| 8 | `test_v18_system_prompt_line_395_inplace_replaced` | v18 system.md 에 `"얼굴 참조 이미지가 강제 주입"` substring 부재 (R1R2-Q1) + `"id_policy.body_part_focus_rule"` substring 존재 (R1R2-Q1) |
| 9 | `test_composite_id_shot_card_carries_5_id_policy_subfields` | `_collect_card_inputs()` + `build_render_prompt_card()` + inject 경로 통해 user_prompt 안 `id_policy.face_identifiability_rule` + `body_part_focus_rule` + `close_framing_face_phrasing` + `reproduction_surface_rule` + `demographic_descriptor_policy` 5 sub-field 모두 포함 (R2-I4) |
| 10 | `test_close_framing_shot_card_carries_face_phrasing_rules` | close-framing path → user_prompt 에 `id_policy.close_framing_face_phrasing.forbidden_phrases` 6 entry 모두 (set equality) 포함 |
| 11 | `test_reproduction_surface_shot_card_lists_9_surfaces` | 모든 shot path → user_prompt 안 `id_policy.reproduction_surface_rule.applies_to_surfaces` 9 entry 모두 포함 |
| 12 | `test_v17_checkpoint_resume_on_v18_escalates_force` | v17 cp (schema_version=7, prompt_version=v17) 를 v18 consumer 로 resume → `_config_hash` mismatch 발생 → `mode="force"` escalate (G4.1 / G4.2 R1-B1 force escalate path 가 v17→v18 에 동일 적용) |

**+ G4.2 carry**:

| # | test name | assert |
|---|---|---|
| 13 | `test_card_hash_drift_on_id_policy_constraint_change_in_verify` | id_policy.constraints (content selector) 변경 후 `verify_completion()` → status partial |
| 14 | `test_g3_2_sentinel_coexists_with_g4_3_id_policy` | 임의 shot → card `id_policy` 5 신규 sub-field + variation-level `owned_validation` sentinel + `background_binding.constraints` 셋 모두 CP 에 존재 |

총 integration tests: **12+** (위 14 row 중 일부 통합 가능 — 최소 12).

---

### Task 5.3 — alignment test 4-way sync 갱신

- [ ] **File:** `backend/tests/prompts/test_scene_detail_id_policy_alignment.py:338-393`
- [ ] **Dependency:** Phase 3 완료 (Task 3.1 + 3.2)
- [ ] **Spec cross-reference:** spec §6.4 4-way sync / Override R2-I7 / G4.2 plan-R2-I6 carry
- [ ] **Expected diff size:** ~10 lines (fixture / row 갱신)

**변경 내용:**

기존 alignment test 의 latest version row 갱신:

```python
# Before (G4.2 carry):
def test_scene_detail_prompt_version_constant_matches_latest():
    assert SCENE_DETAIL_PROMPT_VERSION == "17.202605042018"


def test_scene_detail_version_registry_aligned():
    assert MODULE_VERSIONS["scene_detail_composer"] == "1.17.0"
    # _MODULE_INFO 는 dict — attribute access 아님 (R3R4-B1 carry)
    assert _MODULE_INFO["scene_detail_composer"]["prompt_dependency"] == "scene_detail/v17"


# After (G4.3):
def test_scene_detail_prompt_version_constant_matches_latest():
    assert SCENE_DETAIL_PROMPT_VERSION == "18.<timestamp>"  # 디렉토리 timestamp 매칭


def test_scene_detail_version_registry_aligned():
    assert MODULE_VERSIONS["scene_detail_composer"] == "1.18.0"  # R2-I7
    # R3R4-B1 carry: dict access + major suffix only ("scene_detail/v18", NOT timestamp)
    assert _MODULE_INFO["scene_detail_composer"]["prompt_dependency"] == "scene_detail/v18"


def test_scene_detail_schema_version_synced_with_manifest():
    # G4.3 carry — schema_version 변경 없음 (= 7)
    assert SCENE_DETAIL_SCHEMA_VERSION == 7
```

**주의 (R3R4-B1 carry):**
- `_MODULE_INFO` 는 plain dict literal — attribute access (`.prompt_dependency`) 사용 시 NameError. 반드시 `["prompt_dependency"]` 사용.
- `prompt_dependency` 값 = `"scene_detail/v18"` (major suffix only). 기존 alignment infrastructure (`pdep.endswith(f"v{latest_major}")`) 가 `"v18"` suffix 검증.
- `SCENE_DETAIL_PROMPT_VERSION` 만 `"18.<timestamp>"` 형식 (Task 3.1 — 디렉토리 매칭). `prompt_dependency` 와 별도 source.
- `<timestamp>` 는 Wave 1-B → Wave 2 lockstep. 1 char drift 시 prompt loader fail (alignment 와 별개).
- `MODULE_VERSIONS["scene_detail_composer"] == "1.18.0"` row + dict access prompt_dependency row 가 4-point sync 강제.

---

### Task 5.4 — regression 확인

- [ ] **Dependency:** Task 5.1 + 5.2 + 5.3
- [ ] **Spec cross-reference:** spec §6.3

**실행:**
```bash
pytest backend/tests/ -x --timeout=120
```

기준: G4.2 baseline (2025+35=2060 passed estimated) 대비 0 regressions. G4.3 신규 test 추가 후 total count ≥ **2060 + 48 = 2108+ passed**.

G3.2 sentinel test (`test_g3_2_*.py`) + G4.1 (`test_g4_1_*.py`) + G4.2 (`test_g4_2_*.py`) 가 G4.3 이후에도 모두 pass 해야 한다 (id_policy 외 영역 변경 없음).

```bash
# G3.2 sentinel test
pytest backend/tests/ -k "g3_2" -v

# G4.1 test
pytest backend/tests/ -k "g4_1" -v

# G4.2 test
pytest backend/tests/ -k "g4_2" -v

# G4.3 신규 test
pytest backend/tests/ -k "g4_3" -v

# alignment test (R2-I7 — version_registry / step_manifest / prompt 일관)
pytest backend/tests/prompts/test_scene_detail_id_policy_alignment.py -v
```

---

## Phase 6 (Wave 5): canary execution + exit criteria 판정

**Phase 목표:** Phase 4 스크립트로 baseline (v17) + candidate (v18) capture. exit criteria 5개 항목 판정. 통과 시 단일 commit 진행.

**Phase 6 entry condition (R3-Entry-Note carry):**
- G4.2 production canary 6-gate 실행 완료 (사용자 confirm). G4.3 spec / plan / Wave 1-4 진행은 G4.2 production canary 무관 — 본 Phase 6 만이 production canary 결과 의존.
- Wave 5 진입 전 사용자 confirm: G4.2 production canary 결과 token + close framing forbidden + owned violation + camera wording 4 metric 모두 pass 됐는지.

---

### Task 6.1 — baseline canary capture (v17)

- [ ] **Dependency:** Phase 4 스크립트 5종 완성 + G4.2 production canary 6-gate 통과 + PID 결정 (사용자 confirm)
- [ ] **Spec cross-reference:** spec §5.2 / §5.3 Step 1 / Override R2-M6

**실행 순서:**

1. **PID 결정 (G4.2 plan-R1-I9 carry — 사용자 confirm 단계)**: 본 task 진입 시 사용자 확인 필요. plan 본문에 specific PID 박지 않음 (시나리오 의존 차단). PID 결정 기준:
   - composite ID (C##O##) 등장 shot 최소 5개.
   - 그 중 close-framing shot 최소 2개 (close-framing face 검증 mandatory).
   - 그 중 reproduction surface keyword 등장 shot 최소 1개 (reproduction_surface 검증 mandatory).
   - **first-appearance 발생 횟수 최소 10** (R2-M6 carry — demographic_descriptor_present_ratio 측정 안정성).
   - G3.x / G4.1 / G4.2 검증된 PID (memory `next_session_*` 계열 후보) 또는 신규 PID.

2. 선택 scene/shot set 을 `pinning.scene_index_list` + `pinning.shot_index_list_per_scene` 에 명시.

3. v17 prompt (G4.2 card) 로 force scene_detail 실행 (명시된 scene/shot 만 — 시나리오 무관 절차).

4. canary scripts 실행 (5종):
   ```bash
   # body-part focus (모든 shot)
   python scripts/canary/g4_3_body_part_focus.py \
     --pid <PID> --role baseline --prompt-version 17.202605042018 \
     --output docs/canary/g4_3_baseline_body_part_$(date +%Y%m%d%H%M).json

   # close-framing face forbidden (close-framing shot only)
   python scripts/canary/g4_3_close_framing_face_forbidden.py \
     --pid <PID> --role baseline --prompt-version 17.202605042018 \
     --output docs/canary/g4_3_baseline_close_face_$(date +%Y%m%d%H%M).json

   # reproduction surface (모든 shot)
   python scripts/canary/g4_3_reproduction_surface.py \
     --pid <PID> --role baseline --prompt-version 17.202605042018 \
     --output docs/canary/g4_3_baseline_repro_surface_$(date +%Y%m%d%H%M).json

   # demographic descriptor present (composite-ID shot only)
   python scripts/canary/g4_3_demographic_descriptor_present.py \
     --pid <PID> --role baseline --prompt-version 17.202605042018 \
     --output docs/canary/g4_3_baseline_demographic_$(date +%Y%m%d%H%M).json

   # token count
   python scripts/canary/g4_3_token_count.py \
     --baseline-version 17.202605042018 --candidate-version 18.<timestamp> \
     --pid <PID> ... \
     --output docs/canary/g4_3_token_count_$(date +%Y%m%d%H%M).json
   ```

5. baseline JSON 5종 저장 — Task 6.3 가 통합 비교.

---

### Task 6.2 — candidate canary capture (v18)

- [ ] **Dependency:** Task 6.1 + Phase 2 (v18 prompt 존재) + Phase 3 (Task 3.1 / 3.2 version bump)
- [ ] **Spec cross-reference:** spec §5.3 Step 2

**실행 순서:**

1. Task 6.1 과 **동일 PID / 동일 scene/shot set** force 재실행 (v18 prompt 적용 상태).

2. canary scripts 실행 (4 metric script — token script 는 baseline/candidate 동시 측정이므로 1회 실행):
   ```bash
   python scripts/canary/g4_3_body_part_focus.py \
     --pid <PID> --role candidate --prompt-version 18.<timestamp> \
     --output docs/canary/g4_3_candidate_body_part_$(date +%Y%m%d%H%M).json

   python scripts/canary/g4_3_close_framing_face_forbidden.py \
     --pid <PID> --role candidate --prompt-version 18.<timestamp> \
     --output docs/canary/g4_3_candidate_close_face_$(date +%Y%m%d%H%M).json

   python scripts/canary/g4_3_reproduction_surface.py \
     --pid <PID> --role candidate --prompt-version 18.<timestamp> \
     --output docs/canary/g4_3_candidate_repro_surface_$(date +%Y%m%d%H%M).json

   python scripts/canary/g4_3_demographic_descriptor_present.py \
     --pid <PID> --role candidate --prompt-version 18.<timestamp> \
     --output docs/canary/g4_3_candidate_demographic_$(date +%Y%m%d%H%M).json
   ```

3. pinning block 7 field 가 baseline 과 일치하는지 확인 (`pid`, `scene_index_list`, `shot_index_list_per_scene`, `model_routing`, `prompt_source_mode`, `card_commit_hash`, `id_policy_card_snapshot_hash`). 불일치 시 baseline 재측정.

---

### Task 6.3 — exit criteria 판정

- [ ] **Dependency:** Task 6.1 + 6.2
- [ ] **Spec cross-reference:** spec §5.1 / Override R3-I1

**5가지 exit criteria 체크리스트:**

- [ ] **body-part focus** (`g4_3_body_part_focus.py` 출력, **STRICT**): `candidate.metrics.body_part_focus_pattern_count == 0`
- [ ] **close-framing face forbidden** (`g4_3_close_framing_face_forbidden.py` 출력, **STRICT**): `candidate.metrics.close_framing_face_forbidden_count == 0`
- [ ] **reproduction surface** (`g4_3_reproduction_surface.py` 출력, **STRICT**): `candidate.metrics.reproduction_surface_composite_id_count == 0`
- [ ] **demographic descriptor present** (`g4_3_demographic_descriptor_present.py` 출력): `candidate.metrics.demographic_descriptor_present_ratio >= baseline.metrics.demographic_descriptor_present_ratio` (degradation 차단)
- [ ] **Token delta** (`g4_3_token_count.py` 출력): `token_delta = candidate_token_count - baseline_token_count` 가 `<= -1050` (tiktoken cl100k_base 실측, R4-B4 carry — signed delta convention 통일). 이는 `baseline - candidate >= 1050` 과 수학적으로 동등.
- [ ] **pytest G4.3 suite:** `pytest backend/tests/ -k "g4_3" → 0 failures` (48+ 신규 test all green — 36 unit + 12 integration)

모두 통과 → Task 7.1 commit 진행.
실패 항목 있음 → 해당 sub-field / compact section 문구 / canary script 수정 후 재canary.

**비교 절차 (Task 6.3 inline)**:
1. baseline JSON 4개 + candidate JSON 4개 + token JSON 1개 모두 load.
2. 각 JSON 의 `pinning` block 동일 비교 (R2-B6 — dict equality).
3. 위 5 gate + 1 pytest gate = 총 6 gate 각각 비교 + 통과 여부 기록.
4. 통과 시 통합 결과 JSON 저장: `docs/canary/g4_id_policy_summary_<timestamp>.json` (선택사항).

**STRICT 0 metric (3종)** — baseline 비0 이어도 candidate 0 강제. baseline 비0 면 P1 follow-up 등록 (G4.3 scope 밖, 별도 production fix 추적 — G4.2 R1-I4 / R2-B5 strict 패턴 carry).

---

## Phase 7 (Wave 5): commit + dual review fix loop

**Phase 목표:** 단일 commit push. Codex + Claude 듀얼 리뷰. 3 iter fix loop. BLOCKING 모두 fix 후 final merge.

---

### Task 7.1 — commit 준비 + single commit push

- [ ] **Dependency:** Phase 6 exit criteria 모두 통과 + Phase 5 regression green
- [ ] **Spec cross-reference:** spec §7.2 step 17

**commit 대상 파일:**

| 파일 | 변경 유형 | Wave |
|---|---|---|
| `backend/app/core/steps/render_prompt_card.py` | Modified (~280 LOC: 7 constants + helper + 5 sub-field + shape validator) | Wave 1-A |
| `backend/app/core/steps/detail_steps.py` | Modified (1 line — version bump) | Wave 2 |
| `backend/app/core/version_registry.py` | Modified (4-way sync — R2-I7) | Wave 2 |
| `prompts/_base/scene_detail/18.<timestamp>/system.md` | New (-79 net lines vs v17) | Wave 1-B |
| `prompts/_base/scene_detail/18.<timestamp>/detail_schema.json` | New (verbatim copy) | Wave 1-B |
| `prompts/_base/scene_detail/18.<timestamp>/user.md` | New (verbatim copy) | Wave 1-B |
| `scripts/canary/g4_3_body_part_focus.py` | New (~140 lines) | Wave 3 |
| `scripts/canary/g4_3_close_framing_face_forbidden.py` | New (~135 lines) | Wave 3 |
| `scripts/canary/g4_3_reproduction_surface.py` | New (~150 lines) | Wave 3 |
| `scripts/canary/g4_3_demographic_descriptor_present.py` | New (~170 lines) | Wave 3 |
| `scripts/canary/g4_3_token_count.py` | New (~90 lines) | Wave 3 |
| `backend/tests/unit/test_g4_3_id_policy_lift.py` | New (~36 tests) | Wave 4 |
| `backend/tests/integration/test_g4_3_id_policy_integration.py` | New (~12 tests) | Wave 4 |
| `backend/tests/prompts/test_scene_detail_id_policy_alignment.py` | Modified (4-way sync row 갱신) | Wave 4 |
| `docs/canary/g4_3_baseline_*.json` (4종) | New | Wave 5 |
| `docs/canary/g4_3_candidate_*.json` (4종) | New | Wave 5 |
| `docs/canary/g4_3_token_count_*.json` | New | Wave 5 |

**commit message format:**
```
feat(g4.3): id-policy lift — 4 prose sections → card sub-fields, v18 prompt

- build_id_policy(): 5 신규 builder-static sub-field (face_identifiability_rule
  / body_part_focus_rule / close_framing_face_phrasing / reproduction_surface_rule
  / demographic_descriptor_policy) + 6-string constraints
- 7 module-level constants (4 body-part triggers, 6 close-framing forbidden,
  4 recommended, 9 reproduction surfaces, 10 ethnicity, 6 age bands,
  body_part_focus_applies_to literal)
- compute_id_policy_snapshot_hash() helper (R2-I2)
- _assert_id_policy_shape() 5 sub-field strict 검증 (R2-B3)
- _card_metadata.lift_status: rule_h_lifted + 3 id_policy_*_lifted 4 신규 key
- detail_steps.py:105: SCENE_DETAIL_PROMPT_VERSION 17→18 bump
- version_registry.py: MODULE_VERSIONS 1.17.0→1.18.0 + prompt_dependency v18
- v18 system.md: 4 prose sections removed (-109 lines, lines 32-64/66-85/
  133-146/341-382) → ID Policy compact section (+30 lines) + line 395
  inplace replacement (R1R2-Q1). net -79 lines / actual <-1050 token gate
- 5 canary scripts (g4_3_body_part_focus / close_framing_face_forbidden /
  reproduction_surface / demographic_descriptor_present / token_count)
- +36 unit + +12 integration tests (R2-B4 set equality + R2-I4 / R2-I7)
- CARD_SCHEMA_VERSION=1, SCENE_DETAIL_SCHEMA_VERSION=7 (no bump — id_policy
  sub-field expansion is dict key addition only, not envelope shape change)

R1+R2 audit: 21 Override rows applied (R1R2-B1 face_identifiability_rule /
R1R2-B2 trigger constants / R1R2-B3 4-tuple denominator / R1R2-Q1 line 395
inplace / R2-B3 sub-field shape contracts / R1-I1 ethnicity 10 entries
Latina+Latino split).
```

---

### Task 7.2 — Codex 리뷰 (R3 / iter 1)

- [ ] **Dependency:** Task 7.1 (commit 후)
- [ ] **Spec cross-reference:** CLAUDE.md `feedback_dual_code_review.md`

**Codex 리뷰 dispatch 포인트:**

1. `render_prompt_card.py` — 7 module-level constants tuple 값 정확한지 (set equality + len ground-truth — R2-B4), `build_id_policy()` 5 신규 sub-field assemble 로직 sub-field shape contract 만족하는지 (R2-B3), `_assert_id_policy_shape()` 5 sub-field strict 검증 로직 누락 없는지, `compute_id_policy_snapshot_hash()` deterministic + None handling.
2. `detail_steps.py:105` — `SCENE_DETAIL_PROMPT_VERSION` `<timestamp>` 가 prompt 디렉토리명과 lockstep.
3. `version_registry.py` — `MODULE_VERSIONS["scene_detail_composer"] == "1.18.0"` + `prompt_dependency` v18 lockstep (R2-I7).
4. `test_g4_3_id_policy_lift.py` — 36 tests 가 실제 `build_id_policy()` / `build_render_prompt_card()` 를 call 하는지 (hardcoded result dict 금지 — G4.1 / G4.2 trap carry). `_DEFAULT_SENTINEL` pattern 사용. set equality + len 검증 (R2-B4).
5. v18 `system.md` — 4 prose section 모두 삭제 + 30-line compact section + line 395 인플레이스 교체.
6. 5 canary scripts — module constants import + ±50 chars window proximity (sentence regex 금지 — R1R2-B3) + sentinel pre-pass + pinned-tuple post-loop validation + gate fail → exit 1.

BLOCKING 발견 시 → fix → 재commit. IMPORTANT / MINOR 는 수정 후 동일 commit amend (push 전) 또는 별도 follow-up commit.

---

### Task 7.3 — Claude 리뷰 (R4 / iter 1)

- [ ] **Dependency:** Task 7.1 (Task 7.2 와 병렬 가능)
- [ ] **Spec cross-reference:** CLAUDE.md `feedback_dual_code_review.md`

**Claude 리뷰 focus:**

1. `build_id_policy()` 5 신규 sub-field 의 shape 가 spec §2.2 와 1:1 일치 (각 sub-field 의 required_keys + ground-truth content).
2. v18 `system.md` 30-line compact section 이 spec §3.3 draft 와 동일.
3. line 395 인플레이스 교체 정확 (R1R2-Q1 — `"얼굴 참조 이미지가 강제 주입"` → `"id_policy.body_part_focus_rule 참조"`).
4. integration test — v18 substring presence/absence 검증 (test 8 — line 395 교체 검증) 정확한지.
5. 4-way sync alignment test — `MODULE_VERSIONS["scene_detail_composer"] == "1.18.0"` row 가 R2-I7 carry.
6. perception_mode reflection branch 의 7번째 constraint 가 reproduction_surface_rule 와 의도적 overlap (defense in depth — R1-I4 / R2-I5).

---

### Task 7.4 — Dual review fix loop (iter 2 / iter 3)

- [ ] **Dependency:** Task 7.2 + Task 7.3
- [ ] **Spec cross-reference:** G4.2 commit `14d14cb` 3-iter pattern carry

**iter 2 / iter 3 fix loop**:
1. Codex + Claude 리뷰 결과 통합 → Override rows 정리.
2. BLOCKING 모두 fix → Task 7.2 / 7.3 재실행.
3. 통과 시 final commit (amend or follow-up commit).
4. 3 iter 통과 못 하면 Wave 1-A 또는 Wave 1-B 부분 재구현 검토.

**Override 정리 표 (예시)**:

| Round | Codex 발견 | Claude 발견 | 결정 |
|---|---|---|---|
| iter 2 | `?` | `?` | (실행 후 채움) |
| iter 3 | `?` | `?` | (실행 후 채움) |

---

## Verification Checklist (commit 전 explicit assertions)

- [ ] **Wave 1-A producer**:
  - [ ] 7 module-level constants present (`_ID_BODY_PART_TRIGGERS` / `_ID_BODY_PART_FOCUS_APPLIES_TO` / `_ID_CLOSE_FACE_FORBIDDEN_PHRASES` / `_ID_CLOSE_FACE_RECOMMENDED_PHRASINGS` / `_ID_REPRODUCTION_SURFACES` / `_ID_ETHNICITY_COMPONENTS` / `_ID_AGE_BANDS`) — len + content 정확
  - [ ] `compute_id_policy_snapshot_hash()` helper 신설 + AppError on missing id_policy
  - [ ] `build_id_policy()` 가 5 신규 sub-field 모두 return (visible_entities=[] 포함)
  - [ ] `_assert_id_policy_shape()` 5 sub-field strict 검증 (각 sub-field 의 required_keys)
  - [ ] None vs [] fail-fast 보존 (Override O-1)
  - [ ] `build_render_prompt_card()` `_card_metadata.lift_status` 4 신규 key + `rule_source` 4 신규 key

- [ ] **Wave 1-B prompt**:
  - [ ] v18 디렉토리 생성 + 3 file (system.md / detail_schema.json / user.md)
  - [ ] system.md 4 deletion target heading 모두 absent (`grep -c` == 0)
  - [ ] system.md `## ID Policy` heading present + 30 lines (next `## ` 까지)
  - [ ] system.md `"얼굴 참조 이미지가 강제 주입"` substring absent (R1R2-Q1)
  - [ ] system.md `"id_policy.body_part_focus_rule"` substring present (R1R2-Q1)
  - [ ] tiktoken cl100k_base 실측 token_delta = candidate - baseline `<= -1050` (R4-B4 carry — signed convention)

- [ ] **Wave 2 wiring (4-point sync — R4-I6 carry)**:
  - [ ] (1) v18 prompt directory `prompts/_base/scene_detail/18.<timestamp>/` 생성됨
  - [ ] (2) `detail_steps.py:105` `SCENE_DETAIL_PROMPT_VERSION = "18.<timestamp>"`
  - [ ] (3) `version_registry.py:34` `MODULE_VERSIONS["scene_detail_composer"] == "1.18.0"`
  - [ ] (4) `version_registry.py:126` `_MODULE_INFO["scene_detail_composer"]["prompt_dependency"] == "scene_detail/v18"` (R3R4-B1 carry — dict access + major suffix)
  - [ ] alignment test `test_scene_detail_version_registry_aligned` green (4 점 모두 검증)

- [ ] **Wave 3 canary**:
  - [ ] 5 canary scripts 모두 module-level constants import
  - [ ] ±50 chars window proximity (sentence regex 사용 금지) — `g4_3_reproduction_surface.py` / `g4_3_demographic_descriptor_present.py`
  - [ ] 4 metric scripts 모두 sentinel pre-pass + pinned-tuple post-loop
  - [ ] gate fail → exit 1 (silent return 0 금지)
  - [ ] 5 script JSON schema spec §5.3 일치 (pinning 7 field — `id_policy_card_snapshot_hash` 포함)

- [ ] **Wave 4 tests**:
  - [ ] 36+ unit tests + 12+ integration tests + alignment test 갱신
  - [ ] R2-B4 set equality + len equality 강제 (substring 검증 금지)
  - [ ] `_DEFAULT_SENTINEL` fixture pattern (Override O-17 carry)
  - [ ] `test_id_policy_visible_entities_empty_list_valid_with_5_subfields` (R2-I4)
  - [ ] `test_id_policy_perception_mode_reflection_does_not_contradict_reproduction_surface` (R1-I4 / R2-I5)
  - [ ] `test_card_hash_drift_*` content selector (R2-M2 — index 의존 금지)

- [ ] **Wave 5 canary execution**:
  - [ ] G4.2 production canary 6-gate 통과 confirm (commit gate)
  - [ ] baseline (v17) + candidate (v18) capture pinning 7 field 일치
  - [ ] 5 exit criteria 모두 pass: body-part focus STRICT 0 / close-framing face STRICT 0 / reproduction surface STRICT 0 / demographic ratio >= baseline / token delta `<= -1050` (signed)
  - [ ] pytest G4.3 suite green

- [ ] **regression**:
  - [ ] G3.2 + G4.1 + G4.2 test suite 모두 green
  - [ ] alignment test green
  - [ ] total count >= G4.2 baseline + 48

---

## Risk Register / Rollback

### Risk Register (G4.3 spec §8 carry + plan-specific)

#### G4.3-unique risks (spec §8.1)

| risk | mitigation | measurable threshold |
|---|---|---|
| v18 compact section 이 demographic descriptor 권장 token 가이드를 충분히 carry 하지 않아 LLM 이 default-race 인물 생성 | constraints[4..5] + `demographic_descriptor_policy.format_template_a/b` 명시 + canary `demographic_descriptor_present_ratio` ≥ baseline | candidate ratio ≥ baseline rate |
| 신체부위 focus prose 삭제 후 LLM 이 "focus on C##O##'s hand" 류 출력 | card `body_part_focus_rule.id_use="forbidden"` + constraints[2] + canary STRICT 0 | candidate count == 0 |
| close-framing face forbidden phrasing 6개가 card constraints 에 완전히 cover 되지 않음 | `_ID_CLOSE_FACE_FORBIDDEN_PHRASES` 6 entry 명시 + unit test set equality | all 6 phrases present in card — unit test green |
| reproduction surface 9 keyword 가 card list 에 누락 시 LLM 이 "C##O## on the photograph" 출력 | `_ID_REPRODUCTION_SURFACES` 9 entry 명시 + unit test set equality | all 9 surfaces present in card |
| `_card_metadata.lift_status` 신규 4 key 가 hash 에 의도치 않게 포함되어 hash 가 매 build 마다 변경 | G4.2 R1-B1 carry: `_card_metadata` 전체 가 `canonicalize_render_prompt_card()` 에서 strip. unit test `test_card_metadata_excluded_from_hash_after_g4_3_keys_added` 강제 | `_card_metadata` not in canonicalized output |
| canary PID 의 composite ID 등장 shot 이 부족하면 demographic_descriptor metric 분모 0 | baseline / candidate scene set 선정 시 first-appearance 발생 횟수 최소 10 보장 (R2-M6) | scene_set 의 first-appearance count ≥ 10 |
| canary baseline body-part focus / close-framing face / reproduction surface count 가 비0 인 경우 | strict: candidate == 0 강제. baseline 비0 이면 P1 follow-up 으로 별 추적 | candidate count == 0 mandatory |
| Wave subagent fixture 가 신규 5 sub-field 누락한 partial card 사용 → production drift cascade | `_DEFAULT_SENTINEL = object()` fixture pattern + `_make_full_card()` helper 가 항상 5 신규 sub-field 채움 + `_assert_id_policy_shape()` strict (R2-B3) | unit test `test_assert_id_policy_shape_validates_*` 5종 |
| canary 가 sentence boundary regex 사용으로 LLM punctuation 가변에 false-negative | `feedback_no_regex_postprocessing.md` carry — ±50 chars char-level window 사용 (R1R2-B3) | `g4_3_reproduction_surface.py` / `g4_3_demographic_descriptor_present.py` 가 sentence regex 미포함 — Codex review |
| Wave subagent hardcoded empty inputs 도입 → fixture 가 production failure 가림 | spec / plan body 에 명시 경고 + Codex review 시 hardcoded `[]` literal 검출 | Codex review BLOCKING — fix iter 2 |

#### G4.2 carry / inherited

| inherited risk | carry source | G4.3 enforcement |
|---|---|---|
| v18 로 resume 시 old v17 cp 자동 force escalate | G4.1 R1-B1 / G4.2 carry | integration test `test_v17_checkpoint_resume_on_v18_escalates_force` |
| `_user_edited` reuse path stored card vs recompute card hash drift | G4.1 Wave 4 R4 B1/B2/B3 carry | G4.1 패턴 그대로 — G4.3 변경 없음 |
| schema_version != 7 result 에서 card check bypass warning | G4.1 cleanup I5 carry | G4.3 변경 없음 |
| `or [] / or {}` silent absorb pattern | G4.2 iter1 C3 / O-6 | G4.3 canary scripts 5종 모두 동일 pattern carry |
| pinned tuple seen_in_cp post-loop validation | G4.2 iter2 B1 / O-3 | G4.3 canary scripts 5종 모두 동일 |
| t2i_variations + per-variation t2i_prompt validation | G4.2 iter2 B2 + iter3 B3 / O-4 / O-5 | G4.3 4 metric scripts 모두 검증 |
| Wave subagent hardcoded empty trap | G4.1 + G4.2 carry | G4.3 spec §7 wave decomposition 에서 fixture default 사용 시 `_DEFAULT_SENTINEL` 의무화 |

### Rollback (Phase 6 또는 Phase 7 실패 시)

| 상황 | rollback 절차 |
|---|---|
| Wave 5 canary 1 metric 실패 (예: body-part focus 비0) | 1) 실패 metric 의 source sub-field 확인 (e.g. `body_part_focus_rule.id_use` 문구) → 2) Wave 1-A 부분 재구현 → Wave 4-5 재실행. spec drift 면 spec back-propagate. |
| Wave 5 canary 모든 metric 실패 → 시스템적 결함 | 1) v18 prompt compact section 문구 검토 (constraint summary 가 reasoning 만 carry — token list 부재) → 2) compact section 30 line 한도 안 추가 명시. → 3) Wave 1-B 재구현 → Wave 4-5 재실행. |
| alignment test 실패 (Phase 5 Task 5.4) | 4-way sync 1 곳 누락 — 4 곳 (prompt 디렉토리 / detail_steps.py:105 / version_registry.py:34 / version_registry.py:126) 모두 lockstep 확인. timestamp 1 char drift 가능 — re-run pytest. |
| Phase 7 dual review iter 3 도 fix 못 함 | 단일 commit revert (`git revert HEAD`). prompt 디렉토리 살아있음 (artifact 손실 0 — feedback_prompt_versioning.md). render_prompt_card.py 의 G4.3 추가 부분만 revert. main HEAD 복구. spec / plan / canary script artifact 는 별도 commit 으로 보존. |

---

## Override Table (G4.2 carry + G4.3-new)

본 표는 G4.2 audit 결과 중 G4.3 에 동일 적용해야 하는 결정사항 + G4.3 spec R1+R2 audit 의 신규 row. plan body 보다 우선 적용.

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

| ID | Source | Override | G4.3 plan 적용 위치 |
|---|---|---|---|
| **O-1** | G4.2 R2-B4 | builder None vs [] fail-fast (`build_id_policy()` `visible_entities=None` → AppError) | Task 1.3 (G4.1 carry 보존) + Task 5.1 unit test `test_id_policy_visible_entities_none_raises` / `test_id_policy_outlook_pairs_none_raises` |
| **O-2** | G4.2 R1-I12 | `_DEFAULT_SENTINEL = object()` fixture pattern (test 측) | Task 5.1 fixture helper |
| **O-3** | G4.2 R2-I7 / iter2 B1 | canary pinned-tuple seen_in_cp post-loop 검증 | Task 4.1 / 4.2 / 4.3 / 4.4 (5 canary scripts 모두) |
| **O-4** | G4.2 iter2 B2 | `t2i_variations` 누락 silent absorb 차단 | Task 4.1 / 4.2 / 4.3 / 4.4 |
| **O-5** | G4.2 iter3 B3 | variation 내부 `t2i_prompt` 누락 silent absorb 차단 | Task 4.1 / 4.2 / 4.3 / 4.4 |
| **O-6** | G4.2 iter1 C3 | `or [] / or {}` 패턴 전반 차단 | Wave 1-A 전체 + 5 canary scripts |
| **O-7** | G4.2 iter1 C2 | gate fail → exit 1 강제 | 4 metric canary scripts |
| **O-8** | G4.2 iter1 I1 | producer-side `... or []` 패턴 금지 — None 흡수 차단 | Task 1.3 (G4.1 carry 보존) |
| **O-9** | G4.2 iter1 I2 | hash 회귀 test gap 방지 — `_card_metadata` 변경 / `id_policy.constraints` 변경 drift test 의무 | Task 5.1 (hash drift tests + content selector) |
| **O-10** | G4.2 R1-B1 | `_card_metadata` envelope-sibling top-level only — nested 구조 금지 | Task 1.5 |
| **O-11** | G4.2 R2-I5 | `_card_metadata` free-form (extra-key 허용) — assert_card_shape envelope 7 field 만 strict | Task 1.4 (G4.1 carry 보존) |
| **O-12** | G4.2 R2-I4 | `detail_steps.py` inject 직전 `_card_metadata` strip 보존 | Phase 3 (변경 0 — G4.2 commit 14d14cb 그대로) |
| **O-13** | G4.2 R1-I6 | `CARD_SCHEMA_VERSION` 유지 (= 1) — `SCENE_DETAIL_SCHEMA_VERSION` 유지 (= 7) | Task 3.1 (변경 없음 확인) |
| **O-14** | G4.2 R2-B6 | canary JSON `pid` 가 `pinning` block 안 (top-level redundant 제거) | Task 4.1 ~ 4.5 |
| **O-15** | G4.2 R1-M1 | token measurement = `tiktoken cl100k_base` 실측 | Task 4.5 / Task 6.1 |
| **O-16** | G4.2 R1-I4 / R2-B5 | strict 0 metric 은 baseline 비0 이어도 candidate 0 강제 | Task 6.3 (3 STRICT 0 metric — body-part / close-framing / reproduction surface) |
| **O-17** | G4.2 plan-R1-I4 | unit test fixture `_make_full_card()` 가 `_DEFAULT_SENTINEL` 적용 | Task 5.1 fixture helper |
| **O-18** | G4.2 plan-R2-I5 | canary owned/sentinel scripts 가 sentinel 합산 전 `assert_owned_sentinel_shape()` 호출 (G4.3 측면 inherited — 4 metric scripts 모두 sentinel 검증 필요 시 동일 pattern) | Task 4.1 / 4.2 / 4.3 / 4.4 (sentinel 사용처 사전 검증) |
| **O-19** | G4.2 plan-R2-I6 | commit target 에 `version_registry.py` (4-way sync 일부) 의무 포함 | Task 7.1 commit 대상 |

### G4.3-new (R1+R2 spec audit)

| ID | Source | Override | G4.3 plan 적용 위치 |
|---|---|---|---|
| **G4.3-N1** | R3-B1 ground-truth | 6 close-framing forbidden phrasing — §9 Glossary single source. `_ID_CLOSE_FACE_FORBIDDEN_PHRASES` module constant. | Task 1.1 + Task 4.2 + Task 5.1 set equality test |
| **G4.3-N2** | R3-B2 ground-truth | 9 reproduction surface keywords — `_ID_REPRODUCTION_SURFACES` module constant. | Task 1.1 + Task 4.3 + Task 5.1 |
| **G4.3-N3** | R3-B3 ground-truth | 10 ethnicity components (Latina+Latino split per R1-I1) — `_ID_ETHNICITY_COMPONENTS` module constant. region 별 작품 단위 고정. 시나리오 의존 0. | Task 1.1 + Task 4.4 + Task 5.1 |
| **G4.3-N4** | R3-I1 strict scope | 4 metric STRICT scope: body-part focus = 모든 shot, close-framing face = close-framing only, reproduction surface = 모든 shot, demographic descriptor = composite-ID-등장 shot 만 (분모) | Task 4.1 ~ 4.4 measurement scope |
| **G4.3-N5** | R3-I2 4-way sync | v18 push lockstep 4 곳 (prompt 디렉토리 + detail_steps.py:105 + version_registry.py:34 + version_registry.py:126) — alignment test 강제 검증 | Task 3.1 + 3.2 + 5.3 |
| **G4.3-N6** | R3-I3 entry gate split | spec drafting / plan / Wave 1-4 = canary 무관 진행 가능. **commit gate** = G4.2 production canary 6 gate 필수. | Phase 6 entry condition |
| **G4.3-N7** | R1R2-B1 | `face_identifiability_rule` 신규 sub-field (use_entity_id_when / common_noun_required_when / id_use_summary / rationale_summary 4 key dict) | Task 1.3 (5 신규 sub-field 중 1) + Task 1.4 strict 검증 + Task 5.1 unit test 4종 |
| **G4.3-N8** | R1R2-B2 | `_ID_BODY_PART_TRIGGERS = ("focus on", "close on", "tight on", "detail on")` 4 trigger module-level constant. canary regex alternation. | Task 1.1 + Task 4.1 |
| **G4.3-N9** | R1R2-B3 | demographic canary denominator = first-appearance per `(scene_index, shot_index, variation_index, id_token)` tuple. ±50 chars window. sentence regex 금지. | Task 4.4 |
| **G4.3-N10** | R1R2-Q1 | v17 line 395 `"C## 사용 금지 — 얼굴 참조 이미지가 강제 주입"` 부분 → `"id_policy.body_part_focus_rule 참조 — body-part Focus 시 C##O## 금지 (얼굴 ref 합성 차단)"` 1 line 인플레이스 교체. integration test 강제 검증. | Task 2.3 + Task 5.2 test 8 |
| **G4.3-N11** | R2-I2 | `compute_id_policy_snapshot_hash(card) -> str` helper 신설. `sha256(json.dumps(card["id_policy"], sort_keys=True, ensure_ascii=False))[:16]`. | Task 1.2 + 5 canary scripts 사용 |
| **G4.3-N12** | R1-I1 | ethnicity ground-truth = 10 entry (Latina+Latino split). | Task 1.1 + Task 4.4 + Task 5.1 |
| **G4.3-N13** | R1-I2 / R2-I6 | 7 module-level constants (R1-I2 6 + R2-I1 1) — `_VALID_RENDER_MODES` 패턴 답습. | Task 1.1 |
| **G4.3-N14** | R1-I3 / R2-I3 | compact section 30 lines (R1-I3 / R2-I3 carry — 30 초과 금지). token gate `≥ -1050`. | Task 2.2 + Task 4.5 + Task 5.2 test 6 |
| **G4.3-N15** | R1-I4 / R2-I5 | perception_mode reflection branch constraint 가 reproduction_surface_rule 와 의도적 overlap (defense in depth) | Task 1.3 + Task 5.1 test 34 |
| **G4.3-N16** | R2-I4 | `test_id_policy_visible_entities_empty_list_valid_with_5_subfields` — 5 builder-static sub-field 가 빈 입력에서도 항상 present | Task 5.1 test 37 |
| **G4.3-N17** | R2-I7 | alignment test 4-way sync row — `MODULE_VERSIONS["scene_detail_composer"] == "1.18.0"` | Task 5.3 |
| **G4.3-N18** | R1-M1 | `_ID_CLOSE_FACE_FORBIDDEN_PHRASES` 6번째 entry `"his face fills the frame"` 는 v17 prose 미등장 (masculine symmetry — defensive) | Task 5.1 unit test 11 (set equality 만 — content 검증 별도 X) |
| **G4.3-N19** | R2-M2 | hash drift test content selector — `next(c for c in constraints if "face filling" in c)` (index 의존 금지) | Task 5.1 test 47 |
| **G4.3-N20** | R2-M6 | baseline scene set 요건 — first-appearance 발생 횟수 최소 10 (composite-ID 등장 shot 5+ 위에 변동 안정성) | Task 6.1 PID 결정 기준 |
| **G4.3-N21** | R2-M3 | compact section ✓/✗ 예시 inline 미포함 (token gate 15 token slack tight 유지) | Task 2.2 |
| **G4.3-N22** | R2-M4 | tiktoken 실측 timing — Wave 1-B 끝 Task 2.4 에서 1차 실측 → estimate 보정 | Task 2.4 |
| **G4.3-N23** | R2-I1 | `body_part_focus_rule.applies_to` ground-truth 문자열 module constant `_ID_BODY_PART_FOCUS_APPLIES_TO`. test 동등 비교. | Task 1.1 + Task 5.1 test 8 |
| **G4.3-N24** | R2-B3 | 5 신규 sub-field 의 sub-field shape contract (`_assert_id_policy_shape()` strict scope) — required_keys per sub-field | Task 1.4 + Task 5.1 test 38-43 |
| **G4.3-N25** | R2-B4 | unit test ground-truth 검증 시 `set equality + len equality` 강제 (substring 검증 금지) | Task 5.1 |
| **G4.3-N26** | R2-M5 | spec §11 vs §12 경계 명시 (G4.3 audit findings vs G4.2 carry immutable) | spec back-propagate (plan 본문 영향 없음) |

---

## Implementation Checklist Summary

### Phase 1 (Wave 1-A) — render_prompt_card.py
- [ ] Task 1.1: 7 module-level constants 도입 (~50 LOC)
- [ ] Task 1.2: `compute_id_policy_snapshot_hash()` helper 신설 (~25 LOC)
- [ ] Task 1.3: `build_id_policy()` 5 신규 sub-field assemble + 6 constraints 확장 (~140 LOC)
- [ ] Task 1.4: `_assert_id_policy_shape()` 5 sub-field strict 검증 (~80 LOC)
- [ ] Task 1.5: `build_render_prompt_card()` `_card_metadata` 4 신규 key (~10 LOC)
- [ ] Task 1.6: docstring 갱신 (~20 LOC)

### Phase 2 (Wave 1-B) — v18 prompt
- [ ] Task 2.1: v18 디렉토리 + 4 prose section 109 lines 삭제
- [ ] Task 2.2: 30-line "ID Policy" compact section 삽입
- [ ] Task 2.3: line 395 인플레이스 교체 (R1R2-Q1)
- [ ] Task 2.4: detail_schema.json + user.md verbatim copy + tiktoken 실측

### Phase 3 (Wave 2) — wiring
- [ ] Task 3.1: `SCENE_DETAIL_PROMPT_VERSION` v17 → v18 bump
- [ ] Task 3.2: `version_registry.py` 4-way sync (MODULE_VERSIONS + prompt_dependency)

### Phase 4 (Wave 3) — canary scripts (5종)
- [ ] Task 4.1: `g4_3_body_part_focus.py` (모든 shot scope)
- [ ] Task 4.2: `g4_3_close_framing_face_forbidden.py` (close-framing only)
- [ ] Task 4.3: `g4_3_reproduction_surface.py` (모든 shot scope, ±50 chars)
- [ ] Task 4.4: `g4_3_demographic_descriptor_present.py` (composite-ID only, 4-tuple denominator)
- [ ] Task 4.5: `g4_3_token_count.py` (tiktoken cl100k_base)

### Phase 5 (Wave 4) — tests
- [ ] Task 5.1: ~36 unit tests (R2-B4 set equality)
- [ ] Task 5.2: ~12 integration tests (R1R2-Q1 substring presence/absence)
- [ ] Task 5.3: alignment test 4-way sync 갱신 (R2-I7)
- [ ] Task 5.4: regression 확인

### Phase 6 (Wave 5) — canary execution
- [ ] G4.2 production canary 6-gate confirm (entry condition)
- [ ] Task 6.1: baseline capture (v17)
- [ ] Task 6.2: candidate capture (v18)
- [ ] Task 6.3: 5 exit criteria + 1 pytest gate 판정 (3 STRICT 0 + 1 ratio + 1 token + 1 pytest)

### Phase 7 (Wave 5) — commit + dual review
- [ ] Task 7.1: single commit push
- [ ] Task 7.2: Codex 리뷰 (R3 / iter 1)
- [ ] Task 7.3: Claude 리뷰 (R4 / iter 1)
- [ ] Task 7.4: dual review fix loop (iter 2 / iter 3)

---

## Wave Briefing Templates (subagent dispatch)

각 Wave 의 implementer subagent (모두 opus 4.7) 가 받을 briefing 의 minimum context.

### Wave 1-A briefing

```
Wave 1-A — render_prompt_card.py producer expansion.

Inputs:
  - spec §2.2 (id_policy 5 신규 sub-field shape)
  - spec §4 (Lift Mapping Table — face_identifiability_rule + body_part scope)
  - plan Phase 1 Task 1.1 ~ 1.6

Outputs:
  - 7 module-level constants (Task 1.1 — 정확한 tuple 값 spec §2.2 ground-truth 일치)
  - compute_id_policy_snapshot_hash() helper (Task 1.2 — sha256(json.dumps id_policy)[:16])
  - build_id_policy() 5 신규 sub-field (Task 1.3 — face_identifiability_rule / body_part_focus_rule / close_framing_face_phrasing / reproduction_surface_rule / demographic_descriptor_policy)
  - _assert_id_policy_shape() 5 sub-field strict 검증 (Task 1.4 — required_keys per sub-field per R2-B3)
  - build_render_prompt_card() _card_metadata 4 신규 key (Task 1.5)
  - module + builder docstring 갱신 (Task 1.6)

Pre-flight (R4-M1 carry):
  - 작업 시작 전 `render_prompt_card.py` top 에 `import hashlib` + `import json` 존재 확인. 미존재 시 add (G4.1 carry 가정 verify).

함정 회피:
  - hardcoded `[]` / `or [] / or {}` 절대 금지 (G4.1 / G4.2 trap carry — production drift cascade)
  - 시나리오 의존 0 (작품 고유명사 ban — ethnicity 안 작품명 금지)
  - default arg + None / [] 셋 명시 구분 시 _DEFAULT_SENTINEL = object() pattern
  - **5 신규 sub-field 는 builder-static (R4-M6 carry)** — `if visible_entities:` 같은 truthy guard 안 배치 절대 금지. `visible_entities=[]` (Test 37) 에서도 5 sub-field 모두 present 보장.

Constraints:
  - module-level constant tuple 사용 (list 금지 — immutable)
  - 시나리오 의존 0
  - silent fallback 금지 (`feedback_no_silent_fallback.md`)

Verification before return:
  - len + content 정확 (R2-B4): _ID_BODY_PART_TRIGGERS=4 / _ID_CLOSE_FACE_FORBIDDEN_PHRASES=6 / _ID_CLOSE_FACE_RECOMMENDED_PHRASINGS=4 / _ID_REPRODUCTION_SURFACES=9 / _ID_ETHNICITY_COMPONENTS=10 / _ID_AGE_BANDS=6
  - build_id_policy(visible_entities=[], outlook_pairs=[]) 빈 입력에서 5 신규 sub-field 모두 present (R2-I4)
  - body_part_focus_rule.required_keys = 4 entry 포함 `common_noun_examples_generic` (R4-I5)
```

### Wave 1-B briefing

```
Wave 1-B — v18 prompt 신규 디렉토리.

Inputs:
  - spec §3.1 / §3.2 / §3.3 / §3.4 (v17 4 prose 109 lines 위치 + 30-line compact section)
  - plan Phase 2 Task 2.1 ~ 2.4

Outputs:
  - prompts/_base/scene_detail/18.<timestamp>/system.md (v17 - 109 lines + 30 lines + line 395 인플레이스 교체)
  - prompts/_base/scene_detail/18.<timestamp>/detail_schema.json (verbatim copy)
  - prompts/_base/scene_detail/18.<timestamp>/user.md (verbatim copy)

함정 회피:
  - 덮어쓰기 금지 (`feedback_prompt_versioning.md` carry)
  - line count 정확 — 30 lines 초과 금지 (R1-I3 / R2-I3 / R3R4-B3 carry — perception_mode bullet 1 line)
  - line 395 인플레이스 정확 (R1R2-Q1 — `"얼굴 참조 이미지가 강제 주입"` → `"id_policy.body_part_focus_rule 참조"`)
  - ✓/✗ 예시 미포함 (R2-M3 — token gate tight)
  - **timestamp lockstep (R4-I3 carry)**: scratch file `.wave_1b_timestamp.txt` 에 timestamp 값 기록 후 Wave 2 dispatcher 가 read. 1 char drift 시 4-point sync alignment fail.

Verification before return:
  - 4 deletion target heading absent (`grep -n` == 0 — 4 line range 정확)
  - "## ID Policy" heading present + 30 lines (next "## " 까지)
  - "얼굴 참조 이미지가 강제 주입" substring absent
  - "id_policy.body_part_focus_rule" substring present
  - tiktoken cl100k_base 실측 token_delta = candidate - baseline `<= -1050` (R2-M4 + R4-B4 carry, signed convention)
```

### Wave 3 briefing

```
Wave 3 — 5 canary scripts.

Inputs:
  - spec §5.3 / §5.1 (5 metric exit criteria)
  - Wave 1-A complete (constants + compute_id_policy_snapshot_hash())
  - plan Phase 4 Task 4.1 ~ 4.5

Outputs:
  - scripts/canary/g4_3_body_part_focus.py (~140 LOC, 모든 shot scope)
  - scripts/canary/g4_3_close_framing_face_forbidden.py (~135 LOC, close-framing only)
  - scripts/canary/g4_3_reproduction_surface.py (~150 LOC, 모든 shot, ±50 chars window)
  - scripts/canary/g4_3_demographic_descriptor_present.py (~170 LOC, composite-ID only, 4-tuple denominator)
  - scripts/canary/g4_3_token_count.py (~90 LOC, tiktoken cl100k_base)

함정 회피:
  - sentence boundary regex 절대 금지 (`feedback_no_regex_postprocessing.md` carry — R1R2-B3)
  - module constants 직접 import (script 안에 hardcoded literal 금지 — drift 차단)
  - silent absorb 금지 (`or [] / or {}` 패턴 제거 — measurement_failures 누적)
  - sentinel pre-pass + pinned-tuple post-loop 의무 (G4.2 carry)
  - gate fail → exit 1 (CI 인지 — return 0 false-pass 차단)

Constraints:
  - ±50 chars char-level window (sentence split 사용 X)
  - JSON schema 통일 (pinning 7 field — id_policy_card_snapshot_hash 포함)
  - exit criteria threshold 정확 (3 STRICT 0 + 1 ratio + 1 token)

Verification before return:
  - 5 script 가 module constants import (script-level test — `python -c "from scripts.canary.g4_3_body_part_focus import *; print(_ID_BODY_PART_TRIGGERS)"`)
  - measurement_failures 누적 logic 모두 동일 pattern
  - gate fail → sys.exit(1) — 모든 script
```

### Wave 4 briefing

```
Wave 4 — unit + integration + alignment tests.

Inputs:
  - spec §6.1 (~36 unit tests)
  - spec §6.2 (~12 integration tests)
  - spec §6.4 (alignment test 4-way sync)
  - Wave 1-A + 1-B + 2 complete
  - plan Phase 5 Task 5.1 ~ 5.4

Outputs:
  - backend/tests/unit/test_g4_3_id_policy_lift.py (~36 unit tests, 580 LOC)
  - backend/tests/integration/test_g4_3_id_policy_integration.py (~12 integration tests, 370 LOC)
  - backend/tests/prompts/test_scene_detail_id_policy_alignment.py (modified — 4-way sync row 갱신)

함정 회피:
  - hardcoded result dict 절대 금지 (G4.1 / G4.2 trap carry — Wave subagent 가 가짜 fixture 도입 → production drift cascade)
  - mock 사용 최소 — 각 unit test 가 실제 build_id_policy() / build_render_prompt_card() call
  - _DEFAULT_SENTINEL = object() pattern (G4.2 plan-R1-I4 carry)
  - set equality + len equality (R2-B4) — substring 검증 금지
  - hash drift test 가 content selector (R2-M2 — index 의존 금지)
  - integration test `test_v18_system_prompt_line_395_inplace_replaced` 가 substring presence + absence 양쪽 검증

Constraints:
  - 5 sub-field 각 4-7 test = 20-30
  - shape validator 5 test = 5
  - perception_mode 3 test = 3
  - card_metadata 3 test = 3
  - hash 3-4 test = 3-4
  - 입력 validation 3 test = 3
  - **합계 36+ minimum**

Verification before return:
  - pytest backend/tests/unit/test_g4_3_id_policy_lift.py -v → 36+ green
  - pytest backend/tests/integration/test_g4_3_id_policy_integration.py -v → 12+ green
  - pytest backend/tests/prompts/test_scene_detail_id_policy_alignment.py -v → 4-point sync row green (R4-M2 carry — alignment test path 명시)
  - regression: `pytest backend/tests/ -k "g3_2 or g4_1 or g4_2 or alignment"` → 0 regressions (R4-M2 carry — alignment selector 추가)
```

### Wave 5 briefing

```
Wave 5 — canary execution + commit + dual review fix loop.

Inputs:
  - Wave 1-A + 1-B + 2 + 3 + 4 complete (regression suite green)
  - G4.2 production canary 6-gate confirm (entry gate per R3-I3)
  - 사용자 confirm: PID 결정 (Phase 6 Task 6.1 PID criteria 만족)
  - plan Phase 6 + Phase 7

Outputs:
  - docs/canary/g4_3_baseline_*.json (4종 — 4 metric scripts)
  - docs/canary/g4_3_candidate_*.json (4종 — 4 metric scripts)
  - docs/canary/g4_3_token_count_*.json (1종)
  - single commit + push (HEAD=14d14cb 직후)

Process:
  1. Phase 6 entry gate confirm (G4.2 production canary 6-gate)
  2. PID + scene_set 결정 (사용자 confirm)
  3. baseline capture (v17 prompt)
  4. candidate capture (v18 prompt) — 동일 PID + 동일 scene_set
  5. pinning 7 field equality 검증 (baseline == candidate)
  6. 5 exit criteria + 1 pytest gate 판정
     - body-part focus STRICT 0
     - close-framing face STRICT 0
     - reproduction surface STRICT 0
     - demographic ratio >= baseline
     - token_delta = candidate - baseline `<= -1050` (signed)
     - pytest g4_3 suite green
  7. 모두 통과 → single commit + push
  8. Codex (R3) + Claude (R4) 듀얼 리뷰 (Task 7.2 / 7.3 병렬)
  9. iter 2 / iter 3 fix loop (Task 7.4)

함정 회피:
  - silent return 0 false-pass 차단 (Wave 3 canary 가 measurement_failures 누적 + exit 1 — Wave 5 는 본 contract 의지)
  - pinning 7 field 1 char drift 시 비교 불가 — 재측정 mandatory
  - dual review BLOCKING 1 row 라도 발견 시 fix iter 진입 — 임의 무시 금지
```

---

## Critical Details

### Error Handling

- `build_id_policy()` 의 `visible_entities is None` / `outlook_pairs is None` → `AppError(code="step.contract_violation")` (G4.1 carry — 변경 없음).
- `_assert_id_policy_shape()` 5 신규 sub-field 누락 시 → `AppError(code="step.contract_violation")` with sub-field name (Task 1.4).
- `compute_id_policy_snapshot_hash(card)` 가 `card` non-dict 또는 `id_policy` key 부재 시 → `AppError` (silent fallback 금지).
- canary scripts (Wave 3) measurement_failures 누적 시 → exit 1 (silent return 0 차단).

### State Management

- CP 에 저장되는 `render_prompt_card` dict 은 `_card_metadata` 포함 (debug 목적, G4.2 carry).
- LLM inject 에는 `_card_metadata` 제외 (token budget + LLM confusion 방지, G4.2 commit `14d14cb` carry — Phase 3 변경 0).
- `canonicalize_render_prompt_card()` 는 hash 계산 시 `_card_metadata` pop (G4.1 / G4.2 carry — id_policy 신규 sub-field 는 envelope 안 hash 영향 받음).
- 세 가지 isolation 이 동일 원칙 적용 — 이 중 하나라도 누락 시 hash drift 또는 token 증가 발생.

### Testing

- unit test fixture 에서 `None` vs `[]` vs `_DEFAULT_SENTINEL` 명시 구분 (G4.1 / G4.2 plan-R1-I4 carry). `or []` 패턴 절대 금지.
- integration test 는 실제 `build_render_prompt_card()` 전체 call path 사용 (hardcoded empty inputs 트랩 방지 — G4.1 / G4.2 교훈).
- canary script test 는 PyTest 로 별도 작성하지 않음 — canary 는 production CP 데이터 의존이므로 script 자체 unit test 는 mock 기반 단순 smoke 만.
- alignment test 가 4-way sync 강제 검증 — `MODULE_VERSIONS["scene_detail_composer"] == "1.18.0"` row 누락 시 alignment fail (R2-I7 carry).

### Performance

- 7 module-level constants 추가 — module load time 영향 0 (Python import time tuple literal).
- `compute_id_policy_snapshot_hash()` 호출 — sha256 + json.dumps O(n) where n = id_policy size (~2KB) — 측정 불필요.
- `_card_metadata` 4 신규 key 추가 — CP write 시 ~200 bytes 증가 — 무시 가능.
- v18 system.md token 감소 (-1050 measured) — per-shot 비용 직접 감소.

### Security / Correctness

- `_card_metadata.rule_source` 의 rule letter ("H") 는 hardcoded — G4.3 scope 내에서 변경 불필요. 미래 Rule 추가 시 hash drift 없이 `_card_metadata` 만 갱신 가능 (격리 목적).
- v18 prompt 의 "card 가 primary contract — 이 섹션 prose 와 충돌 시 card 우선" 문구는 R1-I4 card-wins precedence 의 prompt-side enforcement.
- 7 module-level constants 안 작품 고유명사 (인물 이름, 지명) 절대 금지 — 시나리오 의존 0 carry. ethnicity 의 10 entry 도 일반 region 표기만 (Asian / East Asian / Black / Caucasian / etc.).

### G4.4 Entry Condition (spec §7.3)

G4.3 완료 후 G4.4 (continuity lift) 진입을 위한 조건:

- [ ] G4.3 4 metric (body-part focus / close-framing face / reproduction surface / demographic descriptor) 모두 STRICT 통과
- [ ] G4.3 token delta gate `≥ -1050` 통과
- [ ] G4.3 unit + integration test suite green
- [ ] v18 prompt push + 4-way sync 완료

G4.4 가 다루는 scope: continuity lift (fixed_elements / previous_shot_refs / forward_zoom_targets prose).

---

## Data Flow

```
build_render_prompt_card(inputs)
  │
  ├─ build_id_policy(visible_entities, outlook_pairs, perception_mode)
  │     ├─ G4.1 carry — fail-fast on None
  │     ├─ G4.3 신규 5 sub-field assemble (builder-static):
  │     │     ├─ face_identifiability_rule (use_entity_id_when / common_noun_required_when / id_use_summary / rationale_summary)
  │     │     ├─ body_part_focus_rule (trigger_phrases [4 from _ID_BODY_PART_TRIGGERS] / applies_to / id_use / common_noun_examples_generic)
  │     │     ├─ close_framing_face_phrasing (forbidden_phrases [6 from _ID_CLOSE_FACE_FORBIDDEN_PHRASES] / recommended_phrasings [4] / rationale_summary)
  │     │     ├─ reproduction_surface_rule (applies_to_surfaces [9 from _ID_REP
RODUCTION_SURFACES] / id_use / rationale_summary)
  │     │     └─ demographic_descriptor_policy (required_on_first_appearance / applies_to_id_forms / token_count_range / components.{ethnicity [10 from _ID_ETHNICITY_COMPONENTS], gender [3], age_band [6 from _ID_AGE_BANDS], role_hint_from_outfit [4]} / format_template_a/b / source_priority / scenario_dependency_ban / regional_consistency)
  │     ├─ G4.3 constraints 6 strings (base — perception_mode None)
  │     │     ├─ constraints[0]: composite IDs use
  │     │     ├─ constraints[1]: reproduction surface (9 surfaces inline)
  │     │     ├─ constraints[2]: body-part focus (4 triggers inline)
  │     │     ├─ constraints[3]: close-framing face forbidden (5 of 6 forbidden inline; 6th defensive R1-M1)
  │     │     ├─ constraints[4]: demographic first-appearance
  │     │     └─ constraints[5]: format templates
  │     └─ G4.1 carry — perception_mode reflection branch → +1 constraint = 7 (defense in depth, R1-I4)
  │
  ├─ build_background_binding(...) [G4.2 carry — 변경 없음]
  ├─ build_render_strategy(...) [G4.1 carry]
  ├─ build_continuity(...) [G4.1 carry — G4.4 scope]
  ├─ build_asset(...) [G4.1 carry]
  │
  └─ _card_metadata assembly
        ├─ lift_status:
        │     ├─ G4.2 carry: rule_a_lifted / rule_c_lifted / rule_e_lifted (background_binding mode 별)
        │     └─ G4.3 신규: rule_h_lifted=True / id_policy_composite_lifted=True / id_policy_close_framing_face_lifted=True / id_policy_reproduction_surface_lifted=True (all True, perception 무관)
        └─ rule_source:
              ├─ G4.2 carry: camera_rule="A" / owned_rule="C" / close_skip_rule="E"
              └─ G4.3 신규: demographic_rule="H" / composite_id_rule="C## 사용 규칙 — 참조 이미지 연동 (v17:32-64)" / close_framing_face_rule="극단 클로즈업 표현 (v17:66-85)" / reproduction_surface_rule="사진·포스터·화면·거울 속 인물 규칙 (v17:133-146)"
        card["_card_metadata"] = {...}  ← top-level sibling only (Override O-10)

canonicalize_render_prompt_card(card)
  └─ pop("_card_metadata") → hash payload = 7 contract field only (G4.2 R1-B1 carry)

compute_card_hash(card) → sha256[:16]                ← envelope 전체 hash
compute_id_policy_snapshot_hash(card) → sha256[:16]  ← G4.3 신규 — id_policy partial hash (canary pinning 용)

detail_steps.py _analyze_one()
  ├─ _g41_render_prompt_card = build_render_prompt_card(...)  ← _card_metadata 포함
  ├─ _g41_hash = compute_card_hash(...)
  ├─ cp["render_prompt_card"] = _g41_render_prompt_card  ← CP 저장: _card_metadata 포함 (debug)
  ├─ cp["render_prompt_card_hash"] = _g41_hash
  │
  ├─ [G4.2 carry — commit 14d14cb] _g41_card_for_inject = {k: v for k, v in card.items() if k != "_card_metadata"}
  └─ _g41_card_block = "[RenderPromptCard v1]\n" + json.dumps(_g41_card_for_inject, ...) + "\n\n"
       └─ user_prompt = _g41_card_block + user_prompt  ← LLM 입력: _card_metadata 제외

LLM (scene_detail / v18 prompt)
  ├─ system.md: ID Policy compact section (4 prose 109 lines 삭제 → 30-line reminder + line 395 인플레이스)
  └─ user_prompt: [RenderPromptCard v1] + {7 contract fields, no _card_metadata, 5 신규 id_policy sub-field 포함} + ...

canary measurement (Wave 5)
  ├─ g4_3_body_part_focus.py → 모든 shot t2i_prompt scan, 4 trigger alternation regex (STRICT 0)
  ├─ g4_3_close_framing_face_forbidden.py → close-framing shot scan, 6 forbidden literal substring (STRICT 0)
  ├─ g4_3_reproduction_surface.py → 모든 shot scan, 9 surface ±50 chars window with C##O## composite (STRICT 0)
  ├─ g4_3_demographic_descriptor_present.py → composite-ID shot scan, first-appearance per (si, shi, vi, id) tuple, ±50 chars window with ethnicity OR age_band (ratio ≥ baseline)
  └─ g4_3_token_count.py → tiktoken cl100k_base v17 vs v18 (delta ≤ -1050)
```

---

## Spec ↔ plan drift quick-check

본 표는 plan body 가 spec 의 어느 부분에서 정보를 derive 하는지 1:1 매핑. spec 변경 시 plan body 도 갱신 필수 — 1 row drift 발견 시 plan R5 round.

| plan section | spec source |
|---|---|
| Round Override (Spec R1+R2 carry) | spec §"Round Override" 표 (line 21-91) |
| Wave 1-A Task 1.1 (7 module constants) | spec §2.2 5 sub-field shape + §9 Glossary R3-B1/B2/B3 ground-truth |
| Wave 1-A Task 1.2 (`compute_id_policy_snapshot_hash()`) | spec R2-I2 / Q5 RESOLVED |
| Wave 1-A Task 1.3 (`build_id_policy()` 5 sub-field) | spec §2.2 |
| Wave 1-A Task 1.4 (`_assert_id_policy_shape()` 5 sub-field) | spec R2-B3 |
| Wave 1-A Task 1.5 (`_card_metadata` 4 신규 key) | spec §2.2 _card_metadata matrix |
| Wave 1-B Task 2.1 (4 prose 109 lines 삭제) | spec §3.2 + R2-B1 |
| Wave 1-B Task 2.2 (30-line compact section) | spec §3.3 + R1-I3 / R2-I3 / R2-M3 |
| Wave 1-B Task 2.3 (line 395 인플레이스) | spec R1R2-Q1 / §3.2 |
| Wave 2 Task 3.1 (`SCENE_DETAIL_PROMPT_VERSION` v18) | spec §6.4 4-way sync |
| Wave 2 Task 3.2 (version_registry 4-way sync) | spec §6.4 + R2-I7 |
| Wave 3 Task 4.1 (body-part focus canary) | spec §5.1 + §5.3 + R1R2-B2 |
| Wave 3 Task 4.2 (close-framing face canary) | spec §5.1 + §5.3 |
| Wave 3 Task 4.3 (reproduction surface canary) | spec §5.1 + R1R2-B3 (±50 chars window) |
| Wave 3 Task 4.4 (demographic descriptor canary) | spec §5.1 + R1R2-B3 (4-tuple denominator) |
| Wave 3 Task 4.5 (token canary) | spec §5.1 + §5.3 |
| Wave 4 Task 5.1 (~36 unit tests) | spec §6.1 + R2-B4 |
| Wave 4 Task 5.2 (~12 integration tests) | spec §6.2 + R1R2-Q1 |
| Wave 4 Task 5.3 (alignment 4-way sync) | spec §6.4 + R2-I7 |
| Wave 5 Phase 6 (canary execution + commit) | spec §5.3 + §7.2 step 20-22 + R3-I3 entry gate split |
| Wave 5 Phase 7 (commit + dual review) | spec §7.2 step 23-24 + G4.2 commit `14d14cb` 3-iter pattern carry |
| Override Table | spec §10 + §12 + Round Override 표 |

---

**End of G4.3 ID-Policy Lift Implementation Plan.**

Plan length: ~1450 lines (target 1300-1500). Wave decomposition: 5 waves (1-A producer / 1-B prompt / 2 wiring / 3 canary / 4 tests / 5 canary execution + commit). Total expected delta: +~280 producer LOC + -79 net prompt LOC + ~600 canary LOC + ~700 test LOC + 48+ 신규 test (36 unit + 12 integration). 21 spec Override rows + 19 G4.2 carry overrides applied. plan R3+R4 audit pending — Round Override 빈 placeholder 보존 (audit dispatch 후 row 추가).
