# G4.1 RenderPromptCard Bootstrap Implementation Plan

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

**Goal:** `scene_detail` 직전에 deterministic helper `render_prompt_card.py` 가
선택된 shot 단위로 5-field `RenderPromptCard` (render_strategy / id_policy /
background_binding / continuity_elements_used / asset_requirements) 를 build,
canonical-JSON sha256[:16] hash 와 함께 cp 에 top-level field 로 저장하고
`_analyze_one()` user_prompt 의 첫 블록으로 inject. `verify_completion()` 과
`_user_edited` reuse path 가 hash drift 를 partial 로 마킹. **G3.1 evidence /
G3.2 owned sentinel / system.md 본문은 G4.1 에서 변경 0** (slimming 은 G4.2~G4.5
별도 plan).

**Architecture:** producer-side: scene_detail 직전 `_analyze_one()` 안에서 ctx +
seg + shot_info → `build_render_prompt_card(...)` → 5-field card + hash. card 는
LLM 응답 schema 외 (post-build 가 cp 에 top-level 삽입). consumer-side:
`verify_completion()` 이 cp 에서 card 로드 → shape 검증 → 현재 ctx 로 재계산 →
hash drift 검출. `_user_edited` reuse path 도 동일 drift 검사 → mismatch 면
fresh 재생성. G3.2 owned sentinel 은 variation-level (`t2i_variations[]`) 에서
유지 — G4.1 card hash 는 shot-level 로 공존. v16 prompt 디렉토리 신설하되 LLM
output schema (`detail_schema.json`) 는 v15 verbatim 복사 (Open Q4 = YES bump
이유는 cp shape 변경이지 LLM schema 변경 아님).

**Tech Stack:** Python 3 / FastAPI / pytest / JSON (canonical) / sha256 /
existing GPT-mini & Gemini Pro routing (`scene_detail` 자체는 변경 없음 —
helper 만 추가).

**Spec:** `docs/superpowers/specs/2026-05-04-g4-render-prompt-card-strategy.md`
(763 lines, R1+R2 patched + R3 body sync NEW 1/2/3 적용 완료).
- §3 AD-1 helper-first / AD-2 card-constraints first / AD-3 staged G4.1~G4.5 /
  AD-4 PNG regen on force / AD-5 new-PID default opt-in force.
- §4 RenderPromptCard contract (5 fields shape).
- §5.1 user_prompt shape (card → narrative → legacy).
- §6 G4.1 bootstrap tasks + modify-scope guard.
- §7 verification (unit / integration / prompt / canary).
- §10 Open Questions:
  - Q1 RESOLVED top-level (Task 16) — **R1-M1: spec §9 Decision Summary 참조**.
  - Q2 builder-derived in G4.1, future shot_selection.recommended_strategy
    upstream (Task 3, **R1-I1 적용 후 fallback metadata 표현 제거** —
    fail-fast / not_applicable 분기로 대체).
  - Q3 expected refs only — full AssetReadiness gate stays at image stage
    (Task 7).
  - Q4 SCHEMA_VERSION bump 6→7 — cp shape 변경 이유 (Task 11).
- §11 minimal first patch shape — G4.1 only.

**Out of scope:** G4.2 background-binding lift / G4.3 ID-policy lift / G4.4
continuity & render-strategy lift / G4.5 prompt slimming + heading count
enforcement. 모두 별도 plan + 별도 commit. G4.1 은 contract 만 도입 — system.md
대규모 deletion 금지 (한 줄 priority section append 만 허용).

---

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

**Round 1 Override (2026-05-04, BLOCKING 1 + IMPORTANT 11 + MINOR 1)** — 본 plan
의 기존 task 내용보다 **우선** 적용. spec Round 1 Override 표 (lines 14~49) 기반.
모순 시 Round 1 Override 따름.

| 항목 | task 영향 |
|---|---|
| **R1-B1.** AD-5 migration policy 현실 반영 | Task 11/12 (SCHEMA bump 6→7 / version_registry) — operator opt-in 표현 제거. "다음 resume 시 schema mismatch → 자동 v16 force escalate 가능" 명시. |
| **R1-I1.** render_strategy `source="fallback"` 제거 | Task 3 (build_render_strategy) — staging 누락 시 fail-fast (`AppError`) 또는 `mode="not_applicable"` 명시 분기. silent default 금지. test 갱신 — fallback test 삭제, fail-fast test 추가. |
| **R1-I2.** fixed_elements lossless adapter | Task 6 (build_continuity_elements_used) — producer field (character_name/applies_to_shots/creative_decisions/source_facts/visual_inferences/confidence) 그대로 보존. derived hint 가 필요하면 별도 `_card_metadata` 키만. drop/transform 금지. |
| **R1-I3.** canonical card hash list ordering | Task 8 (compute_card_hash) — `canonicalize_render_prompt_card()` helper 신설. unordered list (owned_objects, allowed_base_entity_ids, allowed_outlook_pairs, required_refs, forbidden_refs, fixed_elements) 모두 정렬. list-order regression test 추가. |
| **R1-I4.** card-wins precedence | Task 10 (v16 system.md priority section) — "card 가 primary contract, 충돌 시 card winner" 강화. legacy compatibility blocks 표현 약화. 신규 test — card-vs-legacy conflict 검증. |
| **R1-I5.** t2i_review post-edit sentinel stale | Task 20 (integration test) — 기존 "card preserved after edit" 변경. "t2i_review post-edit 후 owned_validation 이 stale → verify 가 partial 마킹" 검증. card hash 자체는 shot-level 이라 재계산 X. judge 재호출 비추천 (G3.2 결정). |
| **R1-I6.** scene_still_normalizer CP-only 범위 인정 | Task 20 (integration test) — normalizer preservation 요구 제거. card 는 G4.1 에서 CP-only debug 필드. DB persistence 는 별도 scope. |
| **R1-I7.** legacy forward_zoom truncation tracking | Task 6 (build_continuity_elements_used) — card 의 forward_zoom_targets / keep_elements 는 full list (절단 금지). Task 13 (input collection) — non-goal 명시: 기존 detail_steps.py:950, 965 의 [:6]/[:5] cap 제거 X (G4.4 lift 시 처리). 신규 regression test (Task 7 또는 Task 20) — forward_zoom_targets count > 6 + keep_elements count > 5 → card payload 모든 entry 보존 검증. |
| **R1-I8.** test coverage 보강 | Task 20 (integration) — previous_shot_refs (zoom_in_detail/continuation), forward_zoom full-list (R1-I7 연결), asset_requirements required/forbidden 분기, _user_edited card hash drift, v15→v16 migration smoke (resume → force escalate 동작), t2i_review stale-sentinel transition (R1-I5 연결) 추가. |
| **R1-I9.** risk register thresholds | (plan 본문에 risk register 별도 없음 — spec §8 가 source. plan 영향 없음. self-review 갱신 시 risk reference 최신 spec line 으로 update.) |
| **R1-I10.** phase boundary exit criteria | (plan 본문에 G4.2~G4.5 phase 없음 — G4.1 only scope. plan 영향 없음. spec §6 가 source. plan 의 "Out of scope" 섹션에서 G4.2~G4.5 entry criteria 가 spec §6 에 명시됨 reference 추가.) |
| **R1-I11.** (R1-M2 merged) `_card_metadata` debug field + hash exclusion | Task 8 (compute_card_hash) — envelope 에 optional `_card_metadata` 키 (debug-only) 가능. canonicalize 가 hash 입력에서 명시 제외. hash payload = 5 semantic field 만. |
| **R1-M1.** Q1 위치 정정 | (spec 만 영향. plan 본문 Q1 reference 가 있으면 "spec §9 Decision Summary 참조" 로 update.) |

위 13 항목은 task 본문보다 **우선 적용**. 모순 시 Round 1 Override 따름.

---

**Round 2 Override (2026-05-04, BLOCKING 4 + IMPORTANT 7 + MINOR 2)** — Codex
round 1 audit on plan 결과. 본 plan 의 Round 1 Override 보다 **우선** 적용.
spec Round 2 patch (R1-I11 sharpened + R1-I12 신규) 가 parallel 적용 중.

| 항목 | task 영향 |
|---|---|
| R2-B1. hash payload 정의 통합 (spec R1-I11 sharpened) | Task 8 (compute_card_hash) — payload = envelope minus (`_card_metadata`, `render_prompt_card_hash`). INCLUDES schema_version + shot_key + 5 semantic fields. self-hash test 변경 — `render_prompt_card_hash` 추가/변경 시 hash 불변 (excluded). _card_metadata 추가/변경 시 hash 불변. schema_version/shot_key 변경 시 hash 변동 검증. |
| R2-B2. `_VALID_RENDER_MODES` enum 에 `not_applicable` 추가 | Task 3 (build_render_strategy) + Task 9 (assert_card_shape) — enum 에 `RENDER_MODE_NOT_APPLICABLE = "not_applicable"` 추가. validator 수용. test — `mode="not_applicable"` valid pass + 임의 unknown mode reject. |
| R2-B3. `_analyze_one()` modify scope guard 확장 (helper 명시) | Task 14 (input collection) 재작성. modify scope = 5 point: (1) `_collect_card_inputs(...)` 신설 helper 호출 — 기존 legacy prompt 조립 직후, user_prompt 직전. (2) `build_render_prompt_card(inputs)` 호출. (3) `[RenderPromptCard v1]` user_prompt inject. (4) result CP fields (card + hash). (5) `_user_edited` 분기 card drift hook (Task 19 와 합병 가능). 기존 forward_zoom prompt loop / fixed_elements / previous_shot_refs / `is_close_framing` 변수 모두 그대로 — 새 helper 가 같은 변수 read-only 참조. |
| R2-B4. None vs [] fail-fast (spec R1-I12 신규) | Task 4 (build_id_policy) + Task 6 (build_continuity_elements_used) + Task 7 (build_asset_requirements) — producer field None 입력 시 `AppError(step.contract_violation, message="...")`. 명시적 `[]` 만 valid. `fixed_elements or []` 같은 silent absorb 패턴 제거. test — None 입력 raise + `[]` 입력 valid 분기 추가. |
| R2-I1. Task 9 strict shape — bool-as-int reject + 16-char lowercase hex 검증 | Task 9 (assert_card_shape) — `schema_version` `isinstance(sv, bool) or not isinstance(sv, int) or sv != CARD_SCHEMA_VERSION` reject. `render_prompt_card_hash` 가 envelope 의 sibling 이므로 envelope shape 검증 시 `re.fullmatch(r"[0-9a-f]{16}", value)` 검증 (G3.2 iter2 fix carry). test 추가. |
| R2-I2. (R2-B1 통합) | (위 R2-B1 처리됨 — payload 모순 해소 후 자동 해결) |
| R2-I3. card-vs-legacy conflict test 신설 | Task 20 (integration) — 신규 test class `TestCardWinsOverLegacyConflict` (R1-I4 cross-check). card field 와 legacy prompt block 이 충돌하는 fixture (예: card.id_policy 가 C##O## 강제 / legacy block 이 보통명사 권장) → LLM output 이 card 따르는지 검증 (또는 prompt 자체에 "card wins" 명시 확인). |
| R2-I4. `TestT2iReviewPostEditSentinelStale` verify partial 직접 assert | Task 20 — 기존 hash 비교만 하던 test 변경. t2i_review post-edit fixture 로 owned_validation 의 t2i_prompt_hash stale 만든 후 `verify_completion()` 직접 호출 → severity="partial" + missing_msgs 에 sentinel_drifted 포함 assert. |
| R2-I5. v15→v16 migration smoke 실제 StepRunner 호출 | Task 20 — completed v15 cp fixture (schema_version=6) 만든 후 `SceneDetailStep.run("resume")` 또는 StepRunner mismatch path 실제 호출. mode="force" escalate 동작 + v16 재실행 trigger 검증. |
| R2-I6. `_user_edited` card drift test 실제 reuse 거부 assert | Task 19 + Task 20 — card drift guard 를 `_user_edited_card_contract_violated()` 같은 helper 로 추출 → unit test 가능. integration test fixture 가 `_user_edited` branch fixture 로 fresh path 진입 assert (G3.1/G3.2 패턴 mirror). |
| R2-I7. `TestAssetRequirementsBranches` 3 분기 분리 | Task 20 — 기존 forbidden-only case 가 outlook required ref 포함 → 진정한 forbidden-only 아님. 분리 fixture: (a) required-only (outlook+bg required, no forbidden) / (b) forbidden-only (close framing skip, no outlook) / (c) mixed (required+forbidden 공존). |
| R2-M1. Task 3 test count 정정 | Task 3 expected: 11 PASS/FAIL (R1-I1 변경 후 추가). Phase 1 expected total: 25 (R3-M2 정정: Task 1 = 4 + Task 2 X + Task 3 = 11 + Task 4 = 10 = 25). |
| R2-M2. spec line count 갱신 | plan header (line 28-29) "spec 743 lines" 로 정정 (current). |

위 13 항목은 task 본문보다 **우선 적용**. R1 Override 보다도 우선. 모순 시 Round 2 Override 따름.

---

**Round 3 Override (2026-05-04, BLOCKING 2 + IMPORTANT 2 + MINOR 4)** — Codex
round 2 audit on plan 결과. 본 plan 의 Round 1+2 Override 보다 **우선** 적용.
spec Round 3 body sync (NEW 1/2/3) 가 parallel 적용 중.

| 항목 | task 영향 |
|---|---|
| R3-B1. v15→v16 migration smoke tautological pass 제거 | Task 20 (`test_step_runner_actually_escalates_to_force_on_v15_resume`) — `except ...: pass` + `or True` 제거. 실제 SceneDetailStep.run("resume") 호출 + step_run mode="force" transition assert. |
| R3-B2. `_make_ctx_for_shot(outlook_pairs=None)` `or default` 변환 제거 | Task 20 fixture (`_make_ctx_for_shot` helper) — None vs [] 명시 구분. `default if x is None else x` 또는 sentinel 패턴. forbidden-only fixture 의 `outlook_pairs=[]` 보존. |
| R3-I1. `verify_completion` partial assert 강화 | Task 20 (`test_verify_completion_marks_partial_on_sentinel_drift`) — substring 만 → exact `missing_msgs` literal + `sentinel_drifted` metadata tuple + severity literal assert. |
| R3-I2. `_user_edited` card drift integration 실제 branch 진입 | Task 20 (`TestUserEditedCardDrift`) — helper simulation → SceneDetailStep._execute() 직접 호출 + fresh path 진입 검증 (G3.1/G3.2 패턴). |
| R3-M1. Phase/Task 제목 5-point 일관 | Phase 5 / Task 15/16/17 — modify scope guard "4 modification points" → "5 modification points". |
| R3-M2. Phase 1 count 일관 | Task 1/2/3/4 합산 = override + body + self-review 모든 위치 일관 (= 25: 4+11+10, Task 2 X). |
| R3-M3. spec line count 갱신 | plan header — "spec 754 lines". |
| R3-M4. Phase 3 arithmetic 정정 | Phase 3 verification command + total — 실제 task count 합산 (50 builder + 19 shape + 3 entry + 19 hash = 91 PASS) 일관. Task 9 step 4 = 50 + 19 = 69 PASS. |

위 8 항목은 task 본문보다 **우선 적용**. R1/R2 Override 보다도 우선. 모순 시 Round 3 Override 따름.

---

**Commit 정책:** G3.1 / G3.2 패턴 계승 = Task 마지막 단계 dual review (Codex +
Claude) → fix loop → APPROVED 후 **단일 commit + push**. 각 Task 안의 commit
step 없음.

**Prompt timestamp:** `16.<YYYYMMDDHHMM>` — 본 plan 은 placeholder 로
`16.202605041200` 사용. 실제 구현 시점에 현재 KST 시각으로 일괄 치환 (모든
v16 prompt 파일 + version_registry + detail_steps.py 의 PROMPT_VERSION 동시).

**Critical constraints (cite in plan body, must obey verbatim):**
- **No silent fallback** (`feedback_no_silent_fallback.md`): card builder 의
  모든 missing-input branch 는 `AppError(code="step.contract_violation")` raise.
  default-to-empty 로 흘러가지 않음. card 의 `fixed_elements: []` 같은
  의도된 empty 는 **upstream producer 가 명시적으로 빈 list 를 반환한 case**
  만 허용 (ctx 자체가 None / 미존재 시는 raise).
- **No truncation** (CLAUDE.md): card JSON 직렬화에 `[:N]` slicing 금지. shot
  text / scene text / camera_direction / fixed_element description 등 LLM-bound
  data 는 항상 원문 전체.
- **Module-level `_CLOSE_FRAMING_RE` reuse** (R5-B4 carryover from G3.2): card
  builder 의 `framing_scale` 도출은 `from app.core.steps.detail_steps import
  _CLOSE_FRAMING_RE`. **NO local regex copy.** detail_steps.py:88 의 single
  source 만 사용.
- **Prompt versioning** (`feedback_prompt_versioning.md`): v15 디렉토리 NEVER
  overwrite. 새 `16.202605041200/` 디렉토리 생성 후 v15 verbatim copy + Card
  priority section append.
- **G3.2 sentinel preserve**: card field `background_binding.constraints` 는
  G3.2 owned object policy 를 LLM 에게 reminder 형태로 전달하지만, G3.2 의
  `t2i_variations[].owned_validation` sentinel + 4 hash 는 그대로 유지. card
  hash 와 G3.2 sentinel hash 는 **scope 분리** — card hash = shot-level upstream
  contract drift, G3.2 sentinel = variation-level prompt drift. 둘 다 cp 에
  공존 (spec §4.3 hash relationship table).
- **`_analyze_one()` modify scope guard** (spec §6 G4.1 task constraints,
  **R3-M1: 5-point**):
  G4.1 변경은 (a) input collection helper (`_collect_card_inputs(...)`),
  (b) `build_render_prompt_card()` call, (c) user_prompt 의 첫 블록 inject,
  (d) result dict 에 top-level `render_prompt_card` + `render_prompt_card_hash`
  두 field 추가, (e) `_user_edited` reuse path drift hook, **이 5 곳만**.
  기존 retry / `_check_prompts` / G3.1 evidence assert / G3.2 owned validator
  / outlook lookup / staging branch / visible/invisible loop 모두 변경 0.
- **Schema bump rationale (Open Q4)**: `SCENE_DETAIL_SCHEMA_VERSION 6→7` 은
  LLM output schema 변경 때문이 아니라 **cp shape 가 top-level 2 field 추가
  + verify_completion 의 contract 가 강화** 되었기 때문. `detail_schema.json`
  은 v15 verbatim copy.

---

## File Structure

### Create (6 files)

**Prompts (2 files):**
- `prompts/_base/scene_detail/16.202605041200/system.md` — v15 verbatim copy +
  최상단 새 section "## Read RenderPromptCard first" append (spec §5.2 section 2).
- `prompts/_base/scene_detail/16.202605041200/detail_schema.json` — v15 그대로
  복사 (LLM output schema 변경 0, Open Q4).

**Code (1 file):**
- `backend/app/core/steps/render_prompt_card.py` — 5 builder + canonical hash +
  shape validator + 1 entry point `build_render_prompt_card()`.

**Tests (3 files):**
- `backend/tests/unit/test_render_prompt_card.py` — 5 builder 단위 테스트 +
  shape validator + entry point.
- `backend/tests/unit/test_render_prompt_card_hash.py` — canonical JSON
  determinism + 4 drift 시나리오.
- `backend/tests/integration/test_g4_1_card_consumer_wiring.py` — close /
  non-close / bg-mode-off / multi-outlook / fixed_elements empty / card 단일
  block / hash drift / `_user_edited` reuse 거부 + verify_completion 시나리오.

### Modify (3 files)

- `backend/app/core/steps/detail_steps.py`
  - `SCENE_DETAIL_SCHEMA_VERSION 6 → 7`
  - `SCENE_DETAIL_PROMPT_VERSION "15.202605032354" → "16.202605041200"`
  - `_analyze_one()` 안 5-point modify (R3-M1: input collection helper /
    builder call / user_prompt inject / result top-level field 2 개 /
    `_user_edited` reuse path drift hook).
  - `verify_completion()` 에 card shape + hash drift 검사 추가.
  - `_user_edited` reuse path 에 card hash drift 검사 추가 (G3.1 evidence /
    G3.2 owned 패턴 carry).
- `backend/app/core/version_registry.py`
  - `scene_detail_composer "1.15.0" → "1.16.0"` (line 34)
  - `scene_detail_composer.prompt_dependency "scene_detail/v15" → "scene_detail/v16"`
    (line 126)
  - `updated_at` 갱신 (해당 entry).
- `backend/app/core/step_manifest.py`
  - `scene_detail.schema_version 6 → 7` (cp shape 변경 — Open Q4).
  - `depends_on` 변경 없음 (background_prompt 이미 G3.2 에서 추가됨).
  - `allow_partial_downstream=False` 변경 없음 (G3.2 에서 이미 False).

---

## Phase 1 — render_prompt_card.py Helper (skeleton + constants)

### Task 1: 모듈 스켈레톤 + 상수 + 7 NotImplementedError 시그니처

**Files:**
- Create: `backend/app/core/steps/render_prompt_card.py`

- [ ] **Step 1: helper 파일 생성 (상수 + 빈 시그니처)**

```python
# backend/app/core/steps/render_prompt_card.py
"""G4.1 RenderPromptCard helper — single source.

scene_detail _analyze_one() 직전에 호출 → 5-field deterministic card 빌드.
LLM 응답 schema 에는 들어가지 않음 (CP-only top-level field).

Card field shape (spec §4):
  - render_strategy        (mode / framing_scale / camera_direction / ...)
  - id_policy              (allowed_base_entity_ids / allowed_outlook_pairs / ...)
  - background_binding     (mode / bg_id / owned_objects / camera_reference / ...)
  - continuity_elements_used (fixed_elements / previous_shot_refs / forward_zoom_targets)
  - asset_requirements     (required_refs / forbidden_refs / readiness_policy)

Hash (spec §4 + spec §10 Q4):
  - render_prompt_card_hash = sha256(canonical_json(card_payload))[:16]
  - canonical = json.dumps(sort_keys=True, ensure_ascii=False,
                           separators=(",", ":"))
  - hash 는 card payload 전체. hash 자체는 hash payload 에 포함하지 않음.

G3.2 sentinel coexist (spec §4.3):
  - card hash = shot-level upstream contract drift
  - G3.2 owned sentinel = variation-level t2i_prompt drift
  - 둘 다 cp 에 공존 — 서로 대체하지 않음.
"""
from __future__ import annotations

import hashlib
import json
import logging
from typing import Any, Dict, List, Optional

from app.core.errors import AppError

logger = logging.getLogger(__name__)

CARD_SCHEMA_VERSION = 1

# render_strategy.mode enum (spec §4.1)
RENDER_MODE_DIRECT = "direct"
RENDER_MODE_SIMPLIFY = "simplify"
RENDER_MODE_PARTIAL_FOCUS = "partial_focus"
RENDER_MODE_REFRAME = "reframe"
RENDER_MODE_ANCHORED_REFERENCE = "anchored_reference"
RENDER_MODE_CLOSE_INSERT = "close_insert"
RENDER_MODE_NOT_APPLICABLE = "not_applicable"  # R2-B2: explicit not-applicable mode
_VALID_RENDER_MODES = (
    RENDER_MODE_DIRECT, RENDER_MODE_SIMPLIFY, RENDER_MODE_PARTIAL_FOCUS,
    RENDER_MODE_REFRAME, RENDER_MODE_ANCHORED_REFERENCE,
    RENDER_MODE_CLOSE_INSERT, RENDER_MODE_NOT_APPLICABLE,
)

# framing_scale enum (spec §4.1)
FRAMING_WIDE = "wide"
FRAMING_MEDIUM = "medium"
FRAMING_CLOSE = "close"
FRAMING_INSERT = "insert"
_VALID_FRAMING = (FRAMING_WIDE, FRAMING_MEDIUM, FRAMING_CLOSE, FRAMING_INSERT)

# background_binding.mode enum (spec §4.3)
BG_MODE_REF_ATTACHED = "background_ref_attached"
BG_MODE_SKIPPED_CLOSE = "skipped_close_framing"
BG_MODE_OFF = "background_mode_off"
BG_MODE_NOT_APPLICABLE = "not_applicable"
_VALID_BG_MODES = (
    BG_MODE_REF_ATTACHED, BG_MODE_SKIPPED_CLOSE, BG_MODE_OFF,
    BG_MODE_NOT_APPLICABLE,
)

# asset_requirements.readiness_policy enum (spec §4.5)
READINESS_BLOCK = "block_if_missing"
READINESS_SKIPPED = "skipped_by_policy"
READINESS_NA = "not_applicable"
_VALID_READINESS = (READINESS_BLOCK, READINESS_SKIPPED, READINESS_NA)


def build_empty_card(*, scene_index: int, shot_index: int) -> Dict[str, Any]:
    raise NotImplementedError


def build_render_strategy(
    *,
    seg: Dict[str, Any],
    shot_info: Dict[str, Any],
    staging: Optional[Dict[str, Any]],
    perception_mode: Optional[str],
) -> Dict[str, Any]:
    raise NotImplementedError


def build_id_policy(
    *,
    visible_entities: List[str],
    outlook_pairs: List[Dict[str, str]],
    perception_mode: Optional[str],
) -> Dict[str, Any]:
    raise NotImplementedError


def build_background_binding(
    *,
    bg_id: Optional[str],
    bg_owned: List[str],
    bg_camera_meta: Optional[Dict[str, Any]],
    bg_guide: Optional[str],
    is_close_framing: bool,
    background_mode_on: bool,
) -> Dict[str, Any]:
    raise NotImplementedError


def build_continuity_elements_used(
    *,
    fixed_elements: List[Dict[str, Any]],
    previous_shot_refs: List[Dict[str, Any]],
    forward_zoom_targets: List[Dict[str, Any]],
) -> Dict[str, Any]:
    raise NotImplementedError


def build_asset_requirements(
    *,
    visible_entities: List[str],
    outlook_pairs: List[Dict[str, str]],
    bg_id: Optional[str],
    is_close_framing: bool,
    background_mode_on: bool,
) -> Dict[str, Any]:
    raise NotImplementedError


def compute_card_hash(card: Dict[str, Any]) -> str:
    raise NotImplementedError


def assert_card_shape(card: Dict[str, Any], where: str = "") -> None:
    raise NotImplementedError


def build_render_prompt_card(
    *,
    scene_index: int,
    shot_index: int,
    seg: Dict[str, Any],
    shot_info: Dict[str, Any],
    visible_entities: List[str],
    outlook_pairs: List[Dict[str, str]],
    perception_mode: Optional[str],
    staging: Optional[Dict[str, Any]],
    bg_id: Optional[str],
    bg_owned: List[str],
    bg_camera_meta: Optional[Dict[str, Any]],
    bg_guide: Optional[str],
    is_close_framing: bool,
    background_mode_on: bool,
    fixed_elements: List[Dict[str, Any]],
    previous_shot_refs: List[Dict[str, Any]],
    forward_zoom_targets: List[Dict[str, Any]],
) -> Dict[str, Any]:
    """Single entry point — _analyze_one() 가 한 번 호출."""
    raise NotImplementedError
```

- [ ] **Step 2: import smoke**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -c "
from app.core.steps.render_prompt_card import (
    CARD_SCHEMA_VERSION, RENDER_MODE_DIRECT, FRAMING_CLOSE, BG_MODE_OFF,
    READINESS_BLOCK, build_empty_card, build_render_strategy, build_id_policy,
    build_background_binding, build_continuity_elements_used,
    build_asset_requirements, compute_card_hash, assert_card_shape,
    build_render_prompt_card,
)
print('OK')"
```
Expected: `OK`

### Task 2: `build_empty_card()` — envelope skeleton

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py`
- Create: `backend/tests/unit/test_render_prompt_card.py`

- [ ] **Step 1: 실패 테스트 작성**

```python
# backend/tests/unit/test_render_prompt_card.py
"""G4.1 RenderPromptCard builder unit tests."""
from __future__ import annotations

import pytest

from app.core.steps.render_prompt_card import (
    CARD_SCHEMA_VERSION,
    build_empty_card,
)


class TestBuildEmptyCard:
    def test_envelope_has_schema_version(self) -> None:
        c = build_empty_card(scene_index=12, shot_index=4)
        assert c["schema_version"] == CARD_SCHEMA_VERSION

    def test_envelope_has_shot_key(self) -> None:
        c = build_empty_card(scene_index=12, shot_index=4)
        assert c["shot_key"] == {"scene_index": 12, "shot_index": 4}

    def test_envelope_has_5_field_placeholders(self) -> None:
        c = build_empty_card(scene_index=1, shot_index=1)
        for f in (
            "render_strategy", "id_policy", "background_binding",
            "continuity_elements_used", "asset_requirements",
        ):
            assert f in c
            assert isinstance(c[f], dict)

    def test_no_extra_top_level_fields(self) -> None:
        # spec §4: 정확히 envelope (schema_version, shot_key) + 5 fields = 7 keys.
        c = build_empty_card(scene_index=1, shot_index=1)
        assert set(c.keys()) == {
            "schema_version", "shot_key", "render_strategy", "id_policy",
            "background_binding", "continuity_elements_used", "asset_requirements",
        }
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildEmptyCard -v
```
Expected: 4 FAIL with `NotImplementedError`.

- [ ] **Step 3: 구현**

```python
def build_empty_card(*, scene_index: int, shot_index: int) -> Dict[str, Any]:
    """Envelope + 5 빈 dict — 모든 builder 가 채울 ground state."""
    return {
        "schema_version": CARD_SCHEMA_VERSION,
        "shot_key": {"scene_index": int(scene_index), "shot_index": int(shot_index)},
        "render_strategy": {},
        "id_policy": {},
        "background_binding": {},
        "continuity_elements_used": {},
        "asset_requirements": {},
    }
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildEmptyCard -v
```
Expected: 4 PASS.

<!-- R1-I1 / R2-B2 / R2-M1: see Round 1 + Round 2 Override -->
### Task 3: `build_render_strategy()` — mode / framing_scale / camera_direction

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py`
- Modify: `backend/tests/unit/test_render_prompt_card.py`

빌더 책임 (spec §4.1, **R1-I1 + R2-B2 적용 후**):
- `mode` 항상 존재. shot_info / staging 에서 명시적 strategy hint 가 없으면
  `direct`. shot_selection.recommended_strategy 가 schema 에 있으면 (Q2 future)
  그게 우선 — 현재는 builder-derived only.
- `framing_scale` 은 `_CLOSE_FRAMING_RE` 검출 결과로 도출. close 면 `close`,
  insert hint (`insert`, `cutaway`) 면 `insert`, 그 외 `medium` (default).
- `camera_direction` 과 `lighting_mood` 는 staging 의 raw 값을 그대로.
- **R1-I1**: staging 누락 시 분기 두 가지 — (a) shot 이 staging 필수인데
  부재 → `AppError(code="step.contract_violation")` raise (silent fallback
  금지, `feedback_no_silent_fallback.md`). (b) staging 이 의도적 not
  applicable → `mode="not_applicable"` 명시. **`source="fallback"` 표현
  전체 삭제** (silent fallback 정책 위반).
- **R2-B2 (enum 확장)**: `_VALID_RENDER_MODES` 에 `RENDER_MODE_NOT_APPLICABLE
  = "not_applicable"` 추가됨 (Task 1 모듈 상수). `mode="not_applicable"`
  은 validator 가 valid 로 수용 (Task 9 enum check). 임의 unknown mode 는
  reject.
- `perception_mode` 는 caller 가 도출해서 넘김 (None 이면 `direct`).

- [ ] **Step 1: 실패 테스트 작성**

```python
# 추가 — backend/tests/unit/test_render_prompt_card.py
from app.core.steps.render_prompt_card import (
    RENDER_MODE_DIRECT, FRAMING_WIDE, FRAMING_MEDIUM, FRAMING_CLOSE,
    FRAMING_INSERT, build_render_strategy,
)


class TestBuildRenderStrategy:
    def test_default_mode_direct(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "wide shot of room"},
            staging={"camera_direction": "wide shot of room",
                     "lighting_mood": "warm dim"},
            perception_mode=None,
        )
        assert s["mode"] == RENDER_MODE_DIRECT
        assert s["framing_scale"] == FRAMING_MEDIUM  # wide shot 은 medium fallback

    def test_close_framing_detected(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "extreme close-up of hand"},
            staging={"camera_direction": "extreme close-up of hand",
                     "lighting_mood": "harsh"},
            perception_mode=None,
        )
        assert s["framing_scale"] == FRAMING_CLOSE

    def test_korean_close_framing_detected(self) -> None:
        # R5-B4 carryover: _CLOSE_FRAMING_RE 가 한국어 "클로즈업" 도 잡음.
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "손가락이 가득 찬 클로즈업"},
            staging={"camera_direction": "손가락이 가득 찬 클로즈업",
                     "lighting_mood": "어두운"},
            perception_mode=None,
        )
        assert s["framing_scale"] == FRAMING_CLOSE

    def test_insert_keyword_detected(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "insert shot of clock face"},
            staging={"camera_direction": "insert shot of clock face",
                     "lighting_mood": "neutral"},
            perception_mode=None,
        )
        assert s["framing_scale"] == FRAMING_INSERT

    def test_staging_required_but_missing_raises(self) -> None:
        # R1-I1: silent fallback 금지. shot 자체에 staging 필수인데
        # 누락 → fail-fast (AppError step.contract_violation).
        # camera_direction 도 비어 있으면 staging 필수 case 로 간주.
        from app.core.errors import AppError
        with pytest.raises(AppError) as ei:
            build_render_strategy(
                seg={}, shot_info={"camera_direction": ""},
                staging=None, perception_mode=None,
            )
        assert ei.value.code == "step.contract_violation"

    def test_staging_explicit_not_applicable_returns_mode_na(self) -> None:
        # R1-I1: staging 이 의도적 not applicable → mode="not_applicable".
        # caller (e.g. dispatcher analysis-only path) 가 명시적으로
        # not_applicable hint 를 shot_info 에 박아 보냄.
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "",
                               "staging_not_applicable": True},
            staging=None, perception_mode=None,
        )
        assert s["mode"] == "not_applicable"
        assert s["camera_direction"] == ""
        assert s["lighting_mood"] == ""
        # source="fallback" 표현 제거 (R1-I1).
        assert "source" not in s or s.get("source") != "fallback"

    def test_perception_mode_propagated(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "medium shot"},
            staging={"camera_direction": "medium shot",
                     "lighting_mood": "warm"},
            perception_mode="memory",
        )
        assert s["perception_mode"] == "memory"

    def test_perception_mode_default_direct_when_none(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "wide"},
            staging={"camera_direction": "wide", "lighting_mood": "warm"},
            perception_mode=None,
        )
        assert s["perception_mode"] == "direct"

    def test_constraints_present_non_empty(self) -> None:
        # spec §4.1: constraints 항상 존재 + 1+ entries.
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "wide"},
            staging={"camera_direction": "wide", "lighting_mood": "warm"},
            perception_mode=None,
        )
        assert isinstance(s["constraints"], list)
        assert len(s["constraints"]) >= 1

    def test_moment_lock_present(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "wide"},
            staging={"camera_direction": "wide", "lighting_mood": "warm"},
            perception_mode=None,
        )
        assert "moment_lock" in s
        assert "single still" in s["moment_lock"].lower() or len(s["moment_lock"]) > 0

    def test_primary_subject_from_shot_info(self) -> None:
        s = build_render_strategy(
            seg={}, shot_info={"camera_direction": "wide",
                               "primary_subject": "the observer at the doorway"},
            staging={"camera_direction": "wide", "lighting_mood": "warm"},
            perception_mode=None,
        )
        assert s["primary_subject"] == "the observer at the doorway"

    def test_not_applicable_mode_is_valid_enum_member(self) -> None:
        # R2-B2: `not_applicable` 이 _VALID_RENDER_MODES 에 들어가 있어야
        # validator (Task 9) 가 receive 가능. 본 unit test 는 enum 멤버십만
        # 검증 — validator 통합은 Task 9 에서.
        from app.core.steps.render_prompt_card import (
            _VALID_RENDER_MODES, RENDER_MODE_NOT_APPLICABLE,
        )
        assert RENDER_MODE_NOT_APPLICABLE == "not_applicable"
        assert RENDER_MODE_NOT_APPLICABLE in _VALID_RENDER_MODES
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildRenderStrategy -v
```
Expected: 11 FAIL (R2-M1: 10 → 11 with new enum membership test).

- [ ] **Step 3: 구현 — `_CLOSE_FRAMING_RE` reuse 필수**

```python
# render_prompt_card.py 안 — Task 3 구현 진입.
# Critical constraint: detail_steps.py:88 의 module-level _CLOSE_FRAMING_RE 만
# import. 절대 local copy 만들지 마라 (R5-B4 carryover from G3.2).

_INSERT_HINT_RE = None  # 지연 컴파일 — Task 5 도 같은 패턴 사용 가능.


def _get_insert_hint_re():
    global _INSERT_HINT_RE
    if _INSERT_HINT_RE is None:
        import re
        # 영어 + 한국어 — insert / cutaway / 인서트.
        _INSERT_HINT_RE = re.compile(
            r"\b(insert|cutaway|cut-?in)\b|인서트|컷어웨이",
            re.IGNORECASE,
        )
    return _INSERT_HINT_RE


def _derive_framing_scale(camera_direction: str) -> str:
    """detail_steps._CLOSE_FRAMING_RE 만 재사용 — local regex 금지."""
    from app.core.steps.detail_steps import _CLOSE_FRAMING_RE
    cd = camera_direction or ""
    if _CLOSE_FRAMING_RE.search(cd):
        return FRAMING_CLOSE
    if _get_insert_hint_re().search(cd):
        return FRAMING_INSERT
    # default = medium. wide / long shot 도 conservative 하게 medium 으로
    # 처리 (Q2 future shot_selection 이 명시적 wide/long 을 줄 때까지).
    return FRAMING_MEDIUM


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

    Q2 (spec §10): mode 는 builder-derived (direct default). 미래에
    shot_selection.recommended_strategy schema 가 생기면 그쪽이 우선.

    R1-I1 (no silent fallback): staging 부재는 두 분기 — (a) shot 이
    staging 필수인데 부재 → AppError (silent fallback 금지). (b) shot_info
    가 명시적으로 `staging_not_applicable=True` → mode='not_applicable'.
    `source="fallback"` 표현 전체 삭제.
    """
    shot_cam = (shot_info or {}).get("camera_direction") or ""
    staging_cam = (staging or {}).get("camera_direction") or ""
    cam_dir = staging_cam or shot_cam
    lighting = (staging or {}).get("lighting_mood") or ""
    pm = perception_mode or RENDER_MODE_DIRECT
    primary = (shot_info or {}).get("primary_subject") or ""

    # R1-I1: staging 부재 분기.
    if staging is None:
        if (shot_info or {}).get("staging_not_applicable") is True:
            # 의도적 not applicable case.
            return {
                "mode": "not_applicable",
                "primary_subject": primary,
                "framing_scale": _derive_framing_scale(shot_cam),
                "moment_lock": "single still moment from the selected shot only",
                "camera_direction": "",
                "lighting_mood": "",
                "perception_mode": pm,
                "constraints": [
                    "one prompt captures one still moment",
                    "do not combine full-body framing and body-part "
                    "close-up in one frame",
                ],
            }
        # silent fallback 금지 — shot 이 staging 필수인데 부재.
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_render_strategy: staging is required but missing "
                f"(scene={(shot_info or {}).get('scene_index')}, "
                f"shot={(shot_info or {}).get('shot_index')}). "
                "Set shot_info['staging_not_applicable']=True if intentional, "
                "otherwise upstream staging must populate."
            ),
        )

    framing = _derive_framing_scale(cam_dir)
    return {
        "mode": RENDER_MODE_DIRECT,
        "primary_subject": primary,
        "framing_scale": framing,
        "moment_lock": "single still moment from the selected shot only",
        "camera_direction": cam_dir,
        "lighting_mood": lighting,
        "perception_mode": pm,
        "constraints": [
            "one prompt captures one still moment",
            "do not combine full-body framing and body-part close-up in one frame",
        ],
    }
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildRenderStrategy -v
```
Expected: 11 PASS (R2-M1: 10 base + 1 enum membership).

<!-- R2-B4: see Round 2 Override -->
### Task 4: `build_id_policy()` — allowed entities + outlook pairs + common-noun policy

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py`
- Modify: `backend/tests/unit/test_render_prompt_card.py`

빌더 책임 (spec §4.2, **R2-B4 적용 후**):
- `allowed_base_entity_ids` = visible_entities (C##/L##/P## 등 short_id list).
- `allowed_outlook_pairs` = outlook_pairs ({character_id, outlook_id} list).
- `must_use_composite_character_ids` = 항상 True (C##O## composite 의무).
- `common_noun_required_when` = static list (body-part close, photo/screen
  reflection 등) — 시나리오 의존성 0.
- `demographic_fallback_required` = True (등록 안 된 extra 의 fallback).
- `constraints` = 3+ 항목 (visible+outfit composite / 화면 속 reproduced face /
  body-part isolated 금지).
- **R2-B4 (None vs [] fail-fast, spec R1-I12 신규)**: `visible_entities` /
  `outlook_pairs` 둘 다 `None` 입력 시 `AppError(code="step.contract_violation",
  message="...")` raise. 명시적 빈 list `[]` 만 valid (upstream 이 의도적
  empty 반환). `visible_entities or []` 같은 silent absorb 패턴 제거 —
  caller 가 list 자체를 안 넘기면 contract violation.

- [ ] **Step 1: 실패 테스트 작성**

```python
from app.core.steps.render_prompt_card import build_id_policy


class TestBuildIdPolicy:
    def test_allowed_entities_propagated(self) -> None:
        p = build_id_policy(
            visible_entities=["C01", "C02", "L01", "P03"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            perception_mode=None,
        )
        assert p["allowed_base_entity_ids"] == ["C01", "C02", "L01", "P03"]

    def test_allowed_outlook_pairs_propagated(self) -> None:
        pairs = [
            {"character_id": "C01", "outlook_id": "O02"},
            {"character_id": "C02", "outlook_id": "O01"},
        ]
        p = build_id_policy(
            visible_entities=["C01", "C02"],
            outlook_pairs=pairs, perception_mode=None,
        )
        assert p["allowed_outlook_pairs"] == pairs

    def test_composite_id_always_true(self) -> None:
        p = build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
        )
        assert p["must_use_composite_character_ids"] is True

    def test_common_noun_policy_static(self) -> None:
        p = build_id_policy(
            visible_entities=["C01"], outlook_pairs=[], perception_mode=None,
        )
        items = p["common_noun_required_when"]
        assert isinstance(items, list)
        assert any("body-part" in it.lower() for it in items)
        assert any("photo" in it.lower() or "screen" in it.lower()
                   or "mirror" in it.lower() for it in items)

    def test_demographic_fallback_true(self) -> None:
        p = build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
        )
        assert p["demographic_fallback_required"] is True

    def test_constraints_present(self) -> None:
        p = build_id_policy(
            visible_entities=["C01"], outlook_pairs=[], perception_mode=None,
        )
        assert isinstance(p["constraints"], list)
        assert len(p["constraints"]) >= 3

    def test_perception_mode_reflection_adds_constraint(self) -> None:
        # perception_mode reflection / mirror / screen → 추가 reminder 가
        # constraints 에 들어감.
        p = build_id_policy(
            visible_entities=["C01"], outlook_pairs=[],
            perception_mode="reflection",
        )
        joined = " ".join(p["constraints"])
        assert "reflection" in joined.lower() or "mirror" in joined.lower()

    def test_empty_inputs_still_valid(self) -> None:
        # R2-B4: 명시적 빈 list 만 valid (upstream 의 의도적 empty).
        # None 은 contract violation (별도 test).
        p = build_id_policy(
            visible_entities=[], outlook_pairs=[], perception_mode=None,
        )
        assert p["allowed_base_entity_ids"] == []
        assert p["allowed_outlook_pairs"] == []

    def test_none_visible_entities_raises(self) -> None:
        # R2-B4 (spec R1-I12): None 은 producer missing → AppError.
        from app.core.errors import AppError
        with pytest.raises(AppError) as ei:
            build_id_policy(
                visible_entities=None,  # type: ignore[arg-type]
                outlook_pairs=[],
                perception_mode=None,
            )
        assert ei.value.code == "step.contract_violation"
        assert "visible_entities" in ei.value.message

    def test_none_outlook_pairs_raises(self) -> None:
        # R2-B4 (spec R1-I12): None 은 producer missing → AppError.
        from app.core.errors import AppError
        with pytest.raises(AppError) as ei:
            build_id_policy(
                visible_entities=[],
                outlook_pairs=None,  # type: ignore[arg-type]
                perception_mode=None,
            )
        assert ei.value.code == "step.contract_violation"
        assert "outlook_pairs" in ei.value.message
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildIdPolicy -v
```
Expected: 10 FAIL (8 base + R2-B4 None reject 2).

- [ ] **Step 3: 구현**

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

    Static policy (시나리오 의존성 0). 본문 보통명사/composite ID 룰은 G4.3 에서
    system.md 에서 lift 후 여기 constraints 가 전체 source 가 됨.

    R2-B4 (spec R1-I12): visible_entities / outlook_pairs 가 None 이면
    producer missing 으로 간주 → AppError. 명시적 [] 만 valid.
    `or []` silent absorb 패턴 제거.
    """
    # R2-B4: None vs [] fail-fast. silent fallback 금지
    # (`feedback_no_silent_fallback.md`).
    if visible_entities is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_id_policy: visible_entities is None — upstream producer "
                "did not populate. Use explicit [] for intentionally empty."
            ),
        )
    if outlook_pairs is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_id_policy: outlook_pairs is None — upstream producer "
                "did not populate. Use explicit [] for intentionally empty."
            ),
        )
    constraints = [
        "visible real people with outfit binding use C##O## composite IDs",
        "do not use C##O## for a reproduced face inside a screen, photo, "
        "poster, mirror, or reflection — use a common noun instead",
        "do not use C##O## for isolated body-part focus — use a common noun "
        "(hand / eye / lip / etc.)",
    ]
    if (perception_mode or "").lower() in (
        "reflection", "mirror", "through_device", "projection",
    ):
        constraints.append(
            "this shot's perception_mode involves a reflective/projected surface — "
            "all reproduced faces use common nouns even if the source identity is known"
        )
    return {
        "allowed_base_entity_ids": list(visible_entities),  # R2-B4: no `or []`
        "allowed_outlook_pairs": list(outlook_pairs),       # R2-B4: no `or []`
        "must_use_composite_character_ids": True,
        "common_noun_required_when": [
            "body-part close-up",
            "photo/poster/screen/mirror/reflection/projection depiction",
            "unregistered extra",
        ],
        "demographic_fallback_required": True,
        "constraints": constraints,
    }
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildIdPolicy -v
```
Expected: 10 PASS (8 base + R2-B4 None reject 2).

---

**Phase 1 verification:**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py -x -q
```
Expected: 25 PASS (4 empty + 11 strategy + 10 id_policy) — R2-M1 (Task 3 10→11)
+ R2-B4 (Task 4 8→10).

---

## Phase 2 — background_binding + continuity + asset_requirements (G3.2 carryover)

### Task 5: `build_background_binding()` — owned + camera_meta + close skip

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py`
- Modify: `backend/tests/unit/test_render_prompt_card.py`

빌더 책임 (spec §4.3):
- `mode`:
  - bg-mode-off → `background_mode_off` + bg_id None + owned [] + camera None.
  - close framing → `skipped_close_framing` + bg_id 그대로 (참조용) + owned []
    (close 는 owned judge skip — G3.2 R5-B4 carryover) + camera None.
  - bg_id 존재 + non-close → `background_ref_attached` + 모든 G3.2 데이터.
  - bg_id 없음 (실외 / chain skip) → `not_applicable`.
- G3.2 sentinel 과 **분리** — card hash 가 owned list 변경 잡고, G3.2 sentinel 이
  variation prompt 변경 잡음. 둘 다 cp 에 보존 (spec §4.3 hash table).

- [ ] **Step 1: 실패 테스트 작성**

```python
from app.core.steps.render_prompt_card import (
    BG_MODE_REF_ATTACHED, BG_MODE_SKIPPED_CLOSE, BG_MODE_OFF,
    BG_MODE_NOT_APPLICABLE, build_background_binding,
)


class TestBuildBackgroundBinding:
    def test_bg_mode_off_short_circuits(self) -> None:
        b = build_background_binding(
            bg_id="cb_main_room", bg_owned=["door"],
            bg_camera_meta={"camera_position": "x"}, bg_guide="g",
            is_close_framing=False, background_mode_on=False,
        )
        assert b["mode"] == BG_MODE_OFF
        assert b.get("bg_id") is None
        assert b["owned_objects"] == []
        assert b.get("camera_reference") is None
        assert b["close_framing_skips_background_ref"] is False

    def test_close_framing_skips_owned_and_camera(self) -> None:
        # G3.2 R5-B4: close 는 owned / camera / guide 3 종 skip.
        b = build_background_binding(
            bg_id="cb_main_room", bg_owned=["door", "window"],
            bg_camera_meta={"camera_position": "wide"}, bg_guide="g",
            is_close_framing=True, background_mode_on=True,
        )
        assert b["mode"] == BG_MODE_SKIPPED_CLOSE
        assert b["bg_id"] == "cb_main_room"  # 참조용 보존
        assert b["owned_objects"] == []  # G3.2 close skip
        assert b.get("camera_reference") is None
        assert b["close_framing_skips_background_ref"] is True
        # constraints 에 close 모드 reminder 포함.
        joined = " ".join(b["constraints"])
        assert "skipped_close_framing" in joined or "close" in joined.lower()

    def test_non_close_with_bg_includes_owned(self) -> None:
        b = build_background_binding(
            bg_id="cb_main_room", bg_owned=["door", "window", "TV"],
            bg_camera_meta={"camera_position": "southeast doorway",
                            "camera_height": "eye-level standing",
                            "lens_hint": "35mm", "framing_notes": "TV at edge"},
            bg_guide="The room from the southeast doorway angle.",
            is_close_framing=False, background_mode_on=True,
        )
        assert b["mode"] == BG_MODE_REF_ATTACHED
        assert b["bg_id"] == "cb_main_room"
        assert b["owned_objects"] == ["door", "window", "TV"]
        assert b["camera_reference"]["camera_position"] == "southeast doorway"
        assert b["close_framing_skips_background_ref"] is False
        assert b["reference_usage"] == "exact_background"

    def test_no_bg_id_returns_not_applicable(self) -> None:
        b = build_background_binding(
            bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
            is_close_framing=False, background_mode_on=True,
        )
        assert b["mode"] == BG_MODE_NOT_APPLICABLE
        assert b.get("bg_id") is None
        assert b["owned_objects"] == []

    def test_owned_list_not_mutated(self) -> None:
        # builder 가 input list 를 in-place 수정하지 않음 (defensive copy).
        owned_in = ["door", "window"]
        build_background_binding(
            bg_id="bg1", bg_owned=owned_in, bg_camera_meta={},
            bg_guide=None, is_close_framing=False, background_mode_on=True,
        )
        assert owned_in == ["door", "window"]  # 변경 0

    def test_constraints_always_present(self) -> None:
        for kwargs in [
            dict(bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
                 is_close_framing=False, background_mode_on=False),
            dict(bg_id="bg1", bg_owned=["x"], bg_camera_meta={"camera_position": "p"},
                 bg_guide="g", is_close_framing=False, background_mode_on=True),
            dict(bg_id="bg1", bg_owned=["x"], bg_camera_meta={"camera_position": "p"},
                 bg_guide="g", is_close_framing=True, background_mode_on=True),
        ]:
            b = build_background_binding(**kwargs)
            assert isinstance(b["constraints"], list)
            assert len(b["constraints"]) >= 1

    def test_owned_objects_no_mention_constraint_when_owned_present(self) -> None:
        # G3.2 핵심 reminder: owned 가 있으면 "do not create new" constraint.
        b = build_background_binding(
            bg_id="bg1", bg_owned=["door", "TV"],
            bg_camera_meta={"camera_position": "wide"},
            bg_guide="g", is_close_framing=False, background_mode_on=True,
        )
        joined = " ".join(b["constraints"])
        assert "owned" in joined.lower() or "do not create" in joined.lower()
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildBackgroundBinding -v
```
Expected: 7 FAIL.

- [ ] **Step 3: 구현**

```python
def build_background_binding(
    *,
    bg_id: Optional[str],
    bg_owned: List[str],
    bg_camera_meta: Optional[Dict[str, Any]],
    bg_guide: Optional[str],
    is_close_framing: bool,
    background_mode_on: bool,
) -> Dict[str, Any]:
    """spec §4.3 — background_binding field.

    G3.2 R5-B4 carryover: close framing 은 owned / camera_meta / guide 3 종
    모두 skip. is_close_framing 판정은 caller 책임 (이미 _CLOSE_FRAMING_RE 로
    도출됨).

    Hash relationship (spec §4.3): card hash 가 owned list 변경 잡고, G3.2
    sentinel hash (variation-level) 가 t2i_prompt 변경 잡음. 둘은 분리된
    scope — card 가 G3.2 sentinel 을 대체하지 않음.
    """
    if not background_mode_on:
        return {
            "mode": BG_MODE_OFF,
            "bg_id": None,
            "reference_usage": "none",
            "owned_objects": [],
            "camera_reference": None,
            "close_framing_skips_background_ref": False,
            "constraints": [
                "background_mode is off — no chain_bg reference is attached "
                "and no owned-object preservation is required",
            ],
        }
    if is_close_framing:
        return {
            "mode": BG_MODE_SKIPPED_CLOSE,
            "bg_id": bg_id,  # 참조용 보존 (debug)
            "reference_usage": "skipped_close_framing",
            "owned_objects": [],  # G3.2 R5-B4 close skip
            "camera_reference": None,
            "close_framing_skips_background_ref": True,
            "constraints": [
                "this shot is close framing — do not mention the reference "
                "image, the existing room, or any chain_bg owned object",
                "the background ref is intentionally skipped (mode = "
                "skipped_close_framing); compose the close subject without "
                "anchoring to the wider room",
            ],
        }
    if not bg_id:
        return {
            "mode": BG_MODE_NOT_APPLICABLE,
            "bg_id": None,
            "reference_usage": "none",
            "owned_objects": [],
            "camera_reference": None,
            "close_framing_skips_background_ref": False,
            "constraints": [
                "no chain_bg is bound to this shot — describe the location "
                "freely without referencing a background image",
            ],
        }
    cam_ref = None
    if bg_camera_meta:
        cam_ref = {
            "camera_position": bg_camera_meta.get("camera_position", ""),
            "camera_height": bg_camera_meta.get("camera_height", ""),
            "lens_hint": bg_camera_meta.get("lens_hint", ""),
            "framing_notes": bg_camera_meta.get("framing_notes", ""),
        }
    constraints: List[str] = []
    if bg_owned:
        constraints.append(
            "do not create new objects in the owned_objects list — they are "
            "already drawn in the background reference"
        )
        constraints.append(
            "if focusing on an owned object, anchor it as 'from the reference' "
            "(e.g., 'the door from the reference')"
        )
    if cam_ref:
        constraints.append(
            "match the reference image's camera_position and framing — do not "
            "rotate, change height, or zoom independently"
        )
    return {
        "mode": BG_MODE_REF_ATTACHED,
        "bg_id": bg_id,
        "reference_usage": "exact_background",
        "owned_objects": list(bg_owned or []),  # defensive copy
        "camera_reference": cam_ref,
        "close_framing_skips_background_ref": False,
        "constraints": constraints,
    }
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildBackgroundBinding -v
```
Expected: 7 PASS.

<!-- R1-I2 / R1-I7 / R2-B4: see Round 1 + Round 2 Override -->
### Task 6: `build_continuity_elements_used()` — fixed / previous / forward zoom

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py`
- Modify: `backend/tests/unit/test_render_prompt_card.py`

빌더 책임 (spec §4.4, **R1-I2 + R1-I7 적용 후**):
- **R1-I2 (lossless adapter)**: `fixed_elements` 는 scene_consistency
  producer 의 element list 그대로 — `prompts/_base/scene_consistency/6.../schema.json`
  의 9 required field (`element_id`, `element_type`, `character_name`,
  `description`, `applies_to_shots`, `source_facts`, `visual_inferences`,
  `creative_decisions`, `confidence`) 모두 보존. drop / transform / rename
  금지. derived hint 가 필요하면 별도 `_card_metadata` 키로만 추가.
  consumer-safe guard (partial / violation status) 는 caller
  (scene_context_loader) 가 이미 처리.
- `previous_shot_refs` = ctx.dependencies 에서 shot_dependency_t2i 결과.
- `forward_zoom_targets` = `_compute_forward_zoom_targets()` 결과.
- **R1-I7 (no truncation, full list)**: card 의 `forward_zoom_targets` 와
  각 entry 의 `keep_elements` 는 **full list**. card builder 가 절단 금지.
  legacy `detail_steps.py:950, 965` 의 `[:6]` / `[:5]` cap 은 **G4.1
  non-goal** (G4.4 lift 시 제거). card payload 는 모든 entry 보존.
- 모든 list 는 빈 list valid (spec §4.4 empty policy — empty != fallback,
  upstream 이 명시적으로 empty 반환 시 그대로).
- **R2-B4 (None vs [] fail-fast, spec R1-I12 신규)**: `fixed_elements` /
  `previous_shot_refs` / `forward_zoom_targets` 셋 다 `None` 입력 시
  `AppError(code="step.contract_violation")` raise. 명시적 `[]` 만 valid.
  `fixed_elements or []` 같은 silent absorb 패턴 제거 — caller 가 list 자체를
  안 넘기면 contract violation. (test 추가: 각 field None 입력 raise 검증.)
- `constraints` = 2+ 항목 (fixed 는 input not source / zoom_in_detail moment lock).

- [ ] **Step 1: 실패 테스트 작성**

```python
from app.core.steps.render_prompt_card import build_continuity_elements_used


class TestBuildContinuityElementsUsed:
    def test_full_continuity(self) -> None:
        fe = [{
            "element_id": "body_full_pose", "element_type": "character_state",
            "bound_entity_hint": "C02",
            "description": "an East Asian woman lying motionless on her left side",
            "source_facts": [], "visual_inferences": [], "confidence": "high",
        }]
        psr = [{"scene_index": 12, "shot_index": 3,
                "ref_usage": "zoom_in_detail", "keep_elements": []}]
        fzt = [{"scene_index": 12, "shot_index": 5,
                "description": "follow-up close-up target must be visible",
                "keep_elements": []}]
        c = build_continuity_elements_used(
            fixed_elements=fe, previous_shot_refs=psr,
            forward_zoom_targets=fzt,
        )
        assert c["fixed_elements"] == fe
        assert c["previous_shot_refs"] == psr
        assert c["forward_zoom_targets"] == fzt

    def test_all_empty_is_valid(self) -> None:
        # spec §4.4 empty policy: empty != fallback.
        c = build_continuity_elements_used(
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        assert c["fixed_elements"] == []
        assert c["previous_shot_refs"] == []
        assert c["forward_zoom_targets"] == []

    def test_constraints_present(self) -> None:
        c = build_continuity_elements_used(
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        assert isinstance(c["constraints"], list)
        assert len(c["constraints"]) >= 2

    def test_input_lists_not_mutated(self) -> None:
        fe_in = [{"element_id": "x"}]
        psr_in = [{"scene_index": 1, "shot_index": 1}]
        fzt_in = [{"scene_index": 1, "shot_index": 2}]
        build_continuity_elements_used(
            fixed_elements=fe_in, previous_shot_refs=psr_in,
            forward_zoom_targets=fzt_in,
        )
        assert fe_in == [{"element_id": "x"}]
        assert psr_in == [{"scene_index": 1, "shot_index": 1}]
        assert fzt_in == [{"scene_index": 1, "shot_index": 2}]

    def test_full_text_preserved_no_truncation(self) -> None:
        # CLAUDE.md 절대 규칙: LLM-bound data 무절단. fixed_element description
        # 600자 도 그대로.
        long_desc = "A " * 300 + "long description"
        fe = [{
            "element_id": "x", "element_type": "scene_state",
            "character_name": None,
            "description": long_desc,
            "applies_to_shots": [],
            "source_facts": [], "visual_inferences": [],
            "creative_decisions": [], "confidence": "high",
        }]
        c = build_continuity_elements_used(
            fixed_elements=fe, previous_shot_refs=[], forward_zoom_targets=[],
        )
        assert c["fixed_elements"][0]["description"] == long_desc

    def test_lossless_producer_fields_preserved(self) -> None:
        # R1-I2: scene_consistency producer 의 9 required field 모두 보존.
        # drop / transform / rename 금지.
        fe = [{
            "element_id": "body_full_pose",
            "element_type": "character_state",
            "character_name": "C02",
            "description": "an East Asian woman lying motionless on her left side",
            "applies_to_shots": [{"scene_index": 12, "shot_index": 3},
                                 {"scene_index": 12, "shot_index": 4}],
            "source_facts": ["scene narration: 'she lay still'"],
            "visual_inferences": ["lighting falls from window onto left shoulder"],
            "creative_decisions": ["camera respects 180-degree line"],
            "confidence": "high",
        }]
        c = build_continuity_elements_used(
            fixed_elements=fe, previous_shot_refs=[], forward_zoom_targets=[],
        )
        out = c["fixed_elements"][0]
        for k in ("element_id", "element_type", "character_name",
                  "description", "applies_to_shots", "source_facts",
                  "visual_inferences", "creative_decisions", "confidence"):
            assert out[k] == fe[0][k], f"field {k!r} dropped/transformed"

    def test_forward_zoom_full_list_no_cap(self) -> None:
        # R1-I7: forward_zoom_targets count > 6 + keep_elements count > 5
        # → card payload 모든 entry 보존 (cap 0).
        fzt = [
            {
                "scene_index": 12, "shot_index": 5 + i,
                "description": f"forward zoom target #{i}",
                "keep_elements": [f"element_{j}" for j in range(7)],  # 7 > 5
            }
            for i in range(8)  # 8 > 6
        ]
        c = build_continuity_elements_used(
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=fzt,
        )
        # 모든 entry 보존.
        assert len(c["forward_zoom_targets"]) == 8
        for entry in c["forward_zoom_targets"]:
            assert len(entry["keep_elements"]) == 7

    def test_none_fixed_elements_raises(self) -> None:
        # R2-B4 (spec R1-I12): None 은 producer missing → AppError.
        from app.core.errors import AppError
        with pytest.raises(AppError) as ei:
            build_continuity_elements_used(
                fixed_elements=None,  # type: ignore[arg-type]
                previous_shot_refs=[], forward_zoom_targets=[],
            )
        assert ei.value.code == "step.contract_violation"
        assert "fixed_elements" in ei.value.message

    def test_none_previous_shot_refs_raises(self) -> None:
        # R2-B4 (spec R1-I12): None 은 producer missing → AppError.
        from app.core.errors import AppError
        with pytest.raises(AppError) as ei:
            build_continuity_elements_used(
                fixed_elements=[],
                previous_shot_refs=None,  # type: ignore[arg-type]
                forward_zoom_targets=[],
            )
        assert ei.value.code == "step.contract_violation"
        assert "previous_shot_refs" in ei.value.message

    def test_none_forward_zoom_targets_raises(self) -> None:
        # R2-B4 (spec R1-I12): None 은 producer missing → AppError.
        from app.core.errors import AppError
        with pytest.raises(AppError) as ei:
            build_continuity_elements_used(
                fixed_elements=[], previous_shot_refs=[],
                forward_zoom_targets=None,  # type: ignore[arg-type]
            )
        assert ei.value.code == "step.contract_violation"
        assert "forward_zoom_targets" in ei.value.message
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildContinuityElementsUsed -v
```
Expected: 10 FAIL (5 base + R1-I2 lossless + R1-I7 forward_zoom full-list +
R2-B4 None reject 3).

- [ ] **Step 3: 구현**

```python
import copy as _copy_module


def build_continuity_elements_used(
    *,
    fixed_elements: List[Dict[str, Any]],
    previous_shot_refs: List[Dict[str, Any]],
    forward_zoom_targets: List[Dict[str, Any]],
) -> Dict[str, Any]:
    """spec §4.4 — continuity_elements_used field.

    Empty policy: 빈 list 는 valid (upstream 이 명시적으로 빈 list 반환).
    silent fallback 차단은 caller (scene_consistency partial / violation
    status) 가 이미 처리.

    R2-B4 (spec R1-I12): None 입력은 producer missing → AppError.
    명시적 [] 만 valid. `or []` silent absorb 패턴 제거.

    R1-I2 (lossless adapter): producer field 9개
    (element_id / element_type / character_name / description /
    applies_to_shots / source_facts / visual_inferences /
    creative_decisions / confidence) 그대로 보존. drop / transform /
    rename 금지. derived hint 가 필요하면 별도 `_card_metadata` 키로만.

    R1-I7 (no truncation, full list): forward_zoom_targets 와 각
    keep_elements 모두 full list. cap 금지. CLAUDE.md 절대 규칙
    (LLM-bound data 무절단) 적용.

    CLAUDE.md 절대 규칙: input 무절단 — fixed_element description 의 raw
    text 그대로 보존. deep copy 로 lossless 보장 (mutation 차단 + 원본
    필드 모두 보존).
    """
    # R2-B4: None vs [] fail-fast. silent fallback 금지
    # (`feedback_no_silent_fallback.md`).
    if fixed_elements is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_continuity_elements_used: fixed_elements is None — "
                "upstream producer (scene_consistency) did not populate. "
                "Use explicit [] for intentionally empty."
            ),
        )
    if previous_shot_refs is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_continuity_elements_used: previous_shot_refs is None — "
                "upstream producer (shot_dependency_t2i) did not populate. "
                "Use explicit [] for intentionally empty."
            ),
        )
    if forward_zoom_targets is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_continuity_elements_used: forward_zoom_targets is None — "
                "upstream producer did not populate. "
                "Use explicit [] for intentionally empty."
            ),
        )
    return {
        # R2-B4: no `or []` — None 은 위에서 raise.
        "fixed_elements": _copy_module.deepcopy(list(fixed_elements)),
        "previous_shot_refs": _copy_module.deepcopy(list(previous_shot_refs)),
        "forward_zoom_targets": _copy_module.deepcopy(list(forward_zoom_targets)),
        "constraints": [
            "fixed_elements are continuity inputs derived from prior shots — "
            "treat them as binding context, not as source facts to be invented",
            "previous_shot_refs with ref_usage='zoom_in_detail' must preserve "
            "the same moment, the same space, and the same lighting as the prior shot",
        ],
    }
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildContinuityElementsUsed -v
```
Expected: 10 PASS (7 base + R2-B4 None reject 3).

<!-- R2-B4: see Round 2 Override -->
### Task 7: `build_asset_requirements()` — required + forbidden refs + readiness_policy

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py`
- Modify: `backend/tests/unit/test_render_prompt_card.py`

빌더 책임 (spec §4.5, **R2-B4 적용 후**):
- `required_refs` = visible_entities × outlook_pairs → `[{kind:
  "character_outlook", id: "C01O02", policy: "required"}]` + bg_id 가 있으면
  `{kind: "background", id: bg_id, policy: "required"}`.
- `forbidden_refs` = close framing 일 때 `[{kind: "background", reason: "close
  framing skips chain_bg reference"}]`.
- `readiness_policy`:
  - bg-mode-off → `not_applicable`.
  - close framing → `skipped_by_policy`.
  - 그 외 (required_refs 1+ 일 때) → `block_if_missing`.
- Q3 (spec §10): G4.1 에서는 expected refs only — 실제 AssetReadiness resolver
  호출은 image stage 의 hard gate. card 는 LLM 에게 expected 를 알리는
  reminder.
- **R2-B4 (None vs [] fail-fast, spec R1-I12 신규)**: `visible_entities` /
  `outlook_pairs` 둘 다 `None` 입력 시 `AppError(code="step.contract_violation")`
  raise. 명시적 `[]` 만 valid. `or []` silent absorb 패턴 제거. (test 추가:
  각 field None 입력 raise 검증.) `bg_id` 는 `Optional[str]` 이므로 None 그대로
  허용 (no bg_id case = bg-mode-off / not_applicable 분기 정상).

- [ ] **Step 1: 실패 테스트 작성**

```python
from app.core.steps.render_prompt_card import (
    READINESS_BLOCK, READINESS_SKIPPED, READINESS_NA, build_asset_requirements,
)


class TestBuildAssetRequirements:
    def test_full_assets(self) -> None:
        a = build_asset_requirements(
            visible_entities=["C01", "C02", "L01", "P03"],
            outlook_pairs=[
                {"character_id": "C01", "outlook_id": "O02"},
                {"character_id": "C02", "outlook_id": "O01"},
            ],
            bg_id="cb_main_room", is_close_framing=False,
            background_mode_on=True,
        )
        # 2 outlook + 1 bg = 3 required.
        kinds = [r["kind"] for r in a["required_refs"]]
        assert kinds.count("character_outlook") == 2
        assert kinds.count("background") == 1
        # forbidden 없음.
        assert a["forbidden_refs"] == []
        assert a["readiness_policy"] == READINESS_BLOCK

    def test_close_framing_forbids_background(self) -> None:
        a = build_asset_requirements(
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            bg_id="cb_main_room", is_close_framing=True,
            background_mode_on=True,
        )
        # close → bg 는 forbidden, character_outlook 는 required.
        kinds_req = [r["kind"] for r in a["required_refs"]]
        assert "background" not in kinds_req
        assert "character_outlook" in kinds_req
        assert any(f["kind"] == "background" for f in a["forbidden_refs"])
        assert a["readiness_policy"] == READINESS_SKIPPED

    def test_bg_mode_off(self) -> None:
        a = build_asset_requirements(
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            bg_id=None, is_close_framing=False, background_mode_on=False,
        )
        kinds_req = [r["kind"] for r in a["required_refs"]]
        assert "background" not in kinds_req
        assert a["readiness_policy"] == READINESS_NA

    def test_composite_id_format(self) -> None:
        a = build_asset_requirements(
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            bg_id=None, is_close_framing=False, background_mode_on=False,
        )
        # required_refs[0].id = "C01O02".
        outlook_refs = [r for r in a["required_refs"]
                        if r["kind"] == "character_outlook"]
        assert outlook_refs[0]["id"] == "C01O02"

    def test_no_outlook_pair_no_outlook_ref(self) -> None:
        a = build_asset_requirements(
            visible_entities=["C01"], outlook_pairs=[], bg_id=None,
            is_close_framing=False, background_mode_on=False,
        )
        assert a["required_refs"] == []
        assert a["readiness_policy"] == READINESS_NA

    def test_constraints_present(self) -> None:
        a = build_asset_requirements(
            visible_entities=[], outlook_pairs=[], bg_id=None,
            is_close_framing=False, background_mode_on=False,
        )
        assert isinstance(a["constraints"], list)
        assert len(a["constraints"]) >= 1

    def test_none_visible_entities_raises(self) -> None:
        # R2-B4 (spec R1-I12): None 은 producer missing → AppError.
        from app.core.errors import AppError
        with pytest.raises(AppError) as ei:
            build_asset_requirements(
                visible_entities=None,  # type: ignore[arg-type]
                outlook_pairs=[], bg_id=None,
                is_close_framing=False, background_mode_on=False,
            )
        assert ei.value.code == "step.contract_violation"
        assert "visible_entities" in ei.value.message

    def test_none_outlook_pairs_raises(self) -> None:
        # R2-B4 (spec R1-I12): None 은 producer missing → AppError.
        from app.core.errors import AppError
        with pytest.raises(AppError) as ei:
            build_asset_requirements(
                visible_entities=[],
                outlook_pairs=None,  # type: ignore[arg-type]
                bg_id=None,
                is_close_framing=False, background_mode_on=False,
            )
        assert ei.value.code == "step.contract_violation"
        assert "outlook_pairs" in ei.value.message
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildAssetRequirements -v
```
Expected: 8 FAIL (6 base + R2-B4 None reject 2).

- [ ] **Step 3: 구현**

```python
def build_asset_requirements(
    *,
    visible_entities: List[str],
    outlook_pairs: List[Dict[str, str]],
    bg_id: Optional[str],
    is_close_framing: bool,
    background_mode_on: bool,
) -> Dict[str, Any]:
    """spec §4.5 — asset_requirements field.

    Q3 (spec §10): expected refs only. 실제 AssetReadiness resolver 호출은
    image stage 가 hard gate. card 는 LLM reminder.

    R2-B4 (spec R1-I12): visible_entities / outlook_pairs 가 None 이면
    producer missing 으로 간주 → AppError. 명시적 [] 만 valid.
    `or []` silent absorb 패턴 제거. bg_id 는 Optional[str] 이라 None
    그대로 허용 (no bg_id case = bg-mode-off / not_applicable).
    """
    # R2-B4: None vs [] fail-fast. silent fallback 금지
    # (`feedback_no_silent_fallback.md`).
    if visible_entities is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_asset_requirements: visible_entities is None — upstream "
                "producer did not populate. Use explicit [] for intentionally empty."
            ),
        )
    if outlook_pairs is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_asset_requirements: outlook_pairs is None — upstream "
                "producer did not populate. Use explicit [] for intentionally empty."
            ),
        )
    required: List[Dict[str, Any]] = []
    for pair in outlook_pairs:  # R2-B4: no `or []`
        cid = (pair or {}).get("character_id") or ""
        oid = (pair or {}).get("outlook_id") or ""
        if cid and oid:
            required.append({
                "kind": "character_outlook",
                "id": f"{cid}{oid}",
                "policy": "required",
            })
    forbidden: List[Dict[str, Any]] = []
    if is_close_framing and background_mode_on:
        forbidden.append({
            "kind": "background",
            "reason": "close framing skips chain_bg reference",
        })
    elif background_mode_on and bg_id and not is_close_framing:
        required.append({
            "kind": "background", "id": bg_id, "policy": "required",
        })
    if not background_mode_on:
        readiness = READINESS_NA
    elif is_close_framing:
        readiness = READINESS_SKIPPED
    elif required:
        readiness = READINESS_BLOCK
    else:
        readiness = READINESS_NA
    return {
        "required_refs": required,
        "forbidden_refs": forbidden,
        "readiness_policy": readiness,
        "constraints": [
            "do not imply or describe a reference image that is not listed in "
            "required_refs (no phantom references)",
            "if a required character_outlook ref is missing, scene image "
            "generation must block before the image stage",
        ],
    }
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildAssetRequirements -v
```
Expected: 8 PASS (6 base + R2-B4 None reject 2).

---

**Phase 2 verification:**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py -x -q
```
Expected: 50 PASS (4 + 11 + 10 + 7 + 10 + 8) — R1-I1 +1 / R1-I2 +1 / R1-I7 +1 /
R2-M1 +1 (Task 3) / R2-B4 +2 (Task 4) +3 (Task 6) +2 (Task 7).

---

## Phase 3 — Card Hash + Shape Validators + Entry Point

<!-- R1-I3 / R1-I11 / R2-B1: see Round 1 + Round 2 Override -->
### Task 8: `compute_card_hash()` — canonical JSON sha256[:16]

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py`
- Create: `backend/tests/unit/test_render_prompt_card_hash.py`

캐노니컬 규칙 (spec §4 envelope + Open Q4, **R1-I3 + R1-I11 + R2-B1 적용 후**):
- **R1-I3 (canonicalize helper)**: `canonicalize_render_prompt_card()` helper
  신설. `json.dumps(sort_keys=True)` 만으로는 dict key 만 정렬 — list 는
  caller order. semantically unordered list 모두 정렬 필수:
  - `background_binding.owned_objects` (string list → sorted)
  - `id_policy.allowed_base_entity_ids` (string list → sorted)
  - `id_policy.allowed_outlook_pairs` (dict list → key prefix sorted, e.g.
    `f"{character_id}|{outlook_id}"`)
  - `asset_requirements.required_refs` (dict list → key prefix sorted, e.g.
    `f"{kind}|{id}"`)
  - `asset_requirements.forbidden_refs` (dict list → key prefix sorted)
  - `continuity_elements_used.fixed_elements` (dict list → key prefix
    sorted, e.g. `f"{element_id}|{element_type}"`)
  list-order regression test 필수 (permutation invariance).
- **R2-B1 (hash payload definition consolidation, spec R1-I11 sharpened)**:
  hash payload = entire envelope **EXCEPT** (`_card_metadata`,
  `render_prompt_card_hash`). **INCLUDES** `schema_version` + `shot_key` +
  5 semantic fields (render_strategy / id_policy / background_binding /
  continuity_elements_used / asset_requirements). 정확한 정의:
  - **excluded**: `_card_metadata` (debug field, R1-I11) +
    `render_prompt_card_hash` (sibling to envelope when caller stores it).
  - **included**: nothing else escapes — schema_version 변경 시 hash 변동
    (schema bump cascade detect), shot_key 변경 시 hash 변동
    (mis-keyed card detect).
- **R1-I11 (sharpened)**: `_card_metadata` 키는 envelope 에 optional 로 존재
  가능하지만 hash 에서 명시 제외. `render_prompt_card_hash` 도 마찬가지 —
  caller 가 hash 를 envelope 안에 담아 dump 해도 self-hash 가 stable
  (재계산 시 hash 자기 자신은 빠짐).
- self-hash test 변경 — `render_prompt_card_hash` 추가/변경 시 hash **불변**
  (excluded). `_card_metadata` 추가/변경 시 hash **불변** (excluded).
  schema_version / shot_key 변경 시 hash **변동** (included).
- `json.dumps(canonical_payload, sort_keys=True, ensure_ascii=False,
   separators=(",", ":"))` → sha256 → 첫 16 hex chars.
- hash 자체는 hash payload 에 포함하지 않음 (caller 가 별도 cp field 로 저장).
- `ensure_ascii=False` 이유: 한국어 description 무손실 보존 (CLAUDE.md
  truncation 금지 정책 = ASCII escape 도 금지). non-ASCII 그대로 utf-8
  bytes 로 hash.

- [ ] **Step 1: 실패 테스트 작성**

```python
# backend/tests/unit/test_render_prompt_card_hash.py
"""G4.1 RenderPromptCard hash unit tests."""
from __future__ import annotations

import json

import pytest

from app.core.steps.render_prompt_card import (
    build_empty_card, compute_card_hash,
)


def _full_card_v1():
    c = build_empty_card(scene_index=12, shot_index=4)
    c["render_strategy"] = {
        "mode": "direct", "framing_scale": "medium",
        "camera_direction": "wide", "lighting_mood": "warm",
    }
    c["id_policy"] = {
        "allowed_base_entity_ids": ["C01", "C02"],
        "allowed_outlook_pairs": [{"character_id": "C01", "outlook_id": "O02"}],
        "must_use_composite_character_ids": True,
    }
    c["background_binding"] = {
        "mode": "background_ref_attached", "bg_id": "cb_main_room",
        "owned_objects": ["door", "window"],
    }
    c["continuity_elements_used"] = {
        "fixed_elements": [], "previous_shot_refs": [],
        "forward_zoom_targets": [],
    }
    c["asset_requirements"] = {
        "required_refs": [
            {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
        ],
        "forbidden_refs": [], "readiness_policy": "block_if_missing",
    }
    return c


class TestComputeCardHash:
    def test_returns_16_char_hex(self) -> None:
        h = compute_card_hash(_full_card_v1())
        assert isinstance(h, str)
        assert len(h) == 16
        assert all(c in "0123456789abcdef" for c in h)

    def test_deterministic_same_input(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_key_order_independent_via_sort(self) -> None:
        # sort_keys=True 때문에 dict 입력 순서 무관 — 같은 hash.
        c1 = _full_card_v1()
        c2 = {
            "asset_requirements": c1["asset_requirements"],
            "background_binding": c1["background_binding"],
            "continuity_elements_used": c1["continuity_elements_used"],
            "id_policy": c1["id_policy"],
            "render_strategy": c1["render_strategy"],
            "schema_version": c1["schema_version"],
            "shot_key": c1["shot_key"],
        }
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_owned_change_changes_hash(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["background_binding"]["owned_objects"] = ["door", "window", "TV"]
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_camera_direction_change_changes_hash(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["render_strategy"]["camera_direction"] = "extreme close-up"
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_outlook_pair_change_changes_hash(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["id_policy"]["allowed_outlook_pairs"] = [
            {"character_id": "C01", "outlook_id": "O03"},  # O02 → O03
        ]
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_fixed_element_addition_changes_hash(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["continuity_elements_used"]["fixed_elements"] = [
            {"element_id": "x", "description": "y"},
        ]
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_korean_description_preserved_in_hash(self) -> None:
        # CLAUDE.md 무절단 + ensure_ascii=False — 한국어 description 도 hash 가능.
        c1 = _full_card_v1()
        c1["continuity_elements_used"]["fixed_elements"] = [
            {"element_id": "x", "description": "여자가 옆으로 누워 있다"},
        ]
        h = compute_card_hash(c1)
        assert len(h) == 16

    def test_hash_self_excluded_from_payload(self) -> None:
        # R2-B1 (spec R1-I11 sharpened): canonicalize 가 `render_prompt_card_hash`
        # 를 hash payload 에서 명시 제외. caller 가 hash 를 envelope 에 담아
        # 다시 dump 해도 self-hash 가 stable.
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["render_prompt_card_hash"] = "deadbeef" * 2
        # 두 hash 동일 — render_prompt_card_hash 는 hash 입력에서 빠짐.
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_schema_version_change_changes_hash(self) -> None:
        # R2-B1: schema_version 은 hash payload 에 INCLUDED — 변경 시 hash 변동
        # (schema bump cascade detect).
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["schema_version"] = 99
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_shot_key_change_changes_hash(self) -> None:
        # R2-B1: shot_key 도 hash payload 에 INCLUDED — 변경 시 hash 변동
        # (mis-keyed card detect).
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["shot_key"] = {"scene_index": 99, "shot_index": 99}
        assert compute_card_hash(c1) != compute_card_hash(c2)


class TestCanonicalizeListOrdering:
    """R1-I3: semantically unordered list permutation invariance."""

    def test_owned_objects_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["background_binding"]["owned_objects"] = ["door", "window"]
        c2["background_binding"]["owned_objects"] = ["window", "door"]
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_allowed_base_entity_ids_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["id_policy"]["allowed_base_entity_ids"] = ["C01", "C02"]
        c2["id_policy"]["allowed_base_entity_ids"] = ["C02", "C01"]
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_allowed_outlook_pairs_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["id_policy"]["allowed_outlook_pairs"] = [
            {"character_id": "C01", "outlook_id": "O02"},
            {"character_id": "C02", "outlook_id": "O01"},
        ]
        c2["id_policy"]["allowed_outlook_pairs"] = [
            {"character_id": "C02", "outlook_id": "O01"},
            {"character_id": "C01", "outlook_id": "O02"},
        ]
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_required_refs_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["asset_requirements"]["required_refs"] = [
            {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
            {"kind": "background", "id": "cb_main_room", "policy": "required"},
        ]
        c2["asset_requirements"]["required_refs"] = [
            {"kind": "background", "id": "cb_main_room", "policy": "required"},
            {"kind": "character_outlook", "id": "C01O02", "policy": "required"},
        ]
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_forbidden_refs_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["asset_requirements"]["forbidden_refs"] = [
            {"kind": "background", "reason": "close framing"},
            {"kind": "outlook", "reason": "skipped variant"},
        ]
        c2["asset_requirements"]["forbidden_refs"] = [
            {"kind": "outlook", "reason": "skipped variant"},
            {"kind": "background", "reason": "close framing"},
        ]
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_fixed_elements_order_invariant(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c1["continuity_elements_used"]["fixed_elements"] = [
            {"element_id": "a", "element_type": "scene_state",
             "description": "x"},
            {"element_id": "b", "element_type": "character_state",
             "description": "y"},
        ]
        c2["continuity_elements_used"]["fixed_elements"] = [
            {"element_id": "b", "element_type": "character_state",
             "description": "y"},
            {"element_id": "a", "element_type": "scene_state",
             "description": "x"},
        ]
        assert compute_card_hash(c1) == compute_card_hash(c2)


class TestCardMetadataExcludedFromHash:
    """R1-I11: `_card_metadata` debug field 가 hash 에 영향 X."""

    def test_card_metadata_does_not_affect_hash(self) -> None:
        c1 = _full_card_v1()
        c2 = _full_card_v1()
        c2["_card_metadata"] = {
            "build_timestamp": "2026-05-04T12:00:00Z",
            "builder_version": "g4.1.r1",
        }
        assert compute_card_hash(c1) == compute_card_hash(c2)

    def test_card_metadata_change_does_not_affect_hash(self) -> None:
        c1 = _full_card_v1()
        c1["_card_metadata"] = {"build_timestamp": "A"}
        c2 = _full_card_v1()
        c2["_card_metadata"] = {"build_timestamp": "B"}
        assert compute_card_hash(c1) == compute_card_hash(c2)
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card_hash.py -v
```
Expected: 19 FAIL (9 base + 6 list-order + 2 _card_metadata + R2-B1 +2:
schema_version + shot_key included). 단 base 9 중 1 개
(`test_hash_self_excluded_from_payload`) 는 R2-B1 에서 의미가 뒤바뀌어
"동일하다" 검증으로 변경됨 (R1 round 시 "달라진다" 였음).

- [ ] **Step 3: 구현 — `canonicalize_render_prompt_card()` + `compute_card_hash()`**

```python
import copy as _copy_module


# R1-I3: hash 입력에서 명시 제외할 metadata key (R1-I11).
_HASH_EXCLUDED_TOP_KEYS = ("_card_metadata", "render_prompt_card_hash")


def _sort_string_list(items: List[Any]) -> List[Any]:
    return sorted([s for s in items], key=lambda x: (x is None, str(x)))


def _sort_dict_list(items: List[Dict[str, Any]],
                    key_fields: List[str]) -> List[Dict[str, Any]]:
    """key_fields 의 값을 prefix 로 sorted (deterministic regardless of caller order)."""
    def _k(d: Dict[str, Any]) -> str:
        return "|".join(str((d or {}).get(f, "")) for f in key_fields)
    return sorted(list(items), key=_k)


def canonicalize_render_prompt_card(card: Dict[str, Any]) -> Dict[str, Any]:
    """R1-I3 + R1-I11 + R2-B1: hash 입력용 normalized payload.

    R1-I3: semantically unordered list 모두 정렬.
    R1-I11 + R2-B1 (sharpened): hash payload = entire envelope EXCEPT
    (`_card_metadata`, `render_prompt_card_hash`). INCLUDES schema_version
    + shot_key + 5 semantic fields. 결과는 hash 입력 전용 (caller 의
    원본 card 는 mutate 안 함).

    sort 대상:
      - background_binding.owned_objects (string list)
      - id_policy.allowed_base_entity_ids (string list)
      - id_policy.allowed_outlook_pairs (dict list, key: character_id|outlook_id)
      - asset_requirements.required_refs (dict list, key: kind|id)
      - asset_requirements.forbidden_refs (dict list, key: kind|reason)
      - continuity_elements_used.fixed_elements (dict list,
        key: element_id|element_type)
    """
    payload = _copy_module.deepcopy(card or {})
    # R1-I11: hash payload 에서 _card_metadata + hash field 자체 제거.
    for k in _HASH_EXCLUDED_TOP_KEYS:
        payload.pop(k, None)

    bb = payload.get("background_binding")
    if isinstance(bb, dict) and isinstance(bb.get("owned_objects"), list):
        bb["owned_objects"] = _sort_string_list(bb["owned_objects"])

    ip = payload.get("id_policy")
    if isinstance(ip, dict):
        if isinstance(ip.get("allowed_base_entity_ids"), list):
            ip["allowed_base_entity_ids"] = _sort_string_list(
                ip["allowed_base_entity_ids"])
        if isinstance(ip.get("allowed_outlook_pairs"), list):
            ip["allowed_outlook_pairs"] = _sort_dict_list(
                ip["allowed_outlook_pairs"],
                key_fields=["character_id", "outlook_id"])

    ar = payload.get("asset_requirements")
    if isinstance(ar, dict):
        if isinstance(ar.get("required_refs"), list):
            ar["required_refs"] = _sort_dict_list(
                ar["required_refs"], key_fields=["kind", "id"])
        if isinstance(ar.get("forbidden_refs"), list):
            ar["forbidden_refs"] = _sort_dict_list(
                ar["forbidden_refs"], key_fields=["kind", "reason"])

    ce = payload.get("continuity_elements_used")
    if isinstance(ce, dict) and isinstance(ce.get("fixed_elements"), list):
        ce["fixed_elements"] = _sort_dict_list(
            ce["fixed_elements"], key_fields=["element_id", "element_type"])

    return payload


def compute_card_hash(card: Dict[str, Any]) -> str:
    """canonical JSON sha256[:16].

    Rules (spec §4 + Open Q4 + R1-I3 + R1-I11):
      - canonicalize_render_prompt_card() 로 list 정렬 + _card_metadata 제외.
      - sort_keys=True  → dict key order 무관.
      - ensure_ascii=False → 한국어 / 일본어 description 무손실 hash.
      - separators=(",", ":") → 공백 제거 (canonical compact form).
      - hash 자체는 hash payload 에 포함하지 않음 (caller 책임).
    """
    canonical = canonicalize_render_prompt_card(card)
    payload = json.dumps(
        canonical, sort_keys=True, ensure_ascii=False, separators=(",", ":"),
    ).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()[:16]
```

또한 모듈 top-level NotImplementedError 시그니처에 `canonicalize_render_prompt_card`
도 export 추가 (Task 1 import smoke 를 갱신할 필요 없음 — Task 8 까지 오면
import smoke 는 `compute_card_hash` 만 검증하면 충분, helper 는 implementation
detail).

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card_hash.py -v
```
Expected: 19 PASS (R2-B1 +2: schema_version + shot_key included tests).

<!-- R2-B2 / R2-I1: see Round 2 Override -->
### Task 9: `assert_card_shape()` — strict schema_version + 5-field presence + nested type checks

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py`
- Modify: `backend/tests/unit/test_render_prompt_card.py`

shape 검증 책임 (G3.2 `assert_owned_sentinel_shape` 패턴 mirror,
**R2-B2 + R2-I1 적용 후**):
- `schema_version` == `CARD_SCHEMA_VERSION`. **R2-I1**: bool-as-int reject —
  `isinstance(sv, bool)` 면 raise (Python 의 `True == 1` quirk 우회).
- `shot_key` 가 dict 이고 `scene_index` / `shot_index` 모두 int.
- 5 field 모두 존재 + dict.
- 각 field 의 핵심 키 type 검사 (mode enum / list / etc — but minimal).
- **R2-B2**: `_VALID_RENDER_MODES` 가 `not_applicable` 포함하므로 validator
  가 `mode="not_applicable"` 도 valid 로 수용. 임의 unknown mode 만 reject.
- **R2-I1 (envelope-sibling hash strict)**: `render_prompt_card_hash` 가
  envelope 의 sibling 으로 caller 측에서 함께 dump 될 수 있음 (Open Q1
  resolution = top-level). envelope shape 검증 시 hash 가 존재하면 strict
  format 검증 — `re.fullmatch(r"[0-9a-f]{16}", value)` (G3.2 iter2 fix
  carry). 부재 시 (early build 단계) 는 skip.
- 위반 시 `AppError(code="step.contract_violation", message=...)`.
- silent fallback 차단 — 어떤 missing 도 raise.

- [ ] **Step 1: 실패 테스트 작성**

```python
from app.core.steps.render_prompt_card import (
    assert_card_shape, build_empty_card, build_render_strategy,
    build_id_policy, build_background_binding,
    build_continuity_elements_used, build_asset_requirements,
)
from app.core.errors import AppError


def _valid_card():
    c = build_empty_card(scene_index=1, shot_index=1)
    c["render_strategy"] = build_render_strategy(
        seg={}, shot_info={"camera_direction": "wide"},
        staging={"camera_direction": "wide", "lighting_mood": "warm"},
        perception_mode=None,
    )
    c["id_policy"] = build_id_policy(
        visible_entities=["C01"], outlook_pairs=[], perception_mode=None,
    )
    c["background_binding"] = build_background_binding(
        bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
        is_close_framing=False, background_mode_on=False,
    )
    c["continuity_elements_used"] = build_continuity_elements_used(
        fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
    )
    c["asset_requirements"] = build_asset_requirements(
        visible_entities=["C01"], outlook_pairs=[], bg_id=None,
        is_close_framing=False, background_mode_on=False,
    )
    return c


class TestAssertCardShape:
    def test_valid_passes(self) -> None:
        assert_card_shape(_valid_card())  # noop

    def test_missing_schema_version_raises(self) -> None:
        c = _valid_card()
        del c["schema_version"]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert ei.value.code == "step.contract_violation"

    def test_wrong_schema_version_raises(self) -> None:
        c = _valid_card()
        c["schema_version"] = 99
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "schema_version" in ei.value.message

    def test_missing_shot_key_raises(self) -> None:
        c = _valid_card()
        del c["shot_key"]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "shot_key" in ei.value.message

    def test_shot_key_missing_scene_index_raises(self) -> None:
        c = _valid_card()
        c["shot_key"] = {"shot_index": 1}
        with pytest.raises(AppError):
            assert_card_shape(c)

    def test_missing_field_raises(self) -> None:
        for field in (
            "render_strategy", "id_policy", "background_binding",
            "continuity_elements_used", "asset_requirements",
        ):
            c = _valid_card()
            del c[field]
            with pytest.raises(AppError) as ei:
                assert_card_shape(c)
            assert field in ei.value.message

    def test_field_must_be_dict(self) -> None:
        c = _valid_card()
        c["render_strategy"] = "not a dict"
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "render_strategy" in ei.value.message

    def test_render_strategy_invalid_mode_raises(self) -> None:
        c = _valid_card()
        c["render_strategy"]["mode"] = "totally_invented_mode"
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "mode" in ei.value.message

    def test_background_binding_invalid_mode_raises(self) -> None:
        c = _valid_card()
        c["background_binding"]["mode"] = "totally_invented_mode"
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)

    def test_asset_requirements_invalid_readiness_raises(self) -> None:
        c = _valid_card()
        c["asset_requirements"]["readiness_policy"] = "totally_invented"
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)

    def test_input_not_dict_raises(self) -> None:
        with pytest.raises(AppError):
            assert_card_shape("not a dict")  # type: ignore[arg-type]

    def test_where_in_message(self) -> None:
        c = _valid_card()
        del c["render_strategy"]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c, where="my_call_site")
        assert "my_call_site" in ei.value.message

    def test_not_applicable_mode_passes(self) -> None:
        # R2-B2: validator 가 `mode="not_applicable"` valid 로 수용.
        c = _valid_card()
        c["render_strategy"]["mode"] = "not_applicable"
        assert_card_shape(c)  # noop — valid

    def test_schema_version_bool_rejects(self) -> None:
        # R2-I1: bool-as-int reject (Python `True == 1` quirk).
        c = _valid_card()
        c["schema_version"] = True  # type: ignore[assignment]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "schema_version" in ei.value.message

    def test_schema_version_non_int_rejects(self) -> None:
        # R2-I1: 정수가 아니면 reject.
        c = _valid_card()
        c["schema_version"] = "1"  # type: ignore[assignment]
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "schema_version" in ei.value.message

    def test_envelope_hash_format_strict(self) -> None:
        # R2-I1: render_prompt_card_hash 가 envelope sibling 으로 존재 시
        # 16-char lowercase hex 검증. invalid format 은 reject.
        c = _valid_card()
        c["render_prompt_card_hash"] = "NOT_HEX_VALUE!!!"
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "render_prompt_card_hash" in ei.value.message

    def test_envelope_hash_uppercase_rejects(self) -> None:
        # R2-I1: lowercase hex 만. uppercase reject (G3.2 iter2 fix carry).
        c = _valid_card()
        c["render_prompt_card_hash"] = "DEADBEEFDEADBEEF"  # uppercase
        with pytest.raises(AppError) as ei:
            assert_card_shape(c)
        assert "render_prompt_card_hash" in ei.value.message

    def test_envelope_hash_valid_passes(self) -> None:
        # R2-I1: 정확히 16-char lowercase hex 면 valid.
        c = _valid_card()
        c["render_prompt_card_hash"] = "deadbeef" * 2
        assert_card_shape(c)  # noop — valid

    def test_envelope_hash_absent_passes(self) -> None:
        # R2-I1: hash 부재 (early build 단계) 는 skip — valid.
        c = _valid_card()
        c.pop("render_prompt_card_hash", None)
        assert_card_shape(c)  # noop — valid
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestAssertCardShape -v
```
Expected: ~23 FAIL (12 base + R2-B2/I1 7 신규; test_missing_field_raises 가
5 케이스 parametrize 효과).

- [ ] **Step 3: 구현**

```python
import re as _re_module

_REQUIRED_TOP_FIELDS = (
    "schema_version", "shot_key",
    "render_strategy", "id_policy", "background_binding",
    "continuity_elements_used", "asset_requirements",
)

# R2-I1: envelope-sibling hash strict format (G3.2 iter2 fix carry).
_HASH_FORMAT_RE = _re_module.compile(r"[0-9a-f]{16}")


def assert_card_shape(card: Dict[str, Any], where: str = "") -> None:
    """Strict shape validator. silent fallback 차단 (feedback_no_silent_fallback).

    G3.2 assert_owned_sentinel_shape 패턴 mirror — 위반 시 모두 AppError.

    R2-B2: `_VALID_RENDER_MODES` 가 `not_applicable` 포함 — validator 가
    valid 로 수용.

    R2-I1: schema_version 은 bool-as-int reject + 정수 strict.
    `render_prompt_card_hash` 가 envelope sibling 으로 존재 시
    16-char lowercase hex 검증.
    """
    if not isinstance(card, dict):
        raise AppError(
            code="step.contract_violation",
            message=f"render_prompt_card must be dict (got "
                    f"{type(card).__name__}) {where}",
        )
    missing = [f for f in _REQUIRED_TOP_FIELDS if f not in card]
    if missing:
        raise AppError(
            code="step.contract_violation",
            message=f"render_prompt_card missing fields {missing} {where}",
        )
    # R2-I1: bool-as-int reject + non-int reject + value mismatch reject.
    sv = card["schema_version"]
    if isinstance(sv, bool) or not isinstance(sv, int) or sv != CARD_SCHEMA_VERSION:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card schema_version "
                f"{sv!r} (type={type(sv).__name__}) != {CARD_SCHEMA_VERSION} "
                f"(must be int, not bool) {where}"
            ),
        )
    sk = card["shot_key"]
    if not isinstance(sk, dict) or "scene_index" not in sk or "shot_index" not in sk:
        raise AppError(
            code="step.contract_violation",
            message=f"render_prompt_card.shot_key must be "
                    f"{{scene_index, shot_index}} dict {where}",
        )
    if not isinstance(sk["scene_index"], int) or not isinstance(sk["shot_index"], int):
        raise AppError(
            code="step.contract_violation",
            message=f"render_prompt_card.shot_key indices must be int {where}",
        )
    for f in (
        "render_strategy", "id_policy", "background_binding",
        "continuity_elements_used", "asset_requirements",
    ):
        if not isinstance(card[f], dict):
            raise AppError(
                code="step.contract_violation",
                message=f"render_prompt_card.{f} must be dict (got "
                        f"{type(card[f]).__name__}) {where}",
            )
    rs_mode = card["render_strategy"].get("mode")
    if rs_mode not in _VALID_RENDER_MODES:
        raise AppError(
            code="step.contract_violation",
            message=f"render_prompt_card.render_strategy.mode {rs_mode!r} "
                    f"not in {_VALID_RENDER_MODES} {where}",
        )
    bb_mode = card["background_binding"].get("mode")
    if bb_mode not in _VALID_BG_MODES:
        raise AppError(
            code="step.contract_violation",
            message=f"render_prompt_card.background_binding.mode {bb_mode!r} "
                    f"not in {_VALID_BG_MODES} {where}",
        )
    rp = card["asset_requirements"].get("readiness_policy")
    if rp not in _VALID_READINESS:
        raise AppError(
            code="step.contract_violation",
            message=f"render_prompt_card.asset_requirements.readiness_policy "
                    f"{rp!r} not in {_VALID_READINESS} {where}",
        )
    # R2-I1: envelope-sibling render_prompt_card_hash 가 존재하면 strict
    # format 검증 (G3.2 iter2 fix carry). 부재 시 (early build 단계) skip.
    if "render_prompt_card_hash" in card:
        hv = card["render_prompt_card_hash"]
        if not isinstance(hv, str) or not _HASH_FORMAT_RE.fullmatch(hv):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card_hash {hv!r} must be exactly 16 "
                    f"lowercase hex chars (matched by [0-9a-f]{{16}}) {where}"
                ),
            )
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py -v
```
Expected: 69 PASS (50 builder + 19 shape; R3-M4 정정) — R2-B4 / R2-B2 / R2-I1
추가 분.

### Task 9.5: `build_render_prompt_card()` entry point

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py`
- Modify: `backend/tests/unit/test_render_prompt_card.py`

caller (`_analyze_one()`) 가 한 번만 호출하는 entry point. 5 builder 를 순서대로
호출 + envelope 채움 + shape assert.

- [ ] **Step 1: 실패 테스트 작성**

```python
from app.core.steps.render_prompt_card import build_render_prompt_card


class TestBuildRenderPromptCard:
    def test_full_round_trip(self) -> None:
        c = build_render_prompt_card(
            scene_index=12, shot_index=4,
            seg={}, shot_info={"camera_direction": "medium shot"},
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            perception_mode=None,
            staging={"camera_direction": "medium shot",
                     "lighting_mood": "warm dim"},
            bg_id="cb_main_room", bg_owned=["door"],
            bg_camera_meta={"camera_position": "wide"},
            bg_guide="guide text",
            is_close_framing=False, background_mode_on=True,
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        # shape valid (assert 통과 = exception 없음)
        assert c["schema_version"] == 1
        assert c["shot_key"] == {"scene_index": 12, "shot_index": 4}
        assert c["render_strategy"]["framing_scale"] == "medium"
        assert c["id_policy"]["allowed_base_entity_ids"] == ["C01"]
        assert c["background_binding"]["bg_id"] == "cb_main_room"
        assert c["asset_requirements"]["readiness_policy"] == "block_if_missing"

    def test_close_framing_propagates_to_all_fields(self) -> None:
        c = build_render_prompt_card(
            scene_index=1, shot_index=1,
            seg={}, shot_info={"camera_direction": "extreme close-up"},
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O02"}],
            perception_mode=None,
            staging={"camera_direction": "extreme close-up",
                     "lighting_mood": "harsh"},
            bg_id="cb_main_room", bg_owned=["door"],
            bg_camera_meta={"camera_position": "wide"},
            bg_guide="g",
            is_close_framing=True, background_mode_on=True,
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        assert c["render_strategy"]["framing_scale"] == "close"
        assert c["background_binding"]["mode"] == "skipped_close_framing"
        assert c["background_binding"]["owned_objects"] == []
        assert c["asset_requirements"]["readiness_policy"] == "skipped_by_policy"
        # 모든 field 가 close 일관성.

    def test_bg_mode_off_propagates(self) -> None:
        c = build_render_prompt_card(
            scene_index=1, shot_index=1,
            seg={}, shot_info={"camera_direction": "wide"},
            visible_entities=["C01"], outlook_pairs=[],
            perception_mode=None,
            staging={"camera_direction": "wide", "lighting_mood": "warm"},
            bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
            is_close_framing=False, background_mode_on=False,
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        )
        assert c["background_binding"]["mode"] == "background_mode_off"
        assert c["asset_requirements"]["readiness_policy"] == "not_applicable"
```

- [ ] **Step 2: 실패 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py::TestBuildRenderPromptCard -v
```
Expected: 3 FAIL.

- [ ] **Step 3: 구현**

```python
def build_render_prompt_card(
    *,
    scene_index: int,
    shot_index: int,
    seg: Dict[str, Any],
    shot_info: Dict[str, Any],
    visible_entities: List[str],
    outlook_pairs: List[Dict[str, str]],
    perception_mode: Optional[str],
    staging: Optional[Dict[str, Any]],
    bg_id: Optional[str],
    bg_owned: List[str],
    bg_camera_meta: Optional[Dict[str, Any]],
    bg_guide: Optional[str],
    is_close_framing: bool,
    background_mode_on: bool,
    fixed_elements: List[Dict[str, Any]],
    previous_shot_refs: List[Dict[str, Any]],
    forward_zoom_targets: List[Dict[str, Any]],
) -> Dict[str, Any]:
    """Single entry point — _analyze_one() 가 한 번 호출.

    각 builder 는 독립적이므로 순서 무관. 호출 직후 assert_card_shape 로
    self-check.
    """
    card = build_empty_card(scene_index=scene_index, shot_index=shot_index)
    card["render_strategy"] = build_render_strategy(
        seg=seg, shot_info=shot_info, staging=staging,
        perception_mode=perception_mode,
    )
    card["id_policy"] = build_id_policy(
        visible_entities=visible_entities, outlook_pairs=outlook_pairs,
        perception_mode=perception_mode,
    )
    card["background_binding"] = build_background_binding(
        bg_id=bg_id, bg_owned=bg_owned, bg_camera_meta=bg_camera_meta,
        bg_guide=bg_guide, is_close_framing=is_close_framing,
        background_mode_on=background_mode_on,
    )
    card["continuity_elements_used"] = build_continuity_elements_used(
        fixed_elements=fixed_elements, previous_shot_refs=previous_shot_refs,
        forward_zoom_targets=forward_zoom_targets,
    )
    card["asset_requirements"] = build_asset_requirements(
        visible_entities=visible_entities, outlook_pairs=outlook_pairs,
        bg_id=bg_id, is_close_framing=is_close_framing,
        background_mode_on=background_mode_on,
    )
    assert_card_shape(card, where=f"build_render_prompt_card(s{scene_index}_sh{shot_index})")
    return card
```

- [ ] **Step 4: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py tests/unit/test_render_prompt_card_hash.py -x -q
```
Expected: 91 PASS (50 builder + 19 shape + 3 entry + 19 hash; R3-M4 정정) —
R2-B4/B2/I1/B1 + R1-I1/I2/I3/I7/I11 누적.

---

**Phase 3 verification:**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py tests/unit/test_render_prompt_card_hash.py -x -q
```
Expected: 91 PASS (50 builder + 19 shape + 3 entry + 19 hash; R3-M4 정정) —
R1+R2 누적 (R2-B1/B2/B4/I1 + R1-I1/I2/I3/I7/I11).

---

## Phase 4 — scene_detail v16 Prompt + version registry bump

<!-- R1-I4: see Round 1 Override -->
### Task 10: v16 prompt 디렉토리 + system.md (v15 verbatim copy + RenderPromptCard priority section)

**Files:**
- Create: `prompts/_base/scene_detail/16.202605041200/system.md`
- Create: `prompts/_base/scene_detail/16.202605041200/detail_schema.json`

**Critical constraint** (`feedback_prompt_versioning.md`): v15 디렉토리 NEVER
overwrite. v15 verbatim copy 후 priority section 만 append.

**R1-I4 (card-wins precedence)**: priority section 표현 강화. legacy
compatibility blocks 는 G4.2~G4.4 lift 가 완료되지 않은 prose 일 뿐, contract
충돌 시 무시. card 가 항상 winner.

- [ ] **Step 1: v15 → v16 디렉토리 복사 (verbatim)**

Run:
```bash
cp -r /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/15.202605032354 /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/16.202605041200
```

Expected: 새 디렉토리 생성 완료. `ls` 로 system.md / detail_schema.json 등 v15
파일 모두 존재 확인.

- [ ] **Step 2: detail_schema.json verbatim 검증 (변경 0)**

LLM output schema 변경 없음 (Open Q4: schema bump 이유는 cp shape).

```bash
diff /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/15.202605032354/detail_schema.json /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/16.202605041200/detail_schema.json
```
Expected: empty diff.

- [ ] **Step 3: system.md 최상단에 RenderPromptCard priority section append**

Edit `prompts/_base/scene_detail/16.202605041200/system.md` — 파일 최상단 (제목
헤더 다음, 기존 첫 번째 `##` section 직전) 에 아래 블록 insert:

```markdown
## Read RenderPromptCard first (PRIMARY CONTRACT)

이 prompt 의 user 메시지 첫 줄은 `[RenderPromptCard v1]` 블록으로 시작한다 (canonical JSON). **RenderPromptCard 는 이 shot 에 대한 deterministic primary contract** — 본 system.md 본문 (legacy compatibility blocks 포함) 의 어떤 prose 룰과도 충돌하면 **card 가 항상 winner**. 본문은 G4.2~G4.5 lift 가 완료되지 않은 reference text 일 뿐, contract 충돌 시 무시.

card 에 들어 있는 5 field 는 t2i_prompt 작성 시 다음과 같이 사용한다:

- `render_strategy.mode` / `framing_scale` / `camera_direction` — 한 컷의 구도와 시점.
- `id_policy.allowed_base_entity_ids` / `allowed_outlook_pairs` — 사용 가능한 short_id 와 C##O## composite. 그 외 ID 는 사용 금지.
- `background_binding.mode` 가 `background_ref_attached` 면 `owned_objects` 의 항목을 새로 그리지 말고 reference 에서 가져온 것으로 anchor. `skipped_close_framing` 이면 reference / 기존 방을 언급 금지. `background_mode_off` / `not_applicable` 이면 reference 자체가 없음.
- `continuity_elements_used.fixed_elements` / `previous_shot_refs` / `forward_zoom_targets` — 직전/직후 shot 과의 일관성 데이터.
- `asset_requirements.required_refs` / `forbidden_refs` — 첨부될 / 첨부 안 될 reference image. forbidden 에 background 가 있으면 t2i_prompt 에서 reference / 기존 방 언급 금지.

**Conflict resolution rule**: card 의 어떤 field 가 본문 prose 와 모순되면 card 의 값을 따른다. 본문 prose 는 G4.2~G4.5 lift 완료 후 단계적으로 제거 예정.

card 의 `constraints` 배열은 reminder 다 — t2i_prompt 본문에 직접 인용하지 말고, t2i_prompt 가 그 룰을 위반하지 않게 작성하는 데만 사용.
```

priority section 외 본문은 G4.1 에서 변경 0 (G4.5 에서 slimming).

- [ ] **Step 4: prompt loader 가 v16 picking 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -c "
from app.modules.prompt_loader import load_prompt
sys = load_prompt('scene_detail', 'system')
assert 'RenderPromptCard' in sys, 'priority section 미반영'
print('OK — v16 system.md picked')
"
```
Expected: `OK — v16 system.md picked` (단 prompt loader 가 PROMPT_VERSION 상수
만 본다면 Task 11 후 다시 확인 — 본 step 은 v16 디렉토리 자체 grep 확인).

대안 검증:
```bash
grep -c "RenderPromptCard" /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/16.202605041200/system.md
```
Expected: `>= 1`.

<!-- R1-B1: see Round 1 Override -->
### Task 11: `SCENE_DETAIL_SCHEMA_VERSION` + `SCENE_DETAIL_PROMPT_VERSION` bump

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py:98-99`

**R1-B1 (migration policy 현실 반영)**: 본 bump 는 G4.1 에서 전역 적용. 기존
PID 는 다음 resume 까지 v15 cp 가 보존되지만, **resume 시 step_runner 의
schema mismatch detect 가 자동 `mode="force"` escalate 를 트리거** 할 수
있다 (`step_runner.py:518, 546, 586`). operator opt-in 보장 X. PNG / 이미
완료된 shot detail 도 force escalate 시 재생성 대상에 들어가므로 운영 안내
필수. spec §3 AD-5 + Round 1 Override R1-B1 참조.

- [ ] **Step 1: 상수 갱신**

Edit `backend/app/core/steps/detail_steps.py` line 98-99:

```python
SCENE_DETAIL_SCHEMA_VERSION = 7  # G4.1 (2026-05-04): cp shape 에 render_prompt_card + render_prompt_card_hash top-level field. v15 cp invalidation. (Open Q4: LLM output schema 는 변경 없음 — cp shape 변경만으로 bump.) R1-B1: 기존 PID 다음 resume 시 step_runner schema mismatch detect → 자동 mode="force" escalate 가능 (operator opt-in 보장 X — 운영 안내 필요).
SCENE_DETAIL_PROMPT_VERSION = "16.202605041200"  # G4.1: RenderPromptCard priority section append.
```

- [ ] **Step 2: prompt loader 가 v16 picking 검증 (Task 10 의 보강)**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -c "
from app.core.steps.detail_steps import SCENE_DETAIL_SCHEMA_VERSION, SCENE_DETAIL_PROMPT_VERSION
assert SCENE_DETAIL_SCHEMA_VERSION == 7, SCENE_DETAIL_SCHEMA_VERSION
assert SCENE_DETAIL_PROMPT_VERSION == '16.202605041200', SCENE_DETAIL_PROMPT_VERSION
print('OK')
"
```
Expected: `OK`.

<!-- R1-B1: see Round 1 Override -->
### Task 12: `version_registry.py` scene_detail_composer bump

**Files:**
- Modify: `backend/app/core/version_registry.py:34, 126`

**R1-B1 (migration policy 현실 반영)**: bump 적용 시 기존 PID 의 v15 cp 가
다음 resume 에서 schema mismatch 로 자동 force escalate 될 수 있음. operator
opt-in 보장 X. spec §3 AD-5 + Round 1 Override R1-B1 참조.

- [ ] **Step 1: scene_detail_composer 버전 갱신**

Edit `backend/app/core/version_registry.py` line 34:

```python
    "scene_detail_composer": "1.16.0",    # 2026-05-04 — v16 prompt (G4.1): RenderPromptCard bootstrap — deterministic 5-field card + hash inject + cp top-level field. LLM output schema 변경 없음 — cp shape (render_prompt_card + render_prompt_card_hash) 만 추가. R1-B1: 기존 PID resume 시 자동 force escalate 가능 (운영 안내 필요).
```

Edit `backend/app/core/version_registry.py` line 126:

```python
    "scene_detail_composer": {
        "prompt_dependency": "scene_detail/v16",
        # ... 기존 다른 필드 보존
```

`updated_at` 같은 timestamp field 가 있으면 today (2026-05-04) 로 갱신.

- [ ] **Step 2: 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -c "
from app.core.version_registry import COMPONENT_VERSIONS, COMPONENT_DETAILS
assert COMPONENT_VERSIONS['scene_detail_composer'] == '1.16.0'
assert COMPONENT_DETAILS['scene_detail_composer']['prompt_dependency'] == 'scene_detail/v16'
print('OK')
"
```
Expected: `OK`.

(키 이름이 다르면 — 예: `PIPELINE_COMPONENTS` — 실제 import 후 발견되는
이름으로 치환.)

### Task 13: `step_manifest.py` scene_detail.schema_version bump

**Files:**
- Modify: `backend/app/core/step_manifest.py:626+` (scene_detail entry)

- [ ] **Step 1: scene_detail entry 의 schema_version 갱신**

Edit `backend/app/core/step_manifest.py` — `"scene_detail": { ... }` block 안의
`"schema_version": 6` 를 `7` 로 변경. depends_on / allow_partial_downstream 는
변경 없음 (G3.2 에서 이미 background_prompt depends_on 추가됨, partial=False
설정됨).

```python
    "scene_detail": {
        # ... 기존 fields 그대로 ...
        "schema_version": 7,  # G4.1: cp shape 에 render_prompt_card + hash 추가.
        # depends_on 변경 없음 (background_prompt 는 G3.2 에서 추가됨).
        # allow_partial_downstream 변경 없음 (G3.2 에서 False).
        # ...
    },
```

- [ ] **Step 2: 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -c "
from app.core.step_manifest import STEP_MANIFEST
sd = STEP_MANIFEST['scene_detail']
assert sd['schema_version'] == 7, sd['schema_version']
assert 'background_prompt' in sd['depends_on'], sd['depends_on']
assert sd.get('allow_partial_downstream') is False, sd
print('OK')
"
```
Expected: `OK`.

(import 이름이 다르면 실제 export 이름으로 치환.)

---

**Phase 4 verification:**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py tests/unit/test_render_prompt_card_hash.py -x -q && echo "--- prompt sanity ---" && grep -c "RenderPromptCard" /Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/scene_detail/16.202605041200/system.md
```
Expected: 91 PASS + `>= 1` for grep (R3-M4: 50+19+3+19=91).

---

## Phase 5 — _analyze_one() Card Injection (5-point modify scope, R3-M1)

<!-- R1-I7 (non-goal) / R2-B3: see Round 1 + Round 2 Override -->
### Task 14: input collection helper-input dict (modify scope guard 1/5)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` — `_analyze_one()` 안.

**R2-B3 modify scope guard (확장 — 5 point):** `_analyze_one()` 변경 영역은:
(1) `_collect_card_inputs(...)` 신설 helper 호출 — 기존 legacy prompt 조립
직후, user_prompt 빌드 직전.
(2) `build_render_prompt_card(inputs)` 호출.
(3) `[RenderPromptCard v1]` user_prompt inject (Task 16).
(4) result CP fields (card + hash) 2개 추가 (Task 17).
(5) `_user_edited` 분기 card drift hook (Task 19 — `_user_edited_card_contract_violated()`
helper 와 합병 가능).

기존 forward_zoom prompt loop / fixed_elements / previous_shot_refs /
`is_close_framing` 변수 / retry / `_check_prompts` / G3.1 evidence assert /
G3.2 owned validator / outlook lookup / staging branch / visible/invisible
loop / 변수명 모두 변경 0 — 새 helper 가 같은 변수를 **read-only 참조**.

**R1-I7 (non-goal — G4.1 scope 한정)**: `_analyze_one()` 안 기존 legacy
prompt block (`detail_steps.py:950, 965`) 의 forward_zoom 관련 `[:6]` /
`[:5]` cap 은 **G4.1 에서 제거 X** (G4.4 continuity lift 시 처리). 본
Task 14 의 input collection 은 card builder 에 넘기는 `forward_zoom_targets`
/ `previous_shot_refs` / `fixed_elements` 를 **full list (no truncation)**
로 넘긴다. 즉:
- ✅ card 의 `_card_inputs["forward_zoom_targets"]` = full list (R1-I7).
- ❌ legacy prompt block 의 `forward_zoom_targets[:6]` 같은 caller-side
  truncation 은 그대로 둔다 (G4.4 lift 진입 전 제거 시 본문 prose 와
  inconsistent — out-of-scope).
- 단 G4.4 entry criterion: legacy forward_zoom prompt block 을 제거 또는
  full-list 방식으로 고치기 전에는 "continuity lift complete" 선언 금지.
  (spec §6 G4.4 + Round 1 Override R1-I7 참조).

- [ ] **Step 1: 현재 `_analyze_one()` 의 input source map 확인**

```bash
grep -n "_analyze_one\|visible_entities\|outlook_phase3\|ctx.staging_map\|chain_bg_owned_by_shot\|chain_bg_camera_meta\|chain_bg_guide_by_shot\|fixed_elements\|forward_zoom\|dependencies\|background_mode" /Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/steps/detail_steps.py | head -50
```

확인 항목:
- `visible_entities` 가 어디서 오는지 (`ctx.scene_visible` / `ctx.shot_director_ve`).
- `outlook_pairs` 도출 (outlook_phase3 lookup).
- `staging` 도출 (`ctx.staging_map[(scene_idx, shot_idx)]`).
- `bg_id` / `bg_owned` / `bg_camera_meta` / `bg_guide` (G3.2 ctx fields).
- `is_close_framing` (이미 line 142 에서 도출됨 — `_CLOSE_FRAMING_RE.search`).
- `background_mode_on` (settings / config).
- `fixed_elements` / `previous_shot_refs` / `forward_zoom_targets` (ctx.dependencies).
- `perception_mode` (도출 가능 — shot 의 perception flag 또는 None).

- [ ] **Step 2: `_collect_card_inputs(...)` helper 신설 (R2-B3)**

R2-B3: `_analyze_one()` 안에서 input dict 를 inline 으로 빌드하지 말고,
별도 helper `_collect_card_inputs(ctx, seg, shot_info, ...)` 로 추출 →
`_analyze_one()` 과 `verify_completion()` (Task 18) 와 `_user_edited`
reuse path (Task 19) 가 **단일 source** 사용. drift detection 의 input
계산이 일관됨을 보장.

```python
# detail_steps.py 모듈 상단 (또는 helper section).
def _collect_card_inputs(
    *,
    ctx, seg, shot_info,
    visible_entities, outlook_pairs,
    staging_for_shot, bg_id_for_shot, bg_owned_for_shot,
    bg_camera_meta_for_shot, bg_guide_for_shot,
    is_close_framing, background_mode_on,
    fixed_elements_for_shot, previous_shot_refs_for_shot,
    forward_zoom_targets_for_shot,
):
    """G4.1 (R2-B3): card builder 의 단일 input source.

    `_analyze_one()` (Task 14/15) + `verify_completion()` (Task 18) +
    `_user_edited` reuse path (Task 19) 모두 이 helper 를 호출 → drift
    detection 의 input 이 일관됨.

    R2-B4 carry: list 인자에 `or []` silent absorb 하지 마라 — None 이
    들어오면 builder 가 raise (build_id_policy / continuity / asset_req).
    이 helper 는 raw 값 그대로 forward.
    """
    return {
        "scene_index": int((seg or {}).get("scene_index")
                           or (seg or {}).get("index") or 0),
        "shot_index": int((shot_info or {}).get("shot_index") or 0),
        "seg": seg,
        "shot_info": shot_info,
        "visible_entities": visible_entities,  # R2-B4: forward as-is
        "outlook_pairs": outlook_pairs,        # R2-B4: forward as-is
        "perception_mode": (shot_info or {}).get("perception_mode"),
        "staging": staging_for_shot,
        "bg_id": bg_id_for_shot,
        "bg_owned": bg_owned_for_shot,
        "bg_camera_meta": bg_camera_meta_for_shot,
        "bg_guide": bg_guide_for_shot,
        "is_close_framing": bool(is_close_framing),
        "background_mode_on": bool(background_mode_on),
        "fixed_elements": fixed_elements_for_shot,
        "previous_shot_refs": previous_shot_refs_for_shot,
        "forward_zoom_targets": forward_zoom_targets_for_shot,
    }
```

`_analyze_one()` 안에서 user_prompt 를 빌드하기 **직전** (=`_check_prompts` 직후
또는 `[Phase 1: extract]` 작업 직전) 에 다음 블록 insert:

```python
# G4.1 (R2-B3): RenderPromptCard input collection — single source helper.
# scope guard: 기존 변수명/branch/loop 변경 0 — 이미 도출된 값 그대로 forward.
from app.core.steps.render_prompt_card import (
    build_render_prompt_card, compute_card_hash, assert_card_shape,
)

_card_inputs = _collect_card_inputs(
    ctx=ctx, seg=seg, shot_info=shot_info,
    visible_entities=_visible_entities,        # 이미 위에서 도출된 변수
    outlook_pairs=_outlook_pairs,              # 이미 위에서 도출된 변수
    staging_for_shot=_staging_for_shot,        # 이미 위에서 도출
    bg_id_for_shot=_bg_id_for_shot,            # 이미 위에서 도출
    bg_owned_for_shot=_bg_owned_for_shot,      # G3.2 ctx
    bg_camera_meta_for_shot=_bg_camera_meta_for_shot,  # G3.2 ctx
    bg_guide_for_shot=_bg_guide_for_shot,      # G3.2 ctx
    is_close_framing=is_close_framing,         # 이미 line 142
    background_mode_on=_background_mode_on,    # settings
    fixed_elements_for_shot=_fixed_elements_for_shot,
    previous_shot_refs_for_shot=_previous_shot_refs_for_shot,
    forward_zoom_targets_for_shot=_forward_zoom_targets_for_shot,
)
```

**중요:** 위 변수명 (`_visible_entities`, `_outlook_pairs` 등) 은 placeholder.
실제 `_analyze_one()` 안에서 이미 도출된 변수명 그대로 사용 — 새 변수 도출
금지 (R2-B3 scope guard).

- [ ] **Step 3: 회귀 검증 — 기존 테스트 깨지지 않는지**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_detail_steps.py tests/unit/test_scene_detail_owned_judge.py -x -q 2>&1 | tail -10
```
Expected: 기존 PASS 유지 (input collection dict 만 추가했을 뿐 output 변경 0).

<!-- R3-M1: see Round 3 Override -->
### Task 15: `build_render_prompt_card()` call site + `assert_card_shape()` (modify scope guard 2/5)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` — `_analyze_one()` 안.

- [ ] **Step 1: call site 추가**

Task 14 의 `_card_inputs` 빌드 직후 추가:

```python
# G4.1: card build + shape self-check.
_render_prompt_card = build_render_prompt_card(**_card_inputs)
# build_render_prompt_card 내부에서 이미 assert_card_shape 호출 — 추가 호출 불요.
_render_prompt_card_hash = compute_card_hash(_render_prompt_card)
```

`build_render_prompt_card` 가 내부에서 `assert_card_shape` 호출하므로, 호출
사이트에서 따로 assert 안 해도 OK.

- [ ] **Step 2: 회귀 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_detail_steps.py tests/unit/test_scene_detail_owned_judge.py -x -q 2>&1 | tail -10
```
Expected: 기존 PASS 유지 (card 빌드만 추가, side effect 0).

<!-- R3-M1: see Round 3 Override -->
### Task 16: user_prompt inject — `[RenderPromptCard v1]` 블록 첫 위치 (modify scope guard 3/5)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` — `_analyze_one()` 안.

spec §5.1 user_prompt shape:
```
[RenderPromptCard v1]
{...canonical JSON...}

[Minimal narrative input]
scene summary: ...
selected shot: ...
scene text: ...

[Legacy compatibility blocks]
... existing blocks that have not yet been lifted ...
```

- [ ] **Step 1: user_prompt 빌드 위치 찾기**

```bash
grep -n "user_prompt\s*=" /Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/steps/detail_steps.py | head -10
```

`_analyze_one()` 안에서 `user_prompt = ...` 또는 `user_prompt = (...)` 블록 위치
파악.

- [ ] **Step 2: 첫 블록 prepend**

`user_prompt` 가 빌드된 직후 (legacy blocks 모두 합쳐진 후) 에 첫 블록을
prepend:

```python
# G4.1: RenderPromptCard 가 user_prompt 첫 블록 — spec §5.1.
_card_block = (
    "[RenderPromptCard v1]\n"
    + json.dumps(
        _render_prompt_card,
        sort_keys=True, ensure_ascii=False, separators=(",", ":"),
    )
    + "\n\n"
)
user_prompt = _card_block + user_prompt
```

(`json` import 가 detail_steps.py 에 이미 있는지 확인 — 없으면 file 상단 import
추가.)

**Critical**: card JSON 직렬화는 **canonical form** (sort_keys=True,
ensure_ascii=False, separators) — hash 계산과 동일. `[:N]` truncation 절대 금지
(CLAUDE.md).

- [ ] **Step 3: 회귀 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_detail_steps.py -x -q 2>&1 | tail -10
```
Expected: 기존 PASS 유지 (user_prompt 변경은 LLM 호출 행동에는 영향, unit test
는 mocked LLM 이므로 PASS 가능).

<!-- R3-M1: see Round 3 Override -->
### Task 17: result top-level field — `render_prompt_card` + `render_prompt_card_hash` (modify scope guard 4/5)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` — `_analyze_one()` 안의
  result dict 빌드 위치.

**Open Q1 RESOLVED**: card 는 **top-level result** field. variation level 에
넣지 마라. variation drift 는 G3.2 sentinel (`t2i_variations[].owned_validation`)
가 담당.

- [ ] **Step 1: result dict 빌드 위치 찾기 (line ~286 근처)**

```bash
grep -n "schema_version.*SCENE_DETAIL_SCHEMA_VERSION\|prompt_version.*SCENE_DETAIL_PROMPT_VERSION" /Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/steps/detail_steps.py | head -10
```

`_analyze_one()` 의 result dict literal 위치 (line ~286 근처) 확인.

- [ ] **Step 2: 2 field 추가**

```python
result = {
    "schema_version": SCENE_DETAIL_SCHEMA_VERSION,  # 7 (G4.1)
    "prompt_version": SCENE_DETAIL_PROMPT_VERSION,  # 16.202605041200
    # ... 기존 fields 그대로 ...
    "t2i_variations": _t2i_variations,
    # G4.1: top-level card + hash (Open Q1 RESOLVED).
    "render_prompt_card": _render_prompt_card,
    "render_prompt_card_hash": _render_prompt_card_hash,
}
```

**중요**: `t2i_variations[].owned_validation` (G3.2 sentinel) 는 그대로 유지.
card 가 sentinel 을 대체하지 않음.

- [ ] **Step 3: 회귀 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_detail_steps.py tests/unit/test_scene_detail_owned_judge.py -x -q 2>&1 | tail -10
```
Expected: 기존 PASS 유지.

---

**Phase 5 verification:**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py tests/unit/test_render_prompt_card_hash.py tests/unit/test_detail_steps.py tests/unit/test_scene_detail_owned_judge.py -x -q
```
Expected: 91+ PASS + 기존 detail_steps / owned_judge 회귀 0 (R3-M4: 50+19+3+19=91).

---

## Phase 6 — verify_completion() Card Drift Detection

### Task 18: verify_completion 에 card load + shape assert + hash drift 검사

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` — `verify_completion()` 함수.

verify_completion 책임 (G3.2 패턴 mirror):
- cp 의 각 shot 결과에서 `render_prompt_card` + `render_prompt_card_hash` 로드.
- `assert_card_shape(card)` 호출.
- 현재 ctx 로 card 재계산 → 새 hash 와 cp hash 비교.
- mismatch 시 `CompletionReport` 에 partial 마킹 (severity = WARNING 또는
  PARTIAL — G3.2 와 동일 등급).
- card 자체 missing → contract_violation severity (= upgrade 가 필요한 cp).

- [ ] **Step 1: 현재 `verify_completion()` 위치 + G3.2 sentinel drift 패턴 확인**

```bash
grep -n "def verify_completion\|owned_validation.*missing\|owned_hash.*drift\|t2i_prompt_hash.*drift" /Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/steps/detail_steps.py | head -10
```

G3.2 sentinel 검증 패턴 그대로 mirror.

- [ ] **Step 2: card 검증 블록 추가**

`verify_completion()` 안 — 각 shot result 순회 루프 안에서 G3.2 sentinel 검증
직후에 추가:

```python
# G4.1: RenderPromptCard drift detection (G3.2 sentinel 패턴 mirror).
from app.core.steps.render_prompt_card import (
    build_render_prompt_card, compute_card_hash, assert_card_shape,
)

_stored_card = result.get("render_prompt_card")
_stored_card_hash = result.get("render_prompt_card_hash")

if _stored_card is None or _stored_card_hash is None:
    # Open Q4 schema_version=7 인데 card 부재 → 옛 v15 cp 잔존.
    report.add_violation(
        scene_index=scene_idx, shot_index=shot_idx,
        severity="contract_violation",
        message=(
            f"render_prompt_card or render_prompt_card_hash missing in cp "
            f"(scene={scene_idx} shot={shot_idx}) — v15 cp 잔존. "
            "force scene_detail 필요."
        ),
    )
    continue

# Shape strict check — 위반 시 즉시 contract_violation.
try:
    assert_card_shape(
        _stored_card,
        where=f"verify_completion(s{scene_idx}_sh{shot_idx})",
    )
except AppError as e:
    report.add_violation(
        scene_index=scene_idx, shot_index=shot_idx,
        severity="contract_violation", message=str(e),
    )
    continue

# Drift: 현재 ctx 로 재계산 → 비교.
_recomputed_card = build_render_prompt_card(**_card_inputs_for_verify)
_recomputed_hash = compute_card_hash(_recomputed_card)
if _recomputed_hash != _stored_card_hash:
    report.add_violation(
        scene_index=scene_idx, shot_index=shot_idx,
        severity="partial",
        message=(
            f"render_prompt_card_hash drift: stored={_stored_card_hash} "
            f"vs recomputed={_recomputed_hash}. upstream contract changed "
            "(visible/outlook/owned/staging/fixed_elements). force scene_detail "
            "필요."
        ),
    )
```

`_card_inputs_for_verify` 는 verify_completion 안에서 동일 input 도출 — Task 14
의 R2-B3 helper `_collect_card_inputs(...)` 호출 (단일 source). drift detection
의 input 계산이 `_analyze_one()` 과 일관됨을 보장 (mismatch source 에서
오는 false drift 차단).

- [ ] **Step 3: 회귀 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_detail_steps.py -x -q 2>&1 | tail -10
```
Expected: 기존 PASS 유지 (verify 에 새 검증 추가 — 기존 fixture 가 v7 cp shape
없으면 fixture 갱신 필요 — Task 21 cleanup 에서 처리).

---

**Phase 6 verification:**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py tests/unit/test_render_prompt_card_hash.py tests/unit/test_detail_steps.py -x -q
```
Expected: 91+ PASS + 회귀 0 (R3-M4: 50+19+3+19=91).

---

## Phase 7 — _user_edited Reuse Path Drift

<!-- R2-I6 / R3-M1 / R3-I2: see Round 2 + Round 3 Override -->
### Task 19: `_user_edited` reuse path card hash drift 검사 (modify scope guard 5/5, R3-M1)

**Files:**
- Modify: `backend/app/core/steps/detail_steps.py` — `_user_edited` reuse 분기
  (line ~300+, G3.1 evidence assert / G3.2 owned validation 검사 직후).

G3.1 / G3.2 동일 패턴 carry — user 가 cp 를 수동 편집했고 reuse 경로로 진입했을
때, card hash drift 가 있으면 reuse 거부 + fresh 재생성.

**R2-I6 (drift guard helper extraction)**: drift 검사 logic 을 별도 helper
`_user_edited_card_contract_violated(stored_card, stored_hash, card_inputs) -> Optional[str]`
로 추출 → **unit test 가능**. helper 가 None 반환하면 valid (reuse OK),
str 반환하면 violation reason (reuse 거부 → fresh path). G3.1/G3.2 의
`_evidence_violated()` / `_owned_validation_violated()` 패턴 mirror.

- [ ] **Step 1: `_user_edited` 분기 위치 확인**

```bash
grep -n "_user_edited\|user_edited" /Users/manta/Documents/Projects/TheRoad-I1/backend/app/core/steps/detail_steps.py | head -10
```

- [ ] **Step 2: helper extraction (R2-I6) — `_user_edited_card_contract_violated()`**

```python
# detail_steps.py 모듈 helper section.
from typing import Optional

def _user_edited_card_contract_violated(
    *,
    stored_card: Optional[Dict[str, Any]],
    stored_hash: Optional[str],
    card_inputs: Dict[str, Any],
) -> Optional[str]:
    """G4.1 R2-I6: `_user_edited` reuse path card drift guard — testable helper.

    Returns None 이면 reuse OK. str 이면 violation reason (reuse 거부 →
    fresh path). G3.1 `_evidence_violated()` / G3.2 `_owned_validation_violated()`
    패턴 mirror.

    Caller (`_user_edited` reuse 분기) 가 본 helper 의 return 값으로
    reuse vs fresh 결정.
    """
    from app.core.steps.render_prompt_card import (
        build_render_prompt_card, compute_card_hash, assert_card_shape,
    )

    if stored_card is None or stored_hash is None:
        return "card_or_hash_missing"
    try:
        assert_card_shape(stored_card, where="_user_edited")
    except AppError as e:
        return f"card_shape_invalid: {e}"
    try:
        recomputed_card = build_render_prompt_card(**card_inputs)
        recomputed_hash = compute_card_hash(recomputed_card)
    except AppError as e:
        return f"recompute_failed: {e}"
    if recomputed_hash != stored_hash:
        return f"hash_drift: stored={stored_hash} vs recomputed={recomputed_hash}"
    return None
```

- [ ] **Step 2b: drift 검사 추가 (G3.1 evidence + G3.2 owned 직후)**

```python
# G4.1: _user_edited reuse path card drift (G3.1/G3.2 패턴 carry).
# R2-I6: helper 사용 → unit testable.

_stored_card = existing_result.get("render_prompt_card")
_stored_card_hash = existing_result.get("render_prompt_card_hash")
_violation = _user_edited_card_contract_violated(
    stored_card=_stored_card,
    stored_hash=_stored_card_hash,
    card_inputs=_card_inputs,  # Task 14 R2-B3 helper 결과
)
if _violation is not None:
    logger.warning(
        "scene_detail _user_edited reuse: card contract violated (%s) "
        "(s%s_sh%s) — fresh re-generation 진입.",
        _violation, scene_idx, shot_idx,
    )
    return False  # reuse 거부 → fresh 경로
```

- [ ] **Step 3: 회귀 검증**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_detail_steps.py -x -q 2>&1 | tail -10
```
Expected: 기존 PASS 유지.

---

**Phase 7 verification:**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/unit/test_render_prompt_card.py tests/unit/test_render_prompt_card_hash.py tests/unit/test_detail_steps.py -x -q
```
Expected: 91+ PASS + 회귀 0 (R3-M4: 50+19+3+19=91).

---

## Phase 8 — Integration Test + Regression Cleanup

<!-- R1-I5 / R1-I6 / R1-I8 / R2-I3 / R2-I4 / R2-I5 / R2-I6 / R2-I7 / R3-B1 / R3-B2 / R3-I1 / R3-I2: see Round 1 + Round 2 + Round 3 Override -->
### Task 20: integration test — `test_g4_1_card_consumer_wiring.py`

**Files:**
- Create: `backend/tests/integration/test_g4_1_card_consumer_wiring.py`

테스트 시나리오 (spec §7.2, **R1-I5/I6/I8 + R2-I3/I4/I5/I6/I7 + R3-B1/B2/I1/I2 적용 후**):
1. `scene_detail` user_prompt 가 정확히 한 개 `[RenderPromptCard v1]` 블록 포함.
2. 첫 블록 위치 (= prefix, narrative 보다 먼저).
3. card JSON parse 가능 + assert_card_shape 통과.
4. **R1-I6**: `scene_still_normalizer` preservation 요구 **삭제** — card 는
   G4.1 에서 CP-only debug 필드. DB persistence 는 별도 scope (G4.6+ / G5).
5. `verify_completion` 이 missing card 잡음.
6. `verify_completion` 이 hash drift 잡음.
7. `_user_edited` reuse path drift 거부 (R1-I8 추가 verification).
8. close framing variant: bg owned [] / readiness skipped.
9. non-close + bg-on variant: bg owned 보존 / readiness block.
10. bg-mode-off variant: card mode = background_mode_off / readiness na.
11. multi-outlook variant: 모든 pair 가 allowed_outlook_pairs.
12. fixed_elements 빈 list 도 valid card.
13. analysis-only dispatcher path 가 background_prompt depends_on 위반 시 block.
14. **R2-I4 (변경 — verify partial 직접 assert)**: t2i_review post-edit 후
    owned_validation 의 t2i_prompt_hash 가 stale 인 fixture 만든 후
    `verify_completion()` **직접 호출** → severity="partial" + missing_msgs
    에 sentinel_drifted 포함 assert. card hash 자체는 shot-level 이라
    t2i_prompt 수정과 무관 — 재계산 대상 X (variation-level G3.2 sentinel
    의 `t2i_prompt_hash` 만 stale 로 잡힘). R1-I5 의 hash 비교만 하던 test
    가 verify_completion 직접 assert 로 강화됨.
15. **R1-I7 (full-list verification — connect)**: forward_zoom_targets count
    > 6 + 각 keep_elements count > 5 → card payload + card hash 가 모든
    entry 보존 (legacy `[:6]`/`[:5]` cap 의 영향 받지 않음).
16. **R1-I8 (previous_shot_refs variants)**: ref_usage="zoom_in_detail" /
    "continuation" 두 case 모두 card 에 보존 + 각각 hash drift 검출.
17. **R2-I7 (asset_requirements 3 분기 분리)**: 기존 forbidden-only case
    가 outlook required ref 포함되어 진정한 forbidden-only 아니었음 (R1
    audit). 분리 fixture: (a) **required-only** (outlook+bg required, no
    forbidden) / (b) **forbidden-only** (close framing skip, no outlook /
    no bg) / (c) **mixed** (required+forbidden 공존). 각 분기에서
    readiness_policy + required_refs + forbidden_refs 모두 일관성 검증.
18. **R2-I6 (`_user_edited` card drift — 실제 reuse 거부 path 진입 assert)**:
    G3.x 패턴 mirror — `_user_edited_card_contract_violated()` helper 단위
    test (None 반환 시 reuse OK / str 반환 시 reuse 거부). integration
    fixture 가 `_user_edited` branch 로 진입해 fresh path 가 실제로 trigger
    됐는지 assert.
19. **R2-I5 (변경 — v15→v16 migration smoke 실제 StepRunner 호출)**: completed
    v15 cp fixture (schema_version=6) 만든 후 `SceneDetailStep.run("resume")`
    또는 StepRunner mismatch path **실제 호출** — mode="force" escalate
    동작 + v16 재실행 trigger 검증 (R1-B1 운영 동작 + R1-I8 contract
    레벨 진단 모두 cover). PNG 보존 / cp invalidation 동작.
20. **R2-I3 (card-vs-legacy conflict)**: `TestCardWinsOverLegacyConflict`
    신규 — card field 와 legacy prompt block 이 충돌하는 fixture (예:
    card.id_policy 가 C##O## 강제 / legacy block 이 보통명사 권장) →
    LLM output 이 card 따르는지 검증 (또는 prompt 자체에 "card wins" 명시
    grep 확인 — R1-I4 cross-check).

- [ ] **Step 1: 테스트 작성**

```python
# backend/tests/integration/test_g4_1_card_consumer_wiring.py
"""G4.1 RenderPromptCard consumer wiring integration tests."""
from __future__ import annotations

import copy
import json

import pytest

from app.core.steps.render_prompt_card import (
    CARD_SCHEMA_VERSION, build_render_prompt_card, compute_card_hash,
    assert_card_shape,
)


# R3-B2: sentinel 기반 None vs [] 명시 구분.
# `outlook_pairs or default` / `fixed_elements or []` 패턴 제거 — caller 가
# 명시적으로 [] 를 넘기면 빈 list 그대로 보존 (forbidden-only fixture
# 진정성 보장). None 만 default 로 치환.
_DEFAULT_SENTINEL = object()


def _make_ctx_for_shot(
    *, close_framing: bool = False, bg_on: bool = True,
    bg_id: str = "cb_main_room",
    outlook_pairs=_DEFAULT_SENTINEL,
    fixed_elements=_DEFAULT_SENTINEL,
):
    # R3-B2: explicit None vs sentinel — `outlook_pairs=[]` 는 빈 list 보존,
    # `outlook_pairs=None` 도 명시적 None (builder 가 raise) — 둘 다 default
    # 치환 안 함. sentinel (=인자 미전달) 일 때만 default outlook 채움.
    if outlook_pairs is _DEFAULT_SENTINEL:
        outlook_pairs = [{"character_id": "C01", "outlook_id": "O02"}]
    if fixed_elements is _DEFAULT_SENTINEL:
        fixed_elements = []
    return {
        "scene_index": 12, "shot_index": 4,
        "seg": {"index": 12, "text": "scene text full content here"},
        "shot_info": {
            "shot_index": 4,
            "camera_direction": "extreme close-up of hand"
                                if close_framing else "medium shot of doorway",
            "primary_subject": "the observer at the doorway",
        },
        "visible_entities": ["C01"],
        "outlook_pairs": outlook_pairs,  # R3-B2: explicit, no `or default`
        "perception_mode": None,
        "staging": {
            "camera_direction": "extreme close-up of hand"
                                if close_framing else "medium shot of doorway",
            "lighting_mood": "warm dim",
        },
        "bg_id": bg_id if bg_on else None,
        "bg_owned": ["door", "window"] if bg_on else [],
        "bg_camera_meta": {"camera_position": "southeast doorway"} if bg_on else None,
        "bg_guide": "doorway view" if bg_on else None,
        "is_close_framing": close_framing,
        "background_mode_on": bg_on,
        "fixed_elements": fixed_elements,  # R3-B2: explicit, no `or []`
        "previous_shot_refs": [],
        "forward_zoom_targets": [],
    }


class TestCardSinglePromptBlock:
    def test_user_prompt_has_exactly_one_card_block(self) -> None:
        # spec §7.2: scene_detail user_prompt 는 RenderPromptCard 블록 1 회만.
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block = (
            "[RenderPromptCard v1]\n"
            + json.dumps(card, sort_keys=True, ensure_ascii=False,
                         separators=(",", ":"))
            + "\n\n"
        )
        legacy = "[Minimal narrative input]\nscene summary: foo\n"
        user_prompt = block + legacy
        assert user_prompt.count("[RenderPromptCard v1]") == 1
        # 첫 블록 위치 — prefix.
        assert user_prompt.startswith("[RenderPromptCard v1]")

    def test_card_json_parseable_and_valid(self) -> None:
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        block_json = json.dumps(card, sort_keys=True, ensure_ascii=False,
                                separators=(",", ":"))
        parsed = json.loads(block_json)
        assert_card_shape(parsed)


class TestCloseVsNonCloseVariants:
    def test_close_framing_card_consistent(self) -> None:
        ctx = _make_ctx_for_shot(close_framing=True, bg_on=True)
        c = build_render_prompt_card(**ctx)
        assert c["render_strategy"]["framing_scale"] == "close"
        assert c["background_binding"]["mode"] == "skipped_close_framing"
        assert c["background_binding"]["owned_objects"] == []
        assert c["asset_requirements"]["readiness_policy"] == "skipped_by_policy"
        assert any(f["kind"] == "background"
                   for f in c["asset_requirements"]["forbidden_refs"])

    def test_non_close_with_bg_card_consistent(self) -> None:
        ctx = _make_ctx_for_shot(close_framing=False, bg_on=True)
        c = build_render_prompt_card(**ctx)
        assert c["background_binding"]["mode"] == "background_ref_attached"
        assert c["background_binding"]["owned_objects"] == ["door", "window"]
        assert c["asset_requirements"]["readiness_policy"] == "block_if_missing"

    def test_bg_mode_off_card_consistent(self) -> None:
        ctx = _make_ctx_for_shot(close_framing=False, bg_on=False)
        c = build_render_prompt_card(**ctx)
        assert c["background_binding"]["mode"] == "background_mode_off"
        assert c["asset_requirements"]["readiness_policy"] == "not_applicable"


class TestMultiOutlookAndEmptyFixedElements:
    def test_multi_outlook(self) -> None:
        ctx = _make_ctx_for_shot(outlook_pairs=[
            {"character_id": "C01", "outlook_id": "O02"},
            {"character_id": "C02", "outlook_id": "O01"},
        ])
        c = build_render_prompt_card(**ctx)
        assert len(c["id_policy"]["allowed_outlook_pairs"]) == 2
        outlook_refs = [r for r in c["asset_requirements"]["required_refs"]
                        if r["kind"] == "character_outlook"]
        assert len(outlook_refs) == 2
        ids = {r["id"] for r in outlook_refs}
        assert ids == {"C01O02", "C02O01"}

    def test_fixed_elements_empty_is_valid(self) -> None:
        ctx = _make_ctx_for_shot(fixed_elements=[])
        c = build_render_prompt_card(**ctx)
        assert c["continuity_elements_used"]["fixed_elements"] == []
        assert_card_shape(c)  # 통과


class TestHashDrift:
    def test_owned_change_drifts_hash(self) -> None:
        ctx1 = _make_ctx_for_shot()
        ctx2 = _make_ctx_for_shot()
        ctx2["bg_owned"] = ["door", "window", "TV"]
        c1 = build_render_prompt_card(**ctx1)
        c2 = build_render_prompt_card(**ctx2)
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_outlook_change_drifts_hash(self) -> None:
        ctx1 = _make_ctx_for_shot()
        ctx2 = _make_ctx_for_shot(outlook_pairs=[
            {"character_id": "C01", "outlook_id": "O03"},
        ])
        c1 = build_render_prompt_card(**ctx1)
        c2 = build_render_prompt_card(**ctx2)
        assert compute_card_hash(c1) != compute_card_hash(c2)

    def test_camera_change_drifts_hash(self) -> None:
        ctx1 = _make_ctx_for_shot()
        ctx2 = _make_ctx_for_shot()
        ctx2["staging"] = {"camera_direction": "low angle wide",
                           "lighting_mood": "harsh"}
        ctx2["shot_info"]["camera_direction"] = "low angle wide"
        c1 = build_render_prompt_card(**ctx1)
        c2 = build_render_prompt_card(**ctx2)
        assert compute_card_hash(c1) != compute_card_hash(c2)


class TestCpFieldPreservation:
    """R1-I6: card 는 G4.1 에서 CP-only debug 필드. scene_still_normalizer
    preservation 요구는 본 G4.1 scope 밖 (DB persistence 는 G4.6+ / G5).
    deep copy preservation 만 검증 (in-memory CP traversal 보장)."""

    def test_in_memory_deep_copy_preserves_card(self) -> None:
        # cp 가 in-memory 로 deep copy 되는 경로 (e.g. retry, snapshot) 에서
        # card 가 자동 보존 — 명시적 strip 안 함. R1-I6 으로 normalizer 검증
        # 제거.
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        cp_result = {
            "schema_version": 7, "prompt_version": "16.202605041200",
            "render_prompt_card": card,
            "render_prompt_card_hash": compute_card_hash(card),
            "t2i_variations": [{
                "t2i_prompt": "A figure stands by the door.",
                "owned_validation": {  # G3.2 sentinel coexist
                    "schema_version": 1, "validator": "scene_detail_owned_objects.v1",
                    "owned_hash": "x"*16, "camera_direction_hash": "y"*16,
                    "t2i_prompt_hash": "z"*16, "violations": [],
                },
            }],
        }
        copied = copy.deepcopy(cp_result)
        # card / hash 보존.
        assert copied["render_prompt_card"] == card
        assert copied["render_prompt_card_hash"] == cp_result["render_prompt_card_hash"]
        # G3.2 sentinel 보존 — card 가 sentinel 대체하지 않음.
        assert copied["t2i_variations"][0]["owned_validation"]["validator"] == \
            "scene_detail_owned_objects.v1"


# R3-I1: see Round 3 Override
class TestT2iReviewPostEditSentinelStale:
    """R1-I5 + R2-I4 + R3-I1: t2i_review post-edit 후 owned_validation 이 stale
    → verify_completion() **직접 호출** → severity="partial" + missing_msgs 에
    sentinel_drifted 포함 assert. R3-I1 강화: substring 만 → exact literal
    `missing_msgs` + `sentinel_drifted` metadata tuple + severity literal
    assert. card hash 는 shot-level 이라 t2i_prompt 수정과 무관 — 재계산 대상
    X. judge 재호출 비추천 (G3.2 결정)."""

    def test_post_edit_sentinel_t2i_prompt_hash_drifts(self) -> None:
        # G3.2 sentinel 의 t2i_prompt_hash 는 t2i_prompt 와 binding.
        # t2i_review 가 t2i_prompt 를 in-place 수정하면 sentinel 의
        # t2i_prompt_hash 가 stale → 다음 verify 에서 sentinel_drifted partial.
        from app.core.steps._owned_helpers import compute_t2i_prompt_hash
        original_prompt = "A figure stands by the door."
        edited_prompt = "A figure stands by the doorway, holding a key."
        original_hash = compute_t2i_prompt_hash(original_prompt)
        edited_hash = compute_t2i_prompt_hash(edited_prompt)
        assert original_hash != edited_hash
        # card hash 는 shot-level 이라 t2i_prompt 수정과 무관 — 변하지 않음.
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        h_before = compute_card_hash(card)
        # t2i_review 가 t2i_prompt 만 수정. card 는 그대로.
        h_after = compute_card_hash(card)
        assert h_before == h_after  # card hash 무관성

    def test_verify_completion_marks_partial_on_sentinel_drift(self) -> None:
        # R2-I4 + R3-I1: hash 비교만 하던 R1-I5 test 강화 → verify_completion
        # 직접 호출 + R3-I1 강화: substring 만 → exact `missing_msgs` literal +
        # `sentinel_drifted` metadata tuple + severity literal assert.
        from app.core.steps.detail_steps import verify_completion
        from app.core.steps._owned_helpers import compute_t2i_prompt_hash

        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        edited_prompt = "A figure stands by the doorway, holding a key."
        # sentinel hash 는 *원본* prompt 의 hash 로 frozen — 즉 stale.
        stale_sentinel_hash = compute_t2i_prompt_hash("A figure stands by the door.")
        cp_result = {
            "schema_version": 7, "prompt_version": "16.202605041200",
            "render_prompt_card": card,
            "render_prompt_card_hash": compute_card_hash(card),
            "t2i_variations": [{
                "t2i_prompt": edited_prompt,
                "owned_validation": {
                    "schema_version": 1,
                    "validator": "scene_detail_owned_objects.v1",
                    "owned_hash": "x" * 16,
                    "camera_direction_hash": "y" * 16,
                    "t2i_prompt_hash": stale_sentinel_hash,  # stale
                    "violations": [],
                },
            }],
        }
        report = verify_completion(
            results_by_shot={(12, 4): cp_result},
            ctx_factory=lambda s, sh: _make_ctx_for_shot(),
        )
        # R3-I1: literal severity + literal missing message + metadata tuple.
        # report.is_complete is False / report.severity == "partial" /
        # report.missing 안에 정확한 "sentinel_drifted" message 포함 /
        # report.metadata.sentinel_drifted list 가 (12, 4, "t2i_prompt_hash")
        # tuple 포함.
        assert report.is_complete is False, (
            f"R3-I1: report.is_complete must be False (got {report.is_complete!r})"
        )
        assert report.severity == "partial", (
            f"R3-I1: report.severity must be literal 'partial' "
            f"(got {report.severity!r})"
        )
        assert any("sentinel_drifted" in m for m in report.missing), (
            f"R3-I1: report.missing must include 'sentinel_drifted' literal "
            f"(missing={list(report.missing)!r})"
        )
        # R3-I1: metadata 의 sentinel_drifted list literal tuple 포함.
        sentinel_drifted = (report.metadata or {}).get("sentinel_drifted") or []
        assert (12, 4, "t2i_prompt_hash") in [tuple(s) for s in sentinel_drifted], (
            f"R3-I1: report.metadata.sentinel_drifted must include "
            f"(12, 4, 't2i_prompt_hash') tuple (got {sentinel_drifted!r})"
        )


class TestForwardZoomFullListIntegration:
    """R1-I7: forward_zoom_targets count > 6 + keep_elements count > 5 →
    card payload + card hash 가 모든 entry 보존. legacy [:6]/[:5] cap 의
    영향 받지 않음."""

    def test_forward_zoom_full_list_preserved_in_card(self) -> None:
        ctx = _make_ctx_for_shot()
        ctx["forward_zoom_targets"] = [
            {
                "scene_index": 12, "shot_index": 5 + i,
                "description": f"forward zoom #{i}",
                "keep_elements": [f"k_{j}" for j in range(7)],  # > 5
            }
            for i in range(8)  # > 6
        ]
        c = build_render_prompt_card(**ctx)
        assert len(c["continuity_elements_used"]["forward_zoom_targets"]) == 8
        for entry in c["continuity_elements_used"]["forward_zoom_targets"]:
            assert len(entry["keep_elements"]) == 7

    def test_forward_zoom_count_change_drifts_hash(self) -> None:
        # full list 보존 → entry 수 변화 시 hash drift 검출.
        ctx1 = _make_ctx_for_shot()
        ctx1["forward_zoom_targets"] = [
            {"scene_index": 12, "shot_index": 5 + i,
             "description": f"f#{i}", "keep_elements": []}
            for i in range(7)
        ]
        ctx2 = _make_ctx_for_shot()
        ctx2["forward_zoom_targets"] = [
            {"scene_index": 12, "shot_index": 5 + i,
             "description": f"f#{i}", "keep_elements": []}
            for i in range(8)
        ]
        c1 = build_render_prompt_card(**ctx1)
        c2 = build_render_prompt_card(**ctx2)
        assert compute_card_hash(c1) != compute_card_hash(c2)


class TestPreviousShotRefsVariants:
    """R1-I8: previous_shot_refs 다양 case (zoom_in_detail / continuation)."""

    def test_zoom_in_detail_preserved(self) -> None:
        ctx = _make_ctx_for_shot()
        ctx["previous_shot_refs"] = [{
            "scene_index": 12, "shot_index": 3,
            "ref_usage": "zoom_in_detail", "keep_elements": ["lamp"],
        }]
        c = build_render_prompt_card(**ctx)
        psr = c["continuity_elements_used"]["previous_shot_refs"]
        assert psr[0]["ref_usage"] == "zoom_in_detail"

    def test_continuation_preserved(self) -> None:
        ctx = _make_ctx_for_shot()
        ctx["previous_shot_refs"] = [{
            "scene_index": 12, "shot_index": 3,
            "ref_usage": "continuation", "keep_elements": ["chair"],
        }]
        c = build_render_prompt_card(**ctx)
        psr = c["continuity_elements_used"]["previous_shot_refs"]
        assert psr[0]["ref_usage"] == "continuation"

    def test_ref_usage_change_drifts_hash(self) -> None:
        ctx1 = _make_ctx_for_shot()
        ctx2 = _make_ctx_for_shot()
        ctx1["previous_shot_refs"] = [{
            "scene_index": 12, "shot_index": 3,
            "ref_usage": "zoom_in_detail", "keep_elements": [],
        }]
        ctx2["previous_shot_refs"] = [{
            "scene_index": 12, "shot_index": 3,
            "ref_usage": "continuation", "keep_elements": [],
        }]
        c1 = build_render_prompt_card(**ctx1)
        c2 = build_render_prompt_card(**ctx2)
        assert compute_card_hash(c1) != compute_card_hash(c2)


# R3-B2: see Round 3 Override
class TestAssetRequirementsBranches:
    """R2-I7 + R3-B2: asset_requirements 3 분기 분리. R1-I8 의 단일 case 가
    outlook required ref 포함되어 진정한 forbidden-only 아니었음 (R1 audit).
    본 class 는 3 분기 (required-only / forbidden-only / mixed) 명확히 분리.
    R3-B2: forbidden-only fixture 의 `outlook_pairs=[]` 가 sentinel pattern
    으로 빈 list 보존되는지 진단 추가."""

    def test_required_only_branch(self) -> None:
        # (a) required-only: outlook + bg 둘 다 required. forbidden 0.
        # 조건: non-close framing + bg_on=True + outlook_pairs 존재.
        ctx = _make_ctx_for_shot(close_framing=False, bg_on=True)
        c = build_render_prompt_card(**ctx)
        assert c["asset_requirements"]["readiness_policy"] == "block_if_missing"
        assert c["asset_requirements"]["forbidden_refs"] == []  # 진정한 required-only
        kinds = [r["kind"] for r in c["asset_requirements"]["required_refs"]]
        assert "character_outlook" in kinds
        assert "background" in kinds  # bg required

    def test_forbidden_only_branch_pure(self) -> None:
        # (b) forbidden-only: close framing + bg_on=True + **outlook_pairs 없음**.
        # 진정한 forbidden-only — required_refs 비어 있어야 함.
        # close framing → bg forbidden + readiness=skipped_by_policy.
        # R3-B2: `outlook_pairs=[]` 가 sentinel default 로 치환되지 않고
        # 빈 list 그대로 보존되는지 확인 — _make_ctx_for_shot 의 sentinel
        # 패턴 검증.
        ctx = _make_ctx_for_shot(
            close_framing=True, bg_on=True, outlook_pairs=[],
        )
        # R3-B2 진단: ctx 가 빈 list 보존했는지 (default outlook 안 들어감).
        assert ctx["outlook_pairs"] == [], (
            "R3-B2 regression — _make_ctx_for_shot(outlook_pairs=[]) 가 "
            "default 로 치환됨 (sentinel 패턴 깨짐)"
        )
        c = build_render_prompt_card(**ctx)
        assert c["asset_requirements"]["required_refs"] == []  # 진정한 forbidden-only
        assert any(f["kind"] == "background"
                   for f in c["asset_requirements"]["forbidden_refs"])
        assert c["asset_requirements"]["readiness_policy"] == "skipped_by_policy"

    def test_mixed_branch_required_and_forbidden(self) -> None:
        # (c) mixed: close framing + bg_on=True + outlook_pairs 존재.
        # bg forbidden + outlook required 공존.
        ctx = _make_ctx_for_shot(close_framing=True, bg_on=True)
        c = build_render_prompt_card(**ctx)
        assert any(f["kind"] == "background"
                   for f in c["asset_requirements"]["forbidden_refs"])
        # outlook 은 required 그대로 (mixed).
        assert any(r["kind"] == "character_outlook"
                   for r in c["asset_requirements"]["required_refs"])
        # bg 는 required 가 아님 (forbidden 으로 갔으므로).
        assert not any(r["kind"] == "background"
                       for r in c["asset_requirements"]["required_refs"])
        assert c["asset_requirements"]["readiness_policy"] == "skipped_by_policy"

    def test_canonicalize_required_refs_order_invariant(self) -> None:
        # R1-I3 list ordering — required_refs permutation invariance.
        ctx1 = _make_ctx_for_shot(outlook_pairs=[
            {"character_id": "C01", "outlook_id": "O02"},
            {"character_id": "C02", "outlook_id": "O01"},
        ])
        ctx2 = _make_ctx_for_shot(outlook_pairs=[
            {"character_id": "C02", "outlook_id": "O01"},
            {"character_id": "C01", "outlook_id": "O02"},
        ])
        c1 = build_render_prompt_card(**ctx1)
        c2 = build_render_prompt_card(**ctx2)
        assert compute_card_hash(c1) == compute_card_hash(c2)


# R3-I2: see Round 3 Override
class TestUserEditedCardDrift:
    """R1-I8 + R2-I6 + R3-I2: `_user_edited` reuse path 의 card hash drift
    (G3.x 패턴 carry). Task 19 R2-I6 helper `_user_edited_card_contract_violated()`
    를 직접 호출 → unit testable. R3-I2 강화: helper simulation 만 →
    `SceneDetailStep._execute()` 직접 호출 + fresh path 진입 검증
    (`_analyze_one()` 호출 횟수 + `_user_edited` False transition).
    G3.1/G3.2 의 `_user_edited_owned_contract_violated` test shape mirror."""

    def test_helper_returns_none_when_card_matches(self) -> None:
        # R2-I6: helper 가 None 반환 = reuse OK.
        from app.core.steps.detail_steps import (
            _user_edited_card_contract_violated,
        )
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        h = compute_card_hash(card)
        violation = _user_edited_card_contract_violated(
            stored_card=card, stored_hash=h, card_inputs=ctx,
        )
        assert violation is None  # reuse OK

    def test_helper_returns_reason_on_owned_drift(self) -> None:
        # R2-I6: helper 가 str 반환 = reuse 거부 → fresh.
        from app.core.steps.detail_steps import (
            _user_edited_card_contract_violated,
        )
        ctx_old = _make_ctx_for_shot()
        ctx_new = _make_ctx_for_shot()
        ctx_new["bg_owned"] = ["door", "window", "TV"]  # owned 변경
        card_old = build_render_prompt_card(**ctx_old)
        h_old = compute_card_hash(card_old)
        violation = _user_edited_card_contract_violated(
            stored_card=card_old, stored_hash=h_old, card_inputs=ctx_new,
        )
        assert violation is not None
        assert "hash_drift" in violation

    def test_helper_returns_reason_when_card_missing(self) -> None:
        # R2-I6: stored_card None → "card_or_hash_missing".
        from app.core.steps.detail_steps import (
            _user_edited_card_contract_violated,
        )
        ctx = _make_ctx_for_shot()
        violation = _user_edited_card_contract_violated(
            stored_card=None, stored_hash=None, card_inputs=ctx,
        )
        assert violation == "card_or_hash_missing"

    def test_user_edited_branch_enters_fresh_on_drift(self) -> None:
        # R2-I6 + R3-I2 integration: fresh path 진입 assert (G3.1/G3.2 패턴
        # mirror). `_user_edited` 분기에서 helper 가 violation 반환 → reuse
        # 거부 (return False) → caller 가 fresh path 로 진입.
        # R3-I2 강화: helper simulation 만 → 실제 SceneDetailStep._execute()
        # 직접 호출 + fresh path 진입 검증 (`_analyze_one()` 호출 횟수
        # transition / result `_user_edited` False transition).
        from app.core.steps.detail_steps import (
            _user_edited_card_contract_violated,
        )
        ctx = _make_ctx_for_shot()
        ctx_drifted = _make_ctx_for_shot(outlook_pairs=[
            {"character_id": "C01", "outlook_id": "O99"},  # outlook 변경
        ])
        card_old = build_render_prompt_card(**ctx)
        h_old = compute_card_hash(card_old)
        # _user_edited reuse 분기 simulation.
        reuse_decision = _user_edited_card_contract_violated(
            stored_card=card_old, stored_hash=h_old, card_inputs=ctx_drifted,
        )
        # str 반환 = reuse 거부 = caller 가 fresh path 진입.
        assert reuse_decision is not None
        # 이 시점에서 _analyze_one() 의 fresh re-generation 분기가 실행된다.
        # (실제 fresh execution 은 다음 R3-I2 test 에서 직접 호출 검증.)

    def test_user_edited_branch_invokes_fresh_path_via_execute(self) -> None:
        # R3-I2 (강화): 위 test 가 helper simulation 만 했음 → 본 test 는
        # 실제 `SceneDetailStep._execute()` (또는 reuse path 진입 함수) 직접
        # 호출 + fresh path 진입 검증. G3.1/G3.2 의 `_user_edited_owned_
        # contract_violated` test 와 같은 shape.
        #
        # fixture: cp_data 에 `_user_edited=True` + variations 모두 confidence
        # != "legacy" (G3.1 evidence 통과) + card hash mismatch.
        # 검증: SceneDetailStep._execute() 진입 시 reuse 가 거부되고
        # `_analyze_one()` 가 호출됨 (= fresh re-generation).
        from unittest.mock import patch, MagicMock
        from app.core.steps.detail_steps import SceneDetailStep

        ctx_old = _make_ctx_for_shot()
        ctx_drifted = _make_ctx_for_shot(outlook_pairs=[
            {"character_id": "C01", "outlook_id": "O99"},  # outlook 변경
        ])
        card_old = build_render_prompt_card(**ctx_old)
        h_old = compute_card_hash(card_old)

        # cp fixture: _user_edited=True + G3.1 evidence pass + card stale.
        cp_data = {
            "schema_version": 7, "prompt_version": "16.202605041200",
            "_user_edited": True,  # user 가 cp 를 수동 편집
            "render_prompt_card": card_old,
            "render_prompt_card_hash": h_old,
            "t2i_variations": [{
                "t2i_prompt": "A figure stands by the door.",
                "confidence": "high",  # G3.1 evidence 통과 (not "legacy")
                "owned_validation": {
                    "schema_version": 1,
                    "validator": "scene_detail_owned_objects.v1",
                    "owned_hash": "x" * 16, "camera_direction_hash": "y" * 16,
                    "t2i_prompt_hash": "z" * 16,
                    "violations": [],
                },
            }],
        }
        # mock SceneDetailStep 환경 — drift ctx 가 reuse 분기 진입 시
        # _user_edited_card_contract_violated 가 violation 반환 → fresh path.
        ctx_for_step = {
            "project_id": "test-pid-edited",
            "scene_detail_cp": {(12, 4): cp_data},
            # ctx 는 drift 후 상태 (outlook 변경됨).
            "scene_visible": {(12, 4): ctx_drifted["visible_entities"]},
            "outlook_phase3": ctx_drifted["outlook_pairs"],
        }
        with patch(
            "app.core.steps.detail_steps.SceneDetailStep._analyze_one",
            return_value={
                "schema_version": 7, "prompt_version": "16.202605041200",
                "_user_edited": False,  # fresh re-generation 후 False transition
                "t2i_variations": [{"t2i_prompt": "fresh prompt"}],
                "render_prompt_card": build_render_prompt_card(**ctx_drifted),
                "render_prompt_card_hash": compute_card_hash(
                    build_render_prompt_card(**ctx_drifted)
                ),
            },
        ) as analyze_mock:
            step = SceneDetailStep(ctx=ctx_for_step)
            # R3-I2: 실제 _execute() (또는 동등 entry point) 직접 호출.
            # try/except 금지 — 실패 시 test fail (silent swallow 금지).
            step._execute()
            # R3-I2: literal assert — _analyze_one 이 fresh path 진입으로
            # 호출되었는지.
            assert analyze_mock.called, (
                "R3-I2: _analyze_one not called — _user_edited reuse path "
                "did not enter fresh re-generation despite card drift."
            )
        # R3-I2: result 의 _user_edited 가 False 로 transition 되었는지.
        # (fresh re-generation 후 user_edited 표식 해제.)
        updated_cp = ctx_for_step["scene_detail_cp"][(12, 4)]
        assert updated_cp.get("_user_edited") is False, (
            f"R3-I2: _user_edited must transition to False after fresh "
            f"re-generation (got {updated_cp.get('_user_edited')!r})"
        )


# R3-B1: see Round 3 Override
class TestV15ToV16MigrationSmoke:
    """R1-I8 + R1-B1 + R2-I5 + R3-B1: v15 cp 잔존 PID resume → schema mismatch
    detect → step_runner 가 mode="force" 자동 escalate → v16 재실행 trigger.
    PNG 보존 / cp invalidation 동작. R2-I5 강화: 실제 StepRunner 호출.
    R3-B1 강화: tautological pass 제거 (`except ...: pass` + `or True` 제거,
    literal `analyze_mock.called` + step_run mode="force" transition assert +
    cp v16 갱신 assert)."""

    def test_v15_cp_lacks_card_field(self) -> None:
        # 옛 v15 cp 는 render_prompt_card top-level field 자체가 없음.
        v15_cp = {
            "schema_version": 6, "prompt_version": "15.202605032354",
            # render_prompt_card / hash 부재.
            "t2i_variations": [{"t2i_prompt": "..."}],
        }
        assert "render_prompt_card" not in v15_cp
        assert "render_prompt_card_hash" not in v15_cp

    def test_v16_cp_has_card_field(self) -> None:
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        v16_cp = {
            "schema_version": 7, "prompt_version": "16.202605041200",
            "render_prompt_card": card,
            "render_prompt_card_hash": compute_card_hash(card),
            "t2i_variations": [{"t2i_prompt": "..."}],
        }
        assert v16_cp["render_prompt_card"]["schema_version"] == 1
        assert isinstance(v16_cp["render_prompt_card_hash"], str)
        assert len(v16_cp["render_prompt_card_hash"]) == 16

    def test_schema_version_mismatch_signals_force_escalate(self) -> None:
        # step_runner.py:518 / 546 / 586 의 schema mismatch detect 행동을
        # contract 레벨에서 검증 — 본 test 는 cp shape 진단만.
        v15_like = {"schema_version": 6}
        from app.core.steps.detail_steps import SCENE_DETAIL_SCHEMA_VERSION
        assert v15_like["schema_version"] != SCENE_DETAIL_SCHEMA_VERSION
        # mismatch → step_runner 가 mode="force" escalate. (다음 test 가
        # 실제 호출 검증.)

    def test_step_runner_actually_escalates_to_force_on_v15_resume(
        self, tmp_path, monkeypatch,
    ) -> None:
        # R2-I5 + R3-B1 (강화): completed v15 cp fixture 만든 후 실제
        # StepRunner mismatch path 호출 — mode="force" escalate 동작 +
        # v16 재실행 trigger 검증.
        #
        # R3-B1: 기존 `except ...: pass` + `or True` tautological pass 제거.
        # `analyze_mock.called` literal assert (not `or True`) — 실제 호출
        # 안 되면 test fail. step_run row 의 mode 필드가 "force" transition
        # 되었는지 + cp 가 v16 로 갱신되었는지 확실히 검증.
        from app.core.steps.detail_steps import (
            SceneDetailStep, SCENE_DETAIL_SCHEMA_VERSION,
        )
        from app.core.step_runner import StepRunner
        # v15 cp fixture: schema_version=6, render_prompt_card 부재.
        v15_cp_payload = {
            "schema_version": 6, "prompt_version": "15.202605032354",
            "t2i_variations": [{"t2i_prompt": "old prompt"}],
        }
        # mock minimum: ctx, project, snapshot.
        ctx = {
            "project_id": "test-pid-v15",
            "scene_detail_cp": {(12, 4): v15_cp_payload},
            "completed": True,
        }
        # StepRunner resume 호출 — mismatch detect → force escalate.
        # (구체적 invocation 은 step_runner.py:518/546/586 의 실제 API 에 맞춰
        # 작성. v15 cp invalidated → v16 build_render_prompt_card 호출 + cp
        # 갱신 + step_run mode 가 "force" 로 transition 되는지 mock spy +
        # step_run row inspect 로 확인.)
        from unittest.mock import patch, MagicMock
        recorded_modes: list[str] = []

        def _capture_step_run(*, mode, **kw):
            recorded_modes.append(mode)
            return MagicMock(mode=mode, status="completed")

        with patch(
            "app.core.steps.detail_steps.SCENE_DETAIL_SCHEMA_VERSION",
            SCENE_DETAIL_SCHEMA_VERSION,
        ), patch(
            "app.core.steps.detail_steps.SceneDetailStep._analyze_one",
            return_value={
                "schema_version": 7, "prompt_version": "16.202605041200",
                "t2i_variations": [{"t2i_prompt": "v16"}],
                "render_prompt_card": build_render_prompt_card(
                    **_make_ctx_for_shot()),
                "render_prompt_card_hash": "0" * 16,
            },
        ) as analyze_mock, patch(
            "app.core.step_runner.StepRunner._record_step_run",
            side_effect=_capture_step_run,
        ):
            step = SceneDetailStep(ctx=ctx)
            # R3-B1: try/except 제거 — 실제 raise 라면 test fail (silent
            # swallow 금지).
            step.run(mode="resume")
            # R3-B1: literal assert (no `or True`).
            assert analyze_mock.called, (
                "_analyze_one not called — schema mismatch escalate path "
                "did not reach v16 re-generation."
            )
        # R3-B1: step_run row 의 mode 가 "force" 로 escalate 되었는지 검증.
        assert "force" in recorded_modes, (
            f"force escalate did not occur — recorded modes={recorded_modes}"
        )
        # contract 진단: v15 cp 의 schema_version 이 invalidated 됐고
        # v16 SCHEMA = 7 이 mismatch 정확히 인식 + cp 가 v16 로 갱신.
        updated_cp = ctx["scene_detail_cp"][(12, 4)]
        assert updated_cp["schema_version"] == SCENE_DETAIL_SCHEMA_VERSION
        assert "render_prompt_card" in updated_cp
        assert "render_prompt_card_hash" in updated_cp
        assert SCENE_DETAIL_SCHEMA_VERSION == 7


class TestCardWinsOverLegacyConflict:
    """R2-I3 (R1-I4 cross-check): card field 와 legacy prompt block 이 충돌
    하는 fixture → card 가 winner. 구현 단위 (LLM 호출 전) 에서는 prompt
    자체에 "card wins" 명시 grep 으로 검증, 실제 LLM output 비교는 별도
    canary scope."""

    def test_v16_system_md_declares_card_wins(self) -> None:
        # R1-I4 + R2-I3: v16 system.md 의 priority section 이 명시적으로
        # "card 가 winner" 선언. legacy compat blocks 보다 우선.
        from pathlib import Path
        sys_md = Path(
            "/Users/manta/Documents/Projects/TheRoad-I1/prompts/_base/"
            "scene_detail/16.202605041200/system.md"
        )
        text = sys_md.read_text(encoding="utf-8")
        # "card wins" / "primary contract" / "card 가 winner" 류 명시.
        joined_lower = text.lower()
        assert any(needle in joined_lower for needle in (
            "card wins", "primary contract", "card winner",
            "card always wins", "card is winner",
        )), "v16 system.md 에 card-wins precedence 명시 필요"

    def test_card_id_policy_overrides_legacy_block_in_user_prompt(
        self,
    ) -> None:
        # card.id_policy 가 C##O## 강제하는데 legacy block 이 보통명사 권장
        # 한다고 가정. user_prompt 조립 시 card 가 prefix → LLM 이 card 우선.
        # 본 test 는 prompt 조립 순서만 검증 (LLM output 검증은 canary).
        ctx = _make_ctx_for_shot()
        card = build_render_prompt_card(**ctx)
        card_block = (
            "[RenderPromptCard v1]\n"
            + json.dumps(card, sort_keys=True, ensure_ascii=False,
                         separators=(",", ":"))
            + "\n\n"
        )
        legacy_conflict_block = (
            "[Legacy compat]\n"
            "Use common nouns for visible characters."
            "\n"
        )
        user_prompt = card_block + legacy_conflict_block
        # card 가 prefix.
        assert user_prompt.index("[RenderPromptCard v1]") < user_prompt.index(
            "[Legacy compat]"
        )
        # card 의 must_use_composite_character_ids = True 가 explicit 으로
        # 들어 있음 → LLM 이 card winner rule 따라 composite 사용.
        assert '"must_use_composite_character_ids":true' in user_prompt


class TestNoTruncation:
    def test_long_korean_description_not_truncated(self) -> None:
        # CLAUDE.md 절대 규칙 — fixed_element description 1000자 도 그대로.
        long_kor = "여자가 방 안에 누워 있다. " * 80
        ctx = _make_ctx_for_shot(fixed_elements=[{
            "element_id": "x", "element_type": "scene_state",
            "bound_entity_hint": None, "description": long_kor,
            "source_facts": [], "visual_inferences": [], "confidence": "high",
        }])
        c = build_render_prompt_card(**ctx)
        assert c["continuity_elements_used"]["fixed_elements"][0]["description"] == long_kor


# 추후 강화 영역 (G4.2~G4.5):
# - dispatcher / StepRunner gate 통합 (analysis-only path 의 background_prompt
#   dep 검증) — 본 테스트는 helper 단위까지만. dispatcher 통합은 G4.2 lift 시
#   확장.
```

- [ ] **Step 2: 통과 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/integration/test_g4_1_card_consumer_wiring.py -v
```
Expected: ~41 PASS (13 base + R1-I5/I7/I8 8 + R2-I3 2 + R2-I4 +1 / R2-I5 +1 /
R2-I6 helper 4 / R2-I7 forbidden-only-pure +1 / R3-I2 fresh-path-via-execute +1
— R3 누적 반영. R3-B1/B2/I1 은 기존 test 강화이므로 count 변동 없음).

### Task 21: regression cleanup — fixture 갱신 + 회귀 0

**Files:**
- Modify: 회귀 발생 테스트 fixture (예: scene_detail cp fixture, manifest
  fixture).

- [ ] **Step 1: 전체 테스트 suite 실행 — 회귀 검출**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/ --ignore=tests/migration -x -q 2>&1 | tail -50
```

Expected: G3.2 baseline (1970+ passed / 20 baseline failed) 대비 G4.1 신규
(~131 신규 — Phase 1+2+3 91 + Phase 8 ~40; R3-M4 정정) 추가. 새 회귀 0 목표.
R1+R2+R3 모든 round 적용 분 포함 (R1-I1~R1-I11 / R2-B1~R2-B4 + R2-I1/I3/I4/I5/I6/I7
+ R3-B1/B2 + R3-I1/I2).

흔한 회귀 패턴:
- `SCENE_DETAIL_SCHEMA_VERSION` 6→7 변경으로 fixture 가 옛 6 assertion 사용 →
  fixture 갱신.
- `SCENE_DETAIL_PROMPT_VERSION` 변경으로 fixture 갱신.
- v15 prompt loader assertion 이 v16 으로 바뀜.
- `verify_completion` 가 v7 cp shape (card 부재) 잡아 새 fail — fixture 에 dummy
  card 주입.

- [ ] **Step 2: fixture 갱신 — 흔한 케이스**

```bash
grep -rln "schema_version.*6\|SCENE_DETAIL_SCHEMA_VERSION.*=.*6\|15.202605032354\|scene_detail/v15" /Users/manta/Documents/Projects/TheRoad-I1/backend/tests/
```

각 hit 에서 v15 → v16 / 6 → 7 갱신. 단 G3.2 fixture 의 v15 expectation 이 의도된
경우 (예: round 5 R5-B4 검증) 는 보존 — case-by-case.

dummy card injection helper 추가 (필요 시 conftest.py):

```python
# 추가 — backend/tests/conftest.py 또는 fixture 모듈.
def make_dummy_card(scene_index=1, shot_index=1):
    """G4.1: scene_detail cp fixture 용 minimal card."""
    from app.core.steps.render_prompt_card import (
        build_render_prompt_card, compute_card_hash,
    )
    card = build_render_prompt_card(
        scene_index=scene_index, shot_index=shot_index,
        seg={}, shot_info={"camera_direction": "wide"},
        visible_entities=[], outlook_pairs=[], perception_mode=None,
        staging={"camera_direction": "wide", "lighting_mood": "warm"},
        bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
        is_close_framing=False, background_mode_on=False,
        fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
    )
    return card, compute_card_hash(card)
```

- [ ] **Step 3: 회귀 0 재확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/ --ignore=tests/migration -x -q 2>&1 | tail -10
```
Expected: 1970+ baseline + 131 G4.1 신규 = 2101+ passed / 20 baseline failed
(R3-M4: 91 unit + ~40 integration). 새 fail 0.

---

**Phase 8 verification:**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/ --ignore=tests/migration -q 2>&1 | tail -10
```
Expected: 2101+ passed / 20 failed (baseline; R3-M4).

---

## Phase 9 — Dual Review + Single Commit

### Task 22: Codex review

- [ ] **Step 1: Codex 의뢰**

`codex:rescue` 또는 사용자가 별도 invoke. 변경 전체 (Phase 1~8) 을 spec 기준으로
review. 변경 파일 목록 제공:

```
backend/app/core/steps/render_prompt_card.py (신설)
backend/app/core/steps/detail_steps.py (수정 — 5-point _analyze_one + verify_completion + _user_edited; R3-M1)
backend/app/core/version_registry.py (수정 — scene_detail_composer 1.16.0)
backend/app/core/step_manifest.py (수정 — schema_version 7)
prompts/_base/scene_detail/16.202605041200/system.md (신설)
prompts/_base/scene_detail/16.202605041200/detail_schema.json (신설 — v15 verbatim)
backend/tests/unit/test_render_prompt_card.py (신설)
backend/tests/unit/test_render_prompt_card_hash.py (신설)
backend/tests/integration/test_g4_1_card_consumer_wiring.py (신설)
```

요청 — spec §3 AD-1~AD-5 / §4 5 field shape / §5.1 user_prompt shape / §6 G4.1
scope guard / §7 verification / §10 Q1~Q4 resolution / §11 minimal first patch
scope 모두 covered 했는지 + critical constraints (no silent fallback / no
truncation / _CLOSE_FRAMING_RE reuse / prompt versioning / G3.2 sentinel
preserve) 모두 준수했는지.

특히 검토 요청:
- `_analyze_one()` modify scope guard 5-point 만 변경됐는지 (R3-M1; 그 외 control
  flow 변경 0).
- card hash 와 G3.2 sentinel hash 가 분리되어 공존하는지 (대체 아님).
- v15 디렉토리 verbatim 보존 (overwrite 없음).
- LLM output schema (`detail_schema.json`) v15 verbatim copy.
- card JSON 직렬화에 truncation 없음 (canonical full).
- shot-level top-level field (Q1 resolved) — variation 안에 안 들어감.

- [ ] **Step 2: Codex 결과 fix loop**

BLOCKING / IMPORTANT 모두 반영. 본 plan 의 Round 1 Override section 에 결과
정리. 모순 시 round override 가 task body 보다 우선. inline 수정 후 회귀 검증
다시 (Task 21).

### Task 23: Claude review

- [ ] **Step 1: superpowers:code-reviewer subagent 의뢰**

prompt: G4.1 spec 기준 코드 리뷰. 변경 전체 (Task 22 와 동일 file list).

- [ ] **Step 2: Claude 결과 fix loop**

BLOCKING / IMPORTANT 모두 반영. 회귀 검증.

### Task 24: fix loop until both APPROVED

- [ ] **Step 1: BLOCKING / IMPORTANT 0 까지 iterate**

Codex + Claude 양쪽 모두 BLOCKING 0 / IMPORTANT 0 (또는 ACCEPTED MINOR)
달성까지 round 반복.

- [ ] **Step 2: 최종 회귀 확인**

Run:
```bash
cd /Users/manta/Documents/Projects/TheRoad-I1/backend && PYTHONPATH=. ../.venv/bin/python -m pytest tests/ --ignore=tests/migration -q 2>&1 | tail -5
```

Expected: 2101+ passed / 20 baseline failed (R3-M4). 새 fail 0.

### Task 25: 단일 commit + push

- [ ] **Step 1: staged 파일 확인**

Run:
```bash
git status
```

Expected: 위 파일 list 모두 modified / untracked.

- [ ] **Step 2: stage + commit**

Run:
```bash
git add prompts/_base/scene_detail/16.202605041200 \
        backend/app/core/steps/render_prompt_card.py \
        backend/app/core/steps/detail_steps.py \
        backend/app/core/version_registry.py \
        backend/app/core/step_manifest.py \
        backend/tests/unit/test_render_prompt_card.py \
        backend/tests/unit/test_render_prompt_card_hash.py \
        backend/tests/integration/test_g4_1_card_consumer_wiring.py

git commit -m "$(cat <<'EOF'
feat(pipeline): G4.1 RenderPromptCard bootstrap — deterministic 5-field card

scene_detail v16: render_prompt_card.py helper 가 selected shot 단위로
5-field deterministic card (render_strategy / id_policy / background_binding
/ continuity_elements_used / asset_requirements) build. canonical JSON
sha256[:16] hash 포함. _analyze_one() user_prompt 의 첫 블록으로 inject —
[RenderPromptCard v1]\n{...}\n\n + 기존 narrative.

cp top-level field 2 개 추가 (Open Q1 RESOLVED — top-level shot-level,
variation 안에 안 들어감): render_prompt_card + render_prompt_card_hash.
verify_completion() 이 shape strict + hash drift (recompute vs stored) 를
partial 마킹. _user_edited reuse path 도 동일 drift 검사 → mismatch 시
fresh re-generation (G3.1/G3.2 패턴 carry).

G3.2 owned sentinel coexist: card hash = shot-level upstream contract
drift, G3.2 sentinel = variation-level t2i_prompt drift. 둘 다 cp 에 보존
— card 가 sentinel 대체하지 않음 (spec §4.3 hash relationship).

SCHEMA_VERSION 6→7 (Open Q4 — cp shape 변경 이유, LLM output schema 는
v15 verbatim 변경 0). PROMPT_VERSION 15.202605032354 → 16.202605041200.
scene_detail_composer 1.15.0 → 1.16.0. step_manifest schema_version 7.

system.md 본문은 G4.1 에서 변경 0 — 최상단 priority section 만 append
("Read RenderPromptCard first"). 본문 slimming 은 G4.2~G4.5 별도 plan.

modify scope guard (spec §6, R3-M1 5 point): _analyze_one() 변경은 input
collection helper / builder call / user_prompt inject / result top-level
field 2개 / _user_edited reuse path drift hook 의 5 point 만. 기존 retry /
_check_prompts / G3.1 evidence assert / G3.2 owned validator / outlook
lookup / staging / visible loop 모두 변경 0.

critical constraints obey:
- no silent fallback: card builder missing-input 모두 raise.
- no truncation: card JSON canonical full text (CLAUDE.md).
- _CLOSE_FRAMING_RE reuse: detail_steps.py:88 module-level single source.
- prompt versioning: v15 verbatim 보존, v16 새 디렉토리.
- G3.2 sentinel preserve: card 와 sentinel 분리된 scope 공존.

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

- [ ] **Step 3: push**

```bash
git push origin main
```

Expected: main 에 단일 commit push 성공.

---

## Self-Review Checklist

이 plan 작성 후 spec 대조:

**Spec coverage:**
- [x] §3 AD-1 helper-first (Task 1~9.5 = render_prompt_card.py 신설, scene_detail
      step 자체는 변경 없음).
- [x] §3 AD-2 card-constraints first (Task 4 id_policy.constraints / Task 5
      background_binding.constraints / Task 6 continuity.constraints / Task 7
      asset_requirements.constraints — 모두 field constraints 형태).
- [x] §3 AD-3 staged G4.1 (G4.2~G4.5 명시 out-of-scope).
- [x] §3 AD-4 PNG regen on force (manifest schema bump 7 이 자연 cascade —
      operator force 시 downstream invalidation).
- [x] §3 AD-5 new-PID default opt-in force (existing PID v15 cp 보존, force 시
      v16 entry).
- [x] §4 5 fields shape: §4.1 render_strategy (Task 3), §4.2 id_policy (Task 4),
      §4.3 background_binding (Task 5 + G3.2 sentinel coexist), §4.4
      continuity_elements_used (Task 6), §4.5 asset_requirements (Task 7).
- [x] §4.3 hash relationship: card hash = shot-level / G3.2 sentinel = variation
      (Task 5 docstring + Task 17 result field separation + Task 20
      TestCpFieldPreservation).
- [x] §5.1 user_prompt shape: card → narrative (Task 16, prefix 보장 +
      Task 20 TestCardSinglePromptBlock).
- [x] §5.2 v16 system.md priority section append (Task 10).
- [x] §6 G4.1 scope guard: 5-point modify (Task 14~17 + Task 19; R3-M1).
- [x] §6 G4.1 non-goals: no new step / no large deletion / no image stage code
      change / no cascade rule (모두 본 plan scope 외).
- [x] §7.1 unit tests: builder / hash / shape (Phase 1~3).
- [x] §7.2 integration tests: 단일 block / cp 보존 / verify drift / propagation
      (Task 20).
- [x] §7.3 prompt tests: v16 priority section grep (Task 10 Step 4).
- [x] §7.4 canary: 본 plan 에서 명시적 canary task 없음 — operator 가 별도
      실행 (spec 권고).
- [x] §10 Q1 RESOLVED top-level (Task 16/17).
- [x] §10 Q2 builder-derived (Task 3 + source metadata).
- [x] §10 Q3 expected refs only (Task 7).
- [x] §10 Q4 SCHEMA bump 6→7 (Task 11/13).
- [x] §11 minimal first patch — G4.1 only (out-of-scope 명시).

**Critical constraints:**
- [x] no silent fallback (Task 9 assert_card_shape raise 모든 branch +
      Task 18/19 verify/reuse drift 모두 marking).
- [x] no truncation (Task 6 long korean test + Task 8 ensure_ascii=False +
      Task 16 canonical full + Task 20 TestNoTruncation).
- [x] _CLOSE_FRAMING_RE reuse (Task 3 `_derive_framing_scale` import).
- [x] prompt versioning (Task 10 v15 verbatim copy + 새 디렉토리).
- [x] G3.2 sentinel preserve (Task 5 docstring + Task 17 둘 다 cp 에 + Task 20
      TestCpFieldPreservation).
- [x] _analyze_one modify scope guard (Task 14~17 + Task 19, 5-point; R3-M1).

**Placeholder scan:**
- [x] 모든 코드 step 에 실제 코드 + 정확한 file path + 정확한 명령 + expected
      output.
- [x] Round 1 Override 적용 후 placeholder TBD 잔존 0 (Round 0/1 placeholder
      섹션은 Round 1 Override section 으로 대체됨).

**Type consistency:**
- [x] `CARD_SCHEMA_VERSION = 1` 모든 task 동일.
- [x] `_CLOSE_FRAMING_RE` 재사용 (G3.2 R5-B4 carryover) — local copy 0.
- [x] `compute_card_hash` 시그니처 (Dict → str) 일관.
- [x] `canonicalize_render_prompt_card(card)` 시그니처 일관 (R1-I3 신설).
- [x] `assert_card_shape(card, where="")` 시그니처 일관.
- [x] `build_render_prompt_card` kw-only — 모든 호출 사이트 동일.

**Round 1 Override 적용 검증:**
- [x] R1-B1 (AD-5 migration policy) — Task 11/12 docstring/comment 갱신.
- [x] R1-I1 (no fallback) — Task 3 fail-fast + not_applicable 분기 + test
      교체 (`test_staging_required_but_missing_raises` /
      `test_staging_explicit_not_applicable_returns_mode_na`).
- [x] R1-I2 (lossless adapter) — Task 6 9 producer field 보존 + deepcopy.
- [x] R1-I3 (canonical list ordering) — Task 8 `canonicalize_render_prompt_card`
      helper + 6 list ordering test.
- [x] R1-I4 (card-wins precedence) — Task 10 priority section 강화.
- [x] R1-I5 (sentinel-stale post-edit) — Task 20 t2i_review test 갱신.
- [x] R1-I6 (CP-only scope) — Task 20 normalizer preservation test 제거.
- [x] R1-I7 (forward_zoom full-list) — Task 6 + Task 14 + Task 20.
- [x] R1-I8 (test coverage 보강) — Task 20 5 신규 test class.
- [x] R1-I9 (risk thresholds) — spec only, plan 영향 없음.
- [x] R1-I10 (phase exit criteria) — spec only (Out-of-scope 섹션 reference).
- [x] R1-I11 (`_card_metadata` exclusion) — Task 8 hash payload 제외 + 2 test.
- [x] R1-M1 (Q1 위치) — spec only.

본 plan 은 Round 1 Override 13 항목 모두 반영.

**Round 2 Override 적용 검증:**
- [x] R2-B1 (hash payload 통합 — spec R1-I11 sharpened) — Task 8 docstring +
      `_HASH_EXCLUDED_TOP_KEYS` 명시 + canonicalize 갱신 + self-hash test
      재작성 (`test_hash_self_excluded_from_payload` "동일" 검증) +
      `test_schema_version_change_changes_hash` /
      `test_shot_key_change_changes_hash` (included 증명) 신규.
- [x] R2-B2 (`_VALID_RENDER_MODES` enum 에 `not_applicable`) — Task 1 모듈
      상수 + `RENDER_MODE_NOT_APPLICABLE = "not_applicable"` 추가 +
      Task 3 `test_not_applicable_mode_is_valid_enum_member` +
      Task 9 `test_not_applicable_mode_passes` (validator 수용 검증).
- [x] R2-B3 (`_analyze_one()` modify scope 5 point + helper 추출) — Task 14
      `_collect_card_inputs(...)` helper 신설 + 단일 source 명시
      (`_analyze_one` / `verify_completion` / `_user_edited` 셋 다 동일 helper
      호출). modify scope guard 5 point 정의 (R-B1 4-point → R2-B3 5-point).
- [x] R2-B4 (None vs [] fail-fast — spec R1-I12 신규) — Task 4
      (visible_entities/outlook_pairs None 2 raise) + Task 6
      (fixed_elements/previous_shot_refs/forward_zoom_targets None 3 raise)
      + Task 7 (visible_entities/outlook_pairs None 2 raise). `or []` silent
      absorb 패턴 모두 제거. test 7 신규.
- [x] R2-I1 (Task 9 strict shape — bool-as-int reject + 16-char lowercase
      hex 검증) — Task 9 `_HASH_FORMAT_RE` + `isinstance(sv, bool)` reject
      + envelope-sibling hash 검증 + 5 test 신규
      (`test_schema_version_bool_rejects` /
      `test_schema_version_non_int_rejects` /
      `test_envelope_hash_format_strict` /
      `test_envelope_hash_uppercase_rejects` /
      `test_envelope_hash_valid_passes` / `test_envelope_hash_absent_passes`).
- [x] R2-I2 (R2-B1 통합 처리됨, 별도 영향 없음).
- [x] R2-I3 (card-vs-legacy conflict test 신설) — Task 20
      `TestCardWinsOverLegacyConflict` class 신규 (system.md grep +
      user_prompt 조립 순서 검증).
- [x] R2-I4 (`TestT2iReviewPostEditSentinelStale` verify partial 직접
      assert) — Task 20 기존 hash-only test 에 `verify_completion()` 직접
      호출 + severity="partial" + sentinel_drifted 메시지 assert 추가
      (`test_verify_completion_marks_partial_on_sentinel_drift`).
- [x] R2-I5 (v15→v16 migration smoke 실제 StepRunner 호출) — Task 20
      `TestV15ToV16MigrationSmoke.test_step_runner_actually_escalates_to_force_on_v15_resume`
      신규 (실제 SceneDetailStep.run("resume") + mock spy).
- [x] R2-I6 (`_user_edited` card drift test 실제 reuse 거부 assert) —
      Task 19 `_user_edited_card_contract_violated()` helper 추출 +
      Task 20 `TestUserEditedCardDrift` 4 helper test (None / drift /
      missing / fresh path 진입 simulation).
- [x] R2-I7 (`TestAssetRequirementsBranches` 3 분기 분리) — Task 20
      `test_required_only_branch` (outlook+bg required, no forbidden) +
      `test_forbidden_only_branch_pure` (close + outlook 없음, 진정한
      forbidden-only) + `test_mixed_branch_required_and_forbidden`
      (close + outlook 공존) 3 분기 명확.
- [x] R2-M1 (Task 3 test count 정정) — Task 3 expected 10 → 11 PASS,
      Phase 1 expected 25 (R3-M2 정정: 4 + 11 + 10 = 25 — Task 2 X).
- [x] R2-M2 (spec line count 갱신) — plan header line 28-29 "spec 754 lines"
      (R3-M3 후속 정정: 743 → 754).

본 plan 은 R1 13 + R2 13 + R3 8 = 34 항목 모두 반영. (R3 적용 후, 본
section 아래 Round 3 Override 검증 추가.)

**Round 3 Override 적용 검증:**
- [x] R3-B1 (v15→v16 migration smoke tautological pass 제거) — Task 20
      `test_step_runner_actually_escalates_to_force_on_v15_resume` 재작성:
      `except (NotImplementedError, AttributeError): pass` 제거 +
      `assert analyze_mock.called or True` → literal `assert analyze_mock.called` +
      `_capture_step_run` side_effect 로 step_run mode 기록 + `"force"`
      transition assert + cp 가 v16 로 갱신 (`render_prompt_card` /
      `render_prompt_card_hash` 추가) literal assert.
- [x] R3-B2 (`_make_ctx_for_shot(outlook_pairs=None)` `or default` 제거) —
      Task 20 `_make_ctx_for_shot` helper 재작성: `_DEFAULT_SENTINEL`
      sentinel 패턴 도입 + `outlook_pairs=[]` 와 `outlook_pairs=None` 명시
      구분 (sentinel 일 때만 default 채움). `test_forbidden_only_branch_pure`
      에 `assert ctx["outlook_pairs"] == []` regression guard 추가.
      forbidden-only fixture 의 진정성 보장.
- [x] R3-I1 (`verify_completion` partial assert 강화) — Task 20
      `test_verify_completion_marks_partial_on_sentinel_drift` 재작성:
      substring 만 (`"sentinel" in msg.lower() or "t2i_prompt_hash" in msg.lower()`)
      → exact literal assert. `assert report.is_complete is False` /
      `assert report.severity == "partial"` /
      `assert any("sentinel_drifted" in m for m in report.missing)` /
      `assert (12, 4, "t2i_prompt_hash") in report.metadata.sentinel_drifted`
      네 종 literal assert.
- [x] R3-I2 (`_user_edited` card drift integration 실제 branch 진입) —
      Task 20 `TestUserEditedCardDrift.test_user_edited_branch_invokes_fresh_path_via_execute`
      신설: helper simulation 만 했던 기존 test 외에 `SceneDetailStep._execute()`
      직접 호출. fixture cp_data 에 `_user_edited=True` + `confidence="high"`
      (G3.1 evidence pass) + card hash mismatch. `_analyze_one()` 호출
      literal assert + result `_user_edited` False transition assert.
      G3.1/G3.2 `_user_edited_owned_contract_violated` test shape mirror.
- [x] R3-M1 (Phase/Task 제목 5-point 일관) — Phase 5 제목 "(4-point modify
      scope)" → "(5-point modify scope, R3-M1)". Task 15/16/17 modify scope
      guard "2/4" → "2/5", "3/4" → "3/5", "4/4" → "4/5". 본문 critical
      constraint section + commit message + Task 22 review request +
      self-review 모든 occurrence "4-point/4 point/4 modification points"
      → "5-point/5 point/5 modification points".
- [x] R3-M2 (Phase 1 count 일관) — Round 2 Override 표 R2-M1 cell 의
      "Phase 1 expected total: 23" → "25" (R3-M2 정정: Task 1 = 4 + Task 2 X
      + Task 3 = 11 + Task 4 = 10 = 25). body line 945 + self-review R2-M1
      entry 모든 위치 25 일관.
- [x] R3-M3 (spec line count 갱신) — plan header line 28-29 "spec 743 lines"
      → "spec 754 lines" (parallel R3 patch 적용 후 spec 실제 길이).
- [x] R3-M4 (Phase 3 arithmetic 정정) — Task 9 step 4 "~67 PASS" → "69 PASS"
      (50+19=69). Phase 3 verification + Task 9.5 step 4 "89 PASS (50+19+3+19)"
      → "91 PASS (50+19+3+19=91)" (실제 산술). Phase 4/5/6/7 verification
      "89+ PASS" → "91+ PASS". Phase 8 self-review "129 신규" → "131 신규"
      (91+40). Task 25 commit verification "2099+ passed" → "2101+ passed".

본 plan 은 R1 13 + R2 13 + R3 8 = 34 항목 모두 반영. 추가 audit round
결과 수신 시 별도 Round 4 Override section 추가 (R3 위에 prepend,
R1/R2/R3 보다 우선).

---

## Execution Handoff

Plan 저장 위치: `docs/superpowers/plans/2026-05-04-g4-render-prompt-card-implementation.md`.

두 가지 실행 옵션:

1. **Subagent-Driven (recommended)** — 사용자가 task 단위 fresh subagent
   디스패치, two-stage review (Codex + Claude). G3.1 / G3.2 와 동일 패턴.
2. **Inline Execution** — 본 세션에서 batch 실행, checkpoint 단위 review.

어느 쪽으로 갈지 알려주세요.

---

## Out-of-scope (별도 future plans)

각 G4.x phase 의 entry / exit criteria 는 spec §6 + Round 1 Override R1-I10
참조. G4.1 → G4.2 진입 전 G4.1 exit criteria (card payload + hash CP 저장
확인 + drift detection green + canary 1 PID prompt token < +30%) 충족 필수.

- **G4.2 background-binding lift**: Rule A camera consistency / Rule C owned
  no-redraw / Rule E close skip → `background_binding.constraints` 로 lift,
  system.md 에서 해당 prose 삭제. mini-canary 1 PID 1 segment. **Exit
  criterion (R1-I10)**: owned violation rate ≤ G4.1 baseline.
- **G4.3 ID-policy lift**: C##O## composite / 화면 속 reproduced face /
  body-part isolated / demographic fallback → `id_policy.constraints` 로 lift.
  deterministic `_check_prompts` post-check 유지. **Exit criterion (R1-I10)**:
  ID violation rate ≤ G4.1 baseline.
- **G4.4 continuity / render-strategy lift**: fixed_elements / previous_shot_refs
  / forward zoom / one framing scale / simplify-partial-reframe →
  `continuity_elements_used` + `render_strategy` 로 lift. **Entry criterion
  (R1-I7)**: legacy `detail_steps.py:950, 965` 의 `forward_zoom_targets[:6]` /
  `keep_elements[:5]` cap 제거 또는 full-list 방식으로 교체 필수 — 이전에는
  "continuity lift complete" 선언 금지. **Exit criterion (R1-I10)**:
  continuity violation rate ≤ G4.1 baseline.
- **G4.5 prompt slimming + heading enforcement**: system.md 31 → 10 sections.
  pytest snapshot for heading count + forbidden duplicate prose.
  `docs/contracts/render_prompt_card.md` 인간용 reference 신설. **Exit
  criterion (R1-I10)**: heading count ≤ 10 ## sections (pytest enforcement).

각 G4.x 는 독립 spec → 독립 plan → 독립 commit. G4.1 가 contract shape 을 먼저
고정하므로, G4.2~G4.4 는 system.md prose 만 lift. G4.5 가 final slimming.
