# G4.6 Visual Failure Root-Cause Repair Design

본 문서는 PID `298d86d9-615b-4b87-8040-21144c0731c1` / EP `b6544514-babb-4781-95a2-6a0ff1da342f`
production E2E 결과에서 사용자가 보고한 4 visual defect (D1-D4) 의 raw-data
검증된 root-cause 들을 deterministic 하게 수정하기 위한 통합 spec 이다. G4.x
spec/lift 패턴을 답습하나, **본 spec 은 prose-to-envelope lift 가 아니라
defect-driven repair** 다. 5-field envelope contract (G4.2~G4.5a 에서 lift 완료)
는 그대로 유지한다.

- 작성일: 2026-05-06
- 상태: **R1 Codex audit fix iter 5 — required_refs + malformed pair carry**: required_refs 도 _MISSING fail-fast (RO-30) + allowed_outlook_pairs entry 의 모든 ID 부재 시 AppError (RO-31). G4 contract 일관 — silent pass 0건.
- main HEAD: `bb3beb6` (force-cascade prevention fix D)
- 선행 완료:
  - G4.5a spatial-rules lift (`fb3d79e`) — `render_strategy.spatial_consistency`
  - cascade fixes R3+C+D (`51685d4` → `bb3beb6`)
  - canary E2E (`session_20260505_canary_complete.md`) — 140 PNG 생성 후 4 defect 발견
- 목적: 4 defect 의 root-cause (entity ref 누락 / prompt label routing 버그 /
  silhouette identity prompt 모순 / shot_validator characters 추출 실패 /
  zoom_in_detail mis-classification / chain_bg 합성 워딩 부족) 을 **deterministic
  guard 만으로** 막는다. **LLM "visually critical" 판단 플래그는 후속 (G4.6b
  또는 별도 spec) 으로 carry — audit surface 확대 차단**.

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

본문보다 우선 적용. Codex (R1) + Claude (R2) 라운드 감사 + lead/user 결정
결과를 본문 반영 전 fast-path 로 박는다.

### Round 1 — Codex (R1) audit 결과 (2026-05-06)

R1 verdict: `NEEDS_REVISION` (B1+B2+B3+B4+B5+B6 + I1+I2+I3+I4+I5 + M1+M2+M3).
R2 (Claude) 대기 — 아래 RO-1 ~ RO-14 적용 후 dispatch.

| ID | Source | 결정 (Override) | 적용 위치 |
|---|---|---|---|
| **RO-1** | R1-B1 | **shot_validator entity context inject + character_ids merge** (3-part fix). (a) `_validate_one_scene` user_prompt 에 `[Entity map for this scene]` block 주입 — `scene_director.present_entity_ids` 의 base entity 들의 `(short_id, canonical_name, stable_traits 1-2 token)` 을 line 단위로. fetch source: entity_canon (project_id + short_id). 빈 케이스는 block 자체 omit. (b) shot_validator output schema 에 신규 `character_ids: list[str]` 필드 추가 (short_id 배열). 기존 `characters: list[str]` (이름 배열) 그대로 — 다운스트림 `shot_dependency_step.py:89` `lookup_name` 회로 깨지지 않게 분리 (I5). (c) merge 로직 (`shot_validator_step.py:226+`) 에 `character_ids` / 보정된 `characters` 머지 로직 추가 — `changed=true` 외에 `character_ids` 업데이트만 있는 경우도 별도 분기 처리. | §3.4 + §3.5 + §6.2 |
| **RO-2** | R1-B2 | **visible_entities validator 강화 — descriptor + asset_requirements + id_policy 3 source crosscheck**. (a) regex 단독 금지. (b) descriptor match: prompt 안 generic descriptor token (`young East Asian woman/man/girl/boy`, `한국인 남자/여자`, `Korean adult male/female`) 검출 시 — 해당 shot 의 visible_entities base 와 entity_canon name 매칭으로 어떤 ID 가 그 descriptor 를 의미하는지 확인. ID 가 visible_entities 에 없으면 contract violation. (c) `render_prompt_card.asset_requirements.required_refs[].id` 에서 C##O## base 를 visible_entities 와 비교 — required ref 의 base 가 visible_entities 에 없으면 violation. (d) `id_policy.allowed_outlook_pairs[].base_id` (또는 동등 field) 도 visible_entities 와 비교. fixture 는 실제 D3 패턴 ("a young East Asian woman" 만 있고 `C09` token 없음 + visible_entities=["C08","L03"]) 으로 fail. | §3.5 + §5.4 |
| **RO-3** | R1-B3 | **required-by-source primary 변경 + required_refs shape 정정**. (a) primary required entity ID source 는 다음 4 cascade: (1) `scene_director.present_entity_ids` (항상 존재 — primary), (2) `shot_validator.character_ids` (RO-1 후 — secondary), (3) `shot_director.character_id` 등 shot-level entity, (4) `EntityEpisodeLink.t2i_appearance_count >= 1` (DB-derived). 4 source union 으로 base entity ID set 구성. (b) `scene_detail` manifest 는 force/retry rescue source 로만 (이미 ref_image_gen 후 force 재실행 시 contract carry 용). 새 PID first-run 에는 (1)~(4) 만 사용. (c) `asset_requirements.required_refs` 파싱 정정: shape = `[{"kind": "character_outlook" | "character" | "background" | ..., "id": "<short_id>", "policy": "required" | "optional"}]`. base 추출은 `ref["id"].split("O")[0]`. spec §3.1 의 `_collect_required_entity_ids` pseudo-code 정정. | §3.1 + §5.2 |
| **RO-4** | R1-B4 | **focus_transition 양쪽 필드 — `{previous_focus_region, current_focus_region}` schema 채택**. (a) `shot_dependency_t2i` v6 schema 의 `location_refs[]` entry 에 두 필드 동시 추가 (단일 `focus_region` 대신). LLM 이 prev shot 의 focus 도 추출 — prev shot description / shot_validator output 을 user_prompt 에 명시 인용 (shot_dependency_t2i 가 이미 shot_validator 읽음 — line 35). (b) backend `_validate_zoom_in_detail` 가 두 필드 비교. (c) M3 carry: focus_region canonicalizer table — `upper thigh / bare thigh / thigh` → `thigh`, `face / face CU / head / facial expression` → `face`, `hand / palm / fingers / wrist / knuckle` → `hand`, `eye / eyes / pupil / iris` → `eye`, `wide / establishing / full scene / wide shot` → `wide_scene`. canonicalizer 적용 후 equality 비교. | §4.1 + §4.2 + §9 |
| **RO-5** | R1-B5 | **demote 정책 fail-fast 단일화** (silent fallback 금지 carry). (a) `_validate_zoom_in_detail` 는 mismatch 발견 시 **AppError raise** — `logger.warning` 단독 demote 금지. error message 에 prev/current focus + ref_usage 명시. shot_dependency_t2i step 이 contract violation status 로 step 결과 marking. (b) demote 가 실제로 운영상 필요한 케이스 (LLM regen 비용 절약) 가 있으면 그것은 별도 helper `_attempt_zoom_demote_with_evidence` — manifest 에 `ref_usage_original`, `demotion_reason`, `validator="g4_6_zoom_demote"` 3 필드 기록 후 demote. spec §4.1 / §4.2 / §7.1 / §10 의 "fail-fast vs demote" 모순 모두 fail-fast 로 정정. (c) §10 Open Q5 (Pre-Wave commit 전략) 와 별개 — 이 RO 는 fail-fast 가 default. | §4.1 + §4.2 + §7.1 + §10 |
| **RO-6** | R1-B6 | **production regeneration order 정정**. 실제 step dependency (`shot_dependency_t2i_step.py:35` shot_validator 읽음, `:95` scene_detail 읽음, `:228` 자체 LLM) 기반: `ref_image_gen / composite refs (C08+C09+C15+O10+O11+O15)` → `shot_validator v4 (전체)` → `scene_detail v21 (전체 또는 target)` → `shot_dependency_t2i v6 (전체 — shot_validator + scene_detail 모두 읽음)` → `scene_image_pipeline (target 5 shots)`. spec §6.4 step 2/5 swap, "shot_dependency_t2i regen if needed" 라인 제거. dependency 가 항상 shot_validator + scene_detail 후 이므로 한 번만 force. | §6.4 |
| **RO-7** | R1-I1 | **shot_dependency_t2i silent fallback 도 G4.6 범위 — Wave B Step 1 에 흡수**. `shot_dependency_t2i_step.py:228` 의 `except: llm_results[...] = {"location_refs": []}` + `logger.warning` fallback 을 contract_violation 으로 변경. partial 분류 도입 — step status 가 `failed_count` 증가 + step result manifest 에 fallback 발생 shot ID list 기록. retry policy: 1차 자동 retry, 2차 fail-fast (raise AppError). | §4.1 + §7.1 |
| **RO-8** | R1-I2 | **shot_validator failure path partial 정책**. `shot_validator_step.py:97` 의 "실패 시 원본 유지" 패턴이 v4 의 visible-human-action characters fail-fast 와 충돌. 정정: scene 단위 LLM 실패 시 (a) 자동 1회 retry, (b) 그래도 실패 시 그 scene 의 shots 를 `validator_status="failed"` 마킹하여 results 에 append, (c) downstream (scene_detail / shot_dependency_t2i) 가 이 마킹을 읽고 partial 처리 또는 fail-fast. v4 의 characters fail-fast 룰은 LLM **응답** validation 에 적용 — LLM 호출 자체 실패 시는 partial 처리 (혼동 방지). | §3.4 + §7.1 |
| **RO-9** | R1-I3 | **canary path 정정 — `scripts/canary/g4_6_*.py`**. spec §5.6/§5.7 의 `backend/scripts/canaries/...` 모두 정정. G4.2~G4.5a 패턴 (`scripts/canary/g4_X_*.py`) 답습. helper 모듈은 `scripts/canary/_g4_6_common.py` (G4.5a 의 `_g4_5a_common.py` 패턴). | §5.6 + §5.7 |
| **RO-10** | R1-I4 | **protected keyword "shadow" narrowing**. `_PROTECTED_KIND_KEYWORDS_*` 의 `shadow` 키워드는 entity_type=="character" + (name 또는 stable_traits 매칭) 일 때만 적용. description 전체 substring 매칭 금지 — 조명/배경 묘사의 "shadow" 가 entity 보호 대상으로 잘못 잡히지 않게. helper `_has_protected_kind_keyword` signature 변경: `(e: dict) -> bool` 그대로 유지하되 내부에서 `etype == "character"` 가드 + `name + stable_traits` 만 매칭 (description 제외). | §3.1 |
| **RO-11** | R1-I5 | **`character_ids` 신규 필드 분리** (RO-1 와 묶음). spec §3.4 의 "characters 에 short_id / raw text 혼용" 제거. 기존 `characters: list[str]` (이름) + 신규 `character_ids: list[str]` (short_id) 분리. shot_dependency_step.py:89 의 `lookup_name(name_to_sid, name)` 회로 그대로 유지 (회귀 차단). character_ids 는 visible_entities 합성 단계 / scene_detail 의 base entity 식별에 사용. | §3.4 + §3.5 + §10 Q1 |
| **RO-12** | R1-M1 | **pseudo-code etype 인자 정정**. spec §3.1 line 486 `_should_skip_low_freq(e, count, is_base_for_variant, is_variant_self, required_by_scene_detail)` signature 에 `etype: str = e.get("entity_type", "")` 를 helper 내부에서 계산 (인자 추가 X — caller 단순화). 단 location/outlook 가드는 helper 진입 직후 `if etype in ("location", "outlook"): return False`. | §3.1 |
| **RO-13** | R1-M2 | **status 라인 정정**. line 11 "initial draft" → 위 변경 ("R1 Codex audit 1차 완료 ... R2 대기"). | header |
| **RO-14** | R1-M3 | **focus_region canonicalizer table** — RO-4 에 통합. canonicalizer 표 §9 Glossary 에 추가. 신규/예외 region 은 free-text 그대로 보존 + canonicalizer 미적용 시 equality fail. | §9 + §4.1 |

### Round 1 fix iter 1 — Codex (R1) re-audit 결과 (2026-05-06, RO-1~14 적용 후)

R1 re-audit verdict: `NEEDS_REVISION` (B1+B2+B3+B4 + I1+I2+I3+I4).

| ID | Source | 결정 (Override) | 적용 위치 |
|---|---|---|---|
| **RO-15** | R1-iter1-B1 | **`validator_status="failed"` shot bypass 제거 — silent degradation 차단**. (a) §3.5 contract validator 의 `if shot.get("validator_status") == "failed_carry_original": return` 분기 제거 — bypass 가 default 가 아님. characters=[] systemic 실패가 scene_detail/scene_image_pipeline 까지 silently 흘러가는 패턴 차단. (b) downstream 동작 변경: shot_validator failed scene 의 shots 는 `validator_status="failed_carry_original"` 마킹 그대로 — scene_detail step **시작 시점에 fail-fast** (AppError raise) — 운영자가 force flag 또는 별도 retry policy 로 명시적 진행 가능. (c) RO-8 (failure partial) 의 의도는 "downstream 이 markings 를 보고 partial 처리 가능" 이지 "bypass" 가 아니었다 — 정정. partial 처리 = 명시적 contract_violation 마킹 + 운영자 경로 (force) 만 허용. | §3.4 + §3.5 + §6.4 + §7.1 |
| **RO-16** | R1-iter1-B2 | **`id_policy.allowed_outlook_pairs` shape 정정 — `character_id` / `outlook_id` primary**. 코드 검증: `backend/app/core/steps/render_prompt_card.py:2287` strict assert `required_keys=("character_id", "outlook_id")`. spec §3.5 source 3b validator 의 `pair.get("base_id")` / `pair.get("composite_id")` 가정은 잘못. 정정: `pair.get("character_id")` 가 primary, `base_id`/`composite_id` 는 legacy fallback (다른 G4 버전과 mixed deploy 대비). 매칭 우선순위: character_id → base_id → composite_id.split("O")[0]. unit test 추가. | §3.5 (source 3b) |
| **RO-17** | R1-iter1-B3 | **descriptor validator false-positive 차단 — same-clause window 매칭**. 코드 검증: `backend/app/core/steps/render_prompt_card.py:1145` id_policy 권장 패턴 = `C##O## in <옷>, <demographic descriptor>` 또는 `C##O##, <demographic descriptor>, <자세>`. 즉 ID + descriptor 동시 사용이 정상. spec §3.5 source 2 가 descriptor 단독 검출 시 contract_violation raise 하면 정상 prompt 도 fail. 정정: descriptor 검출 시 — 그 token 의 same clause/window (±20 char 또는 같은 sentence) 안에 visible C##/C##O## token 이 있으면 PASS (ID-anchored descriptor). 같은 window 안에 ID 없으면만 contract_violation 검사. window 정의: 같은 sentence (period 단위) 또는 prev-after-next clause boundary. | §3.5 (source 2) |
| **RO-18** | R1-iter1-B4 | **zoom_in_detail validator 위치 — `shot_dependency_t2i_step.py:208-225` 저장 전**. 코드 검증: shot_dependency_t2i_step.py LLM 결과를 line 208 에서 받아 line 224 에서 `llm_results[key]` 에 저장. invalid dependency 가 manifest 에 commit 된 후 image stage 에서 터지는 것보다 저장 전 검증이 단일 contract source. spec §4.1 정정: primary validator 는 shot_dependency_t2i_step.py — `_validate_zoom_in_detail` 호출 위치를 line 208~224 사이 ref 검증 loop 안 으로. scene_reference_service.py:680 zoom_in_detail label 분기는 defensive `assert` 만 (이미 step level 에서 통과한 상태가 invariant). 위반 시 manifest contract violation. | §4.1 + §4.2 |
| **RO-19** | R1-iter1-I1 | **characters / character_ids 잔재 정리 — ID 검증은 모두 `character_ids`**. (a) §3.4 의 v4 prompt 본문 룰 중 "characters 매핑 시도 후 매칭 실패" 같은 표현 → `character_ids` 매핑 + `characters` 는 이름 보존 분리 명시. (b) §3.5 source 1/2/3 모든 ID 검증은 `character_ids` 와 visible_entities 비교 (RO-1 신규 필드) — `characters` 와는 비교 X. (c) §6.5 verification gate 의 "shot_validator characters 가 visible-human-action shot 에서 non-empty" → "shot_validator character_ids 가 ..." 정정. (d) §10 Q1 표현 일관. | §3.4 + §3.5 + §6.5 + §10 |
| **RO-20** | R1-iter1-I2 | **demote 잔재 (canary/fixture 이름) → fail-fast 일관**. (a) §5.1 fixture `s1_shot4_dependency.json` 의 용도 코멘트 "RC-A demote 테스트" → "RC-A fail-fast 테스트". (b) §5.7 canary `g4_6_zoom_in_detail_demotion.py` → `g4_6_zoom_in_detail_focus_mismatch.py` 또는 `g4_6_zoom_in_detail_fail_fast.py`. (c) §4.1 fixture 의 demote 표현 잔재 일소 — RO-5 fail-fast 일관. | §5.1 + §5.7 + §4.1 |
| **RO-21** | R1-iter1-I3 | **low_freq_skip 기대값에 outlook 분리**. 코드 검증: `reference_pipeline_orchestrator.py:291` `if etype in ("location", "outlook"): continue` — outlook 은 low_freq_skip 룰 미적용. spec §5.2 의 `EXPECTED_OUTLOOK_REFS = {O09:1, O10:1, O15:1}` 는 low_freq_skip 보호와 무관 — outlook composite ref 생성 path 에 의존 (별도 path). 분리: `EXPECTED_BASE_CHARACTER_REFS` (low_freq_skip 보호 결과) vs `EXPECTED_OUTLOOK_COMPOSITE_REFS` (composite 생성 path). spec line 정정 + 명시적 path 표시. | §5.2 |
| **RO-22** | R1-iter1-I4 | **AppError constructor signature — code/message keyword 필수**. 코드 검증: `backend/app/core/errors.py:5` `AppError.__init__(self, code: str, message: str, status_code: int = 400)`. spec pseudo-code 의 `raise AppError(f"...")` 는 TypeError. 모든 raise 정정: `raise AppError(code="step.contract_violation", message=f"...", status_code=400)`. 위치: §3.5 multiple places, §4.1, §3.3. | §3.3 + §3.5 + §4.1 |

### Round 1 fix iter 2 — Codex (R1) re-audit 결과 (2026-05-06, RO-15~22 적용 후)

R1 fix iter 2 verdict: `NEEDS_REVISION` (B1 + I1 + I2 + I3). 사용자 결정 후
정정. R2 Claude audit 생략, plan 진입.

| ID | Source | 결정 (Override) | 적용 위치 |
|---|---|---|---|
| **RO-23** | R1-iter2-B1 | **descriptor same-clause check 를 character-window 방식으로 교체**. 코드 검증: 정상 권장 패턴 = `C##O## in a dark hooded jacket, a young East Asian man` (`render_prompt_card.py:1145`). `_split_into_clauses` 가 comma 를 boundary 로 자르면 descriptor `, a young East Asian man` clause 에 ID 없음 → false-positive. 정정: clause boundary 가 아니라 **descriptor 위치 기준 ±60 char window** 안에서 visible C##/C##O## 토큰 검색. window 안 ID 있으면 PASS. helper 변경: `_descriptor_has_anchored_id_in_window(prompt, desc_pos, desc_len, visible_bases, window=60)`. clause split 함수는 제거. 테스트 fixture 추가: `C08O10 in a dark hooded jacket, a young East Asian man, his teeth touching` → PASS. 분리된 sentence (separate sentence boundary 만, comma 분리는 same-window) → fail. | §3.5 (source 2) |
| **RO-24** | R1-iter2-I1 | **§6.3 Wave B Step table canary 이름 일관**. table 안 `zoom_in_detail_demotion.py` → `zoom_in_detail_focus_mismatch.py` (RO-20 / §5.7 일관). | §6.3 |
| **RO-25** | R1-iter2-I2 | **fixture description outlook ID 제외**. §5.1 의 `ref_low_freq_skip_post_g4_6.json` 설명 "C08, C09, C15, O10, O15 제외" → "C08, C09, C15 만 (RO-21: low_freq_skip 은 base character path. outlook 은 reference_pipeline_orchestrator.py:291 에서 skip 룰 미적용 — composite_image_gen path 별도)". | §5.1 |
| **RO-26** | R1-iter2-I3 | **verification gate raw characters fallback 약화**. §6.5 #4 "character_ids 또는 fallback characters non-empty" → "**`character_ids` non-empty primary gate**. raw `characters` non-empty 만 (character_ids=[]) 인 케이스는 warning/partial 만 (D3 root 통과 차단)". scene_director.present_entity_ids 가 정의된 visible-human-action shot 에서 character_ids=[] 발견 시 verification fail (운영자가 `allow_failed_validator` flag 로만 진행 가능). | §6.5 |

### Round 1 fix iter 5 — Codex (R1) re-audit 결과 (2026-05-06, RO-29 + plan PRO-10/11 적용 후)

R1 fix iter 5: required_refs 와 malformed allowed_outlook_pairs entry 처리에
silent fallback 잔재 발견. G4 contract 일관 — _MISSING fail-fast.

| ID | Source | 결정 (Override) | 적용 위치 |
|---|---|---|---|
| **RO-30** | R1-iter5-B1 | **`required_refs` 도 required field — `_MISSING` fail-fast**. spec §3.5 body + plan PRO-4 grep gate 충돌 발견. body 는 `required_refs = asset_req_raw.get("required_refs", [])` 로 optional 처리 — grep gate 는 `required_refs` 의 `.get(..., [])` 를 금지 패턴에 포함 (PRO-11). G4 contract 관점: `asset_requirements` 가 required dict 라면 그 안 `required_refs` 도 required list (각 shot 마다 0+ 항목 가능 — 단 field 자체는 schema 강제). 정정: `required_refs = asset_req_raw.get("required_refs", _MISSING)` + missing 시 AppError. type list 검증은 그대로. | §3.5 (source 3a) |
| **RO-31** | R1-iter5-B2 | **`allowed_outlook_pairs` entry 의 모든 ID 부재 silent pass 차단**. base derivation `pair.get("character_id") or pair.get("base_id") or composite_id ... or ""` 결과가 `""` (모든 후보 ID missing) 일 때 `if base and base not in visible_bases:` 분기로 silent pass — schema 위반 entry 무시됨. 정정: base derivation 후 `if not base: AppError("entry missing all of character_id/base_id/composite_id")` raise. 추가로 `outlook_id` 도 검증 — missing/type mismatch 면 RO-29 helper 와 동일 fail-fast (단 base 만 visible_bases 와 비교 — outlook 은 visible_entities 의 base 가 아닌 outlook_id 별도 검사 X). | §3.5 (source 3b) |

### Round 1 fix iter 4 — Codex (R1) re-audit 결과 (2026-05-06, plan PRO-4 적용 후)

R1 fix iter 4: plan PRO-4 (visible_entities_validator silent fallback 제거)
적용 후 spec §3.5 body 가 plan 과 충돌 발견. spec body 도 fail-fast sync.

| ID | Source | 결정 (Override) | 적용 위치 |
|---|---|---|---|
| **RO-29** | R1-iter4-PRO4-carry | **spec §3.5 body silent fallback 제거 — plan PRO-4 와 sync**. spec §3.5 의 source 3a/3b 가 `rpc.get("asset_requirements") or {}` / `asset_req.get("required_refs") or []` / `id_policy.get("allowed_outlook_pairs") or []` 패턴 + `if not isinstance(...): continue` 잔재. Override 는 fail-fast 인데 body 는 silent absorb — plan PRO-4 와 직접 충돌. 정정: source 3a/3b 의 모든 required field 를 `_MISSING` sentinel + type check + AppError fail-fast 로 변경 (plan §6.3 Task 4.3 의 `_require_field` helper 패턴 답습). silent `continue` 도 contract violation raise 로 변경 (non-dict / no-id entry 는 schema 위반). | §3.5 (source 3a + 3b) |

### Round 1 fix iter 3 — Codex (R1) re-audit 결과 (2026-05-06, RO-23~26 적용 후)

R1 fix iter 3 verdict: 2 cleanup (RO-23 helper 통합 + RO-15 stale 문구 제거)
후 plan 진입 승인.

| ID | Source | 결정 (Override) | 적용 위치 |
|---|---|---|---|
| **RO-27** | R1-iter3-cleanup1 | **`_descriptor_has_anchored_id_in_window` 에 sentence-boundary 통합**. 현재 helper 가 ±60 char window 만 검사. `C08O10 sits. A young East Asian woman walks in.` 처럼 ID 와 descriptor 사이에 sentence boundary (`.` `!` `?` `\n` `。`) 가 있어도 60 char 안이면 PASS — false-positive. 정정: helper 안에서 window 내 visible ID 발견 시 ID position 과 descriptor position 사이에 sentence boundary char 가 있는지 추가 검사. 있으면 anchor 불인정. `_is_separate_sentence_descriptor` 별도 helper 제거 (통합). | §3.5 (helper) |
| **RO-28** | R1-iter3-cleanup2 | **stale 문구 제거 — RO-15/RO-26 일관**. (a) §3.5 line 1502 "shot_validator failure 마킹 (RO-8) 인 shot 은 약화" → "step 시작 시점 fail-fast (RO-15). `allow_failed_validator=true` 로 운영자 force 진행 시에도 contract validator 는 enforce". (b) §7.4 line 2318 "`character_ids` 또는 `characters` non-empty" → "`character_ids` non-empty primary (RO-26). raw `characters` only (`character_ids=[]`) 은 partial — verification fail". | §3.5 + §7.4 |

### Round 2 — 예정 (R2 Claude audit 생략 — 사용자 결정. plan drafting 진입)

### 메타룰 (G4.5a carry)

- Round Override 표가 §1-§9 본문보다 우선
- Round 1 Override IDs prefix `RO-N`. §11 carry IDs prefix `O-N`
- 절대 규칙 위반 (LLM 입력 truncation / 시나리오 의존 고유명사 / silent
  fallback / 새 top-level field 도입) 발견 시 BLOCKING 최우선
- silent fallback 패턴 발견 시 BLOCKING (`feedback_no_silent_fallback.md`)
- regex post-processing 의존 발견 시 IMPORTANT (`feedback_no_regex_postprocessing.md`)
- **LLM "visually critical" 플래그 도입 시 BLOCKING** — 사용자 binding
  ("audit surface 확대 차단, deterministic 보호부터")
- **shot_validator 전체 재설계 시 BLOCKING** — 사용자 binding ("type
  descriptor 매핑 / violent freeze / characters=[] fail-fast 까지만")

---

## 1. Overview

### 1.1 Why now

PID `298d86d9` (EP `b6544514`) E2E force 재실행 후 사용자가 viewer 검토에서 4 defect
보고:

1. **D1 — S1_Shot3 ≈ S1_Shot4 visually identical** (PNG md5 다름이지만 둘 다
   허벅지+이빨자국 렌더 — 프롬프트는 leg ECU vs face CU 로 명백히 다름)
2. **D2 — S1_Shot5 검은 형상 → 외국인 face** (silhouette entity 였는데 visible
   non-Korean face 로 렌더)
3. **D3 — S2_Shot4 키스 자세** (시나리오 = 목 흡혈, 결과 = passive 친밀 접촉.
   요괴화 trait 0건 / 폭력 어휘 0건)
4. **D4 — S8_Shot6 프린팅된 배경** (chain_bg 사용했지만 인물 lighting/perspective
   가 bg pixel data 와 부정합 — 컷아웃 인상)

raw step manifest 와 DB / source code / prompt v20 검증 결과 4 defect 모두
**deterministic 으로 추적 가능한 6 root cause**:

| Defect | Root cause cluster |
|---|---|
| D1 | RC-A (zoom_in_detail mis-classification) + RC-B (same-frame label hardcoding) |
| D2 | RC-C (variant ref 보호 누락) + RC-D (prompt_service face-substring 버그) + RC-E (silhouette identity prose 모순) |
| D3 | RC-F (shot_validator characters=[] systemic) + RC-G (violent moment freeze 룰 부재) + RC-C (variant ref 보호 누락) + RC-H (variant trait inject 누락) |
| D4 | RC-I (chain_bg compositing 워딩 부족) |

### 1.2 사용자 binding 핵심 원칙

> 1. **Wave A / Wave B 분리 강제** — single big wave 금지. Wave A 먼저 완전
>    종료 후 Wave B. 각 Wave 별 dual review (Codex + Claude) + push.
> 2. **shot_validator characters=[] systemic 문제는 Wave A BLOCKING** — D3 의
>    부속이 아닌 prerequisite. 단 shot_validator 전체 재설계 금지 — type
>    descriptor 매핑 / violent freeze / characters=[] fail-fast 까지만.
> 3. **entity_filter 보호는 deterministic 1차** — `is_variant` 자체 보호 +
>    silhouette/yokai/monster/attacker/predator/transformed/disguised/
>    obscured-face 계열 보호 + scene_detail asset_requirements / visible_entities
>    필요 ID skip 금지. **LLM "visually critical" 플래그는 후속 carry**.
> 4. **prompt_service.py face-substring 버그는 Wave A/B 공통 hotfix** —
>    "previous shot" 계열 분기를 face/character 분기보다 먼저 평가, face/character
>    는 정확한 reference label 일 때만 매치. BLOCKING.
> 5. **G4.x 패턴 준수** — raw failure fixtures + DB/ref count expectations +
>    prompt label routing tests + scene_detail contract tests + target-shot
>    regeneration order 모두 spec 에 명시.

### 1.3 Wave 분리 rationale

| Wave | Scope | Rationale |
|---|---|---|
| **Pre-Wave hotfix** | RC-D (prompt_service face-substring) | 단일 elif 순서 + literal 일치 변경. 5 line code change. 양 Wave 의 production canary 가 즉시 영향받음. |
| **Wave A** | RC-C, RC-E, RC-F, RC-G, RC-H + visible_entities contract | entity ref 보호 / scene_detail prompt v21 / shot_validator v4 / contract validator. **변형 entity ref 와 contract 가 없으면 Wave B 의 prompt 개선 효과가 무의미** (모델이 trait 없이 vanilla 렌더). |
| **Wave B** | RC-A, RC-B, RC-I | shot_dependency_t2i body-region check / scene_reference_service zoom_in_detail label / prompt_service chain_bg compositing. Wave A 가 entity/contract 안정화 후 적용. |

### 1.4 Non-goals (명시)

G4.6 은 다음을 **하지 않는다**:

- **production code 에 시나리오 derived keyword frozenset/list 도입 (A-prime
  binding, RC-C carry)** — nationality (`korean`, `한국인`, `east asian`),
  ethnicity, 작품 고유명사 (`yokai`, `요괴`, `vampire`, `흡혈`, `fang`,
  `monstrous`, `괴물`), age/gender pattern (`young east asian woman`, `adult
  male`) 등 production module / v21 prompt / authored test / canary 어디에도
  hardcoded constant 로 박지 않는다. 본 spec/plan 의 prose / fixture 도 동일
  — 시나리오 의존 어휘는 generic placeholder 로만. validator 의 descriptor
  매칭은 Source 1 ID coverage (regex C##/C##O##) primary + Source 2 dynamic
  `entity_canon.name` (project_id scoped DB fetch) secondary 로 해결. Phase
  4 grep gate 가 enforce (§6.4).
- **shot_validator 전체 재설계** — type descriptor / violent freeze /
  characters=[] fail-fast 만. 한 찰나 룰 / motion direction freeze 룰 등
  v3 기존 룰은 그대로 유지.
- **LLM "visually critical" 판단 플래그** — `is_variant` + 명시적 kind list
  보호로 해결. LLM 판단 도입 시 audit surface 가 entity_filter / ref_image_gen /
  scene_detail 3 곳 동시 영향. 후속 carry.
- **scene_detail v20 → v21 prose 전체 재작성** — variant trait inject /
  silhouette identity preservation / visible_entities contract 3 룰만 추가.
  나머지 prose / 5-field envelope 는 그대로.
- **shot_dependency_t2i 전체 재설계** — `ref_usage` enum / location_refs 구조는
  그대로. body-region 비교 룰만 추가.
- **chain_bg group anchor selection 재구조화** — 사용자 의견대로 prompt
  hotfix 가 우선. anchor 선택 algorithm 재설계는 G4.6 이후 별도 spec.
- **새 top-level field 도입** — render_strategy / id_policy /
  background_binding / continuity_elements_used / asset_requirements 5-field
  envelope 그대로 유지 (G4.5a binding carry).

---

## 2. Defect → Root cause → Fix mapping

### 2.1 D1 — S1_Shot3 ≈ Shot4 (allegedly identical)

**Symptom**: 프롬프트 명백히 다름 (Shot3 = ECU on bare thigh / Shot4 = CU on
face) 인데 두 PNG 모두 허벅지+이빨자국 렌더. md5: `bf243aac8427` vs
`db37917cb4a4` (다른 byte stream 이지만 시각적으로 거의 동일).

**Evidence — raw step data**:

shot_dependency_t2i manifest (`projects/{PID}/checkpoints/episodes/{EP}/shot_dependency_t2i/manifest.json`):

```json
{
  "scene_index": 1, "shot_index": 4,
  "location_refs": [{
    "scene_index": 1, "shot_index": 3,
    "reason": "Same forest location and same man in the same immediate action; this shot is a tighter continuation that keeps the same background atmosphere while reframing from the leg to the face.",
    "ref_usage": "zoom_in_detail",
    "keep_elements": ["the same man continuing through the forest", "the blurred muddy forest floor", "the wet dark foliage", "the cold blue-gray dusk lighting"]
  }]
}
```

LLM 의 reason 필드는 "**leg → face** reframing"을 명시 — 신체 부위 변경을
인식했음에도 `ref_usage="zoom_in_detail"` 선택. shot_dependency_t2i v5 prompt
가 body-region 비교 룰을 가지고 있지 않음.

ImageAsset.prompt_used (S1_Shot4):
```
Reference image 1: PREVIOUS SHOT (SAME FRAME, zoomed-in reframing) — reuse the exact frame
- use image 1 as the SAME frame with only reframing/cropping — the camera moved closer
  but the moment, subject, pose, and environment are identical
- do NOT add new subjects, do NOT duplicate body parts, do NOT change the pose shown in image 1
- from image 1: Keep: the same man continuing through the forest, the blurred muddy forest floor, the wet dark foliage, the cold blue-gray dusk lighting

Tight framing on the face of the character from Image 2 ...
```

**Root cause cluster**:

- **RC-A (Wave B)** — `shot_dependency_t2i` v5 prompt 가 `ref_usage="zoom_in_detail"`
  선택 시 body-region 일치 검증 없음. LLM 이 "leg → face" 인지하고도 zoom 으로
  분류.
- **RC-B (Wave B)** — `backend/app/services/scene_reference_service.py:680`
  zoom_in_detail label 하드코딩이 "SAME moment, SAME subject, SAME environment.
  Do NOT add new subjects, do NOT change the pose" 를 무조건 출력.
  `backend/app/services/prompt_service.py:71-83` 가 추가 prepend ("use image 1
  as the SAME frame", "do NOT change the pose"). 두 layer 모두 ref_usage 가
  잘못 들어와도 그대로 적용.

**Fix**:

- Wave B Step 1: shot_dependency_t2i v6 prompt 에 body-region 비교 룰 추가 —
  "previous shot 의 focus 가 body part X (face / hand / thigh / eye / wrist
  / hair / object detail) 이고 current shot 의 focus 가 body part Y (X ≠ Y)
  이면 `ref_usage="zoom_in_detail"` 선택 금지. `ref_usage="exact_background"`
  또는 unset (no ref) 로 재분류". focus 추출 정확도는 prompt rule 로 강제 +
  schema 에 `focus_region` field 추가.
- Wave B Step 2: `scene_reference_service.py:680` zoom_in_detail label 출력
  분기에 body-region check 추가. `ref_usage="zoom_in_detail"` 이지만
  upstream focus_region 이 다르면 fail-fast (이상 상태 — schema 위반)
- Wave B Step 3: `prompt_service.py:71-83` 의 "do NOT change the pose / SAME
  frame" 지시문 prepend 시 body-region 검증된 zoom 만 적용.

### 2.2 D2 — S1_Shot5 검은 형상 → 외국인

**Symptom**: scene 의 attacker (C15 "검은 형상" — 얼굴 보이지 않는 검은
실루엣) 가 외국인/혼혈 face 의 visible 인물로 렌더됨.

**Evidence — raw data + DB**:

entity_canon (DB):
```
C15 entity_type=character name=검은 형상 status=active
  description: 불투명한 검은 인간형 실루엣. 얼굴과 세부 신체 형태가 보이지
    않고, 전신 윤곽만 드러나는 어두운 그림자 같은 형상.
  stable_traits: ["검은색 전신 실루엣", "인간형 윤곽", "얼굴이 보이지 않음",
    "세부 형태가 불명확함", "그림자 같은 어두운 외형"]
  t2i_prompt: ... full-body dark Korean human-shaped silhouette, entirely black
    and opaque, face completely obscured, no visible facial features ...
```

DB ImageAsset 조회 결과: **C15 ref_count=0** (C07 만 ref=1). C08, C09, C15
모두 `ref_low_freq_skip.json` 에 포함 (UUID 17개).

ref_image_gen skip rule (`backend/app/services/reference_pipeline_orchestrator.py:289`):
```python
_low_freq_skip_ids: set = set()
for e in entities:
    eid = e["id"]
    etype = e.get("entity_type", "")
    if etype in ("location", "outlook"):
        continue
    count = _t2i_count_map.get(eid, 0)
    is_base_for_variant = eid in _reverse_dep_ids
    if count <= 1 and not is_base_for_variant:
        _low_freq_skip_ids.add(eid)
```

C15 = 1 shot 등장 (S1_Shot5 단일) + `is_base_for_variant=False` (C15 는 base
가 아니라 standalone) → skip.

scene_detail v20 prompt (`prompts/_base/scene_detail/20.202605051240/system.md`)
silhouette 섹션 권장 표현:
```
✓ "C11O10 appears as a backlit figure, rim light tracing the shoulders and outstretched arm,
   body volume preserved against the dim ambient fill, face in deep shadow but jaw contour visible"
```

C15 의 stable_traits "얼굴이 보이지 않음" 과 정면 충돌. v20 의 silhouette
recommended phrasing 이 모든 silhouette 에 "jaw contour visible" 권장.

scene_detail t2i_prompt (S1_Shot5) 출력:
```
... C15O15 appears as a backlit figure, body volume preserved with cold rim light tracing
the shoulders and jaw contour ...
```

→ v20 권장 표현 그대로 적용.

prompt_service.py:62 substring matching (BLOCKING bug):
```python
elif "face" in label_lower or "character" in label_lower:
    ref_roles.append(f"Reference image {i}: {label}")
    ref_instructions.append(
        f"- use image {i} as character appearance reference — "
        f"match the person's identity where visible in the scene"
    )
```

S1_Shot5 ref label: `"previous shot at same location (SAME ROOM) — use this
background as-is. **Ignore the close-up face framing** ..."` — `"face"`
substring 이 `"face framing"` 에서 매치 → bg 라벨이 character 분기로 흘러감 →
"use image 1 as character appearance reference" 출력. scene_detail t2i_prompt
가 attacker C15O15 에 대해 "the character from Reference image 1" 로 substitute
→ image 1 = prev-shot bg (S1_Shot4 에 victim face 가 있음) → attacker 가
victim face data 로 oriented + scene_detail 의 "torn cloak / mysterious /
occult" 문구 → western horror 디폴트 face 합성.

**Root cause cluster**:

- **RC-C (Wave A)** — `reference_pipeline_orchestrator.py:289` skip rule 이
  `is_base_for_variant` 만 보호. variant 자체 (C08 yokai / O10 / O15) 와
  silhouette / monster 류 (C15) 는 unprotected.
- **RC-D (Pre-Wave hotfix)** — `prompt_service.py:62` substring matching
  버그. `"face" in label_lower` 가 `"face framing"` 에서 false-positive.
- **RC-E (Wave A)** — scene_detail v20 silhouette 섹션이 entity stable_traits
  와 충돌하는 generic 룰을 LLM 에게 지시. C15 의 "face completely obscured"
  trait 가 "jaw contour visible" 권장에 덮임.

**Fix**:

- Pre-Wave hotfix: `prompt_service.py` elif 순서 재배열 + substring matching
  엄격화 (§3.2 상세).
- Wave A Step 1: `reference_pipeline_orchestrator.py:289` skip rule 보강 —
  `is_variant=True` (entity 가 variant pole 에 있는 경우, 즉 RelationFact
  자식) 보호 + entity stable_traits / kind 키워드 list (silhouette, yokai,
  monster, attacker, predator, transformed, disguised, obscured-face,
  half-yokai, possessed) 매칭 보호 + scene_detail asset_requirements 또는
  visible_entities 에 등장하는 ID 보호 (§3.1 상세).
- Wave A Step 2: scene_detail v21 prompt 에서 silhouette 섹션 권장 phrasing
  변경 — entity stable_traits 의 "face completely obscured / no visible facial
  features / 얼굴이 보이지 않음 / 세부 형태가 불명확함" 키워드 감지 시 "jaw
  contour visible" 류 표현 금지 (§3.3 상세).

### 2.3 D3 — S2_Shot4 키스처럼 보임

**Symptom**: 시나리오 원문 = "목의 살점을 뜯는 이빨" (능동 흡혈), 결과 PNG
= 두 인물 친밀한 대칭 접촉. 붉은 눈 / 날카로운 이빨 / 피 0건.

**Evidence — raw data + DB**:

scene_save segment text (S#2):
```
도심 희미한 가로등 불빛. 전봇대에 뒤엉켜있는 두 사람. 남녀로 보인다. 가쁜
숨소리 점점 커지더니… 격렬하게 흡입하는 소리로 바뀌고 남자의 붉은 눈동자,
목의 살점을 뜯는 이빨… 푹 힘없이 쓰러지는 여자.
```

shot_validator output (S2_Shot4):
```
description: 한국인 남자의 치아가 여자의 핏기 없는 목 피부에 닿은 정지 순간.
characters: []
visible_entities: None
audio_entities: None
```

scene_director output (S2):
```
present_entity_ids: ["C08", "C09", "L03"]
```

scene_detail visible_entities (S2_Shot4): `["C08", "L03"]` — **C09 누락**.

shot_validator output 에서 characters=[] systemic — 다른 4 shot 에서도 같은
패턴:

| Shot | shot_validator.characters |
|---|---|
| S1_Shot3 | `[]` |
| S1_Shot4 | `[]` |
| S1_Shot5 | `[]` |
| S2_Shot4 | `[]` |
| S8_Shot6 | `["수리영"]` |

차이: S8 의 "수리영" 은 entity 고유명사 (C01 의 canonical_name). S1/S2 의
description 은 type descriptor ("한국인 남자" = C07/C08 canonical_name, "여자"
= C09 canonical_name). shot_validator 가 type-descriptor 캐릭터 추출에 systemically
실패.

scene_detail t2i_prompt (S2_Shot4):
```
... a young East Asian woman ... a young East Asian man in a dark hooded jacket.
The woman is in left profile, her head tilted back, looking up, her shoulder
edge visible in the lower frame. The man is in right profile, his jaw pressed
against her throat, eyes fixed on the pale skin, his teeth touching the surface
at dead center.
```

C08 stable_traits = `["Korean adult male", "red irises", "sharp pointed teeth",
"yokai-like facial features", "human-like face"]` — 이 중 prompt 에 들어간 것:
**0건**. yokai trait inject 누락.

scene_detail v20 prompt 의 폭력 어휘 섹션 (line 215-260) 은 존재 — attacker /
victim / brutal / tearing flesh / pinning down 류 어휘 + 권력 불균형 자세
가이드. **그러나 적용 안 됨** — shot_validator 가 정적 "치아가 닿은 정지
순간" 으로 freeze 했으므로 scene_detail LLM 이 그 정적 모먼트를 따름.

shot_validator v3 prompt 의 "동적 동사 freeze 규칙" 은 locomotion / riding /
swimming / leaping / chasing 카테고리만 커버. **violent contact (biting /
attacking / piercing / tearing / striking) 카테고리 부재**.

DB ImageAsset (S2_Shot4): `reference_image_ids=[]` — C08, C09 모두 low_freq_skip
이므로 ref 첨부 없음. yokai trait 의 시각 anchor 도 없음.

**Root cause cluster**:

- **RC-F (Wave A)** — shot_validator v3 가 type descriptor entity (한국인 남자
  / 여자 / 직원 류) 를 characters 에 추출하지 않음. 결과 visible_entities 가
  systemically 누락.
- **RC-G (Wave A)** — shot_validator v3 의 "동적 동사 freeze 규칙" 이 violent
  contact 카테고리 미커버. "치아가 닿은 정지 순간" / "before the bite" /
  "teeth touch surface" 같은 정적 freeze 가 폭력 의도와 시각적으로 모순됨에도
  통과.
- **RC-C (Wave A)** — C08 (variant) / C09 (low frequency) ref skip — D2 와
  같은 cause.
- **RC-H (Wave A)** — scene_detail v20 prompt 가 variant entity 의 stable_traits
  ("red irises" / "sharp pointed teeth") 를 t2i_prompt 에 강제 inject 하는
  룰 부재. 결과 C08 yokai trait 가 silently 누락.

**Fix**:

- Wave A Step 3: shot_validator v4 prompt — type descriptor → entity ID 매핑
  룰 + violent contact freeze 룰 + characters=[] visible-human-action shot
  fail-fast (§3.4 상세).
- Wave A Step 4: scene_detail v21 prompt — entity stable_traits 강제 inject
  룰 (variant trait 가 t2i_prompt 에 등장) + visible_entities ⊃ C##/C##O##
  contract validator (§3.3, §3.5 상세).

### 2.4 D4 — S8_Shot6 프린팅된 배경

**Symptom**: 인물이 실제 공간에 있지 않고 배경 사진 앞에 컷아웃된 듯한 인상.
인물 lighting 과 bg pixel lighting / contact shadow / camera height 가 부정합.

**Evidence — raw data**:

background_render group (`bg_exterior_entrance_wall_dusk_neon_auto_door`):
```
floor_plan_used: True
camera_recommendations: {
  "camera_position": "near number 10 (smartphone) on the sidewalk, facing diagonally toward number 6",
  "camera_height": "eye-level standing ~1.6m",
  "lens_hint": "35mm wide angle",
  "framing_notes": "keep number 10 low in the foreground area and keep the entrance assembly centered"
}
shot_guides[S8_Shot6].guide: "보도 위 스마트폰 근처에서 자동 미닫이 출입문을
향해 비스듬히 바라보는 구도를 유지한다. 스마트폰은 화면 아래쪽 전경에 작게
보이게 두고, 중앙에는 유리 자동문 ... 사람이나 얼굴은 절대 넣지 않는다."
```

bg PNG 는 사람 0건 / 보도 near smartphone POV 로 렌더됨.

ImageAsset.prompt_used (S8_Shot6):
```
Reference image 1: pre-rendered BACKGROUND chain reference — use as-is
Reference image 2: Image 2 (character reference): 수리영 — ...

... the character from Reference image 2 stands on the damp sidewalk in right
profile, occupying the right third of the frame. ...

Generate one image:
- match the wall, floor, ceiling, furniture layout, and lighting exactly from image 1
- do NOT copy any people from image 1
- use image 2 as character appearance reference ...
```

prompt_service.py:121-131 chain_bg 분기 출력:
```python
elif "background chain ref" in label_lower:
    ref_roles.append(f"Reference image {i}: pre-rendered BACKGROUND chain reference — use as-is")
    ref_instructions.append(f"- match the wall, floor, ceiling, furniture layout, and lighting exactly from image {i}")
    ref_instructions.append(f"- do NOT copy any people from image {i}")
```

**없는 지시**: subject 의 lighting key/fill direction 매칭 / contact shadow
under feet / camera height 일치 / lens / perspective.

**Root cause**:

- **RC-I (Wave B)** — `prompt_service.py:120-131` chain_bg 분기에 인물 합성
  consistency 지시 부재. bg 의 wall/floor/lighting 은 매칭 지시되지만 인물의
  lighting / contact shadow / perspective 는 prompt body 워딩에만 의존.

**Fix**:

- Wave B Step 4: `prompt_service.py:120-131` chain_bg 분기에 4 신규 지시 추가
  (§4.4 상세):
  - "match the lighting key/fill direction visible in image 1 onto the inserted subject"
  - "include realistic contact shadow under the subject's feet on the same ground plane shown in image 1"
  - "match the camera height and perspective implied by image 1"
  - "subject must occupy 3D space within image 1's geometry, not appear pasted in front of it"

### 2.5 Defect → Wave assignment summary

| Defect | RC | Wave | Step (구현 단계) |
|---|---|---|---|
| D1 | RC-A | Wave B | Step 1 (shot_dependency_t2i v6 prompt) |
| D1 | RC-B | Wave B | Step 2-3 (scene_reference_service / prompt_service) |
| D2 | RC-C | Wave A | Step 1 (reference_pipeline_orchestrator) |
| D2 | RC-D | Pre-Wave | hotfix (prompt_service substring) |
| D2 | RC-E | Wave A | Step 2 (scene_detail v21 silhouette) |
| D3 | RC-F | Wave A | Step 3 (shot_validator v4 type descriptor) |
| D3 | RC-G | Wave A | Step 3 (shot_validator v4 violent freeze) |
| D3 | RC-C | Wave A | Step 1 (variant 보호) |
| D3 | RC-H | Wave A | Step 4 (scene_detail v21 trait inject) |
| D4 | RC-I | Wave B | Step 4 (prompt_service chain_bg) |

추가: **visible_entities ⊃ C##/C##O## contract** (Wave A Step 5) — Codex
지적 사항. scene_detail 가 t2i_prompt 에 등장시킨 모든 C##/C##O## 가
shot_validator/scene_director 의 visible_entities 안에 있어야 함 (asset_requirements
필요 ID 와 일치). 현재 contract 부재.

---

## 3. Wave A — entity / ref / scene_detail / shot_validator

### 3.1 Reference protection (RC-C)

**File**: `backend/app/services/reference_pipeline_orchestrator.py:289`

**Current** (skip rule):
```python
if count <= 1 and not is_base_for_variant:
    _low_freq_skip_ids.add(eid)
```

**New** (deterministic 보호 다층 — RO-3 + RO-10 + RO-12):
```python
def _should_skip_low_freq(
    e: dict, count: int, is_base_for_variant: bool,
    is_variant_self: bool, required_by_pipeline: bool,
) -> bool:
    """RO-12: etype 은 helper 내부에서 계산 (caller 단순화)."""
    etype = e.get("entity_type", "")
    if etype in ("location", "outlook"):
        return False
    if count > 1:
        return False
    # protect base entities of variant relations (existing)
    if is_base_for_variant:
        return False
    # RO-1 carry: protect variant entities themselves
    if is_variant_self:
        return False
    # RO-10: protect by canonical kind keywords (character only — shadow 등의 false-positive 차단)
    if _has_protected_kind_keyword(e):
        return False
    # RO-3: protect entities required by upstream pipeline (4-source cascade)
    if required_by_pipeline:
        return False
    return True
```

**Protected kind keywords** (deterministic list, 영/한 양쪽 매칭 — RO-10):

```python
_PROTECTED_KIND_KEYWORDS_EN = frozenset({
    "silhouette", "yokai", "monster", "creature", "demon", "ghost", "spirit",
    "attacker", "assailant", "predator", "pursuer",
    "transformed", "transformation", "morphed", "mutated",
    "disguised", "obscured", "hidden", "masked",
    "half-yokai", "possessed",
    # RO-10: "shadow" 제거 — 조명/배경 묘사의 흔한 단어로 false-positive 위험.
    # silhouette / 검은 형상 / 그림자 (entity name 직접) 가 dedicated keyword 로 대체.
})
_PROTECTED_KIND_KEYWORDS_KO = frozenset({
    "검은 형상", "검은 인영", "그림자", "실루엣",
    "요괴", "요괴화", "괴물", "악령", "유령", "혼령",
    "공격자", "포식자",
    "변신", "변형", "변모",
    "위장", "가린",
    "보이지 않는 얼굴",
})

def _has_protected_kind_keyword(e: dict) -> bool:
    """RO-10: entity_type=="character" 에만 적용. name + stable_traits 만 매칭
    (description 제외 — 조명/배경 묘사 false-positive 차단)."""
    if e.get("entity_type") != "character":
        return False
    name = (e.get("name") or "").lower()
    traits_text = " ".join(str(t).lower() for t in (e.get("stable_traits") or []))
    haystack = f"{name}\n{traits_text}"
    for kw in _PROTECTED_KIND_KEYWORDS_EN:
        if kw in haystack:
            return True
    # 한국어는 lowercasing 무관 (case 개념 X) — 그러나 일관성 유지
    name_ko = e.get("name") or ""
    traits_ko = " ".join(str(t) for t in (e.get("stable_traits") or []))
    haystack_ko = f"{name_ko}\n{traits_ko}"
    return any(kw in haystack_ko for kw in _PROTECTED_KIND_KEYWORDS_KO)
```

매칭 대상: `entity.name`, `entity.stable_traits` (`entity.description` **제외** —
RO-10 narrowing). entity_type=="character" 가 아닌 경우 무조건 False (location/
prop/outlook 은 이 보호 path 미적용).

**Required-by-pipeline 보호 (RO-3 정정)**:

primary required entity ID source 는 다음 4 cascade — ref_image_gen 이 scene_detail
보다 먼저 실행되므로 scene_detail manifest 는 force/retry rescue 만:

```python
def _collect_required_entity_ids(
    project_id: str, episode_id: str, db,
) -> set[str]:
    """4 source cascade — ref_image_gen first-run 시 scene_detail 미생성.

    Source priority (RO-3):
    1. scene_director.present_entity_ids (항상 존재 — primary)
    2. shot_validator.character_ids (RO-1 후 — secondary)
    3. shot_director 의 character_id 등 shot-level entity (tertiary)
    4. EntityEpisodeLink.t2i_appearance_count >= 1 (DB-derived — fallback)

    rescue source (force / retry only):
    5. scene_detail.visible_entities + asset_requirements.required_refs
       (이미 생성된 경우만 — first run miss)
    """
    short_ids: set[str] = set()

    # (1) scene_director
    director_cp = _load_cp(project_id, episode_id, "scene_director")
    if director_cp:
        for sc in director_cp.get("data", {}).get("scenes", []):
            for sid in sc.get("present_entity_ids", []) or []:
                short_ids.add(sid.split("O")[0])

    # (2) shot_validator.character_ids — RO-1 후 schema 에 등장
    sv_cp = _load_cp(project_id, episode_id, "shot_validator")
    if sv_cp:
        for sc in sv_cp.get("data", {}).get("scenes", []):
            for sh in sc.get("shots", []):
                for sid in sh.get("character_ids", []) or []:
                    short_ids.add(sid.split("O")[0])

    # (3) shot_director — character-level entity
    sdir_cp = _load_cp(project_id, episode_id, "shot_director")
    if sdir_cp:
        for sh in sdir_cp.get("data", {}).get("shots", []):
            for ca in sh.get("character_angles", []) or []:
                cid = ca.get("character_id") or ca.get("character_short_id")
                if cid:
                    short_ids.add(cid.split("O")[0])

    # (4) EntityEpisodeLink.t2i_appearance_count
    # PRO-6 carry (RO-3 body sync): project_id filter 양쪽 (defense in depth —
    # cross-project leakage 차단). plan §4.3 Task 2.1 와 일관.
    rows = db.query(EntityCanon.short_id).join(
        EntityEpisodeLink, EntityEpisodeLink.canon_id == EntityCanon.id
    ).filter(
        EntityCanon.project_id == project_id,
        EntityEpisodeLink.project_id == project_id,
        EntityEpisodeLink.episode_id == episode_id,
        EntityEpisodeLink.t2i_appearance_count >= 1,
    ).all()
    for r in rows:
        short_ids.add(r.short_id.split("O")[0])

    # (5) rescue: scene_detail (force/retry only)
    sd_cp = _load_cp(project_id, episode_id, "scene_detail")
    if sd_cp:
        for sh in sd_cp.get("data", {}).get("scenes", []):
            for sid in sh.get("visible_entities", []) or []:
                short_ids.add(sid.split("O")[0])
            # required_refs 는 dict array — RO-3 shape 정정
            rpc = sh.get("render_prompt_card") or {}
            asset_req = rpc.get("asset_requirements") or {}
            for ref in asset_req.get("required_refs", []) or []:
                if not isinstance(ref, dict):
                    continue
                rid = ref.get("id")
                if rid:
                    short_ids.add(rid.split("O")[0])
    return short_ids
```

`short_ids` 를 entity_canon.short_id 로 resolve → entity_canon.id 변환 → skip
체크에 사용. **shape 정정**: `asset_requirements.required_refs` 는 dict array
(`[{"kind":"character_outlook","id":"C##O##","policy":"required"}, ...]`).
`ref["id"]` 에서 base 추출.

**기존 호출자 수정** (RO-3 carry):

```python
required_short_ids = _collect_required_entity_ids(self._project_id, episode_id, self._db)
required_canon_uuids = self._db.query(EntityCanon.id).filter(
    EntityCanon.project_id == self._project_id,
    EntityCanon.short_id.in_(required_short_ids),
).all()
required_canon_uuids = {r[0] for r in required_canon_uuids}

for e in entities:
    eid = e["id"]
    is_variant_self = self._is_variant_pole(eid, deps)
    required = eid in required_canon_uuids
    skipped = _should_skip_low_freq(
        e, count, is_base_for_variant, is_variant_self, required,
    )
    if skipped:
        _low_freq_skip_ids.add(eid)
    logger.info(
        "Low-freq decision: %s (%s, t2i_count=%d, variant=%s, required=%s, kind=%s, skipped=%s)",
        e["name"], e.get("entity_type", ""), count, is_variant_self, required,
        _has_protected_kind_keyword(e), skipped,
    )
```

`_is_variant_pole` helper: `RelationFact.relation_family in ("state_variant",
"transform_variant", "outlook_variant")` 이고 `RelationParticipant.canon_id ==
eid` 이고 자식 pole 인 경우 True (G4.5a 의 dependency graph traversal carry).

**테스트** (Wave A Step 1):

- `test_skip_low_freq_protects_variant_self` — C08 같은 variant 자체 보호.
- `test_skip_low_freq_protects_silhouette_keyword` — C15 같은 entity name 에
  "silhouette" / "검은 형상" 포함 보호.
- `test_skip_low_freq_protects_yokai_in_stable_traits` — C08 stable_traits
  의 "yokai-like facial features" 매칭 보호.
- `test_skip_low_freq_protects_required_by_scene_director` — RO-3 source 1
  (`scene_director.present_entity_ids` 에 C09 등장) 보호.
- `test_skip_low_freq_protects_required_by_shot_validator_character_ids` —
  RO-3 source 2 (RO-1 schema) 보호.
- `test_skip_low_freq_protects_required_by_t2i_appearance_count` — RO-3 source 4
  (`EntityEpisodeLink.t2i_appearance_count >= 1`) 보호.
- `test_skip_low_freq_protects_required_by_scene_detail_rescue` — scene_detail
  manifest 가 이미 있을 때 (force/retry rescue) 추가 보호.
- `test_skip_low_freq_unprotected_low_freq_skipped` — count<=1 + 무변형 +
  무키워드 + 무참조 → skip 정상.
- `test_protected_kind_keywords_case_insensitive` — "Silhouette" / "SILHOUETTE"
  매칭.
- `test_protected_kind_keyword_shadow_excluded` (RO-10) — entity description
  에 "shadow" 가 등장해도 entity_type=="character" + name/stable_traits 매칭
  없으면 보호 안 됨.
- `test_protected_kind_keyword_skip_non_character` (RO-10) — entity_type ==
  "prop" / "location" 인 경우 키워드 매칭 무관하게 False.
- `test_collect_required_refs_parses_dict_shape` (RO-3) — `required_refs[]` 가
  dict shape 일 때 정확히 base 추출. malformed entry (string / non-dict) 는
  skip + warning.

### 3.2 prompt_service.py label routing fix (Pre-Wave hotfix, RC-D)

**File**: `backend/app/services/prompt_service.py:43-148`

**Bug**: `elif "face" in label_lower or "character" in label_lower:` (line 62)
가 `"face"` substring 을 `"face framing"` 같은 다른 의미 텍스트에서도 매치.
"previous shot" 분기 (line 71+, 92+) 가 뒤에 평가되어 reach 못함.

**Fix**:

1. **분기 순서 재배열** — `"previous shot"` 계열을 `"face"/"character"` 보다
   먼저 평가:
   ```python
   if label_lower == "outfit appearance":
       ...
   elif "wearing" in label_lower or "outfit" in label_lower:
       ...
   # NEW: previous shot 계열 우선
   elif "previous shot" in label_lower and "same frame zoomed" in label_lower:
       ...  # zoom_in_detail
   elif "previous shot" in label_lower and "same room" in label_lower:
       ...  # exact_background
   elif "previous shot" in label_lower or "previous scene" in label_lower or "continuity" in label_lower:
       ...  # atmosphere_reference
   elif "background chain ref" in label_lower:
       ...  # chain_bg
   # face/character 는 정확한 reference label 일 때만
   elif _is_explicit_character_ref_label(label_lower):
       ...
   elif "prop" in label_lower or "object" in label_lower:
       ...
   elif "background" in label_lower or "location" in label_lower:
       ...
   else:
       ...
   ```

2. **`_is_explicit_character_ref_label` 도입** — substring 대신 정확 token 체크:
   ```python
   _CHARACTER_REF_LABEL_PATTERNS = (
       "(character reference)",
       "character reference:",
       "(face reference)",
       "face reference:",
   )
   def _is_explicit_character_ref_label(label_lower: str) -> bool:
       """substring 'face'/'character' 의 false positive 차단.

       Why: previous-shot ref label 에 'face framing' / 'face crop' 같은
       부정형 substring 이 있어도 character 분기가 매치되는 버그 차단.
       """
       return any(p in label_lower for p in _CHARACTER_REF_LABEL_PATTERNS)
   ```

3. **fallback "Image N (character reference)" 형식 강제** — 호출자가 character
   ref label 을 만들 때 위 pattern 중 하나 사용. shot_reference / scene_reference
   service 의 character ref label 생성부 검사 + 정정.

**테스트** (Pre-Wave hotfix):

- `test_label_routing_previous_shot_with_face_substring_routes_to_bg` —
  S1_Shot5 fixture 재현 (`"previous shot at same location (SAME ROOM) — ...
  Ignore the close-up face framing ..."`) → bg 분기로 매칭, instructions 에
  "use image 1 as character appearance reference" 부재 강제.
- `test_label_routing_explicit_character_ref` — `"Image 2 (character reference):
  한국인 남자 ..."` → character 분기 매칭.
- `test_label_routing_zoom_in_detail` — `"... (SAME FRAME ZOOMED) ..."` →
  zoom 분기 매칭.
- `test_label_routing_chain_bg_ref` — `"pre-rendered BACKGROUND chain
  reference — use as-is"` → chain_bg 분기 매칭.
- `test_label_routing_no_face_substring_fallthrough` — `"some generic ref"` →
  else 분기 매칭.

### 3.3 scene_detail v21 prompt — silhouette identity + variant trait inject (RC-E, RC-H)

**File**: `prompts/_base/scene_detail/21.{YYYYMMDDHHmm}/system.md` (NEW
version dir, v20 carry over).

**Change 1 — silhouette 섹션 entity-aware**:

v20 line "## 실루엣 묘사 규칙 — 평면 cutout 금지" 섹션의 권장 표현 앞에 다음
조건 추가:

```markdown
### entity-aware silhouette policy (G4.6 신규)

**우선순위 룰**: 실루엣 묘사 권장 표현 (rim light / jaw contour / shoulder
volume 류) 을 적용하기 전, 해당 인물 entity 의 stable_traits / description /
canonical_name 에 다음 키워드 중 하나라도 있으면 **face/jaw/feature 묘사
표현 금지**, "no visible facial features" / "face fully obscured in shadow" /
"only the body silhouette outline visible" 류만 사용:

- 영어: "face completely obscured", "no visible facial features",
  "face hidden", "featureless silhouette" (단 cutout 보호 위해 depth
  표현은 유지)
- 한국어: "얼굴이 보이지 않음", "얼굴이 가려진", "세부 형태가 불명확함",
  "얼굴 디테일 없음"

이 entity 의 t2i_prompt 안에 jaw / chin / nose / cheek / forehead / mouth /
eye / 이목구비 / 윤곽 같은 face-feature 단어를 직접 출력하지 마라. 대신
backlit body shape / shoulder volume / arm silhouette / cloak silhouette /
neck-and-shoulder outline 처럼 face 가 아닌 body 부위만 묘사.

권장 표현 (entity stable_traits = "face fully obscured" 인 경우):

- ✓ "C##O## appears as a backlit figure, body volume preserved with cold rim
  light tracing the shoulders and outstretched arm, the figure's face fully
  swallowed in shadow with no discernible features, the torn cloak silhouette
  trailing upward in the direction opposite to the downward motion"

위 사례에서 "face fully swallowed in shadow with no discernible features" 가
entity stable_traits 를 prompt 에 직접 carry. "jaw contour" / "neck-jaw line"
류 표현 금지.
```

**Change 2 — variant entity trait inject (Rule X 신규)**:

scene_detail v21 에 신규 섹션:

```markdown
## Variant entity trait inject (Rule X — G4.6 신규)

`id_policy.allowed_outlook_pairs` / `allowed_base_entity_ids` 안의 entity
ID 가 entity_canon.stable_traits 에 visually-distinguishing trait 를 포함하면
**그 trait 를 t2i_prompt 본문에 반드시 등장시켜라**. variant entity 는 base 와
시각적으로 다른 trait 가 있어야 실제 의미를 가진다 (예: 변신 / 부상 / 의식 상태
변화 / 사망 등 entity_canon 이 정의하는 비표준 상태).

trait category (시나리오 의존 keyword 박지 말 것 — 실제 token 은 entity_canon.
stable_traits 의 작성 그대로 carry, 아래는 어떤 카테고리가 visually-
distinguishing 인지 분류만 안내):

- altered eye coloration / iris anomaly
- altered dentition / non-standard canine structure
- non-human facial morphology
- injury marker / blood marker / wound visible
- status marker (unconscious / lifeless / non-responsive 등 entity_canon 명시)

각 trait 는 t2i_prompt 의 character 묘사 부분에 stable_traits 의 실제 token 을
**그대로** carry (token 단위로 paraphrase 허용 — 의미 보존, 약화 금지). trait 가
stable_traits 에 있는데 t2i_prompt 에 없으면 contract 위반.

예 (C##.stable_traits = [
  `<altered_eye_coloration_token>`,
  `<altered_dentition_token>`,
  `<non_human_facial_morphology_token>`
] — 실제 token 은 entity_canon 이 정의):

- ✗ "C##O## in a dark hooded jacket, an adult figure, teeth touching the
  surface" — altered features 누락, vanilla 인물 묘사
- ✓ "C##O## in a dark hooded jacket, an adult figure showing the variant
  features from stable_traits, the altered eye coloration fixed on the
  surface and the non-standard canine structure visible at the contact
  point" — 3 trait 모두 carry. 실제 prompt 출력은 stable_traits 의 token 을
  hardcoded keyword 가 아닌 entity_canon 의 정의대로 직접 carry.

## Rule X-2 — visible character ID-based representation 필수 (RO-2)

`visible_entities` 안 모든 character base ID 는 t2i_prompt 에서 **ID 형식
(C## 또는 C##O##) 으로 표현**해야 한다. visible_entities 안에 있는 entity 가
descriptor (자연어 인물 묘사) 만으로 등장하고 그 entity 의 ID 가 prompt 안
어디에도 없으면 contract violation — Wave A Step 5 validator 가 fail-fast.

이유: descriptor 만 있으면 image generator 가 어떤 entity 인지 식별 불가.
asset_requirements.required_refs / id_policy.allowed_outlook_pairs 의 base 가
visible_entities 안에 있지만 prompt 본문이 ID 표현 없이 묘사하면 — ref 가
attach 됐어도 모델이 "어떤 ref 가 이 descriptor 를 가리키는지" 매핑 못함.

권장 패턴 (한 인물 = 한 ID 표현 + descriptor 보조):

- ✗ "an adult figure in left profile, head tilted back" (C## 가
  visible_entities 안인데 ID 없음)
- ✓ "C## in left profile, an adult figure with head tilted back"
- ✓ "C##O## in a white blouse, an adult figure, head tilted back"

descriptor 와 ID 동시 사용 OK — descriptor 가 ID 를 보조 (자연어 설명). 단
descriptor only 는 금지.

예외: visible_entities 안에 없는 우연한 background figure (예: "a passerby in
the distance") 는 ID 표현 불필요 — 단 visible_entities 에 등록되지도 않는다.
이런 경우 prompt 에 명시적으로 "background extra" / "an unnamed passerby" 같이
신원 미상 표현으로 카메라 외부 인물임을 명확히.
```

**Change 3 — Glossary cross-ref**:

v21 의 "## 구조화 입력 우선순위" 섹션에 다음 추가:

```markdown
- entity_canon stable_traits 는 본 prompt 입력에 inject 된 `[Entity stable_traits]`
  block 으로 전달된다. 해당 block 의 모든 visually-distinguishing trait 는
  Rule X 에 따라 t2i_prompt 에 carry.
```

**Wave A Step 4 implementation**: scene_detail step (`backend/app/core/steps/detail_steps.py`)
에서 user prompt 생성 시 entity_canon.stable_traits 를 fetch 하여 user prompt
에 `[Entity stable_traits for this shot]` block 으로 prepend.

```python
def _build_entity_traits_block(
    visible_entities: list,
    name_by_short_id: dict,
    traits_by_short_id: dict,
) -> str:
    """G4.6 RC-H — visible_entities 안 character base 들의 stable_traits block.

    Codex iter 1 B1 carry — db 인자 제거, ctx 의 prebuilt map 만 사용.
    ThreadPool worker 안 SQLAlchemy session race 회피 (Phase 3 prebuild
    pattern lockstep). prebuild 는 SceneContextLoader.
    `_load_entity_canon_character_maps` 가 main thread 에서 1회 query.

    visible_entities 안 character base ID (C##) 만 추출 → prebuild map 조회 →
    stable_traits 가 있는 entity 만 block 에 포함. fail-fast: visible_entities
    안 character base ID 가 prebuild map 에 없으면 AppError (RO-15 silent
    bypass 금지).

    location/prop/outlook ID 는 무시 (block 은 character 한정).
    """
    base_ids = {
        sid.split("O")[0] for sid in visible_entities
        if isinstance(sid, str) and sid.startswith("C")
    }
    if not base_ids:
        return ""
    missing = base_ids - set(name_by_short_id.keys())
    if missing:
        raise AppError(
            code="step.scene_detail.unknown_short_id",
            message=(
                f"visible_entities references unknown character short_id: "
                f"{sorted(missing)} — prebuild EntityCanon map 에 없음 "
                f"(entity_type='character' scoped). Fix: shot_validator "
                f"character_ids must reference existing entities."
            ),
            status_code=400,
        )
    lines = ["[Entity stable_traits for this shot]"]
    has_any = False
    for sid in sorted(base_ids):
        traits = traits_by_short_id.get(sid) or []
        if not traits:
            continue
        traits_str = ", ".join(traits)
        lines.append(f"- {sid} ({name_by_short_id.get(sid, '')}): {traits_str}")
        has_any = True
    if not has_any:
        return ""
    return "\n".join(lines) + "\n\n"
```

이 block 을 user prompt 에 RenderPromptCard 다음에 prepend.

**테스트** (Wave A Step 2 + 4):

- `test_v21_silhouette_section_has_entity_aware_policy` — v21 system.md 안에
  "entity-aware silhouette policy" 섹션 존재.
- `test_v21_rule_x_variant_trait_inject_section_present` — "## Variant entity
  trait inject (Rule X" 섹션 존재.
- `test_v21_silhouette_recommended_phrasing_no_jaw_contour` — entity-aware
  policy 권장 표현에 "jaw contour" / "chin" / "lips" 류 없음.
- `test_build_entity_traits_block_includes_all_visible` — visible_entities
  에 있는 모든 base ID 의 stable_traits 가 block 에 포함.
- `test_build_entity_traits_block_fail_fast_on_missing` — visible_entities
  에 있는데 entity_canon 에 없는 ID 발견 시 AppError.
- `test_scene_detail_user_prompt_contains_entity_traits_block` — 실제 detail_steps
  에서 user prompt 에 block 이 포함되는지.

### 3.4 shot_validator v4 prompt + step code 변경 — type descriptor + violent freeze + character_ids merge + failure partial (RC-F, RC-G, RO-1, RO-8, RO-11)

**Files**:
- `prompts/_base/shot_validator/4.{YYYYMMDDHHmm}/system.md` (NEW prompt)
- `backend/app/core/steps/shot_validator_step.py` (3 code change — entity context
  inject + character_ids merge + failure partial)

v3 prompt carry + 다음 3 신규 prompt 섹션 + 3 step code 변경.

#### 3.4.0 step code 변경 (RO-1 + RO-8 — code 가 prompt 보다 먼저)

**(a) entity context inject** (`shot_validator_step.py:104-110` user_prompt 구성):

```python
def _build_entity_map_block(scene_index: int, db, project_id: str) -> str:
    """RO-1: scene_director.present_entity_ids 기반 entity map block.

    빈 entity ID list 면 block 자체 omit (LLM 입력 noise 차단).
    """
    director_cp = _load_cp(project_id, episode_id, "scene_director")
    if not director_cp:
        return ""
    sc = next((s for s in director_cp["data"]["scenes"]
               if s["scene_index"] == scene_index), None)
    if not sc:
        return ""
    base_ids = {sid.split("O")[0] for sid in sc.get("present_entity_ids", []) or []}
    if not base_ids:
        return ""
    rows = db.query(EntityCanon.short_id, EntityCanon.name, EntityCanon.stable_traits).filter(
        EntityCanon.project_id == project_id,
        EntityCanon.short_id.in_(base_ids),
    ).all()
    if not rows:
        return ""
    lines = ["[Entity map for this scene]"]
    for r in rows:
        traits = (r.stable_traits or [])[:2]  # 1-2 token 만 (noise 차단)
        traits_str = f" — {', '.join(str(t) for t in traits)}" if traits else ""
        lines.append(f"- {r.short_id}: {r.name}{traits_str}")
    return "\n".join(lines) + "\n\n"

# user_prompt 조립 (line 104+):
entity_block = _build_entity_map_block(si, self._db, self.project_id)
user_prompt = (
    f"[씬 {si}]\n"
    f"{scene_text}\n\n"
    f"{entity_block}"  # NEW: empty 또는 block
    f"[Shots 검증 대상]\n{shots_block}\n\n"
    f"각 shot의 description이 '한 찰나' 원칙에 맞는지 검증하고, "
    f"위반 시 재작성하세요. character_ids 는 entity map 의 short_id 사용."
)
```

**(b) character_ids merge** (`shot_validator_step.py:226-238` merge 로직):

기존:
```python
for sh in shots:
    rev = revisions.get(sh.get("shot_index"))
    new_sh = dict(sh)
    if rev and rev.get("changed"):
        revised = rev.get("revised_description", "").strip()
        if revised and revised != sh.get("description", ""):
            new_sh["original_description"] = sh.get("description", "")
            new_sh["description"] = revised
            new_sh["validator_reason"] = rev.get("reason", "")
            changed += 1
    new_shots.append(new_sh)
```

새로운 (RO-1 + RO-11):
```python
for sh in shots:
    rev = revisions.get(sh.get("shot_index"))
    new_sh = dict(sh)
    if rev:
        # description 변경 (기존)
        if rev.get("changed"):
            revised = rev.get("revised_description", "").strip()
            if revised and revised != sh.get("description", ""):
                new_sh["original_description"] = sh.get("description", "")
                new_sh["description"] = revised
                new_sh["validator_reason"] = rev.get("reason", "")
                changed += 1
        # character_ids merge (RO-1 — changed=false 여도 적용)
        rev_char_ids = rev.get("character_ids")
        if isinstance(rev_char_ids, list):
            new_sh["character_ids"] = list(rev_char_ids)  # short_id 배열
        # characters (이름 배열) — RO-11: 다운스트림 lookup 호환 위해 유지
        rev_chars = rev.get("characters")
        if isinstance(rev_chars, list):
            new_sh["characters"] = list(rev_chars)
    new_shots.append(new_sh)
```

**(c) failure partial** (`shot_validator_step.py:97-107` failure path) — RO-8:

기존: `except: results.append(orig)` (원본 그대로).

새로운:
```python
except Exception as exc:
    logger.warning("shot_validator: scene %s failed — %s", si, exc)
    failed_scenes += 1
    orig = next((x for x in scenes_input if x.get("scene_index") == si), None)
    if orig is not None:
        # RO-8: validator_status="failed" 마킹 — downstream partial 처리 가능
        marked = dict(orig)
        marked["validator_status"] = "failed"
        marked["validator_failure_reason"] = str(exc)[:200]
        # shots 안 각 shot 에도 status 마킹 (downstream contract validator 가
        # 이 마킹을 보고 character_ids 부재 fail-fast 회피)
        marked["shots"] = [
            {**sh, "validator_status": "failed_carry_original"}
            for sh in orig.get("shots", [])
        ]
        results.append(marked)
    else:
        logger.error("shot_validator: scene %s not found — skipping", si)
    continue
```

downstream (scene_detail / shot_dependency_t2i / scene_image_pipeline) 가
`validator_status="failed"` 검출 시 — **RO-15 fail-fast (silent bypass 금지)**:

- scene_detail: 이 scene 의 shots 가 input 으로 들어오면 step **시작 시점에
  AppError raise** — characters=[] systemic 실패가 scene_detail/image 까지
  silently 흘러가는 것 차단. 운영자는 명시적 force flag (`--allow-failed-validator`
  같은) 또는 별도 retry policy 로 진행 가능 — default 는 fail-fast.
  ```python
  failed_scene_indices = {
      sc["scene_index"] for sc in shot_validator_cp["data"]["scenes"]
      if sc.get("validator_status") == "failed"
  }
  if failed_scene_indices and not project_config.get("allow_failed_validator", False):
      raise AppError(
          code="step.upstream_validator_failed",
          message=(
              f"scene_detail blocked — shot_validator failed for scenes "
              f"{sorted(failed_scene_indices)}. Re-run shot_validator first or "
              f"set allow_failed_validator=true to proceed with partial."
          ),
          status_code=400,
      )
  ```
- shot_dependency_t2i: 동일 패턴 — failed scene 에 의존하는 shot 의 ref 생성
  시작 전 fail-fast.
- scene_image_pipeline: 동일 패턴.

`allow_failed_validator=true` 설정 시 partial 진행 — 그 경우 RO-3 의 source 2
(shot_validator.character_ids) 부재로 source 1/3/4 fallback 작동. 단 contract
validator (§3.5) 는 정상 동작 — failed shot 의 visible_entities/character_ids
가 부재면 fail-fast (운영자가 force 로 진행했어도 contract 자체는 enforce).

#### 3.4.1 Prompt Change 1 — type descriptor → entity ID 매핑 룰

v4 신규 섹션:

```markdown
## Type descriptor → entity ID 매핑 (G4.6 신규)

shot description 안의 character 표현이 [Entity map for this scene] block 안
entity 의 short_id+name+stable_traits 와 매핑 가능하면 **`character_ids` 배열
(short_id) + `characters` 배열 (이름)** 두 필드에 모두 채운다. 두 필드 분리
이유: 다운스트림 `shot_dependency_step.py:89` 가 characters 를 이름 lookup 으로
사용 — 회로 보존 (RO-11).

매핑 인정 type:

- 고유명사 (예: "수리영", "혜수") → entity name 정확 일치
- type descriptor (예: "한국인 남자", "한국인 여자", "남자 직원", "여직원",
  "40대 한국인 여성", "거친 남자") → entity name 정확 일치 또는 description 안
  character 표현이 entity name substring 일치
- 변형 표현 (예: "요괴화된 남자", "쓰러진 여자") → base entity name 매칭 +
  variant trait (요괴화 / 쓰러진 / 의식불명) 키워드 보존

복수 entity 가 같은 type descriptor 를 canonical_name 으로 가지면 — [Entity
map for this scene] block 안 entity 만 후보. 그 안에서도 ambiguity 면 scene
context (다른 shot 에 등장한 ID 와 일관) 우선.

description 의 character 표현이 entity map 안 어떤 entity 와도 매칭 안 되면 —
`character_ids` 에 entry 추가 금지. `characters` 에는 raw 표현 그대로 (이름
배열 — 다운스트림 lookup 자체로 처리). 매칭 실패 + visible-human-action 패턴
검출 시 다음 §3.4.3 fail-fast 룰 적용.

### output schema (v4 — RO-1 + RO-11)

```json
{
  "shots": [{
    "shot_index": int,
    "changed": bool,
    "revised_description": string,
    "reason": string,
    "characters": [string],   // 이름 배열 (RO-11: 기존 다운스트림 호환 유지)
    "character_ids": [string] // short_id 배열 — 신규 (RO-1)
  }]
}
```

`characters` 와 `character_ids` 는 동일 인물 set 을 가리키되 representation 만
다름. `character_ids` 가 비어있으면 매핑 실패한 케이스 (entity map 부재 또는
매칭 실패) — `characters` 의 raw 표현은 보존.
```

**Change 2 — violent contact freeze 룰**:

v4 의 기존 "## 동적 동사 freeze 규칙" 섹션 다음에 신규 섹션:

```markdown
## 폭력 접촉 freeze 규칙 (G4.6 신규) — 정적 모먼트 금지

원문 (시나리오) 이 다음 카테고리의 폭력적 접촉을 묘사한 경우, freeze 시
**mid-impact (한가운데, 진행 중) 모먼트** 를 선택해야 한다. 폭력 직전 ("before
the bite", "치아가 닿은 정지 순간", "주먹이 다가오는 순간") 같은 정적 freeze
는 친밀 / 키스 / 포옹으로 잘못 해석되므로 금지.

대상 카테고리 (어휘 다양 — 한/영 양쪽):
- 깨물기/물어뜯기: biting, tearing flesh, fangs sinking, 물어뜯음, 흡혈,
  뜯어내기, 깨물기
- 찌르기/베기: stabbing, slashing, piercing, 찌름, 베기, 벼린, 칼날
- 타격: punching, striking, slamming, hitting, 주먹, 가격, 후려치기, 강타
- 짓누르기/제압: pinning, choking, strangling, crushing, 제압, 목조름, 짓누름

### 금지 — 정적 freeze (친밀 오해)

- ✗ 원문이 "목의 살점을 뜯는 이빨 ... 푹 힘없이 쓰러지는 여자"
  → 잘못된 freeze: "한국인 남자의 치아가 여자의 핏기 없는 목 피부에 닿은
    정지 순간"
  - 이유: "닿은 정지 순간" 은 키스 직전 자세와 시각적으로 동일.
- ✗ 원문이 "주먹으로 강타하는"
  → 잘못된 freeze: "주먹이 상대방 얼굴에 다가가는 순간"

### 권장 — mid-impact freeze (폭력 시각 명확)

- ✓ 원문이 "목의 살점을 뜯는 이빨"
  → 올바른 freeze: "이빨이 여자의 목 살점을 뜯어내는 한가운데, 피가 솟구치고
    상처가 벌어진 순간" — 능동 동사 + 결과 (피, 벌어진 상처) + 권력 불균형
    자세 (남자가 위에서 누르고 여자는 뒤로 젖혀진).
- ✓ 원문이 "주먹으로 강타"
  → 올바른 freeze: "주먹이 얼굴에 닿아 피부가 일그러지고 침이 튀는 한가운데"

### 적용 조건

- 원문에 위 카테고리의 폭력 접촉 동사가 있는가?
- freeze 표현이 "닿은 / 다가오는 / 직전 / 직후" 같은 정적 모먼트인가?
- 정적 모먼트라면 mid-impact 로 재작성 — 능동 동사 + 결과 (피/상처/일그러짐)
  + 권력 불균형 자세를 포함.
- character_ids 에 attacker / victim 양쪽 entity ID 모두 포함 (둘 다 visible
  이면). characters (이름) 도 동시 채움.

### 예외

- 원문이 명시적 정지 자세 (정복 후 / 시신 / 의식불명) → 그대로 freeze.
- 원문이 폭력 동작이 끝난 결과 상태 (피 흘리며 쓰러진 / 부상으로 무릎 꿇은) →
  결과 상태 그대로 freeze.
```

**Change 3 — characters=[] fail-fast (visible-human-action shot)**:

v4 신규 섹션:

```markdown
## characters / character_ids 검출 (G4.6 신규 — RO-19)

description 이 visible-human-action 을 묘사하는데 `characters` (이름) 와
`character_ids` (short_id) 가 모두 빈 배열이면 재작성 또는 fail-fast.

visible-human-action 판정 기준 (3 중 1 이상 만족):

- description 에 신체 부위 표현 (얼굴, 손, 다리, 팔, 머리, 가슴, 목, 입,
  눈, 어깨, 등, 발, 무릎, 허벅지) 등장
- description 에 폭력 / 친밀 / 대화 / 응시 / 자세 동작 동사 등장 (잡기, 보기,
  말하기, 안기, 눕기, 앉기, 서기, 던지기, 받기, 만지기, 닿기 등 + 영어 대응)
- description 에 type descriptor (한국인 남자/여자, 남자/여자 직원, 학생,
  의사, 형사 등) 또는 고유명사 등장

visible-human-action 인 shot 인데 `character_ids=[]` + `characters=[]` 가 나오면
— type-descriptor → entity ID 매핑 룰을 다시 적용한 후, 매칭 결과:

- ID 매핑 성공 → `character_ids` 에 short_id 추가, `characters` 에 entity name 추가
- ID 매핑 실패 (entity map 안에 없음) → `character_ids` 는 비워둠, `characters`
  에는 description 의 raw 표현 그대로 (`["한국인 남자", "여자"]` 류). 다운스트림
  lookup 가 처리.

scene_director 의 present_entity_ids 가 정의된 씬 내 shot 이라면 `character_ids`
또는 `characters` 둘 중 하나에 최소 1 entry 강제. 둘 다 0 entry +
visible-human-action = fail-fast.
```

(schema 는 §3.4.1 신규 섹션의 output schema 와 동일 — RO-11 분리 적용 후
`characters` (이름) + `character_ids` (short_id) 두 필드.)

**테스트** (Wave A Step 3):

- `test_v4_type_descriptor_section_present` — v4 system.md 안에 "Type descriptor"
  섹션 존재.
- `test_v4_violent_contact_freeze_section_present` — "폭력 접촉 freeze 규칙"
  섹션 존재.
- `test_v4_characters_empty_fail_fast_section_present` — "characters=[] 검출"
  섹션 존재.
- `test_shot_validator_output_resolves_type_descriptor_to_short_id` — fixture
  description "한국인 남자가 ..." + scene context (C07) → character_ids: ["C07"]
  + characters: ["한국인 남자"].
- `test_shot_validator_output_violent_freeze_rewrites_static_moment` —
  fixture description "치아가 닿은 정지 순간" → revised_description 이 mid-impact.
- `test_shot_validator_output_visible_human_action_fail_fast_when_empty` —
  visible-human-action 패턴 + characters=[] → AppError.

### 3.5 visible_entities ⊃ C##/C##O## contract validator (RO-2 강화 — ID coverage primary + dynamic entity_canon.name secondary)

**File**: `backend/app/core/visible_entities_validator.py` (신규 module — RO-2
분리, scene_detail step 에서 import).

scene_detail 단계에서 t2i_prompt 가 LLM 출력으로 만들어진 후, validator 가
**ID coverage primary + dynamic entity_canon.name secondary** 로 contract 검증.

**A-prime binding (필수)**:

- 시나리오 의존 keyword frozenset/tuple/list 0건 — production code 에 박힌
  nationality / ethnicity / 작품 고유명사 descriptor list 금지
  (`feedback_no_scenario_keywords.md`).
- Source 1 primary 는 **ID coverage** — visible_entities 안 character base
  C## 가 t2i_prompt 안에 C## 또는 C##O## 로 등장하지 않으면 위반 (descriptor
  의 언어/국적/성별 무관).
- Source 2 secondary 는 **dynamic entity_canon.name** — DB project_id scoped
  fetch, name token 이 prompt 에 등장하면 그 specific entity 의 ID 가 same
  sentence + ±60 char window 안에 있어야 pass. 다른 visible C## 가 있어도
  pass 안 됨 (specific 매칭).

```python
import logging
import re
from app.core.errors import AppError
from app.models.project import EntityCanon

logger = logging.getLogger(__name__)

_MISSING = object()

_ENTITY_ID_PATTERN = re.compile(r"\b(C\d{2,3}(?:O\d{2,3})?)\b")
_OUTLOOK_ID_SHORT = re.compile(r"^O\d{2,3}$")
_COMPOSITE_ID = re.compile(r"^C\d{2,3}O\d{2,3}$")
_ANCHOR_WINDOW = 60
_SENTENCE_BOUNDARY_CHARS = ".!?\n。"


def _require_field(d: dict, key: str, expected_type: type, shot_label: str):
    """RO-29 — required field 부재 / 타입 mismatch fail-fast helper."""
    value = d.get(key, _MISSING)
    if value is _MISSING:
        raise AppError(
            code="step.scene_detail.contract_violation_missing_field",
            message=(
                f"{shot_label} (RO-29): required field '{key}' missing. "
                f"Fix: scene_detail step output must include this field."
            ),
            status_code=400,
        )
    if not isinstance(value, expected_type):
        raise AppError(
            code="step.scene_detail.contract_violation_field_type",
            message=(
                f"{shot_label} (RO-29): field '{key}' must be "
                f"{expected_type.__name__}, got {type(value).__name__}."
            ),
            status_code=400,
        )
    return value


def _entity_specific_id_in_window(
    prompt: str,
    name_pos: int,
    name_len: int,
    entity_id_candidates: set[str],
) -> bool:
    """RO-23 + RO-27 — name_pos 기준 ±60 char window + sentence boundary
    + specific entity ID 매칭. window 안에 검사 중인 name 의 specific
    entity 의 ID (C## 또는 C##O##) 가 있어야만 True.

    핵심 (RO-23 false-positive 차단): 다른 visible C## 가 window 안에 있어도,
    검사 중인 name 의 specific entity 의 ID 가 아니면 reject. 따라서
    `entity_id_candidates` 는 그 base + 그 base 의 모든 allowed outlook
    composite 만 포함.
    """
    start = max(0, name_pos - _ANCHOR_WINDOW)
    end = min(len(prompt), name_pos + name_len + _ANCHOR_WINDOW)
    window_text = prompt[start:end]
    for m in _ENTITY_ID_PATTERN.finditer(window_text):
        sid = m.group(0)
        if sid not in entity_id_candidates:
            continue
        id_abs_pos = start + m.start()
        a, b = sorted([id_abs_pos, name_pos])
        between = prompt[a:b]
        if any(ch in _SENTENCE_BOUNDARY_CHARS for ch in between):
            continue
        return True
    return False


def _outlook_id_candidates_for_base(
    base_id: str, allowed_outlook_pairs: list, shot_label: str,
) -> set[str]:
    """base C## + 그 base 의 모든 allowed C##O## composite ID set.

    Pair 에서 candidate 도출 우선순위:
      1. `composite_id` (C##O## 형식) 있으면 직접 추가 (가장 신뢰).
      2. 없으면 `character_id` (또는 `base_id`) + `outlook_id` 합성.
         단 `outlook_id` 는 O## short id (e.g., "O11", "O123") 형태 강제.

    Why: production 데이터에서 outlook_id 가 short id (O##) 또는 UUID 둘 다
    가능. composite_id 가 있으면 그쪽이 truth — outlook_id 가 UUID 라도
    composite_id 로 정확한 ID 식별 가능. composite_id 가 없을 때만 outlook_id
    가 O## short id 라는 가정 — UUID / 다른 형태면 fail-fast (silent absorb
    금지, RO-15 carry).

    Raises:
        AppError: composite_id 가 C##O## 형식 위반 또는 (composite_id 부재 시)
            outlook_id 가 O## 형식 위반.
    """
    candidates = {base_id}
    for pair in allowed_outlook_pairs:
        if not isinstance(pair, dict):
            continue  # source 3b 의 PRO-4 가 fail-fast 잡음
        char_id = (
            pair.get("character_id")
            or pair.get("base_id")
            or (pair.get("composite_id") or "").split("O")[0]
            or ""
        )
        if char_id != base_id:
            continue

        # 우선순위 1: composite_id 직접 사용 (UUID outlook 도 정확 식별)
        composite_id = pair.get("composite_id")
        if composite_id:
            if not _COMPOSITE_ID.match(composite_id):
                raise AppError(
                    code="step.scene_detail.contract_violation_composite_id_format",
                    message=(
                        f"{shot_label}: allowed_outlook_pairs.composite_id "
                        f"'{composite_id}' must match C##O## (2-3 digits each). "
                        f"entry={pair!r}"
                    ),
                    status_code=400,
                )
            candidates.add(composite_id)
            continue

        # 우선순위 2: outlook_id (O## short id) 합성
        outlook_id = pair.get("outlook_id")
        if not outlook_id:
            continue  # source 3b PRO-13 fail-fast 가 잡음
        if not _OUTLOOK_ID_SHORT.match(outlook_id):
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_id_format",
                message=(
                    f"{shot_label}: allowed_outlook_pairs.outlook_id "
                    f"'{outlook_id}' must match O## short id (O followed by "
                    f"2-3 digits). composite_id 부재 시 outlook_id 만으로 "
                    f"candidate 합성 — UUID / 다른 형태는 명시적으로 "
                    f"composite_id 필드로 표현하세요. entry={pair!r}"
                ),
                status_code=400,
            )
        candidates.add(f"{base_id}{outlook_id}")
    return candidates


def validate_visible_entities_contract(
    shot: dict,
    name_by_short_id: dict,
) -> None:
    """ID coverage primary + dynamic entity_canon.name secondary.

    silent bypass 없음 (RO-15 + PRO-4) — required field 모두 _MISSING fail-fast.

    Codex iter 1 B1 carry — db / project_id 인자 제거. caller (detail_steps
    `_post_process` 또는 ctx-aware test) 가 main thread 에서 prebuild 한
    `name_by_short_id` map 을 전달. ThreadPool worker 안 SQLAlchemy session
    race 회피.

    Source 1 (Primary — ID coverage):
      - Forward: visible_entities 안 character base C## 모두 t2i_prompt 에
        C## 또는 C##O## 로 등장해야 함 (RC-E 핵심).
      - Reverse: t2i_prompt 안 사용된 ID 가 visible_entities 안에 있어야 함.

    Source 2 (Secondary — dynamic entity_canon.name diagnostic):
      - DB 에서 project_id scoped EntityCanon.name fetch.
      - name token 이 t2i_prompt 에 등장하면 그 specific entity 의 ID
        candidates (base C## + 모든 allowed C##O## composite) 가 same
        sentence + ±60 char window 안에 있어야 pass.
      - 다른 visible C## 가 window 안에 있어도 specific 매칭 안 되면 reject.

    Source 3a — render_prompt_card.asset_requirements.required_refs[].id +
        kind ∈ {character, character_outlook} → visible_entities 검증.

    Source 3b — render_prompt_card.id_policy.allowed_outlook_pairs[].
        character_id (legacy fallback: base_id / composite_id) →
        visible_entities 검증.

    RO-15: validator_status="failed_carry_original" shot 도 contract enforcement
    유지 — silent bypass 금지. shot_validator failed scene 차단은 step 시작
    시점 (§3.4) 에서 처리.

    RO-29: required field 모두 _require_field 로 fail-fast — silent absorb 금지.
    """
    scene_idx = shot.get("scene_index")
    shot_idx = shot.get("_shot_index") or shot.get("shot_index")
    shot_label = f"S{scene_idx}_Shot{shot_idx}"

    # RO-29: required field 강제 검증
    visible_list = _require_field(shot, "visible_entities", list, shot_label)
    visible = set(visible_list)
    visible_bases = {
        sid.split("O")[0] for sid in visible if sid.startswith("C")
    }

    t2i_variations = _require_field(shot, "t2i_variations", list, shot_label)
    if not t2i_variations:
        raise AppError(
            code="step.scene_detail.contract_violation_empty_t2i",
            message=(
                f"{shot_label} (RO-29): t2i_variations is empty. scene_detail "
                f"must produce at least one variation per shot."
            ),
            status_code=400,
        )

    rpc = _require_field(shot, "render_prompt_card", dict, shot_label)

    # asset_requirements / id_policy 모두 missing/type fail-fast
    asset_req_raw = rpc.get("asset_requirements", _MISSING)
    if asset_req_raw is _MISSING:
        raise AppError(
            code="step.scene_detail.contract_violation_missing_field",
            message=f"{shot_label} (RO-29): render_prompt_card.asset_requirements missing.",
            status_code=400,
        )
    if not isinstance(asset_req_raw, dict):
        raise AppError(
            code="step.scene_detail.contract_violation_field_type",
            message=(
                f"{shot_label} (RO-29): asset_requirements must be dict, "
                f"got {type(asset_req_raw).__name__}."
            ),
            status_code=400,
        )

    id_policy_raw = rpc.get("id_policy", _MISSING)
    if id_policy_raw is _MISSING:
        raise AppError(
            code="step.scene_detail.contract_violation_missing_field",
            message=f"{shot_label} (RO-29): render_prompt_card.id_policy missing.",
            status_code=400,
        )
    if not isinstance(id_policy_raw, dict):
        raise AppError(
            code="step.scene_detail.contract_violation_field_type",
            message=(
                f"{shot_label} (RO-29): id_policy must be dict, "
                f"got {type(id_policy_raw).__name__}."
            ),
            status_code=400,
        )

    allowed_outlook_pairs = id_policy_raw.get("allowed_outlook_pairs", _MISSING)
    if allowed_outlook_pairs is _MISSING:
        raise AppError(
            code="step.scene_detail.contract_violation_missing_field",
            message=(
                f"{shot_label} (PRO-10): id_policy.allowed_outlook_pairs missing — "
                f"required field per RO-16 contract."
            ),
            status_code=400,
        )
    if not isinstance(allowed_outlook_pairs, list):
        raise AppError(
            code="step.scene_detail.contract_violation_outlook_pairs_type",
            message=f"{shot_label} (PRO-4): id_policy.allowed_outlook_pairs must be list.",
            status_code=400,
        )

    # Source 2 prep — entity_canon.name fetch (project_id scoped, DB single query)
    rows = []
    if visible_bases:
        rows = db.query(EntityCanon.short_id, EntityCanon.name).filter(
            EntityCanon.project_id == project_id,
            EntityCanon.short_id.in_(visible_bases),
        ).all()
    base_to_name: dict[str, str] = {
        r.short_id: r.name for r in rows if r.name
    }

    # Source 1 — Primary ID coverage (forward + reverse)
    for vidx, variation in enumerate(t2i_variations):
        if not isinstance(variation, dict):
            raise AppError(
                code="step.scene_detail.contract_violation_variation_type",
                message=f"{shot_label} (PRO-4): t2i_variations entry must be dict.",
                status_code=400,
            )
        prompt = variation.get("t2i_prompt", _MISSING)
        if prompt is _MISSING or not isinstance(prompt, str):
            raise AppError(
                code="step.scene_detail.contract_violation_variation_prompt",
                message=(
                    f"{shot_label} (PRO-4): t2i_variations[].t2i_prompt missing "
                    f"or non-string."
                ),
                status_code=400,
            )

        used_ids = set(_ENTITY_ID_PATTERN.findall(prompt))
        used_bases = {sid.split("O")[0] for sid in used_ids}

        # Reverse: used IDs 의 base ⊆ visible_bases
        for sid in used_ids:
            base = sid.split("O")[0]
            if base not in visible_bases:
                raise AppError(
                    code="step.scene_detail.contract_violation_id_not_visible",
                    message=(
                        f"{shot_label} (Source 1, RO-2): t2i_variations[{vidx}]."
                        f"t2i_prompt uses '{sid}' (base={base}) but "
                        f"visible_entities={sorted(visible)} does not include it. "
                        f"Fix: shot_validator character_ids missing or "
                        f"scene_detail hallucination."
                    ),
                    status_code=400,
                )

        # Forward (RC-E primary): visible character base 가 prompt 에 어느
        # 형식으로든 (C## 또는 C##O##) 등장해야 함. descriptor only / 누락
        # 모두 fail-fast — Rule X-2 의 contract 강제.
        missing_bases = visible_bases - used_bases
        if missing_bases:
            missing_with_names = sorted(
                f"{b}({base_to_name.get(b, '?')})" for b in missing_bases
            )
            raise AppError(
                code="step.scene_detail.contract_violation_visible_id_not_in_prompt",
                message=(
                    f"{shot_label} (Source 1, RO-2): t2i_variations[{vidx}]."
                    f"t2i_prompt missing visible_entities character base IDs: "
                    f"{missing_with_names}. visible_entities={sorted(visible)}. "
                    f"Fix: t2i_prompt must reference each visible character base "
                    f"as C## or C##O## (Rule X-2). descriptor-only representation "
                    f"is insufficient — image generator cannot map ref to "
                    f"descriptor."
                ),
                status_code=400,
            )

    # Source 2 — Secondary dynamic entity_canon.name diagnostic
    # 각 visible base 의 name token 이 t2i_prompt 에 등장하면 그 specific
    # entity 의 ID 가 same sentence + ±60 char window 안에 있어야 함. 다른
    # visible C## 가 window 안에 있어도 reject (specific 매칭).
    for vidx, variation in enumerate(t2i_variations):
        prompt = variation["t2i_prompt"]  # Source 1 에서 검증됨
        prompt_lower = prompt.lower()

        for base_id, name in base_to_name.items():
            if not name or len(name.strip()) < 2:
                continue
            entity_id_cands = _outlook_id_candidates_for_base(
                base_id, allowed_outlook_pairs, shot_label,
            )
            name_lower = name.lower()
            offset = 0
            while True:
                pos = prompt_lower.find(name_lower, offset)
                if pos == -1:
                    break
                if not _entity_specific_id_in_window(
                    prompt, pos, len(name_lower), entity_id_cands,
                ):
                    raise AppError(
                        code="step.scene_detail.contract_violation_entity_name_no_specific_id",
                        message=(
                            f"{shot_label} (Source 2, RO-23+RO-27): "
                            f"t2i_variations[{vidx}].t2i_prompt uses "
                            f"entity_canon.name '{name}' (base={base_id}) but "
                            f"no specific ID from {sorted(entity_id_cands)} "
                            f"found within ±{_ANCHOR_WINDOW} char window in "
                            f"same sentence. Other visible C## tokens do not "
                            f"satisfy — must be the specific entity's own ID."
                        ),
                        status_code=400,
                    )
                offset = pos + len(name_lower)

    # Source 3a — required_refs (RO-3 dict shape, RO-30 + PRO-12 fail-fast)
    required_refs = asset_req_raw.get("required_refs", _MISSING)
    if required_refs is _MISSING:
        raise AppError(
            code="step.scene_detail.contract_violation_missing_field",
            message=(
                f"{shot_label} (RO-30): asset_requirements.required_refs missing — "
                f"required field. 빈 list 가능, missing 불가."
            ),
            status_code=400,
        )
    if not isinstance(required_refs, list):
        raise AppError(
            code="step.scene_detail.contract_violation_required_refs_type",
            message=f"{shot_label} (RO-30): asset_requirements.required_refs must be list.",
            status_code=400,
        )
    for ref in required_refs:
        if not isinstance(ref, dict):
            raise AppError(
                code="step.scene_detail.contract_violation_required_ref_type",
                message=f"{shot_label} (PRO-4): required_refs entry must be dict.",
                status_code=400,
            )
        rid = ref.get("id")
        if not rid:
            raise AppError(
                code="step.scene_detail.contract_violation_required_ref_no_id",
                message=f"{shot_label} (PRO-4): required_refs entry missing 'id'.",
                status_code=400,
            )
        kind = ref.get("kind", "")
        base = rid.split("O")[0]
        if kind in ("character", "character_outlook") and base not in visible_bases:
            raise AppError(
                code="step.scene_detail.contract_violation_required_ref_not_visible",
                message=(
                    f"{shot_label} (Source 3a, RO-2): "
                    f"asset_requirements.required_refs has {kind} id={rid} but "
                    f"visible_entities={sorted(visible)} does not include "
                    f"base={base}."
                ),
                status_code=400,
            )

    # Source 3b — allowed_outlook_pairs (RO-16 character_id primary,
    #            RO-31 + PRO-13 fail-fast)
    for pair in allowed_outlook_pairs:
        if not isinstance(pair, dict):
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_pair_type",
                message=f"{shot_label} (PRO-4): allowed_outlook_pairs entry must be dict.",
                status_code=400,
            )
        # RO-16: character_id primary (강제 schema), legacy fallback 으로
        # base_id / composite_id (다른 G4 버전과의 deploy 안정성)
        base = (
            pair.get("character_id")
            or pair.get("base_id")
            or (pair.get("composite_id") or "").split("O")[0]
            or ""
        )
        # RO-31: 모든 ID 후보 부재 시 fail-fast (silent pass 차단)
        if not base:
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_pair_no_id",
                message=(
                    f"{shot_label} (RO-31): allowed_outlook_pairs entry missing "
                    f"all of character_id / base_id / composite_id. entry={pair!r}"
                ),
                status_code=400,
            )
        # RO-31: outlook_id 도 검증 (missing/type mismatch fail-fast)
        outlook_id = pair.get("outlook_id", _MISSING)
        if outlook_id is _MISSING:
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_pair_no_outlook_id",
                message=(
                    f"{shot_label} (RO-31): allowed_outlook_pairs entry missing "
                    f"'outlook_id' field. entry={pair!r}"
                ),
                status_code=400,
            )
        if not isinstance(outlook_id, str):
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_pair_outlook_id_type",
                message=(
                    f"{shot_label} (RO-31): outlook_id must be str, "
                    f"got {type(outlook_id).__name__}."
                ),
                status_code=400,
            )
        if base not in visible_bases:
            raise AppError(
                code="step.scene_detail.contract_violation_outlook_pair_not_visible",
                message=(
                    f"{shot_label} (Source 3b, RO-16): "
                    f"id_policy.allowed_outlook_pairs has character_id={base} "
                    f"but visible_entities={sorted(visible)} does not include "
                    f"it."
                ),
                status_code=400,
            )
```

**Where to call**: `_post_process_shot_detail()` after LLM 응답 받음, 매 shot
별로. db + project_id 인자 추가.

**Why fail-fast**: silent fallback 금지 (memory `feedback_no_silent_fallback.md`).
contract 위반은 즉시 발견 + 재실행 가이드. **RO-28 carry**: shot_validator
failure 의 일관 정책 = step 시작 시점 fail-fast (§3.4 — RO-15). 운영자가
`allow_failed_validator=true` 로 force 진행해도 contract validator 는 enforce
(silent bypass 금지 — RO-15 binding).

**A-prime grep gate (PRO 신규)**:

- `backend/app/core/visible_entities_validator.py` 안에 nationality /
  ethnicity / 작품 고유명사 token 0건. 검사 패턴 (대소문자 무관):
  `(?i)(korean|east asian|asian woman|asian man|한국인|일본인|중국인|어린 소녀|남자 직원|여직원|여자 직원|young.{0,8}(man|woman|girl|boy)|adult (male|female))`
  중 하나라도 production module 에 hardcoded constant 로 등장하면 fail.
- `backend/tests/core/test_visible_entities_validator.py` fixture 도 동일 0건 —
  generic placeholder (`Adult Character A`, `Adult Character B`, `dark
  jacket`, `white blouse`, `front-desk attendant`, `hooded adult figure` 등)
  사용. fixture 의 entity_canon.name 도 generic placeholder 만.

**테스트** (Wave A Step 5 — generic fixture only):

- `test_validate_pass_when_all_visible_ids_in_prompt` — visible=["C08","C09"],
  prompt 안 C08 + C09 token 모두 + descriptor 0 → pass.
- `test_validate_fail_when_visible_base_missing_in_prompt` (Source 1 forward,
  RC-E 핵심 D2 root fixture 추상화) — visible=["C08","L03"], prompt 에 C08
  base 가 어떤 형식 (C08 / C08O##) 으로도 등장 안 함 → AppError
  `contract_violation_visible_id_not_in_prompt`.
- `test_validate_fail_when_used_id_not_in_visible` — visible=["C08"], prompt
  에 "C09" 등장 → Source 1 reverse AppError `contract_violation_id_not_visible`.
- `test_validate_pass_when_entity_name_anchored_with_specific_id` (Source 2,
  RO-23 핵심 false-positive 차단) — visible=["C08"], entity_canon.name(C08)=
  "Adult Character A", prompt 안 "C08O10 in dark jacket, Adult Character A,
  hands raised" → Source 2 specific ID anchor PASS.
- `test_validate_fail_when_entity_name_anchored_to_other_id` (Source 2 specific
  match — 다른 visible ID 가 window 안에 있어도 reject) — visible=["C08","C09"],
  entity_canon.name(C09)="Adult Character B", prompt 안 "C08O10 ... Adult
  Character B walks in" (C08O10 가 window 안에 있지만 검사 중인 name 은
  C09) → AppError `contract_violation_entity_name_no_specific_id`.
- `test_validate_fail_when_entity_name_outside_window` (RO-23) — visible=
  ["C08"], entity_canon.name(C08)="Adult Character A", prompt 안 "C08 sits at
  the desk watching the monitor flicker. The room is dim, the air is heavy.
  Across the table, Adult Character A walks in." (name 위치가 C08 으로부터
  ±60 char 밖) → Source 2 AppError.
- `test_validate_fail_when_entity_name_in_separate_sentence` (RO-27) — visible=
  ["C08"], entity_canon.name(C08)="Adult Character A", prompt 안 "C08 stands.
  Adult Character A turns away." (period 로 sentence 분리) → Source 2 AppError.
- `test_validate_pass_when_entity_name_with_outlook_composite` (Source 2
  outlook ID candidate 인정 — outlook_id 합성) — visible=["C08"],
  allowed_outlook_pairs=[{"character_id":"C08","outlook_id":"O10"}],
  entity_canon.name(C08)="Adult Character A", prompt 안 "C08O10 in white
  blouse, Adult Character A, smiling" → C08O10 도 specific ID candidate
  (outlook_id 합성) → PASS.
- `test_validate_pass_when_outlook_uses_composite_id_field` (helper
  우선순위 1) — visible=["C08"], allowed_outlook_pairs=[{"character_id":"C08",
  "composite_id":"C08O15","outlook_id":"some-uuid-form"}], prompt 안 "C08O15 in
  white blouse, Adult Character A, smiling" → composite_id 가 outlook_id 의
  UUID 형태를 우회해 정확 식별 → PASS.
- `test_validate_fail_when_composite_id_malformed_format` (helper format
  강제) — pair=[{"character_id":"C08","composite_id":"C08-O15"}] → AppError
  `contract_violation_composite_id_format`.
- `test_validate_fail_when_outlook_id_uuid_no_composite` (helper format
  강제 — composite_id 부재 + outlook_id 가 UUID/다른 형태) —
  pair=[{"character_id":"C08","outlook_id":"abc-uuid-123"}] (composite_id
  없음) → AppError `contract_violation_outlook_id_format` (사용자가
  composite_id 필드로 명시 권장).
- `test_validate_fail_when_required_refs_id_missing_from_visible` —
  asset_requirements.required_refs=[{"kind":"character_outlook","id":"C09O11"}]
  + visible_entities=["C08"] → Source 3a AppError.
- `test_validate_fail_when_id_policy_character_id_missing_from_visible`
  (RO-16) — id_policy.allowed_outlook_pairs=[{"character_id":"C09",
  "outlook_id":"O11"}] + visible_entities=["C08"] → Source 3b AppError.
- `test_validate_fail_with_legacy_base_id_shape` (RO-16 legacy fallback) —
  `[{"base_id":"C09","outlook_id":"O11"}]` + visible=["C08"] → Source 3b
  AppError (legacy fallback 정상 동작).
- `test_validate_no_bypass_for_failed_validator_status` (RO-15) — shot.
  validator_status="failed_carry_original" + visible_entities 부재 + prompt
  안 C 토큰 → 그래도 contract 위반 raise (silent bypass 차단).
- `test_validate_required_refs_dict_shape_robust` — malformed ref entry
  (string 등) → AppError `contract_violation_required_ref_type` (silent
  skip 금지).
- `test_apperror_constructor_signature` (RO-22) — 모든 raise 가
  `code=`, `message=`, `status_code=` keyword 사용. positional
  `AppError(f"...")` 사용 시 grep 검출 → unit test 실패.
- `test_no_scenario_descriptor_constants_in_module` (A-prime 강제) — module
  source 안에 nationality / ethnicity / 작품 고유명사 token 부재 (위 grep gate
  의 패턴 0 매칭 검증).

### 3.6 Wave A Step 정리 (구현 단계)

| Step | 영역 | 파일/Prompt | 의존 |
|---|---|---|---|
| Pre-Wave | RC-D hotfix | prompt_service.py:43-148 | — |
| 1 | RC-C variant 보호 | reference_pipeline_orchestrator.py + low_freq_skip 검증 helper | — |
| 2 | RC-E silhouette identity | scene_detail v21 system.md (Change 1) | — |
| 3 | RC-F + RC-G shot_validator v4 | shot_validator v4 system.md + schema | — |
| 4 | RC-H variant trait inject | scene_detail v21 system.md (Change 2) + detail_steps.py traits block + version_registry | Step 2 (v21 dir 같음) |
| 5 | visible_entities contract | detail_steps.py validator | Step 4 |

각 Step 후 unit + integration 테스트 통과 + canary smoke (Step 별).

---

## 4. Wave B — same-frame guard + chain_bg compositing

### 4.1 shot_dependency_t2i v6 prompt + step code 변경 — body region check + silent fallback 제거 (RC-A, RO-4, RO-5, RO-7)

**Files**:
- `prompts/_base/shot_dependency_t2i/6.{YYYYMMDDHHmm}/system.md` (NEW prompt)
- `backend/app/core/steps/shot_dependency_t2i_step.py` (RO-7 silent fallback
  제거)
- `backend/app/services/scene_reference_service.py` (RO-5 fail-fast)

v5 prompt carry + 다음 prompt 변경:

```markdown
## ref_usage 신중 선택 (G4.6 신규) — body region 일치 검증

`ref_usage="zoom_in_detail"` 은 다음 조건 모두 만족 시에만 선택한다:

1. previous shot 과 current shot 의 카메라 위치 / 각도 / 인물 모두 일치
2. **focus 가 같은 body region 또는 같은 sub-area** (same face / same hand /
   same prop / same wound / same eye)
3. current shot 이 prev shot 의 sub-region 확대 (same frame, just closer)

다음 경우 `zoom_in_detail` 절대 금지:

- prev focus 가 신체 부위 X (face / hand / thigh / eye / wrist / hair) 이고
  current focus 가 신체 부위 Y (X ≠ Y) — 예: prev "허벅지 ECU" + current
  "얼굴 CU" → reframing 이 아니라 다른 shot. `ref_usage="exact_background"`
  또는 unset 으로 분류.
- prev shot 과 current shot 의 카메라 각도 차이 > 30° (이미 다른 시점).
- prev shot 의 인물 / 자세 / 표정과 current 가 다른 동작 / 다른 표정.

### output schema 추가 field (RO-4)

각 `location_refs[]` entry 에 **두 focus 필드** 동시 추가:

```json
{
  "scene_index": int, "shot_index": int,
  "location_refs": [{
    "scene_index": int, "shot_index": int,  // previous shot
    "ref_usage": "zoom_in_detail" | "exact_background" | "atmosphere_reference",
    "previous_focus_region": "face" | "hand" | "thigh" | "eye" | "wrist" | "wound" | "prop:<P##>" | "wide_scene" | "other:<free-text>",
    "current_focus_region": "<same enum>",
    "reason": string,
    "ignore_elements": string,
    "keep_elements": [string]
  }]
}
```

- `previous_focus_region`: 그 ref 가 가리키는 이전 shot 의 focus
- `current_focus_region`: 현재 분석 중인 shot 의 focus

LLM 이 두 값 모두 추출 — shot_dependency_t2i_step.py:35 가 이미 shot_validator
manifest 를 읽으므로 prev shot description / character_ids 를 LLM user_prompt
에 명시 인용 가능. backend `_validate_zoom_in_detail` 가 두 값 비교.

### focus region canonicalizer (RO-14 / M3)

LLM 이 free-text 표현을 사용해도 backend 비교는 canonical form 으로:

| Raw expression | Canonical |
|---|---|
| `upper thigh / bare thigh / thigh / leg` | `thigh` |
| `face / face CU / facial expression / head` | `face` |
| `hand / palm / fingers / wrist / knuckle / fist` | `hand` |
| `eye / eyes / pupil / iris / gaze` | `eye` |
| `wound / cut / bite mark / injury / scar` | `wound` |
| `wide / establishing / full scene / wide shot / 전경 / 전신` | `wide_scene` |
| `mouth / lip / lips / teeth` | `mouth` |
| `neck / throat` | `neck` |
| 그 외 | raw text 그대로 (canonicalizer 미적용) |

canonicalizer 미적용 region 끼리는 equality 비교 — substring 비교 X (false
match 차단). 신규 region 은 free-text 보존 + 비교 시 정확 일치만.

### body region 매트릭스 (참고)

| prev focus | current focus | ref_usage |
|---|---|---|
| face | face (closer) | zoom_in_detail OK |
| hand | hand (closer) | zoom_in_detail OK |
| thigh | face | zoom_in_detail X → contract_violation (RO-5) |
| wide scene | face | zoom_in_detail X → contract_violation |
| eye | wrist | zoom_in_detail X → contract_violation |
| wide_scene | wide_scene | zoom_in_detail OK |
```

**Code change** — `_validate_zoom_in_detail` 의 primary 호출 위치는 **`shot_dependency_t2i_step.py:208~225` LLM 결과 처리 loop 안** (RO-18):

```python
# shot_dependency_t2i_step.py:208 직후 — LLM result 처리 loop
for dep in result.get("dependencies", []):
    key = (dep.get("scene_index"), dep.get("shot_index"))
    loc_refs = dep.get("location_refs", [])
    validated_refs = []
    for ref in loc_refs[:1]:
        ref_key = (ref.get("scene_index"), ref.get("shot_index"))
        # 기존 forward reference 검증
        ref_idx = ...
        cur_idx = ...
        if not (0 <= ref_idx < cur_idx):
            logger.warning("forward ref skipping ...")
            continue
        # RO-18 신규 — zoom_in_detail focus check (저장 전)
        ref_usage = ref.get("ref_usage", "")
        prev_focus = ref.get("previous_focus_region", "")
        cur_focus = ref.get("current_focus_region", "")
        try:
            ref["ref_usage"] = _validate_zoom_in_detail(
                prev_focus_raw=prev_focus,
                current_focus_raw=cur_focus,
                ref_usage=ref_usage,
                scene_index=key[0], shot_index=key[1],
                ref_target_si=ref_key[0], ref_target_shi=ref_key[1],
            )
        except AppError:
            # contract violation — 이 ref 자체를 dependency manifest 에 포함하지
            # 않고 raise. step 전체 contract violation status.
            raise
        validated_refs.append(ref)
    llm_results[key] = {"location_refs": validated_refs}
```

scene_reference_service.py 의 zoom_in_detail label 분기에는 **defensive assert
만** (RO-18 — 이미 step level 에서 통과한 상태):

```python
# scene_reference_service.py:680 zoom_in_detail label 분기
if ref_usage == "zoom_in_detail":
    # RO-18: invariant — shot_dependency_t2i_step 에서 이미 검증됨.
    # defensive assert (assert 가 disabled 되도 step level 검증으로 충분).
    prev_canon = _canonicalize_focus_region(dep_info.get("previous_focus_region", ""))
    cur_canon = _canonicalize_focus_region(dep_info.get("current_focus_region", ""))
    assert prev_canon == cur_canon and prev_canon != "", (
        f"INVARIANT VIOLATION: zoom_in_detail reached scene_reference_service "
        f"with focus mismatch ({prev_canon!r} != {cur_canon!r}) — "
        f"shot_dependency_t2i_step validator failed to catch."
    )
    label = "previous shot at same location (SAME FRAME ZOOMED) — ..."
```

`_validate_zoom_in_detail` helper 자체는 별도 module (e.g. `backend/app/core/
zoom_validator.py`) 으로 분리 — shot_dependency_t2i_step + scene_reference_service
양쪽에서 import.

```python
def _canonicalize_focus_region(raw: str) -> str:
    """RO-14 / M3 — raw → canonical. 미매칭 시 raw 그대로 (lowercased)."""
    if not raw:
        return ""
    s = raw.lower().strip()
    # canonicalizer table (RO-14)
    table = {
        "thigh": ("upper thigh", "bare thigh", "thigh", "leg"),
        "face": ("face", "face cu", "facial expression", "head"),
        "hand": ("hand", "palm", "fingers", "wrist", "knuckle", "fist"),
        "eye": ("eye", "eyes", "pupil", "iris", "gaze"),
        "wound": ("wound", "cut", "bite mark", "injury", "scar"),
        "wide_scene": ("wide", "establishing", "full scene", "wide shot",
                       "전경", "전신"),
        "mouth": ("mouth", "lip", "lips", "teeth"),
        "neck": ("neck", "throat"),
    }
    for canonical, raws in table.items():
        if s in raws:
            return canonical
    return s  # 미매칭 — raw 그대로

def _validate_zoom_in_detail(
    prev_focus_raw: str, current_focus_raw: str, ref_usage: str,
    *, scene_index: int, shot_index: int, ref_target_si: int, ref_target_shi: int,
) -> str:
    """RO-5: contract_violation 발견 시 AppError fail-fast (silent demote 금지).

    Why: D1 의 root cause 는 LLM 이 'leg → face' 인지하고도 zoom_in_detail
    선택. silent demote 는 사용자가 결함 root cause 추적 못하게 함 — fail-fast
    가 force 재실행 가이드 + retry 회수 추적 가능.
    """
    if ref_usage != "zoom_in_detail":
        return ref_usage
    prev = _canonicalize_focus_region(prev_focus_raw)
    cur = _canonicalize_focus_region(current_focus_raw)
    if prev == cur and prev != "":
        return ref_usage
    # 양쪽이 wide_scene 류 — same canonical 'wide_scene' 면 위 분기에서 통과
    raise AppError(
        code="step.shot_dependency_t2i.zoom_focus_mismatch",
        message=(
            f"S{scene_index}_Shot{shot_index} ref → S{ref_target_si}_Shot{ref_target_shi}: "
            f"ref_usage='zoom_in_detail' but focus mismatch — "
            f"previous_focus_region={prev_focus_raw!r}/{prev!r}, "
            f"current_focus_region={current_focus_raw!r}/{cur!r}. "
            f"shot_dependency_t2i v6 prompt body region check failed. "
            f"Re-classify ref_usage as exact_background or atmosphere_reference "
            f"and re-run shot_dependency_t2i (LLM regen)."
        ),
        status_code=400,
    )
```

(별도 helper `_attempt_zoom_demote_with_evidence` 가 manifest 에 `ref_usage_original`,
`demotion_reason`, `validator="g4_6_zoom_demote"` 3 필드 기록 후 demote — 본
spec scope 에서는 fail-fast 가 default. demote helper 는 follow-up — 운영자
가 명시적으로 demote 옵션 활성화 시에만.)

**Code change** — `shot_dependency_t2i_step.py:228` silent fallback 제거 (RO-7):

기존:
```python
except Exception as e:
    logger.warning("shot_dependency_t2i: %s LLM failed: %s — fallback to empty", loc_id, e)
    failed += 1
    for s in shots:
        llm_results[(s["scene_index"], s["shot_index"])] = {"location_refs": []}
```

새로운:
```python
except Exception as e:
    logger.warning("shot_dependency_t2i: %s LLM 1차 실패: %s — retry 1회 시도", loc_id, e)
    # RO-7: 1차 자동 retry — same model, same prompt
    try:
        res = call_structured(..., retry=True)
        # ... 정상 path 반복
    except Exception as e2:
        # 2차도 실패 — partial / contract_violation 마킹 + step result manifest 기록
        logger.error("shot_dependency_t2i: %s LLM 2차 실패: %s — contract violation", loc_id, e2)
        failed += 1
        for s in shots:
            llm_results[(s["scene_index"], s["shot_index"])] = {
                "location_refs": [],
                "contract_status": "llm_failed",
                "contract_failure_reason": str(e2)[:200],
            }
        # Wave B Step 2 이후 step manifest 에 failed_shot_ids list 기록
        manifest_failures.append({"loc_id": loc_id, "shots": [(s["scene_index"], s["shot_index"]) for s in shots], "reason": str(e2)[:200]})
```

step result manifest 의 `failed_count` 가 이 `manifest_failures` 의 entry 수
로 갱신. `data.contract_failures` field 신규.

partial 처리: downstream (scene_reference_service) 가 `contract_status="llm_failed"`
인 dependency entry 검출 시 prev_shot ref 를 그 shot 에 attach 안 함 — fallback
to no-ref scene_image_pipeline.

**테스트** (Wave B Step 1):

- `test_v6_body_region_matrix_section_present` — v6 prompt 안 매트릭스 + 룰
  섹션.
- `test_v6_focus_transition_schema_two_fields` — schema 가 previous_focus_region
  + current_focus_region 두 필드 모두 strict.
- `test_canonicalize_focus_region_thigh_variants` — "upper thigh" / "bare
  thigh" / "leg" → "thigh".
- `test_canonicalize_focus_region_unknown_keeps_raw` — "elbow" → "elbow"
  (lowercased).
- `test_validate_zoom_in_detail_fail_fast_when_focus_differs` — prev_focus=
  "thigh" + current_focus="face" + ref_usage="zoom_in_detail" → AppError
  with code "step.shot_dependency_t2i.zoom_focus_mismatch".
- `test_validate_zoom_in_detail_pass_when_focus_matches` — same canonical →
  통과.
- `test_validate_zoom_in_detail_pass_when_ref_usage_not_zoom` — ref_usage=
  "exact_background" → bypass 통과.
- `test_shot_dependency_t2i_step_retry_on_llm_failure` (RO-7) — 1차 LLM 실패
  → retry 1회 → 성공 케이스.
- `test_shot_dependency_t2i_step_contract_violation_on_persistent_failure`
  (RO-7) — 2차도 실패 → contract_status="llm_failed" 마킹 + manifest_failures
  기록.

### 4.2 scene_reference_service.py — zoom_in_detail label conditional (RC-B)

**File**: `backend/app/services/scene_reference_service.py:680`

**Current**:
```python
if ref_usage == "zoom_in_detail":
    label = "previous shot at same location (SAME FRAME ZOOMED) — reuse this exact frame as the base. The camera moved closer or shifted to a detail region within the SAME moment, SAME subject, SAME environment. Do NOT add new subjects, do NOT change the pose, do NOT duplicate body parts. Only the focused region is being reshown at closer range."
    if ignore: ...
```

**New** (RO-5: fail-fast 로 도달 시점에 zoom 정합성 보장):
```python
if ref_usage == "zoom_in_detail":
    # RO-5: §4.1 _validate_zoom_in_detail() 가 mismatch 시 AppError raise —
    # 이 분기 도달 = 검증 완료된 zoom. silent demote 없음.
    label = (
        "previous shot at same location (SAME FRAME ZOOMED) — reuse this exact "
        "frame as the base. The camera moved closer or shifted to a detail "
        "region within the SAME moment, SAME subject, SAME environment. Only "
        "the focused region is being reshown at closer range."
    )
    # 'Do NOT change the pose / Do NOT duplicate body parts' 는 prompt_service
    # 의 instructions 단계에서 이미 출력 — label 에서 제거 (중복 prepend 차단).
    if ignore: ...
```

추가로 prompt_service.py:71-83 의 instructions 도 다음 조건부:

```python
elif "previous shot" in label_lower and "same frame zoomed" in label_lower:
    ref_roles.append(
        f"Reference image {i}: PREVIOUS SHOT (SAME FRAME, zoomed-in reframing) — reuse the exact frame"
    )
    ref_instructions.append(
        f"- use image {i} as the SAME frame with only reframing/cropping — the camera moved closer but the moment, subject, pose, and environment are identical"
    )
    # G4.6 RO-5/RO-18: pose/body-part 제약은 §4.1 fail-fast 통과한 zoom 에 한정 — 그대로 유지
    ref_instructions.append(
        f"- do NOT add new subjects, do NOT duplicate body parts, do NOT change the pose shown in image {i}"
    )
    ...
```

### 4.3 prompt_service.py chain_bg compositing 지시 (RC-I)

**File**: `backend/app/services/prompt_service.py:120-131`

**Current**:
```python
elif "background chain ref" in label_lower:
    ref_roles.append(
        f"Reference image {i}: pre-rendered BACKGROUND chain reference — use as-is"
    )
    ref_instructions.append(
        f"- match the wall, floor, ceiling, furniture layout, and lighting "
        f"exactly from image {i}"
    )
    ref_instructions.append(f"- do NOT copy any people from image {i}")
```

**New**:
```python
elif "background chain ref" in label_lower:
    ref_roles.append(
        f"Reference image {i}: pre-rendered BACKGROUND chain reference — use as-is"
    )
    ref_instructions.extend([
        f"- match the wall, floor, ceiling, furniture layout, and lighting exactly from image {i}",
        f"- do NOT copy any people from image {i}",
        # G4.6 RC-I: subject 합성 정합성 4 directive
        f"- match the lighting key/fill direction visible in image {i} onto the inserted subject — the subject's shadows must fall in the same direction as shadows already in image {i}",
        f"- include realistic contact shadow under the subject's feet on the same ground plane shown in image {i}, with shadow opacity matching image {i}'s ambient occlusion",
        f"- match the camera height, lens, and perspective implied by image {i} — the subject must be rendered as if photographed from the same camera position as image {i}",
        f"- the subject must occupy 3D space within image {i}'s geometry, not appear pasted in front of it as a 2D cutout",
    ])
```

**테스트** (Wave B Step 4):

- `test_prompt_service_chain_bg_includes_lighting_direction_directive`
- `test_prompt_service_chain_bg_includes_contact_shadow_directive`
- `test_prompt_service_chain_bg_includes_camera_perspective_directive`
- `test_prompt_service_chain_bg_includes_3d_geometry_directive`
- 4 directive 모두 ref_instructions 안 substring 으로 매칭.

### 4.4 Wave B Step 정리

| Step | 영역 | 파일/Prompt | 의존 |
|---|---|---|---|
| 1 | RC-A body region check | shot_dependency_t2i v6 + scene_reference_service.py | — |
| 2 | RC-B zoom label conditional | scene_reference_service.py + prompt_service.py | Wave B Step 1 |
| 3 | RC-I chain_bg compositing | prompt_service.py | — |

Step 1+2 는 같은 file (scene_reference_service.py) 변경하므로 sequential.
Step 3 은 별도 분기 (chain_bg branch) 라 parallel.

---

## 5. Test Fixtures

### 5.1 Raw failure fixtures (capture from PID 298d86d9)

**Location**: `backend/tests/fixtures/g4_6/`

다음 raw step manifest 의 부분 (defect 4 케이스만 copy) 를 fixture 로 보존.
현재 PID 의 manifest 는 시간이 지나면 cleanup 가능 — fixture 는 immutable
snapshot.

| Fixture | Source | 용도 |
|---|---|---|
| `s1_shot3_shot4_dependency.json` | shot_dependency_t2i manifest, S1_Shot4 entry | RC-A fail-fast 테스트 (RO-20: focus mismatch detection) |
| `s1_shot4_image_asset.json` | DB ImageAsset (S1_Shot4) — prompt_used + reference_image_ids | RC-B prompt 검증 |
| `s1_shot5_dependency.json` | shot_dependency_t2i, S1_Shot5 entry | RC-D label routing 테스트 |
| `s1_shot5_scene_detail.json` | scene_detail manifest, S1_Shot5 entry — render_prompt_card + t2i_variations | RC-E silhouette 검증 |
| `s2_shot4_shot_validator.json` | shot_validator manifest, S2_Shot4 entry — characters=[] | RC-F type descriptor + characters=[] |
| `s2_shot4_scene_detail.json` | scene_detail, S2_Shot4 — visible_entities=["C08","L03"] + t2i 안 C09 | RC-H trait inject + visible_entities contract |
| `s8_shot6_image_asset.json` | DB ImageAsset (S8_Shot6) — prompt_used | RC-I chain_bg 4 directive |
| `c15_entity_canon.json` | DB EntityCanon C15 — stable_traits "얼굴이 보이지 않음" | RC-C silhouette 보호 + RC-E identity preserve |
| `c08_entity_canon.json` | DB EntityCanon C08 — stable_traits "red irises", "sharp pointed teeth" | RC-C variant 보호 + RC-H trait inject |
| `c09_entity_canon.json` | DB EntityCanon C09 — low frequency 보호 | RC-C low-freq + scene_detail required |
| `ref_low_freq_skip_pre_g4_6.json` | 17 UUID + resolved short_id | baseline before fix |
| `ref_low_freq_skip_post_g4_6.json` | 보호 적용 후 — **C08, C09, C15 만** low_freq_skip 에서 제외된 결과 (RO-25: outlook 은 reference_pipeline_orchestrator.py:291 에서 skip 룰 미적용 — composite_image_gen path 별도) | post-fix expected base character protection |

### 5.2 DB / reference count expectations

```python
# RO-21: low_freq_skip 룰은 base character 만 적용 (location/outlook 제외 —
# reference_pipeline_orchestrator.py:291). EXPECTED 두 path 분리:
#
# (a) base character ref count — Wave A Step 1 (RC-C 보호) 결과
EXPECTED_BASE_CHARACTER_REFS = {
    # 기존 보호된 base
    "C01": 1, "C07": 1,
    # G4.6 신규 보호 (low_freq_skip 룰의 is_variant_self / kind keyword /
    # required_by_pipeline cascade 발동)
    "C08": 1,   # variant pole (RO-1 carry)
    "C09": 1,   # required by scene_director.present_entity_ids (RO-3 source 1)
    "C15": 1,   # silhouette keyword (RO-10 narrowed)
    # 그 외 entities 는 low_freq_skip 유지 (변동 없음)
}

# (b) outlook composite ref count — composite_image_gen path (G4.6 와 무관)
# C08/C09/C15 base ref 가 생성되어야 그 entity 들의 outlook composite 도 생성
# 가능. composite path 는 별도 step (composite_image_gen).
EXPECTED_OUTLOOK_COMPOSITE_REFS = {
    "O09": 1,   # 기존 — C07O09 등
    "O10": 1,   # G4.6 base 보호 (C08) 후 composite 생성 가능
    "O11": 1,   # G4.6 base 보호 (C09) 후 composite 생성 가능
    "O15": 1,   # G4.6 base 보호 (C15) 후 composite 생성 가능
}
```

테스트:

```python
def test_post_g4_6_base_character_refs_match_expected(db_with_g4_6_protection):
    """RO-21: base character ref 만 — low_freq_skip 보호 결과."""
    rows = db.query(EntityCanon.short_id, func.count(ImageAsset.id)).join(
        ImageAsset, ImageAsset.entity_id == EntityCanon.id,
    ).filter(EntityCanon.entity_type == "character", ImageAsset.is_primary == 1)...
    actual = {r.short_id: r.count for r in rows}
    for sid, expected in EXPECTED_BASE_CHARACTER_REFS.items():
        assert actual.get(sid, 0) >= expected, f"{sid} base ref missing"

def test_post_g4_6_outlook_composite_refs_match_expected(db_with_g4_6_protection):
    """RO-21: outlook composite — composite_image_gen path 결과."""
    rows = db.query(EntityCanon.short_id, func.count(ImageAsset.id)).join(
        ImageAsset, ImageAsset.entity_id == EntityCanon.id,
    ).filter(EntityCanon.entity_type == "outlook")...
    actual = {r.short_id: r.count for r in rows}
    for sid, expected in EXPECTED_OUTLOOK_COMPOSITE_REFS.items():
        assert actual.get(sid, 0) >= expected, f"{sid} outlook composite missing"
```

### 5.3 Prompt label routing tests

§3.2 Pre-Wave hotfix 의 핵심 표 (label → 분기) 를 단위 테스트로 박는다:

```python
@pytest.mark.parametrize("label,expected_branch", [
    # G4.6 RC-D 회귀 케이스
    ("previous shot at same location (SAME ROOM) — use this background as-is. Ignore the close-up face framing and the tight shoulder-level crop. Keep: ...", "previous_shot_same_room"),
    ("previous shot at same location (SAME FRAME ZOOMED) — reuse this exact frame ... Do NOT change the pose ...", "previous_shot_same_frame_zoomed"),
    ("Image 2 (character reference): 한국인 남자 — Set in modern 대한민국 ...", "character_ref"),
    ("Image 1 (face reference): 수리영", "character_ref"),
    ("pre-rendered BACKGROUND chain reference — use as-is", "background_chain_ref"),
    ("Image 3 (object reference): 휴대폰 — ...", "prop_ref"),
    ("standalone outfit/costume reference", "outfit_ref"),
    ("some generic ref text", "fallback"),
])
def test_prompt_service_label_routing(label, expected_branch):
    branch = _classify_label(label.lower())  # NEW helper
    assert branch == expected_branch
```

`_classify_label` helper 는 resolve_ref_roles 의 branch 식별 로직을 분리한
pure function. 테스트 가독성 + label 추가 시 격리.

### 5.4 scene_detail contract tests (generic fixture only — A-prime binding)

모든 fixture 는 시나리오 의존 token 0건 (`feedback_no_scenario_keywords.md`).
entity_canon.name / stable_traits 는 generic placeholder ("Adult Character A",
"Adult Character B", "front-desk attendant" 등) 사용. trait token 도 fixture
가 정의한 generic placeholder 만 (`<altered_eye_token>` 등).

```python
def test_scene_detail_visible_entities_contract_pass():
    # Source 1 forward + reverse PASS
    shot = {
        "scene_index": 1, "_shot_index": 1,
        "visible_entities": ["C08", "C09", "L03"],
        "t2i_variations": [
            {"t2i_prompt": "C08O10 in dark jacket and C09 in white blouse"}
        ],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {"allowed_outlook_pairs": []},
        },
    }
    validate_visible_entities_contract(shot, mock_db_minimal, "test-pid")

def test_scene_detail_visible_entities_contract_fail_unknown_id():
    # Source 1 reverse — used 'C09' not in visible
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08", "L03"],
        "t2i_variations": [
            {"t2i_prompt": "C08O10 standing. C09 walks in."}
        ],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {"allowed_outlook_pairs": []},
        },
    }
    with pytest.raises(AppError, match="contract_violation_id_not_visible"):
        validate_visible_entities_contract(shot, mock_db_minimal, "test-pid")

def test_scene_detail_visible_entities_contract_fail_visible_id_missing():
    # Source 1 forward — visible 'C09' base 가 prompt 어디에도 등장 X
    shot = {
        "scene_index": 2, "_shot_index": 4,
        "visible_entities": ["C08", "C09", "L03"],
        "t2i_variations": [
            {"t2i_prompt": "C08O10 in dark jacket, an adult figure, hands raised"}
        ],
        "render_prompt_card": {
            "asset_requirements": {"required_refs": []},
            "id_policy": {"allowed_outlook_pairs": []},
        },
    }
    with pytest.raises(
        AppError, match="contract_violation_visible_id_not_in_prompt"
    ):
        validate_visible_entities_contract(shot, mock_db_minimal, "test-pid")

def test_scene_detail_entity_traits_block_carries_visible_traits(generic_traits_fixture):
    # entity_canon.stable_traits 그대로 carry — keyword hardcoded 0
    # generic_traits_fixture.c08_stable_traits 는 fixture 가 정의한 placeholder list
    block = _build_entity_traits_block(["C08", "C09", "L03"], db, "test-pid")
    assert "C08" in block
    for trait_token in generic_traits_fixture["c08_stable_traits"]:
        assert trait_token in block  # 어떤 token 이든 (generic) 그대로 carry
```

### 5.5 shot_validator characters=[] tests (generic fixture only — A-prime binding)

Phase 3 영역 — fixture 안 type descriptor / scene text 모두 generic placeholder.
실제 token 은 fixture 가 정의 (entity_canon 의 generic name + scene fixture 의
generic text + revised expected substring 모두 fixture 가 expose).

```python
def test_shot_validator_v4_resolves_type_descriptor_to_short_id(
    scene_context_with_c07_generic,
):
    # RO-19: type descriptor (entity_canon.descriptor 의 generic placeholder)
    # → entity short_id 매핑
    fix = scene_context_with_c07_generic
    shot = run_shot_validator_v4(
        description=fix["description_using_descriptor_token"],
        scene_present_entity_ids=["C07", "L01"],
    )
    assert "C07" in shot["character_ids"]
    # characters (entity_canon.name) 그대로 carry — fixture 가 정의한 generic name
    assert fix["c07_name"] in shot["characters"]

def test_shot_validator_v4_visible_human_action_fail_fast_when_empty(
    scene_context_with_human_action_generic,
):
    # RO-26: visible-human-action + character_ids=[] → fail-fast
    fix = scene_context_with_human_action_generic
    shot = {"description": fix["human_action_description_generic"]}
    with pytest.raises(AppError, match="visible-human-action"):
        run_shot_validator_v4(shot, scene_present_entity_ids=[])

def test_shot_validator_v4_violent_freeze_rewrites_static_moment(
    scene_context_with_violent_action_generic,
):
    # RC-G: 정적 freeze 어휘 → mid-impact 어휘 rewritten
    fix = scene_context_with_violent_action_generic
    shot = run_shot_validator_v4(
        description=fix["static_contact_description_generic"],
        scene_present_entity_ids=["C08", "C09"],
        scene_text=fix["scene_text_with_dynamic_action_generic"],
    )
    # 정적 어휘는 rewritten — fixture 가 정적 어휘 substring 노출
    assert fix["static_substring_to_be_removed"] not in shot["revised_description"]
    # 동적 어휘 후보 — fixture 가 expected_dynamic_substrings 노출 (generic)
    assert any(
        kw in shot["revised_description"]
        for kw in fix["expected_dynamic_substrings"]
    )
```

### 5.6 Wave A canary scripts (RO-9)

**File**: `scripts/canary/g4_6_*.py` (G4.2~G4.5a 답습 — `_g4_6_common.py` helper).

| Script | 검증 대상 |
|---|---|
| `g4_6_low_freq_skip_protection.py` | 보호된 entity 가 `ref_low_freq_skip.json` 에서 제외되는지. 1 fixture project 로 dry-run. |
| `g4_6_silhouette_identity_preservation.py` | scene_detail v21 user prompt 가 `[Entity stable_traits]` block 포함하는지. C15 stable_traits → t2i_prompt 안 "face fully obscured" 표현으로 carry. |
| `g4_6_variant_trait_inject.py` | C08 stable_traits → t2i_prompt 안 "red irises" + "sharp pointed teeth" carry. |
| `g4_6_shot_validator_type_descriptor.py` | shot_validator v4 가 type descriptor entity → short_id 매핑. |
| `g4_6_visible_entities_contract.py` | scene_detail validator 가 contract 위반 시 fail-fast. |

각 canary 는 fixture-only (실제 LLM 호출 없음) — prompt 입력 / template 검증.

### 5.7 Wave B canary scripts (RO-9)

**File**: `scripts/canary/g4_6_*.py`

| Script | 검증 대상 |
|---|---|
| `g4_6_zoom_in_detail_focus_mismatch.py` (RO-20) | shot_dependency_t2i v6 + step level validator (RO-18) 가 prev=thigh + current=face → AppError fail-fast. |
| `g4_6_chain_bg_compositing_directives.py` | prompt_service 가 chain_bg 분기에 4 directive 포함. |
| `g4_6_label_routing_face_substring_fix.py` | "face framing" substring 이 character 분기로 새지 않음. |

### 5.8 Token / line count gates (G4.5a 패턴 carry)

scene_detail v21 system.md:

- system token delta vs v20 baseline: **report-only** (G4.6 은 prose 추가
  spec 이라 negative gate 없음). v21 add 예상: ~100 line (silhouette
  entity-aware + Rule X variant trait inject + Glossary cross-ref). 만약
  150 line 이상 추가 시 IMPORTANT — Round 1 Override 에서 tighten.

shot_validator v4 system.md:

- v3 baseline 약 100 line. v4 add 예상: ~70 line (3 신규 섹션). 200 line 이상
  IMPORTANT.

shot_dependency_t2i v6 system.md:

- v5 baseline 확인 후 결정 (Wave B Step 1 진입 직전).

각 prompt 의 compact section line count gate 는 Round 1 Override 에서
binding (G4.4/G4.5a 패턴).

---

## 6. Implementation Order

### 6.1 Pre-Wave hotfix (RC-D)

1. `prompt_service.py:43-148` — elif 순서 재배열 + `_is_explicit_character_ref_label`
   helper.
2. 호출자 검사: shot_reference_service / scene_reference_service 의 character
   ref label 생성부 — `(character reference)` / `(face reference)` 패턴 사용
   여부 검증, 미사용 시 변경.
3. unit test (§5.3 label routing).
4. canary `g4_6_label_routing_face_substring_fix.py`.
5. dual review (Codex + Claude) — 단일 PR / commit.

Pre-Wave hotfix 통과 후 Wave A 진입.

### 6.2 Wave A waves

| Wave-A Step | 작업 | 의존 | 테스트 |
|---|---|---|---|
| Step 1 | RC-C — `reference_pipeline_orchestrator.py` skip rule 보강 | — | unit (§3.1 6 tests) + canary `low_freq_skip_protection.py` |
| Step 2 | RC-E — scene_detail v21 silhouette section | — | unit (§3.3 3 tests) + canary `silhouette_identity_preservation.py` |
| Step 3 | RC-F + RC-G — shot_validator v4 prompt + schema | — | unit (§3.4 6 tests) + canary `shot_validator_type_descriptor.py` |
| Step 4 | RC-H — scene_detail v21 Rule X variant trait inject + detail_steps.py traits block | Step 2 (v21 dir) | unit (§3.3 3 tests) + canary `variant_trait_inject.py` |
| Step 5 | visible_entities ⊃ C##/C##O## contract validator | Step 4 | unit (§3.5 4 tests) + canary `visible_entities_contract.py` |

각 Step 후 `pytest backend/tests/` 회귀 0 + canary smoke pass + dual review.

Wave A 종료 시 single commit (G4.x 패턴) 또는 step 별 commit + 마지막 push.
사용자 binding "Wave A → Wave B 분리" 답습 — Wave A push 직후 Wave B 진입.

### 6.3 Wave B waves

| Wave-B Step | 작업 | 의존 | 테스트 |
|---|---|---|---|
| Step 1 | RC-A — shot_dependency_t2i v6 + `_validate_zoom_in_detail` helper | — | unit (§4.1 4 tests) + canary `g4_6_zoom_in_detail_focus_mismatch.py` (RO-24) |
| Step 2 | RC-B — scene_reference_service.py:680 + prompt_service.py:71-83 conditional | Step 1 (validate helper) | unit + canary 회귀 |
| Step 3 | RC-I — prompt_service.py:120-131 chain_bg 4 directive | — | unit (§4.3 4 tests) + canary `chain_bg_compositing_directives.py` |

Step 1+2 sequential (같은 file). Step 3 parallel.

### 6.4 Production regeneration order (RO-6 정정)

실제 step dependency (`shot_dependency_t2i_step.py:35` shot_validator 읽음,
`:95` scene_detail 읽음) 기반 — shot_dependency_t2i 는 항상 shot_validator +
scene_detail 후. Pre-Wave hotfix + Wave A + Wave B 완료 후:

```
1. ref_image_gen / composite refs
   - C08 (요괴화) base ref + outlook O10 ref
   - C09 (한국인 여자) base ref + outlook O11 ref
   - C15 (검은 형상) base ref + outlook O15 ref
   - 보호 룰 (Wave A Step 1) 발동 검증
   force step: ref_image_gen + composite_image_gen

2. shot_validator (전체) — Wave A v4 prompt
   - type descriptor 매핑 + violent freeze + character_ids merge
   - target: S1_Shot3, S1_Shot4, S1_Shot5, S2_Shot4 (characters=[] → C07/C08/C09/C15)
   force step: shot_validator

3. scene_detail (전체) — Wave A v21 prompt
   - silhouette identity (Rule X) + variant trait inject (Rule X-2)
     + ID-based representation 강제 + visible_entities contract validator
   - target: S1_Shot5 (C15 silhouette identity preserve), S2_Shot4 (C08 yokai
     trait carry + C09 ID-based representation)
   force step: scene_detail

4. shot_dependency_t2i (전체) — Wave B v6 prompt
   - body region check (focus_transition) + RO-5 fail-fast + RO-7 retry
   - target: S1_Shot4 (zoom_in_detail → fail-fast → LLM 재추론)
   force step: shot_dependency_t2i

5. scene_image_pipeline (target shots) — Wave B Step 3 + RC-D Pre-Wave hotfix
   - target: S1_Shot3, S1_Shot4, S1_Shot5, S2_Shot4, S8_Shot6
   - chain_bg compositing 4 directive 발동 (D4 fix)
   force step: scene_image_pipeline (image category)
```

각 step force 시 `feedback_dispatch_category_all.md` 룰 검토 — 기존 PID 의
다음 force trigger 라 image 단독 force 가능. 단 step 4 (shot_dependency_t2i)
는 analysis category — analysis force 후 image force 순서.

**Why this order** (RO-6 — 실제 dependency):

- ref 부터: scene_image_pipeline 이 ref attach 가능해야 visual anchor 확보.
- shot_validator → scene_detail → shot_dependency_t2i: shot_dependency_t2i_step
  이 두 manifest 를 모두 읽음. Pre-RO-6 spec 의 "shot_dependency_t2i 가 step 2"
  는 잘못 — dependency 충족 안 됨.
- scene_image_pipeline 마지막: 위 4 step 의 결과 manifest 와 contract 모두
  consume.

cost 추정: ref ~$2 + shot_validator ~$2 + scene_detail ~$3 + shot_dependency_t2i
~$1 + scene_image_pipeline ~$2 = ~$10, 30-60분.

### 6.5 Verification gate

각 Wave 종료 + production regeneration 후:

1. `pytest backend/tests/` — Wave A 후 ≥(baseline + Wave A test count) 통과,
   Wave B 후 ≥(baseline + Wave A + Wave B test count) 통과
2. canary smoke 모두 pass
3. ImageAsset DB 의 `reference_image_ids` 가 D2/D3 target shot 에서 비어있지
   않음 (C08/C09/C15 ref 첨부 확인)
4. **`character_ids` non-empty primary gate** (RO-26): scene_director.present_entity_ids
   가 정의된 visible-human-action shot 에서 shot_validator `character_ids` 가
   non-empty 여야 verification PASS. raw `characters` non-empty 만 (
   `character_ids=[]`) 인 케이스는 partial — verification fail (운영자가
   `allow_failed_validator` flag 로만 진행 가능). D3 root (characters=[] 잔재)
   가 다시 통과하지 못하게 차단.
5. scene_detail t2i_prompt 가 visible_entities 안 모든 ID 의 stable_traits
   를 carry (variant trait inject)
6. 4 defective shot 재렌더 PNG 사용자 검토 (visual sign-off)

verification 미통과 시 fix iter 1+ — fix 후 재실행. 3+ iter 시 spec 재검토.

---

## 7. Risks

### 7.1 silent fallback 위반 우려

§3.3 `_build_entity_traits_block` 에서 `(stable_traits or [])` 패턴 사용 —
이는 **silent fallback 이 아님** — entity_canon row 가 보장된 후의 nullable
column 처리. 그러나 visible_entities 안 ID 가 entity_canon 에 없으면
fail-fast (raise AppError). silent fallback 룰 (`feedback_no_silent_fallback.md`)
준수.

§3.5 visible_entities contract validator 도 fail-fast — silent skip 없음.

§3.2 prompt_service label routing — `else: ...` 분기는 generic fallback 이지만
contract 위반 신호는 아님 (LLM 이 임의 label 출력 가능). silent OK.

§4.1 `_validate_zoom_in_detail` 는 RO-5 후 **AppError fail-fast**. silent
demote 가 아닌 explicit raise — `feedback_no_silent_fallback.md` 답습. demote
가 운영상 필요하면 별도 helper `_attempt_zoom_demote_with_evidence` (manifest
3 필드 기록 후 demote) — G4.6 scope 외 follow-up.

§4.1 `shot_dependency_t2i_step.py:228` silent fallback 도 RO-7 로 제거.
1차 LLM 실패 시 retry 1회 → 2차 실패 시 `contract_status="llm_failed"` 마킹
+ step manifest 에 failed shot list 기록. silent skip 없음.

### 7.2 regex post-processing 의존 우려

§3.5 `_ENTITY_ID_PATTERN = re.compile(r"\b(C\d{2,3}(?:O\d{2,3})?)\b")` —
LLM 출력에서 entity ID 추출. 의미 기반이 아니라 형식 기반 (C## / C##O##
패턴은 deterministic). regex 사용 적정 (`feedback_no_regex_postprocessing.md`
는 의미 추출에 한정).

§3.1 protected kind keyword matching — substring 매칭은 LLM 의미 판단이 아닌
deterministic pattern matching. 사용자 binding "deterministic 보호 1차" 답습.

§3.4 shot_validator v4 의 type descriptor 매핑 — entity_canon.name substring
일치는 deterministic. LLM 의 추가 inference 는 prompt 안에서.

### 7.3 새 top-level field 도입 우려

5-field envelope (render_strategy / id_policy / background_binding /
continuity_elements_used / asset_requirements) 그대로 유지. shot_dependency_t2i
의 `focus_region` 은 dependency manifest 의 location_refs 안 sibling field
추가 (top-level 아님). shot_validator schema 의 `characters` 는 v3 에 이미
존재 (semantic 강화). 신규 sibling field `character_ids` (RO-1+RO-11) 추가는
schema-level addition 이지만 다운스트림 호환 보존 (RO-11 분리 정책). scene_detail
t2i_variations / RenderPromptCard 변경 없음.

사용자 binding 준수.

### 7.4 LLM "visually critical" 플래그 도입 우려

§3.1 보호는 모두 deterministic — `is_variant_self` (DB 쿼리) /
`_PROTECTED_KIND_KEYWORDS_*` (frozen set) / `required_by_scene_detail`
(manifest read). LLM 호출 0건. 사용자 binding "audit surface 확대 차단"
준수.

§3.4 shot_validator v4 의 type descriptor 매핑은 prompt 안 LLM 룰. 단 schema
변경은 단순 enforcement — `character_ids` non-empty primary gate (RO-26 +
RO-28). raw `characters` only (`character_ids=[]`) 은 partial — verification
fail (D3 root 재발 차단). LLM 추가 판단 없이 deterministic.

### 7.5 force regeneration 비용 / 시간

§6.4 production regeneration:

- ref_image_gen (3 base + 3 outlook ref): ~$2, 5-10분
- shot_dependency_t2i (전체): ~$1, 3-5분
- shot_validator (전체): ~$2, 5-10분
- scene_detail (전체): ~$3, 10-20분
- scene_image_pipeline (target 5 shot): ~$2, 5-10분

총: ~$10, 30-60분. 사용자 승인 필요.

부분 force (target 5 shot 만) 가능 시 비용/시간 절반 감소.

### 7.6 shot_validator characters 강제가 이전 PID 회귀 위험

기존 PID (canary 완료된 140 PNG) 의 shot_validator 출력은 v3 기준 — characters=[]
다수. v4 적용 + visible-human-action 룰이 fail-fast 면 기존 PID 의 force 재실행
이 막힘.

**완화**: characters=[] fail-fast 는 v4 prompt 내부 룰 — LLM 이 description
재작성 시도 후 매핑 실패 시에만 fail-fast. PID 별 force 재실행 시 v4 prompt
가 매핑 시도 → 대부분 매핑 성공 → fail-fast 회피. 매핑 실패 케이스는 진짜
character 없는 shot (배경만) 인데 description 이 마치 사람을 묘사한 것처럼
보이는 케이스 — fail-fast 가 정당.

backout: 회귀 발견 시 v4 prompt 의 fail-fast 룰을 `logger.warning` 으로 demote
가능 (Wave A Step 3 의 spec change 만, code change 없음).

---

## 8. Migration / Rollback

### 8.1 Migration

- Pre-Wave hotfix: `prompt_service.py` patch — 단일 file, no schema change.
  rollout: code deploy 후 즉시 effective.
- Wave A:
  - `reference_pipeline_orchestrator.py` patch — entity_canon / RelationFact
    schema 변경 없음. 새 PID 부터 효과.
  - 기존 PID 의 `ref_low_freq_skip.json` 파일은 그대로 — 새 force 재실행 시
    재계산 (보호 적용).
  - scene_detail v21 prompt — 새 dir, version_registry update. 기존 PID 의
    cached scene_detail 결과 cp 는 v20 기준이므로 force 재실행 시에만 v21
    효과.
  - shot_validator v4 prompt — 동일.
  - detail_steps.py traits block + visible_entities validator — 새 PID 부터.
- Wave B:
  - shot_dependency_t2i v6 + `_validate_zoom_in_detail` — 새 PID 부터.
  - scene_reference_service / prompt_service patch — 즉시 effective.

기존 PID `298d86d9` 는 §6.4 force 재실행 시 effective.

### 8.2 Rollback (각 Step 별)

- Pre-Wave hotfix: `git revert` — 5 line code change 단일 commit.
- Wave A:
  - reference_pipeline_orchestrator: revert + 새 PID 의 ref_low_freq_skip.json
    재계산 (이전 룰).
  - scene_detail v21 → v20: version_registry rollback. 기존 cached cp 영향
    없음.
  - shot_validator v4 → v3: version_registry rollback.
  - detail_steps.py: revert.
- Wave B:
  - shot_dependency_t2i v6 → v5: version_registry rollback.
  - scene_reference_service / prompt_service: revert.

각 rollback 후 회귀 테스트 + canary 회귀.

---

## 9. Glossary

- **D1-D4**: 사용자 보고 4 defect (S1_Shot3≈Shot4 / S1_Shot5 외국인 / S2_Shot4
  키스 / S8_Shot6 프린팅 배경).
- **RC-A ~ RC-I**: root cause cluster IDs. §2 mapping.
- **Pre-Wave hotfix**: prompt_service substring 버그 (RC-D) 의 사전 수정.
  Wave A/B 양쪽이 의존하므로 분리.
- **Wave A / Wave B**: 사용자 binding 분리 단위. Wave A 종료 후 Wave B.
- **focus_region**: shot_dependency_t2i v6 의 신규 schema field.
- **type descriptor**: "한국인 남자" / "여자 직원" / "40대 한국인 여성" 같은
  type 기반 entity canonical_name. 고유명사 (수리영) 와 대비.
- **visible-human-action shot**: shot description 이 신체 부위 + 동작 동사
  + character 표현 중 1 이상 포함.
- **violent contact**: 깨물기 / 찌르기 / 타격 / 짓누르기 카테고리. 폭력 freeze
  룰 적용 대상.
- **entity stable_traits**: entity_canon.stable_traits column. variant entity
  의 visually-distinguishing trait. scene_detail v21 Rule X carry 대상.
- **5-field envelope**: G4.2~G4.5a lift 결과의 RenderPromptCard 5 sibling
  field. G4.6 변경 없음.
- **G4.6a/b/...**: 본 spec 은 G4.6 통합. 후속 (e.g. LLM critical flag) 은
  G4.7 또는 별도.

### Focus region canonicalizer (RO-14 / M3)

`shot_dependency_t2i` v6 의 `previous_focus_region` / `current_focus_region`
값 비교 시 raw → canonical mapping:

| Raw expression (lower) | Canonical |
|---|---|
| `upper thigh`, `bare thigh`, `thigh`, `leg` | `thigh` |
| `face`, `face cu`, `facial expression`, `head` | `face` |
| `hand`, `palm`, `fingers`, `wrist`, `knuckle`, `fist` | `hand` |
| `eye`, `eyes`, `pupil`, `iris`, `gaze` | `eye` |
| `wound`, `cut`, `bite mark`, `injury`, `scar` | `wound` |
| `wide`, `establishing`, `full scene`, `wide shot`, `전경`, `전신` | `wide_scene` |
| `mouth`, `lip`, `lips`, `teeth` | `mouth` |
| `neck`, `throat` | `neck` |
| 그 외 free-text | raw 그대로 (lowercased) |

canonicalizer 미매칭 region 끼리는 equality 비교 — substring 비교 X. 신규
region 발견 시 spec carry 추가 (G4.6 follow-up 또는 G4.7).

---

## 10. Open Questions

1. ~~shot_validator characters 의 short_id vs raw text 혼용 정책~~ —
   **RO-1 + RO-11 로 해소**: `characters` (이름) + `character_ids` (short_id)
   분리. 하위 `shot_dependency_step.py:89` 의 `lookup_name(name_to_sid, name)`
   회로 그대로 유지. `character_ids` 는 visible_entities 합성 / scene_detail
   contract 검증에 사용.

2. **scene_detail v21 entity_traits block 위치** — RenderPromptCard 직후 vs
   직전 vs 별도 block. RenderPromptCard 가 primary contract 이므로 직후 inject
   가 자연. 단 v21 system.md 의 prose 가 block 위치를 명시해야 LLM 이 우선순위
   인식. Round 1 audit 에서 확정.

3. ~~shot_dependency_t2i focus_region enum vs free-text~~ — **RO-4 + RO-14
   로 해소**: enum 표 + free-text 둘 다 허용. canonicalizer (§9 Glossary)
   가 enum 구간 정규화 → equality. 미매칭 region 은 raw 그대로 — 정확 일치만.

4. **regeneration 시 chain_bg PNG 재렌더 여부** — §6.4 step 5/6 만 명시. chain_bg
   PNG 자체는 재렌더 불필요 (D4 fix 는 prompt_service.py 만) — 그러나 sanity
   check 로 1-2 group 만 재렌더 권장? 비용 trade-off.

5. **Pre-Wave hotfix 와 Wave A/B 분리 commit 전략** — Pre-Wave 는 단일 commit
   + 별도 push? 아니면 Wave A 첫 commit 에 fold? Pre-Wave 는 Wave A/B 양쪽
   의존이라 별도 push 가 안전. 사용자 confirm 필요.

6. **shot_validator v4 의 type descriptor 매핑이 LLM 처리 — deterministic
   regex 가능?** — type descriptor 와 entity_canon.name 의 substring 매칭은
   deterministic 가능. 그러나 LLM 의 context inference (어떤 entity 가 더
   적합한지 — 동명이인 처리) 가 필요한 경우 LLM 우선. Round 1 audit 에서
   prompt vs deterministic 비율 확정.

---

## 11. Carry from G4.5a

| O-ID | G4.5a Source | G4.6 적용 |
|---|---|---|
| O-1 (token gate) | G4.5a RO-1 | G4.6 은 prose 추가 spec — token gate 부재. report-only. |
| O-2 (compact section line count) | G4.5a RO-2 | scene_detail v21 / shot_validator v4 add line count gate Round 1 Override 에서 binding. |
| O-7 (Override numbering RO-N / O-N) | G4.5a RO-7 | 답습. 본 spec §0 Round 1 Override = RO-N, §11 carry = O-N. |
| O-31 (cross-card reference literal) | G4.5a RO-13 | 본 spec 변경 없음. continuity 룰 그대로. |
| 메타룰 (silent fallback BLOCKING / regex IMPORTANT / 새 top-level field BLOCKING) | G4.2~G4.5a carry | §0 메타룰 + §7 risks 모두 답습. |

---

본 spec 은 R1+R2 dual audit 를 위한 initial draft. spec drafting 종료 후
즉시 audit dispatch — Codex (R1) + Claude code-reviewer (R2). Round 1
Override 결정사항 §0 표에 row 추가 + 본문 inline 동기화 후 plan drafting
진입.
