"""G4.1 / G4.2 / G4.3 / G4.4 / G4.5a RenderPromptCard helper — single source.

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

G4.2 (2026-05-04): v16 system.md 의 Rule A (camera consistency) / Rule C
(owned object preservation) / Rule E (close-framing reference omission)
산문을 `background_binding.constraints` machine-readable 문자열로 lift.
관측용 `_card_metadata` envelope-sibling (lift_status + rule_source) 추가
— canonicalize_render_prompt_card() 가 hash 입력에서 제외하므로 hash 영향 0.

G4.3 (2026-05-04): v17 system.md 의 4 ID-policy prose section (C## 사용 규칙
33L + 극단 클로즈업 표현 20L + 사진·포스터·화면·거울 속 인물 규칙 14L +
Rule H demographic 42L = 109 lines) 을 `id_policy` 의 builder-static sub-field
(face_identifiability_rule / reproduction_surface_rule /
demographic_descriptor_policy) + constraints 로 lift.
Area #1 (2026-05-16): body-part / close-framing face noun-list 폐기 →
per-subject reference policy SOT (shot_staging v12 producer +
subject_reference_policy.py helper). compute_id_policy_snapshot_hash() helper
신설 (R2-I2 carry — canary pinning block 산출용). _card_metadata.lift_status /
rule_source 키 추가.

G4.4 (2026-05-05): v18 system.md 의 3 continuity prose section (교차 샷 고정
요소 통합 규칙 12L + 앞쪽 참조 샷 처리 ref_usage 35L + 단일 시점 19L = 66 lines)
+ detail_steps.py:1555-1563 inline 9 lines = 합계 75 lines 을
`continuity_elements_used` 의 3 신규 builder-static sibling policy dicts
(cross_shot_id_substitution_rule / ref_usage_constraints / view_consistency)
+ 7-string constraints 로 lift (G4.1 base 2 → G4.4 7). 9 module-level
constants (Override O-6 / O-7 / O-12 / O-19 — single source for builder + 5
canary + unit tests). _CONTINUITY_ID_POLICY_CROSS_REF_LITERAL 는 Override O-7
paired-string substring single source (cross-card reference; Area #1 v1 →
id_policy.subject_reference_policy). compute_continuity_snapshot_hash() helper
신설 (Override O-12 — canary pinning block 산출용).
_card_metadata.lift_status 4 신규 key (Override O-9 정확 명) + rule_source
3 신규 key 추가. forward_zoom_targets shape 변경 0 (Override O-4).

G4.5a (2026-05-05): v19 system.md 의 3 spatial-rules prose section (Rule F
33L + Rule G 41L + Rule J 50L = 124 lines) 을 `render_strategy.spatial_consistency`
신규 sibling nested dict (4 sub-key — camera_frame_rule (7 keys, RO-3 — core_principles
4-entry list 추가) / fg_bg_shared_anchor_rule (6 keys) / primary_framing_rule
(5 keys + close_framing_rules 4 incl subject_reference_policy_cross_ref Area #1 /
wide_medium_rules 4 incl subject_reference_policy_cross_ref Area #1) /
rationale_summary) 로 lift. constraints
G4.4 base 2 → G4.5a 5 strings. 9 module-level constants (PR4-B5 / RO-11
single source for builder + canary + unit tests).
compute_render_strategy_snapshot_hash() helper 신설 (PR4-B4 / PR4-I2
binding — render_strategy 전체 hash, NOT narrower spatial_consistency).
`_assert_spatial_consistency_shape()` + `_assert_primary_framing_rule_shape()`
nested helpers (RO-9 / PR4-I5 — nested call chain). _card_metadata.lift_status
key + rule_source key 추가. PR4-B6 binding: G4.1+G4.4 base
8 keys 보존, NEW sibling 추가만. PR4-B3 binding: cross-card refs ONLY in
primary_framing_rule (close_framing_rules + wide_medium_rules), NOT in
camera_frame_rule. PR4-B9 binding: in-module direct reference (NO self-import,
NO typed-only forward declaration).

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 + R1-I3 + R1-I11 + R2-B1):
  - render_prompt_card_hash = sha256(canonical_json(card_payload))[:16]
  - canonical = json.dumps(sort_keys=True, ensure_ascii=False,
                           separators=(",", ":"))
  - hash payload = envelope minus (`_card_metadata`, `render_prompt_card_hash`).
    INCLUDES schema_version + shot_key + 5 semantic fields.
  - 6 unordered list 정렬 (owned_objects, allowed_base_entity_ids,
    allowed_outlook_pairs, required_refs, forbidden_refs, fixed_elements).

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 copy as _copy_module
import hashlib
import json
import logging
import re as _re_module
from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple

from app.core.config import settings
from app.core.episode_reference_policy import _is_character_subject
from app.core.errors import AppError
from app.core.perception_mode import is_reproduction_perception
from app.core.subject_reference_policy import (
    SubjectReferencePolicy,
    derive_visible_subject_ids,
    apply_episode_reference_policy_downgrade,
    apply_screen_presence_downgrade,
    filter_subject_reference_policy_to_visible,
    get_subject_reference_policy_or_default,
    normalize_subject_reference_policy_items,
    serialize_subject_reference_policy_map,
)

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,
)

# =========================================================================
# G4.3 ID-policy lift — module-level ground-truth constants
# (R1-I2 / R2-I6 carry — single source: builder + 5 canary scripts + unit test).
# 시나리오 의존 0 (작품 고유명사 ban). 모두 immutable tuple.
# =========================================================================

# Area #1 (2026-05-16) — body-part / face-close-up noun-list constants 폐기.
# 대체 SOT = shot_staging v12 subject_reference_policy[] (per-shot, per-subject).
# helper module: app.core.subject_reference_policy.

# Area C (2026-05-12) — directionality_class enum drift defense + reproduction
# surface derivation. shot_staging/9.../schema.json:58-64 의 5 enum 과 정확
# 동일. drift 차단은 `test_orientation_enum_alignment.py` 가 검증.
_ALLOWED_DIRECTIONALITY_CLASSES: FrozenSet[str] = frozenset({
    "content_surface",
    "reflective_surface",
    "transparent_surface",
    "directional_3d",
    "non_directional",
})

# reproduction surface = content_surface ∪ reflective_surface (Area C SOT).
# shot_staging.ORIENTATION_REQUIRED_CLASSES 와 정확 동일.
_REPRODUCTION_SURFACE_CLASSES: FrozenSet[str] = frozenset({
    "content_surface",
    "reflective_surface",
})

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

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

# =========================================================================
# G4.4 Continuity lift — module-level ground-truth constants
# (Override O-6 / O-7 / O-12 / O-19 carry — single source: builder + 5 canary
# scripts + unit tests). 시나리오 의존 0 (작품 고유명사 ban). 모두 immutable
# tuple (4 ground-truth tuples) + int counts (4) + str literal (1, paired-string).
# =========================================================================

# G4.4 ground-truth: generic person nouns for cross-shot double-description
# detection (R1+R2 audit 시 ground-truth list 확정). canary
# `g4_4_double_description.py` ±50 chars window proximity source. id_policy
# 등록된 character 의 보통명사 동시 등장 검출. regional_consistency carry from
# G4.3 — 한 작품 region 일관 가정 위 generic Asian-default placeholders
# (`feedback_t2i_storyboard.md` carry — 영어 t2i_prompt 가정).
# Override O-19 follow-up: Korean t2i prompt 도입 시 G4.5 에서 generic-noun
# 패턴 재검토.
_CONTINUITY_GENERIC_PERSON_NOUNS: Tuple[str, ...] = (
    "an Asian man",
    "a young woman",
    "a figure",
    "a man",
    "a woman",
    "an elderly figure",
    "a child",
)

# G4.4 ground-truth: furniture / wall / layout import tokens for
# atmosphere_reference violation detection. canary
# `g4_4_atmosphere_no_layout_import.py` 검출 source.
# Override O-17 — regex 1차 detector, baseline 비0 시 P1 follow-up 으로
# LLM-validator (gpt-5.4-mini judge) 전환 검토
# (`feedback_no_regex_postprocessing.md`).
_CONTINUITY_FURNITURE_LAYOUT_TOKENS: Tuple[str, ...] = (
    "furniture",
    "wall position",
    "room layout",
    "same chair",
    "same table",
    "same wall",
    "same window",
)

# G4.4 ground-truth: framing scope options
# (view_consistency.framing_scope_options). 정확 2 entries —
# `_assert_continuity_elements_used_shape()` 가 list equality 강제.
_CONTINUITY_FRAMING_SCOPE_OPTIONS: Tuple[str, ...] = (
    "third_person",
    "close_up",
)

# G4.4 ground-truth: ref_usage values (previous_shot_refs[i].ref_usage).
# 정확 3 entries — `ref_usage_constraints` 의 3 sub-key 와 1:1.
_CONTINUITY_REF_USAGE_VALUES: Tuple[str, ...] = (
    "zoom_in_detail",
    "exact_background",
    "atmosphere_reference",
)

# G4.4 count constants — sub-field list-len 검증용 single source.
# spec §2.2 ground-truth list/literal counts row carry.
_CONTINUITY_ZOOM_FORBIDDEN_ADDITIONS_COUNT: int = 5
_CONTINUITY_ZOOM_REQUIRED_PHRASINGS_COUNT: int = 3
_CONTINUITY_EXACT_PERMITTED_ADDITIONS_COUNT: int = 2
_CONTINUITY_ATMOSPHERE_FORBIDDEN_IMPORTS_COUNT: int = 3

# G4.4 Override O-7 paired-string substring single source (cross-card reference
# literal). Area #1 v1: Used by view_consistency.subject_reference_policy_cross_ref
# AND ref_usage_constraints.zoom_in_detail.subject_reference_policy_cross_ref
# (paired drift 차단). 5 canary scripts + unit tests + integration test 모두
# 본 constant 를 import.
# Paired-string cross-card ref. Rename requires coordinated G4.4 spec patch +
# id_policy renaming.
_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL: str = "id_policy.subject_reference_policy"

# G4.4 required-keys tuples — module-level single source for builder + assert
# helper + unit tests. Override O-5 strict superset (extra keys OK).
_CONTINUITY_CROSS_SHOT_ID_SUB_REQUIRED_KEYS: Tuple[str, ...] = (
    "applies_when",
    "substitution",
    "double_description_forbidden",
    "preserve_pose_unchanged",
    "no_repeat_after_pose",
    "rationale_summary",
)

_CONTINUITY_REF_USAGE_TOP_REQUIRED_KEYS: Tuple[str, ...] = (
    "zoom_in_detail",
    "exact_background",
    "atmosphere_reference",
)

_CONTINUITY_REF_USAGE_ZOOM_REQUIRED_KEYS: Tuple[str, ...] = (
    "scope_summary",
    "forbidden_additions",
    "required_phrasings",
    "subject_reference_policy_cross_ref",  # Area #1 rename
    "rationale_summary",
)

_CONTINUITY_REF_USAGE_EXACT_REQUIRED_KEYS: Tuple[str, ...] = (
    "scope_summary",
    "permitted_additions",
    "background_consistency_rule",
    "ignore_keep_handled_elsewhere",
    "rationale_summary",
)

_CONTINUITY_REF_USAGE_ATMOSPHERE_REQUIRED_KEYS: Tuple[str, ...] = (
    "scope_summary",
    "forbidden_imports",
    "required_handling",
    "rationale_summary",
)

_CONTINUITY_VIEW_CONSISTENCY_REQUIRED_KEYS: Tuple[str, ...] = (
    "framing_scope_options",
    "single_camera_rule",
    "third_person_handling",
    "close_up_handling",
    "focus_on_is_focus_area_not_view_switch",
    "subject_reference_policy_cross_ref",  # Area #1 rename
    "prop_orientation_rule",
    "mixing_forbidden",
    "rationale_summary",
)

# Area B (2026-05-13, Task 6): Patch A story-critical prop binding constants
# 모두 폐기. visible-prop AND filter (5 category groups + flat tuples +
# min-length) 는 render_contracts (visual_identity.reference_required SOT) 로
# 대체됨. 옛 noun list 잔재 0 (시나리오 의존 0 carry).

# =========================================================================
# G4.5a Spatial rules lift — module-level ground-truth constants
# (PR4-B5 / RO-11 binding — single source for builder + canary scripts +
# unit tests). 시나리오 의존 0 (작품 고유명사 ban). 모두 immutable tuple.
# Area #1 (2026-05-16): body-part trigger constants 폐기 — subject_reference_policy
# SOT (helper module) 가 per-subject reference 결정.
# =========================================================================

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

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

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

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

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

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

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

# =========================================================================
# G4.5a spatial_consistency required-keys frozensets — module-level single
# source for builder + assert helpers + unit tests. Override O-30 strict
# superset (`EXPECTED <= returned_keys`). extra keys 허용 (G4.5b/c forward-compat).
# =========================================================================

_SPATIAL_CONSISTENCY_REQUIRED_KEYS: FrozenSet[str] = frozenset({
    "camera_frame_rule",
    "fg_bg_shared_anchor_rule",
    "primary_framing_rule",
    "rationale_summary",
})

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

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

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

# RO-6 / PR4-B3 binding: cross-card refs in close_framing_rules + wide_medium_rules
# only — NOT in camera_frame_rule.
_SPATIAL_CLOSE_FRAMING_RULES_REQUIRED_SUBKEYS: FrozenSet[str] = frozenset({
    "primary_subject_only_fully_visible",
    "other_entities_appearance_options",
    "forbidden_combinations",
    "subject_reference_policy_cross_ref",  # Area #1 rename
})

_SPATIAL_WIDE_MEDIUM_RULES_REQUIRED_SUBKEYS: FrozenSet[str] = frozenset({
    "primary_and_secondary_both_visible",
    "body_part_close_up_forbidden",
    "fg_bg_separation_requires_shared_anchor",
    "subject_reference_policy_cross_ref",  # Area #1 rename
})

# 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)

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

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

# Top-level required envelope fields (Task 9 shape validator).
# Area B (2026-05-13): render_contracts 추가 — 6-field envelope invariant
# (integration test_g4_5a_spatial_integration.py:721 + test_g4_1_card_consumer_wiring.py
# binding contract). Area C lesson §6 carry — production shape deny-list
# defense-in-depth: assert_card_shape 가 render_contracts 누락/타입오류 silent
# 통과 차단 의무.
_REQUIRED_TOP_FIELDS = (
    "schema_version", "shot_key",
    "render_strategy", "id_policy", "background_binding",
    "continuity_elements_used", "asset_requirements",
    "render_contracts",
)

def _resolve_framing_scale_for_render(
    staging: Optional[Dict[str, Any]],
    shot_info: Optional[Dict[str, Any]],
) -> str:
    """Resolve render_strategy.framing_scale from shot_staging enum SOT.

    `staging_not_applicable` is a deterministic sentinel path. All other
    missing/invalid inputs fail through the shared framing_scale helper.
    """
    from app.core.framing_scale import (
        FRAMING_MEDIUM,
        get_framing_scale_or_raise,
    )

    if staging is None and (shot_info or {}).get("staging_not_applicable") is True:
        return FRAMING_MEDIUM
    return get_framing_scale_or_raise(
        staging,
        where="render_prompt_card.framing_scale",
    )


# =========================================================================
# Builders
# =========================================================================

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": {},
    }


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

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

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

    PR4-B9 binding: `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` is referenced
    directly in-module — NO self-import. NO typed-only forward declaration.

    RO-9 invariant: builder-static — 입력 무관 항상 동일 dict. 테스트
    `test_spatial_consistency_4_subfields_independent_of_input` 가 강제.

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

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

    # G4.5a sub-rule 3: primary_framing_rule (5 required keys + nested).
    # Framing scale is consumed from shot_staging.framing_scale enum SOT.
    # RO-6 / PR4-B3 binding: cross-card refs in close_framing_rules +
    #   wide_medium_rules — NOT camera_frame_rule.
    primary_framing_rule = {
        "applies_when": (
            "framing_scale ∈ {close, medium, wide, insert} from "
            "`shot_staging.framing_scale` enum "
            "(render_strategy.framing_scale, production SOT). LLM consumes "
            "the enum only; keyword classification is forbidden."
        ),
        "close_framing_rules": {
            "primary_subject_only_fully_visible": (
                "the named primary subject (hand / face / object) is the only "
                "fully visible entity in the frame"
            ),
            "other_entities_appearance_options": [
                "partial visibility — shoulder slice / arm slice / sleeve / "
                "fingertips entering the frame edge",
                "soft background — out-of-focus silhouette via shallow depth "
                "of field",
                "absent — not rendered at all even if listed in "
                "visible_entities",
            ],
            "forbidden_combinations": [
                "same character's full body and body-part close-up in the "
                "same frame (view mixing)",
                "two characters both rendered as sharp close-up faces in the "
                "same frame",
            ],
            # Area #1 — cross-card paired-string substring to per-subject SOT.
            "subject_reference_policy_cross_ref": (
                f"see {_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} "
                "(subject_reference_policy SOT — policy enum 에 따라 ID/outlook 사용)"
            ),
        },
        "wide_medium_rules": {
            "primary_and_secondary_both_visible": (
                "primary subject + secondary entities all renderable"
            ),
            "body_part_close_up_forbidden": (
                "body-part close-up of any character is forbidden in "
                "wide/medium framing — close-up belongs in a separate shot"
            ),
            "fg_bg_separation_requires_shared_anchor": (
                "if two characters are rendered fg/bg separated, also apply "
                "fg_bg_shared_anchor_rule"
            ),
            # Area #1 — cross-card paired-string substring to per-subject SOT.
            "subject_reference_policy_cross_ref": (
                f"see {_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} "
                "(subject_reference_policy SOT — policy enum 에 따라 ID/outlook 사용)"
            ),
        },
        "self_check_steps": [
            "read framing_scale enum from render_strategy.framing_scale (do NOT infer from camera_direction)",
            "if close: verify only primary subject is fully visible; other "
            "entities are partial / soft bg / absent",
            "verify same character's full body + body-part close-up are not "
            "combined",
            "if close: verify two faces are not both rendered sharp",
        ],
        "rationale_summary": (
            "한 shot 안 framing scale 가 일관되어야 모델이 단일 카메라 셔터로 "
            "해석. close/wide/medium 혼합은 multi-camera 합성으로 인식되어 "
            "인물 분열 / 부위 분리 / 시점 혼합 합성"
        ),
    }

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


# ── 이 샷에 해당하는 공간 규칙만 싣는다 (2026-08-26) ──────────────────
#
# 무엇을 고치나: 인물이 **한 명**인 샷에 「두 인물이 상호작용할 때」 규칙
# (`fg_bg_shared_anchor_rule`)이 실리고, framing 이 `medium` 인 샷에
# `close_framing_rules` 가 실린다. 그 안에는
#     "absent — not rendered at all even if listed in visible_entities"
# 처럼 **적용되면 안 되는 지시**가 들어 있다.
#
# ★RO-9(builder-static)가 막던 실패와 **다른 실패**를 막는다. 나란히 적으면
#   RO-9  : 입력이 **비어서** 규칙이 조용히 사라지는 것 (Trap #14)
#   여기  : 입력이 **확정적으로 「해당 없음」**인데 전문이 실리는 것
#  그래서 fail-safe 방향을 그대로 둔다 — **확정됐을 때만** 걷고, 모르면
#  지금과 똑같이 전문을 싣는다. 플래그 OFF 면 입력 객체를 그대로 돌려준다.

def _count_characters(visible_entities: Optional[List[str]]) -> Optional[int]:
    """`visible_entities` 안의 **인물 수**. 모르면 None (걷지 않는다).

    ★축 판별은 `_is_character_subject`(C## = identity 를 가진 depicted
     subject) 를 **그대로 쓴다.** 여기에 같은 규칙을 따로 적으면 두 곳이
     따로 틀린다.
    """
    if visible_entities is None:
        return None
    return sum(1 for e in visible_entities
               if isinstance(e, str) and _is_character_subject(e))


def _scoped_out(rule: Any, reason: str) -> Dict[str, Any]:
    """해당 없는 규칙의 **본문만 걷고 자리는 남긴다.**

    ★통째로 빼지 않는 이유: 팩 `scene_detail/*/system.md` 가 세 규칙 이름을
     직접 부른다(「카드가 …를 담고 있다」). 키가 없으면 **「준다는데 없다」**
     는 지금보다 나쁜 모순이 된다.
    ★`applies_when` 은 남긴다 — 왜 해당이 없는지 그 자리에서 읽힌다.
    """
    out: Dict[str, Any] = {
        "not_applicable": True,
        "not_applicable_reason": reason,
    }
    if isinstance(rule, dict) and rule.get("applies_when"):
        out["applies_when"] = rule["applies_when"]
    return out


def _is_scoped_out(x: Any) -> bool:
    """`_scoped_out()` 이 만든 자리인가."""
    return isinstance(x, dict) and x.get("not_applicable") is True


def _assert_scoped_out_shape(x: Dict[str, Any], path: str, where: str) -> None:
    """걷힌 자리도 **정확한 모양**이어야 한다.

    ★2026-08-26 Codex BLOCK-1: 카드 반환 직전 `assert_card_shape()` 가
     원래 키를 요구해 **플래그 ON 이면 카드 생산이 통째로 죽었다**. 내
     시험은 `build_render_strategy` 까지만 태워 그 끝점을 건너뛰었다.
     validator 에 이 갈래를 넣어 계약을 **명시적으로** 넓힌다 — 「해당
     없음」이 계약의 일부가 된다.
    ★아무 dict 나 통과시키면 validator 가 있으나 마나다. 사유는 반드시
     있어야 하고, 정해진 세 칸 밖은 못 넣는다.
    """
    if not str(x.get("not_applicable_reason") or "").strip():
        raise AppError(
            code="step.contract_violation",
            message=(f"{path} 가 not_applicable 인데 사유가 비어 있다 "
                     f"{where}"),
        )
    남은키 = set(x.keys()) - {
        "not_applicable", "not_applicable_reason", "applies_when"}
    if 남은키:
        raise AppError(
            code="step.contract_violation",
            message=(f"{path} not_applicable 자리에 예상 밖 키 "
                     f"{sorted(남은키)} {where}"),
        )


def _scope_spatial_rules(
    spatial: Dict[str, Any],
    *,
    framing_scale: Optional[str],
    character_count: Optional[int],
) -> Dict[str, Any]:
    """확정된 framing·인물 수로 **해당 없는 규칙만** 걷는다.

    플래그 OFF 면 **받은 것을 그대로** 돌려준다(한 글자도 안 바뀐다).
    """
    if not getattr(settings, "card_spatial_rules_scoped_enabled", False):
        return spatial

    out = dict(spatial)

    # ① framing — `get_framing_scale_or_raise` 가 준 확정 enum 이다.
    #    `insert` 와 알 수 없는 값은 **손대지 않는다** (어느 쪽이 해당하는지
    #    단정할 수 없다).
    pfr = out.get("primary_framing_rule")
    if isinstance(pfr, dict) and framing_scale in (
            FRAMING_CLOSE, FRAMING_WIDE, FRAMING_MEDIUM):
        pfr = dict(pfr)
        if framing_scale == FRAMING_CLOSE:
            pfr["wide_medium_rules"] = _scoped_out(
                pfr.get("wide_medium_rules"),
                f"framing_scale={framing_scale} — wide/medium 규칙은 이 샷에 "
                "해당하지 않는다")
        else:
            pfr["close_framing_rules"] = _scoped_out(
                pfr.get("close_framing_rules"),
                f"framing_scale={framing_scale} — close 규칙은 이 샷에 "
                "해당하지 않는다")
        out["primary_framing_rule"] = pfr

    # ② 인물 수 — 한 명 이하면 「두 인물이 상호작용할 때」 규칙은 성립하지
    #    않는다. 두 명 이상이어도 **해당한다고는 단정 못 한다**(상호작용·
    #    fg/bg 분리 여부는 코드가 모른다) — 그래서 1 이하일 때만 걷는다.
    #
    # ★`0` 도 걷는다 (2026-08-26 Codex 지적). `visible_entities=[]` 는 이
    #  코드베이스에서 **명시적 빈 목록**이고 「모름」은 `None` 이다. 0 을
    #  빼 두면 인물이 없는 샷에 1,381자가 계속 실려 「확정됐을 때만 걷는다」는
    #  설계와 어긋난다.
    if character_count is not None and character_count <= 1:
        out["fg_bg_shared_anchor_rule"] = _scoped_out(
            out.get("fg_bg_shared_anchor_rule"),
            f"이 샷의 visible_entities 에 인물이 {character_count}명이라 두 "
            "인물 사이의 공유 anchor 규칙은 성립하지 않는다")

    return out


def _scope_constraints(
    constraints: List[str], *, character_count: Optional[int],
) -> List[str]:
    """걷어낸 규칙을 **가리키는 지시**도 같이 뺀다.

    ★`_scope_spatial_rules` 만으로는 **걷다 만다.** `constraints[3]` 은

        two characters in fg/bg separation **must** share a named
        surface/space anchor — see …fg_bg_shared_anchor_rule

    라고 **명령형**으로 말한다. 인물이 한 명 이하인 샷에서 규칙 본문만
    걷고 이 줄을 두면, 모델은 여전히 「두 인물이 …해야 한다」는 지시를
    받는다. 없앤 것을 가리키는 지시가 남는 것이 더 나쁘다.

    ★**새 문장을 짓지 않는다.** 「해당 없음」 문구를 새로 쓰면 이 파일의
     하드프롬프트가 더 는다. 해당 없는 줄은 그냥 뺀다 — 인물 한 명 샷에
     그 재료는 필요가 없다.
    ★`render_strategy.constraints` 를 인덱스로 읽는 코드도, 이것만 따로
     읽는 소비자도 없다(팩이 카드 JSON 을 통째로 읽는다). 길이가 줄어도
     깨지는 자리가 없음을 확인했다.
    """
    if not getattr(settings, "card_spatial_rules_scoped_enabled", False):
        return constraints
    if character_count is None or character_count > 1:
        return constraints
    표 = "spatial_consistency.fg_bg_shared_anchor_rule"
    return [c for c in constraints if 표 not in c]


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

    PR4-B6 carry: G4.4 base 2 strings 보존 — `[0] one prompt captures one
    still moment / [1] do not combine full-body framing and body-part close-up
    in one frame` 그대로. G4.5a 신규 3 strings — camera_frame_rule /
    fg_bg_shared_anchor_rule / primary_framing_rule 의 inline reference.

    RO-9 invariant: builder-static — 입력 무관 항상 동일 list.
    """
    return [
        # constraints[0] — G4.4 carry verbatim.
        "one prompt captures one still moment",
        # constraints[1] — G4.4 carry verbatim.
        "do not combine full-body framing and body-part close-up in one frame",
        # constraints[2] — G4.5a 신규: camera_frame_rule inline.
        (
            "camera position and frame-edge elements must be geometrically "
            "consistent — see render_strategy.spatial_consistency.camera_frame_rule"
        ),
        # constraints[3] — G4.5a 신규: fg_bg_shared_anchor_rule inline.
        (
            "two characters in fg/bg separation must share a named "
            "surface/space anchor — see "
            "render_strategy.spatial_consistency.fg_bg_shared_anchor_rule"
        ),
        # constraints[4] — G4.5a 신규: primary_framing_rule inline.
        (
            "framing_scale binds entity rendering — close = primary subject "
            "only fully visible, wide/medium = no body-part close-up — see "
            "render_strategy.spatial_consistency.primary_framing_rule"
        ),
    ]


def build_render_strategy(
    *,
    seg: Dict[str, Any],
    shot_info: Dict[str, Any],
    staging: Optional[Dict[str, Any]],
    perception_mode: Optional[str],
    visible_entities: Optional[List[str]] = None,
) -> 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"` 표현 전체 삭제.

    G4.5a (2026-05-05): spatial_consistency 신규 sibling nested dict 추가
    (4 sub-key — camera_frame_rule (7 keys, RO-3 — core_principles 4-entry
    list 추가) / fg_bg_shared_anchor_rule (6 keys) / primary_framing_rule
    (5 keys + close_framing_rules 4 incl subject_reference_policy_cross_ref /
    wide_medium_rules 4 incl subject_reference_policy_cross_ref) /
    rationale_summary). constraints 2 → 5
    strings 확장 (G4.4 carry 2 + 3 신규 spatial inline). 양 분기 모두에서
    `spatial_consistency` 4 sub-key 는 builder-static (입력 무관) — RO-9
    invariant. PR4-B2 binding: spec §2.2 ground-truth source. PR4-B3 binding:
    cross-card refs in primary_framing_rule only (NOT camera_frame_rule).
    PR4-B6 binding: G4.1+G4.4 base 8 keys 보존, NEW sibling 추가만.
    PR4-B9 binding: `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` (G4.4 line ~240
    defined) 직접 in-module reference — NO self-import.
    `_card_metadata.lift_status.spatial_*_lifted` 4 신규 key + rule_source
    3 신규 key 는 build_render_prompt_card wrapper 책임 (Task 1.5).

    Area Frame Spatial Contract (2026-05-14): `visible_entities` (Optional
    List[str], default None for legacy callers) + `frame_spatial_contract`
    carry sibling. staging.frame_spatial_contract 가 None 이면 None pass-through,
    object 이면 frame_spatial_contract.validate_and_prepare 가 shape validate
    + constraint_id assign + visible_entities cross-check. 양 분기
    (staging is None / 정상) 모두 frame_spatial_contract sibling 포함 —
    assert_card_shape 의 inline nullable shape check 와 정합.
    """
    # Area Frame Spatial Contract (2026-05-14) — function-local import 으로
    # circular dependency 회피 (helper module 도 app.core.errors 를 import).
    from app.core.frame_spatial_contract import (
        filter_fsc_constraints_to_visible as _fsc_filter_to_visible,
        validate_and_prepare as _fsc_validate_and_prepare,
    )

    shot_cam = (shot_info or {}).get("camera_direction") or ""
    staging_cam = (staging or {}).get("camera_direction") or ""
    cam_dir = staging_cam or shot_cam
    lighting = (staging or {}).get("lighting_mood") or ""
    pm = perception_mode or RENDER_MODE_DIRECT
    primary = (shot_info or {}).get("primary_subject") or ""

    # G4.5a 신규 nested dict — spatial_consistency 4 sub-key (builder-static).
    # PR4-B6 carry: G4.1+G4.4 base 8 keys 보존, NEW sibling 추가만.
    # RO-9 invariant: 양 분기 모두 동일 dict — `if camera_direction:` 류
    # truthy guard 안 배치 절대 금지 (Trap #14).
    spatial_consistency = _build_spatial_consistency_dict()
    constraints = _build_render_strategy_constraints()

    # Area Frame Spatial Contract (2026-05-14) — staging.frame_spatial_contract
    # carry + constraint_id assign + cross-check. visible_entities 는 SID list
    # (render_prompt_card.py:1020/3518 일관 — dict 변환 X).
    raw_fsc = (staging or {}).get("frame_spatial_contract") if staging is not None else None
    # FINDING 6 W4a — consumer-boundary normalization: shot_director.visible
    # SOT 기준으로 non-shot-visible character/prop target constraint 를 drop
    # (depicted-but-not-present / shot-level visibility narrowing). malformed 는
    # 보존 → validate_and_prepare 가 fail-fast.
    raw_fsc = _fsc_filter_to_visible(raw_fsc, visible_entities)
    fsc_prepared = _fsc_validate_and_prepare(
        raw_fsc,
        visible_entities,
    )

    # R1-I1: staging 부재 분기.
    if staging is None:
        if (shot_info or {}).get("staging_not_applicable") is True:
            # 의도적 not applicable case.
            _framing_na = _resolve_framing_scale_for_render(staging, shot_info)
            _인물_na = _count_characters(visible_entities)
            return {
                "mode": RENDER_MODE_NOT_APPLICABLE,
                "primary_subject": primary,
                "framing_scale": _framing_na,
                "moment_lock": "single still moment from the selected shot only",
                "camera_direction": "",
                "lighting_mood": "",
                "perception_mode": pm,
                # G4.5a NEW sibling — builder-static (RO-9 invariant).
                # ★플래그 OFF 면 `_scope_spatial_rules` 가 받은 것을 그대로
                #  돌려준다 — RO-9 는 그대로다.
                "spatial_consistency": _scope_spatial_rules(
                    spatial_consistency,
                    framing_scale=_framing_na,
                    character_count=_인물_na),
                # Area Frame Spatial Contract (2026-05-14) — nullable opt-in.
                "frame_spatial_contract": fsc_prepared,
                # ★걷어낸 규칙을 가리키는 지시도 같이 뺀다 — 안 그러면
                #  없앤 것을 가리키는 명령형 문장이 남는다.
                "constraints": _scope_constraints(
                    constraints, character_count=_인물_na),
            }
        # 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 = _resolve_framing_scale_for_render(staging, shot_info)
    _인물 = _count_characters(visible_entities)
    return {
        "mode": RENDER_MODE_DIRECT,
        "primary_subject": primary,
        "framing_scale": framing,
        "moment_lock": "single still moment from the selected shot only",
        "camera_direction": cam_dir,
        "lighting_mood": lighting,
        "perception_mode": pm,
        # G4.5a NEW sibling — builder-static (RO-9 invariant).
        # ★플래그 OFF 면 `_scope_spatial_rules` 가 받은 것을 그대로 돌려준다.
        "spatial_consistency": _scope_spatial_rules(
            spatial_consistency,
            framing_scale=framing,
            character_count=_인물),
        # Area Frame Spatial Contract (2026-05-14) — nullable opt-in.
        "frame_spatial_contract": fsc_prepared,
        # ★걷어낸 규칙을 가리키는 지시도 같이 뺀다.
        "constraints": _scope_constraints(constraints, character_count=_인물),
    }


def _extract_key_bg_elements_or_raise(
    staging: Optional[Dict[str, Any]],
    shot_info: Optional[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    """Area C migration — resolve shot_staging.key_bg_elements for build_id_policy.

    Returns list 또는 raise AppError (Gate 4 정합).

    `build_render_strategy` (line 980) 이 이미 staging fail-fast 를 처리 —
    본 helper 는 그 fail-fast 와 분리된 단일 contract (Area C 의 reproduction
    derivation 영역). silent fallback 0.

    shot_info is normalized to `{}` if None — matches build_render_strategy
    defensive posture (line 970).

    Returns:
        list: 정상 key_bg_elements (빈 list 포함 — staging_not_applicable 케이스).

    Raises:
        AppError(staging_required): staging=None + flag 미설정.
        AppError(staging_key_bg_elements_invalid): key_bg_elements 가 list 아님.
    """
    shot_info = shot_info or {}
    if shot_info.get("staging_not_applicable") is True:
        return []
    if staging is None:
        raise AppError(
            code="render_prompt_card.staging_required",
            message=(
                "_extract_key_bg_elements_or_raise: staging is None and "
                "staging_not_applicable is not set."
            ),
        )
    key_bg = staging.get("key_bg_elements")
    if not isinstance(key_bg, list):
        raise AppError(
            code="render_prompt_card.staging_key_bg_elements_invalid",
            message=(
                f"_extract_key_bg_elements_or_raise: key_bg_elements "
                f"expected list, got {type(key_bg).__name__}."
            ),
        )
    return key_bg


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

    Area #1 (2026-05-16): subject_reference_policies kwarg required (no default;
    Gate 4 silent fallback 차단). id_policy["subject_reference_policy"] = array
    항상 inject. Caller (build_render_prompt_card) 가 helper normalize 후 array
    pass; build_id_policy 는 staging 모름 (책임 경계).

    G4.3 sub-fields (Area #1 폐기 2 + 남은 3):
      - face_identifiability_rule (R1R2-B1) — builder-static.
      - reproduction_surface_rule — `applies` bool derived from
        directionality_class SOT (Area C migration 2026-05-12).
      - demographic_descriptor_policy (Rule H lift) — builder-static.
      - (Area #1 폐기 2 entries — subject_reference_policy SOT 대체)

    G4.1 R2-B4 carry: visible_entities / outlook_pairs 가 None 이면 producer
    missing 으로 간주 → AppError. 명시적 [] 만 valid.

    R1-I4 / R2-I5 carry: perception_mode (reflection / mirror / through_device /
    projection) constraint 가 reproduction_surface_rule 와 의도적 overlap
    (defense in depth).
    """
    # 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."
            ),
        )

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

    # Area #1 (2026-05-16) — body-part / close-framing face noun-list 폐기.
    # 대체 SOT = id_policy.subject_reference_policy (per-subject array,
    # exceptions-first emit by shot_staging v12).

    # G4.3 sub-field 4 (Area C migration 2026-05-12): noun list 제거.
    # directionality_class SOT (shot_staging) 에서 applies bool derive.
    applies = False
    for elem in key_bg_elements:
        # Minor 2 (review fix-up 2026-05-12): helper validates outer list is
        # a list, but does NOT validate each item. Non-dict elem (string slip,
        # producer drift) must fail-fast with typed AppError instead of
        # AttributeError on `.get`.
        if not isinstance(elem, dict):
            raise AppError(
                code="render_prompt_card.key_bg_element_invalid",
                message=(
                    f"key_bg_elements item must be dict, got "
                    f"{type(elem).__name__} ({elem!r})."
                ),
            )
        dc = elem.get("directionality_class")
        if not isinstance(dc, str) or dc == "":
            raise AppError(
                code="render_prompt_card.directionality_class_missing",
                message=(
                    "shot_staging key_bg_elements element missing/empty "
                    "directionality_class — shot_staging is stale; "
                    "rerun shot_staging with Area A schema."
                ),
            )
        if dc not in _ALLOWED_DIRECTIONALITY_CLASSES:
            raise AppError(
                code="render_prompt_card.directionality_class_invalid",
                message=(
                    f"directionality_class={dc!r} not in allowed enum "
                    f"{sorted(_ALLOWED_DIRECTIONALITY_CLASSES)}."
                ),
            )
        if dc in _REPRODUCTION_SURFACE_CLASSES:
            applies = True
            # fall-through (no break) — 나머지 element enum 검증 도 진행.

    reproduction_surface_rule = {
        "applies": applies,
        "id_use": "forbidden — generic descriptor with demographic only",
        "rationale_summary": (
            "C##O## 은 얼굴 reference 이미지를 원본 해상도로 inject 하므로 "
            "사진/화면/반사 표면 안 얼굴이 표면 밖 실물 크기로 합성됨. "
            "이 rule 은 표면 안 얼굴 (face) 에만 적용 — 물리적 사진/액자/문서 "
            "prop 자체는 P## ID 보존 (Patch A)."
        ),
    }

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

    # constraints: 5 base strings (Area #1: body-part inline + close-framing
    # forbidden inline 폐기 → per-subject policy 1 constraint 로 통합)
    # + optional perception_mode constraint (defense in depth).
    # Phase3 W2 (2026-05-23): constraints[0]/[2]/[3]/[4] policy-conditional —
    # generic_descriptor_allowed subjects MUST NOT use C##/C##O##. constraints[0]
    # is no longer unconditional; constraint[2] carries the explicit 3-way enum
    # rule; constraints[3]/[4] explicitly scoped to "when an ID is required".
    constraints = [
        # constraints[0] — Phase3 W2: cross-ref to per-subject policy SOT.
        # Phrase still mentions C##O## composite IDs so downstream substring
        # checks (test_g4_3) hold, but no longer asserts unconditional use —
        # constraints[2] carries the per-policy enum directive. Avoids the
        # substring "subject_reference_policy" so existing test_g4_3 next()
        # selector still resolves to constraints[2] for the enum check.
        (
            "ID format for visible subjects (C## bare, C##O## composite IDs, "
            "or common-noun descriptor) is determined per-subject by the "
            "id_policy per-subject policy array — see constraints[2] below"
        ),
        # constraints[1] — reproduction surface (Area C migration 2026-05-12).
        # No inline noun enumeration: SOT = id_policy.reproduction_surface_rule
        # .applies (bool derived from shot_staging directionality_class).
        # Patch A — NOTE clause: physical prop entity itself preserves P## ID;
        # common-noun rule applies only to face reproduction inside the surface.
        (
            "do not use C##O## for a reproduced face inside any element flagged "
            "by id_policy.reproduction_surface_rule.applies (declared "
            "reproduction surface) — use a generic descriptor with demographic "
            "instead. "
            "NOTE: the physical photo/poster/document/picture-frame/map/key prop "
            "itself MUST use its P## ID — the generic-descriptor rule applies "
            "only to a face reproduction, not to the prop entity."
        ),
        # constraints[2] — Area #1: per-subject reference policy SOT.
        # body-part / face close-up noun-list 폐기 — subject_reference_policy
        # array (shot_staging v12 producer) consume.
        # Phase3 W2 (2026-05-23): expanded with 3-way enum semantics so the LLM
        # sees the explicit rule — generic_descriptor_allowed must NOT use
        # C## or C##O## (W1 validator now enforces this server-side).
        (
            "per-subject identity reference policy is provided in "
            "id_policy.subject_reference_policy[] — apply each subject's policy "
            "when emitting references: "
            "(a) id_and_outlook_required ⇒ subject must use C##O## composite ID; "
            "(b) base_id_required ⇒ subject must use bare C## only (no outlook); "
            "(c) generic_descriptor_allowed ⇒ subject must NOT use C## or "
            "C##O##, render with a common/demographic descriptor only. "
            "omitted subjects default to id_and_outlook_required"
        ),
        # constraints[3] — demographic first-appearance (Rule H).
        # Phase3 W2: scoped to "when an ID is required" — generic_descriptor_allowed
        # subjects skip ID emission entirely so this rule does not apply.
        (
            "when an ID is required for a subject (policy = id_and_outlook_required "
            "or base_id_required) and a C##O## (or C##) is introduced for the "
            "first time in this t2i_prompt, append a 1-2 token demographic "
            "descriptor (ethnicity + age band, or ethnicity + role hint) "
            "consistent with visual_world_rules.region — never use "
            "scenario-specific proper nouns. does not apply to "
            "generic_descriptor_allowed subjects (no ID emitted)"
        ),
        # constraints[4] — format templates.
        # Phase3 W2: scoped to "when an ID is required" — does not govern
        # generic_descriptor_allowed subjects (which use bare common nouns).
        (
            "when an ID is required (policy = id_and_outlook_required or "
            "base_id_required), use the format "
            "`C##O## in <옷 1-3 단어>, <demographic descriptor>` "
            "or `C##O##, <demographic descriptor>, <자세 표현>` — main characters "
            "with face refs may include this descriptor too "
            "(image inject takes precedence). does not apply to "
            "generic_descriptor_allowed subjects — those use a "
            "common/demographic descriptor with no C##/C##O##"
        ),
    ]

    # perception_mode reflection branch (defense in depth, R1-I4).
    # base 5 위에 6번째 constraint — base 의 일부가 아님 (R1-I4 / R2-I5 carry).
    # C1 v1: 4-tuple literal → perception_mode.REPRODUCTION_PERCEPTION_MODES helper SOT consume.
    # Wave4 (e2e-review-fix-v1): arbitrary .lower() normalization 제거 — consumer
    # boundary tighten. None/empty 만 "direct" 로 coerce (documented legacy caller
    # responsibility); 비정규 casing(예: "MIRROR")은 helper 가 perception_mode.unknown
    # 으로 fail-fast — silent 정규화로 enum SOT 를 우회하지 않는다.
    pm = perception_mode or "direct"
    if is_reproduction_perception(pm):
        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,
        # Patch A — photo depiction 통합 항목 split.
        # Area #1 (2026-05-16): body-part entry 폐기 — per-subject policy SOT
        # (subject_reference_policy[].policy = generic_descriptor_allowed) 가
        # 같은 의도를 per-subject 로 표현.
        # common_noun: 사진/화면 안의 face (얼굴 identity 방어).
        # id_use_required_when: 사진/액자/문서/지도/열쇠 prop *자체* (P## 보존).
        "common_noun_required_when": [
            "a reproduced face inside any declared reproduction surface",
            "unregistered extra",
        ],
        "id_use_required_when": [
            (
                "a physical photo/poster/document/picture-frame/map/key prop "
                "appearing in the scene — use its P## ID and preserve canonical "
                "content from entity_canon"
            ),
        ],
        "demographic_fallback_required": True,
        # G4.3 sub-field (Area #1 폐기 2 entries). 남은 3 builder-static
        # + Area C reproduction_surface_rule.
        "face_identifiability_rule": face_identifiability_rule,
        "reproduction_surface_rule": reproduction_surface_rule,
        "demographic_descriptor_policy": demographic_descriptor_policy,
        # Area #1 신규 — per-subject reference policy SOT (always inject; Gate 4).
        "subject_reference_policy": subject_reference_policies,
        # G4.3 확장 constraints (6 base + 0~1 perception_mode).
        "constraints": constraints,
    }


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 책임.

    Hash relationship (spec §4.3): card hash 가 owned list 변경 잡고, G3.2
    sentinel hash (variation-level) 가 t2i_prompt 변경 잡음. 둘은 분리된
    scope — card 가 G3.2 sentinel 을 대체하지 않음.

    R1-I12 (Wave 4 R4 I2): bg_owned 가 None 이면 producer missing → AppError
    fail-fast. 명시적 [] 만 valid (= "no owned objects" 의 의도). 옛 silent
    `list(bg_owned or [])` 패턴은 None ≠ [] 차이를 흡수해 진단 신호 손실.

    G4.2 (2026-05-04):
      - skipped_close_framing 분기: 6 ground-truth 문구 + 4 Rule A invalidation
        forms 를 합쳐 5-string constraints 로 재구성 (R1-I2 / R2-B3).
      - background_ref_attached 분기: owned/camera 4 sub-case (A/B/C/D) 명시 —
        sub-case D fallback 은 "ref 있는데 owned/camera 둘 다 없음" 진단용
        문구 (R1-I1) — 이전 implicit empty list 대체.
      - Rule A (camera) / Rule C (owned) / Rule E (close skip) prose 가
        v16 system.md 에서 lift 되어 이 함수의 constraints 가 single source.
      - `_card_metadata.lift_status` / `rule_source` 는 envelope sibling 으로
        `build_render_prompt_card()` 가 작성 (R1-B1).
    """
    if bg_owned is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_background_binding: bg_owned is None — upstream producer "
                "(chain_bg_owned_by_shot loader) did not populate. "
                "Use explicit [] for intentionally empty (no owned objects)."
            ),
        )
    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 uses close framing — the background reference is intentionally omitted at composition time",
                "describe the close subject and its immediate surroundings without relying on the absent background reference",
                "use [L##: ...] block for frame-edge surface description",
            ],
        }
    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 and cam_ref:
        # sub-case A: owned + camera 둘 다
        constraints.extend([
            "owned_objects lists all environment objects already drawn in the background reference — do not create new ones",
            "if focusing on an owned object, declare its reference-kind via the reference_phrase_kinds sidecar (producer contract); do not enumerate forbidden phrasings here",
            "camera_reference specifies the framing under which the background was painted — match camera_position, camera_height, and framing",
            "if deviating from the reference framing (push-in, angle shift, ECU), state the deviation explicitly so the compositor can account for the scale difference",
        ])
    elif bg_owned and not cam_ref:
        # sub-case B: owned only
        constraints.extend([
            "owned_objects lists all environment objects already drawn in the background reference — do not create new ones",
            "if focusing on an owned object, declare its reference-kind via the reference_phrase_kinds sidecar (producer contract)",
        ])
    elif not bg_owned and cam_ref:
        # sub-case C: camera only
        constraints.extend([
            "camera_reference specifies the framing under which the background was painted — match camera_position, camera_height, and framing",
            "if deviating from the reference framing (push-in, angle shift, ECU), state the deviation explicitly so the compositor can account for the scale difference",
        ])
    else:
        # sub-case D: neither — fallback (R1-I1)
        constraints.append(
            "the chain_bg reference is attached but carries no owned-object list and no camera metadata "
            "— describe the scene freely while staying consistent with the reference image overall mood"
        )
    return {
        "mode": BG_MODE_REF_ATTACHED,
        "bg_id": bg_id,
        "reference_usage": "exact_background",
        # I2: None 은 위에서 raise — 여기서는 list 그대로 (defensive copy).
        "owned_objects": list(bg_owned),
        "camera_reference": cam_ref,
        "close_framing_skips_background_ref": False,
        "constraints": constraints,
    }


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 / §2.2 — 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 (list[{label, kind}], Area D-next 2026-05-14) 모두
    full list. cap 금지. CLAUDE.md 절대 규칙 (LLM-bound data 무절단)
    적용.

    CLAUDE.md 절대 규칙: input 무절단 — fixed_element description 의 raw
    text 그대로 보존. deep copy 로 lossless 보장 (mutation 차단 + 원본
    필드 모두 보존).

    G4.4 (2026-05-05) — Override O-6 명명 정정 (3 new sibling policy dicts):
      - cross_shot_id_substitution_rule (6 required keys) 신규 sibling policy
        dict — fixed_element 의 character_name → C##/C##O## 매핑 시 보통명사
        인물 묘사 치환 + 이중 묘사 금지 + 자세 보존 + 반복 금지.
      - ref_usage_constraints (3 sub-key: zoom_in_detail / exact_background /
        atmosphere_reference, 각 5/5/4 required keys) 신규 sibling policy
        dict — ref_usage 별 forbidden/required wording.
      - view_consistency (9 required keys) 신규 sibling policy dict —
        single-camera rule + Focus area 의미 + prop orientation + Area #1
        per-subject reference cross-ref (Override O-7
        paired-string substring `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` —
        value = id_policy.subject_reference_policy).
      - constraints G4.1 base 2 → G4.4 7 strings.

    Override O-4 carry: forward_zoom_targets shape 변경 절대 금지 (G4.4 lift
      대상 아님). detail_steps.py:1566-1620 forward zoom prose inject 별도
      mechanism, v19 LLM consumer 영향 0.

    Override O-9 invariant — 3 new sibling policy dicts 는 builder-static
      (입력 무관 항상 동일 dict). `if previous_shot_refs:` 류 truthy guard
      안 배치 절대 금지 — fixed_elements=[] / previous_shot_refs=[] /
      forward_zoom_targets=[] 빈 입력에서도 모두 present 보장.
    """
    # R2-B4: None vs [] fail-fast. silent fallback 금지.
    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."
            ),
        )

    # G4.4 Override O-7 carry: paired-string substring
    # `"id_policy.subject_reference_policy"` (Area #1 v1 value) 를 literal 로
    # 참조. helper sub-field rename 시 본 spec / plan / canary / unit test
    # 모두 동시 patch 필요 (paired drift 차단 —
    # `test_continuity_cross_card_pair_substrings_match`).

    # G4.4 신규 sibling policy dict 1: cross_shot_id_substitution_rule
    # (Override O-6) — builder-static, 입력 무관 동일 dict.
    cross_shot_id_substitution_rule = {
        "applies_when": (
            "fixed_elements[i].character_name maps to a C## allowed in "
            "id_policy.allowed_base_entity_ids OR a C##O## in "
            "id_policy.allowed_outlook_pairs"
        ),
        "substitution": (
            "replace the common-noun person reference inside "
            "fixed_elements[i].description (e.g. 'An Asian man' / 'a woman' / "
            "'a figure') with the matched C## or C##O##"
        ),
        "double_description_forbidden": (
            "do not describe the same character with both a C##/C##O## and a "
            "separate common-noun person reference in the same t2i_prompt — "
            "produces phantom extra figures"
        ),
        "preserve_pose_unchanged": (
            "fixed_elements[i].description 의 자세/위치/상태 keyword 는 "
            "그대로 유지 — 인물 참조 토큰만 교체"
        ),
        "no_repeat_after_pose": (
            "이미 t2i_prompt 안에서 같은 인물의 자세를 묘사했다면, 같은 "
            "fixed_element description 을 별도 문장으로 반복하지 마라"
        ),
        "rationale_summary": (
            "fixed_element description 이 보통명사로 작성되었고 같은 인물의 "
            "C## 묘사가 t2i_prompt 에 따로 있으면 모델이 두 인물로 인식해 "
            "인원 수가 부풀려진다"
        ),
    }

    # G4.4 신규 sibling policy dict 2: ref_usage_constraints (Override O-6)
    # — 3 sub-key: zoom_in_detail / exact_background / atmosphere_reference.
    # builder-static, 입력 무관 동일 dict.
    ref_usage_constraints = {
        "zoom_in_detail": {
            "scope_summary": (
                "same moment, same space, same primary subject; camera "
                "moved/zoomed onto a sub-region only"
            ),
            "forbidden_additions": [
                "new persons not in the prior shot",
                "new props not in the prior shot",
                "new background elements not in the prior shot",
                "new poses, gestures, or expressions not in the prior shot",
                "different moments of action",
            ],  # exactly 5 entries — _CONTINUITY_ZOOM_FORBIDDEN_ADDITIONS_COUNT
            "required_phrasings": [
                "describe only the focused sub-region in concrete terms",
                (
                    "explicitly state the same environment "
                    "(lighting, background) is preserved"
                ),
                (
                    "use continuity tokens such as 'the same figure', "
                    "'the same room', 'the same surface'"
                ),
            ],  # exactly 3 entries — _CONTINUITY_ZOOM_REQUIRED_PHRASINGS_COUNT
            # Area #1 cross-card paired-string — per-subject policy SOT.
            "subject_reference_policy_cross_ref": (
                f"{_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} applies per-subject; "
                "policy enum (id_and_outlook_required / base_id_required / "
                "generic_descriptor_allowed) determines C##/C##O## use"
            ),
            "rationale_summary": (
                "zoom_in_detail = 앞 샷의 특정 영역을 확대한 '같은 사진'. "
                "새 정보 추가 금지 — 현재 프레임이 보여주는 부분만 정확히 묘사"
            ),
        },
        "exact_background": {
            "scope_summary": (
                "same room, possibly different moment or angle; background "
                "carries through, persons/actions are described fresh for "
                "this shot"
            ),
            "permitted_additions": [
                (
                    "new persons, poses, expressions, actions for this shot's "
                    "moment"
                ),
                "different camera angle within the same room",
            ],  # exactly 2 entries — _CONTINUITY_EXACT_PERMITTED_ADDITIONS_COUNT
            "background_consistency_rule": (
                "background elements established in the prior shot must "
                "remain consistent (no contradictory walls, doors, furniture)"
            ),
            "ignore_keep_handled_elsewhere": (
                "ignore/keep directives are auto-applied via reference image "
                "processing — do not echo them inside t2i_prompt"
            ),
            "rationale_summary": (
                "같은 공간, 새 순간 — 인물·동작은 이 샷의 instantaneous "
                "moment 로 묘사하되 배경은 prior shot 과 일관"
            ),
        },
        "atmosphere_reference": {
            "scope_summary": (
                "different room/floor/angle entirely — only mood/tone is "
                "shared with the prior shot"
            ),
            "forbidden_imports": [
                "furniture layout from the prior shot",
                "wall positions from the prior shot",
                "specific architectural features from the prior shot",
            ],  # exactly 3 entries — _CONTINUITY_ATMOSPHERE_FORBIDDEN_IMPORTS_COUNT
            "required_handling": (
                "describe the new space's background directly in this shot; "
                "carry through only color tone or lighting mood "
                "(e.g. 'cool gray ambient' / 'dim warm pool')"
            ),
            "rationale_summary": (
                "atmosphere_reference = 분위기 reference 만, 레이아웃 "
                "reference 아님. 가구·벽 배치를 끌어오면 모델이 두 공간을 "
                "한 공간으로 합성"
            ),
        },
    }

    # G4.4 신규 sibling policy dict 3: view_consistency (Override O-6)
    # — builder-static, 입력 무관 동일 dict.
    # Area #1 (2026-05-16): body-part vocab + "Focus on" phrasing 모두 폐기 —
    # per-subject reference policy SOT 가 의미 표현 단일 채널.
    view_consistency = {
        "framing_scope_options": list(_CONTINUITY_FRAMING_SCOPE_OPTIONS),  # 2
        "single_camera_rule": (
            "one t2i_prompt = one camera position. third-person full body "
            "and close-up of the same character must not be combined in "
            "a single prompt."
        ),
        "third_person_handling": (
            "describe persons with full or partial body in frame; "
            "single-camera consistency forbids combining a partial close-up "
            "and the same character's full body in one prompt"
        ),
        "close_up_handling": (
            "an isolated framed area of a single subject must not coexist "
            "with that subject's full-body description in the same prompt"
        ),
        "focus_on_is_focus_area_not_view_switch": (
            "any framing-intent wording designates the area shown by the "
            "single camera position; it does not switch the view"
        ),
        # Area #1 cross-card paired-string — per-subject policy SOT.
        # Phase3 W2 (2026-05-23): extended — generic_descriptor_allowed
        # subjects also forbid bare C## (W1 validator enforces this).
        "subject_reference_policy_cross_ref": (
            f"{_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} — subjects with policy = "
            "'base_id_required' or 'generic_descriptor_allowed' must not use "
            "outlook reference (C##O## form); additionally, subjects with "
            "policy = 'generic_descriptor_allowed' must not use bare C## either "
            "— render with a common/demographic descriptor only"
        ),
        "prop_orientation_rule": (
            "props the character holds must face one direction only — "
            "toward camera OR toward character, not both. forbidden "
            "contradiction example: 'photo angled partly toward the camera' "
            "+ 'eyes fixed on the photograph' (양방향 모순)"
        ),
        # bool literal — NOT bool int subclass (G3.2 / G4.3 trap carry).
        "mixing_forbidden": True,
        "rationale_summary": (
            "single t2i_prompt = single camera shutter at 1/1000s. "
            "시점 혼합은 모델이 두 카메라 합성으로 해석해 인물이 두 번 "
            "등장 또는 부위 분리"
        ),
    }

    # G4.4 constraints: G4.1 base 2 → G4.4 7 strings.
    constraints = [
        # constraints[0] — G4.1 carry: fixed_elements binding.
        (
            "fixed_elements are continuity inputs derived from prior shots — "
            "treat them as binding context, not as source facts to be invented"
        ),
        # constraints[1] — cross_shot_id_substitution_rule inline.
        (
            "when fixed_elements[i].character_name maps to a C## in "
            "id_policy.allowed_base_entity_ids or a C##O## in "
            "id_policy.allowed_outlook_pairs, replace the common-noun person "
            "reference inside fixed_elements[i].description with that "
            "C##/C##O## and do not also write a separate common-noun "
            "reference for the same character in the same t2i_prompt"
        ),
        # constraints[2] — zoom_in_detail inline.
        (
            "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 — no new persons, props, background elements, poses, "
            "or moments"
        ),
        # constraints[3] — exact_background inline.
        (
            "previous_shot_refs with ref_usage='exact_background' may freely "
            "describe this shot's persons, poses, and moment, but must keep "
            "background elements consistent with the prior shot's "
            "established room"
        ),
        # constraints[4] — atmosphere_reference inline.
        (
            "previous_shot_refs with ref_usage='atmosphere_reference' must "
            "describe a new space directly — do not import furniture layout "
            "or wall positions from the prior shot, only mood/tone"
        ),
        # constraints[5] — single_camera + Area #1 cross-ref to per-subject
        # policy SOT.
        (
            "one t2i_prompt = one camera position — third-person full body "
            "and close-up of the same character must not be combined in a "
            "single shot. any framing-intent wording designates the area "
            "shown by the single camera position, not a view switch "
            f"({_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL} applies per-subject)"
        ),
        # constraints[6] — prop orientation.
        (
            "props held by characters face one direction only — toward "
            "camera OR toward character, never both"
        ),
    ]

    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)
        ),
        # G4.4 신규 3 new sibling policy dicts (Override O-6) — builder-static.
        "cross_shot_id_substitution_rule": cross_shot_id_substitution_rule,
        "ref_usage_constraints": ref_usage_constraints,
        "view_consistency": view_consistency,
        # G4.4 확장 constraints (G4.1 base 2 → G4.4 7 strings).
        "constraints": constraints,
    }


# =========================================================================
# Area B (2026-05-13) — render_contracts[] introduction.
# shot-level visual requirement registry. case-name registry 가 아닌
# visual rendering requirement registry — dimension 은 시각 요구의 축.
# B-min: visual_identity dimension 1 개만 strict enum.
#
# spec: docs/superpowers/specs/2026-05-13-area-b-render-contracts-design.md
# =========================================================================

_AREA_B_DIMENSION = "visual_identity"
_AREA_B_OPERATION = "preserve"
_AREA_B_STRENGTH = "required"
_AREA_B_REFERENCE_POLICY = "use_entity_reference"
_AREA_B_TARGET_ROLE = "visual_target"
_AREA_B_DURATION = "single_shot"

_PROP_ENTITY_ID_RE = _re_module.compile(r"^P[0-9]+$")


def build_render_contracts(
    *,
    visible_entities: List[str],
    visible_entity_details: List[Dict[str, Any]],
    current_scene_index: int,
    current_shot_index: int,
) -> List[Dict[str, Any]]:
    """Area B — visible prop ∩ visual_identity.reference_required=true → contract list.

    B-min: dimension=visual_identity, single target (prop short_id ^P[0-9]+$),
    single requirement, single_shot scope. card-local rc_NNN.

    deterministic sort by target_entity_id (lexicographic).

    Raises:
        AppError (entity_metadata.shape_violation) — stale visible_entity_details
        (helper get fail-fast).
        AppError (render_prompt_card.render_contracts_malformed) — None inputs
        or non-dict entries or invalid prop short_id pattern.
    """
    if visible_entities is None or visible_entity_details is None:
        raise AppError(
            code="render_prompt_card.render_contracts_malformed",
            message=(
                "build_render_contracts: visible_entities / visible_entity_details "
                "must not be None. caller contract violation."
            ),
        )

    from app.core.entity_metadata import get_visual_identity_reference_required

    visible_sids = set(visible_entities)

    # 후보 prop 수집 + deterministic sort.
    prop_candidates: List[str] = []  # short_id list
    for entry in visible_entity_details:
        if not isinstance(entry, dict):
            raise AppError(
                code="render_prompt_card.render_contracts_malformed",
                message=(
                    f"build_render_contracts: visible_entity_details entry must "
                    f"be dict, got {type(entry).__name__}."
                ),
            )
        if entry.get("entity_type") != "prop":
            continue
        sid = (entry.get("short_id") or "").strip()
        if not sid or sid not in visible_sids:
            continue
        if not _PROP_ENTITY_ID_RE.match(sid):
            raise AppError(
                code="render_prompt_card.render_contracts_malformed",
                message=(
                    f"build_render_contracts: prop short_id {sid!r} does not "
                    f"match ^P[0-9]+$ pattern (B-min entity_id contract)."
                ),
            )
        rbr = get_visual_identity_reference_required(
            entry.get("metadata_json"), short_id=sid,
        )
        if rbr:
            prop_candidates.append(sid)

    prop_candidates.sort()  # lexicographic

    contracts: List[Dict[str, Any]] = []
    for idx, sid in enumerate(prop_candidates, start=1):
        contracts.append({
            "contract_id": f"rc_{idx:03d}",
            "scope": {
                "scene_index": current_scene_index,
                "shot_indices": [current_shot_index],
                "duration": _AREA_B_DURATION,
            },
            "targets": [{
                "entity_id": sid,
                "role": _AREA_B_TARGET_ROLE,
            }],
            "requirements": [{
                "dimension": _AREA_B_DIMENSION,
                "operation": _AREA_B_OPERATION,
                "strength": _AREA_B_STRENGTH,
                "reference_policy": _AREA_B_REFERENCE_POLICY,
            }],
        })
    return contracts


# Area B render_contracts jsonschema (B-min strict shape).
_RENDER_CONTRACTS_SCHEMA = {
    "type": "array",
    "items": {
        "type": "object",
        "properties": {
            "contract_id": {"type": "string", "pattern": "^rc_[0-9]{3}$"},
            "scope": {
                "type": "object",
                "properties": {
                    "scene_index": {"type": "integer", "minimum": 0},
                    "shot_indices": {
                        "type": "array",
                        "items": {"type": "integer", "minimum": 0},
                        "minItems": 1, "maxItems": 1,
                    },
                    "duration": {"type": "string", "enum": [_AREA_B_DURATION]},
                },
                "required": ["scene_index", "shot_indices", "duration"],
                "additionalProperties": False,
            },
            "targets": {
                "type": "array",
                "minItems": 1, "maxItems": 1,
                "items": {
                    "type": "object",
                    "properties": {
                        "entity_id": {"type": "string", "pattern": "^P[0-9]+$"},
                        "role": {"type": "string", "enum": [_AREA_B_TARGET_ROLE]},
                    },
                    "required": ["entity_id", "role"],
                    "additionalProperties": False,
                },
            },
            "requirements": {
                "type": "array",
                "minItems": 1, "maxItems": 1,
                "items": {
                    "type": "object",
                    "properties": {
                        "dimension": {"type": "string", "enum": [_AREA_B_DIMENSION]},
                        "operation": {"type": "string", "enum": [_AREA_B_OPERATION]},
                        "strength": {"type": "string", "enum": [_AREA_B_STRENGTH]},
                        "reference_policy": {
                            "type": "string", "enum": [_AREA_B_REFERENCE_POLICY],
                        },
                    },
                    "required": [
                        "dimension", "operation", "strength", "reference_policy",
                    ],
                    "additionalProperties": False,
                },
            },
        },
        "required": ["contract_id", "scope", "targets", "requirements"],
        "additionalProperties": False,
    },
}


def validate_render_contracts(
    render_contracts: List[Dict[str, Any]],
    scene_index: int,
    shot_index: int,
) -> None:
    """Area B B-min strict — jsonschema validate + scope ↔ current scene/shot 검증.

    Raises:
        AppError(code="render_prompt_card.render_contracts_malformed") on violation.
    """
    import jsonschema
    try:
        jsonschema.validate(
            instance=render_contracts, schema=_RENDER_CONTRACTS_SCHEMA,
        )
    except jsonschema.ValidationError as exc:
        raise AppError(
            code="render_prompt_card.render_contracts_malformed",
            message=(
                f"validate_render_contracts: jsonschema validation failed — "
                f"{exc.message} (path: {list(exc.absolute_path)})"
            ),
        )

    # M-2 review fix (2026-05-13): jsonschema validate 통과 후 scope / shot_indices
    # 는 required + minItems=1 + maxItems=1 + items.type=integer 보장 → 옛
    # `scope = c.get("scope") or {}` + `(scope.get("shot_indices") or [None])[0]`
    # fallback 은 dead code. 직접 access (KeyError/IndexError 는 jsonschema
    # validate pre-condition 위반 — 도달 불가).
    for c in render_contracts:
        scope = c["scope"]
        if scope["scene_index"] != scene_index:
            raise AppError(
                code="render_prompt_card.render_contracts_malformed",
                message=(
                    f"validate_render_contracts: contract {c.get('contract_id')!r} "
                    f"scope.scene_index={scope['scene_index']} != current "
                    f"scene_index={scene_index}."
                ),
            )
        if scope["shot_indices"][0] != shot_index:
            raise AppError(
                code="render_prompt_card.render_contracts_malformed",
                message=(
                    f"validate_render_contracts: contract {c.get('contract_id')!r} "
                    f"scope.shot_indices[0]={scope['shot_indices'][0]} "
                    f"!= current shot_index={shot_index}."
                ),
            )


def required_refs_from_render_contracts(
    render_contracts: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    """Area B — schema-pass contract input 가정 + 방어적 second check.

    B-min: dimension=visual_identity, operation=preserve, reference_policy=
    use_entity_reference 조합만 honor → {"kind": "prop", "id": entity_id,
    "policy": "required"} emit.

    Raises:
        AppError(code="render_prompt_card.render_contracts_malformed") on unknown
        dimension/operation/policy (방어적 second check — validator 우회 시
        consumer fail-fast).
    """
    result: List[Dict[str, Any]] = []
    for c in render_contracts:
        targets = c.get("targets") or []
        requirements = c.get("requirements") or []
        for req in requirements:
            dim = req.get("dimension")
            op = req.get("operation")
            strength = req.get("strength")
            policy = req.get("reference_policy")
            if dim != _AREA_B_DIMENSION:
                raise AppError(
                    code="render_prompt_card.render_contracts_malformed",
                    message=(
                        f"required_refs_from_render_contracts: unknown dimension "
                        f"{dim!r} in contract {c.get('contract_id')!r}. "
                        f"B-min consumer honors only {_AREA_B_DIMENSION!r}."
                    ),
                )
            if op != _AREA_B_OPERATION:
                raise AppError(
                    code="render_prompt_card.render_contracts_malformed",
                    message=(
                        f"required_refs_from_render_contracts: unknown operation "
                        f"{op!r} in contract {c.get('contract_id')!r}. "
                        f"B-min consumer honors only {_AREA_B_OPERATION!r}."
                    ),
                )
            # M-1 review fix (2026-05-13): strength defensive check — spec §3.2
            # 4-field strict lock symmetry (dimension/operation/strength/reference_policy).
            if strength != _AREA_B_STRENGTH:
                raise AppError(
                    code="render_prompt_card.render_contracts_malformed",
                    message=(
                        f"required_refs_from_render_contracts: unknown strength "
                        f"{strength!r} in contract {c.get('contract_id')!r}. "
                        f"B-min consumer honors only {_AREA_B_STRENGTH!r}."
                    ),
                )
            if policy != _AREA_B_REFERENCE_POLICY:
                raise AppError(
                    code="render_prompt_card.render_contracts_malformed",
                    message=(
                        f"required_refs_from_render_contracts: unknown "
                        f"reference_policy {policy!r} in contract "
                        f"{c.get('contract_id')!r}. B-min consumer honors only "
                        f"{_AREA_B_REFERENCE_POLICY!r}."
                    ),
                )
            for tgt in targets:
                entity_id = tgt.get("entity_id") or ""
                result.append({
                    "kind": "prop",
                    "id": entity_id,
                    "policy": "required",
                })
    return result


def _research_forced_from_policy(manifest) -> Set[str]:
    """정책 manifest → **조사가 올린** short_id. ★`text_only` 쪽과 반대다.

    ★`extract_text_only_subjects` 는 **내리는** 목록이라 여기 못 쓴다.
    ★★★**구조화된 칸을 읽는다.** 전에는 `reason` 에 특정 낱말이 있는지로
    뜻을 되살렸는데, 그건 저장소 계약(「글자/substring 으로 의미 판단 금지」)에
    정면으로 어긋나고 **문구만 바꿔도 강제가 사라진다** (Codex).
    `compute_episode_reference_policy` 를 부르는 스텝이 이미
    `grounding.research_required_short_ids` 로 남긴다 — 그것이 정본이다.
    """
    if not isinstance(manifest, dict):
        return set()
    g = manifest.get("grounding")
    if not isinstance(g, dict):
        return set()
    return {str(x) for x in (g.get("research_required_short_ids") or ())
            if str(x).strip()}


def research_forced_refs(forced, already, *, visible_entities=None):
    """조사가 강제한 참조를 **owner 별로** 만든다. ★이미 있는 것은 안 겹친다.

    ★★`text_only` 를 내리는 overlay 와 **반대 방향**이다. 그쪽은 빼는 것이고
    이건 **더하는 것**이라, 그 경로를 재사용하면 아무것도 안 더해진다.
    ★안 보이는 엔티티에는 안 건다 — 이 컷에 없는 것의 참조를 요구하면
    「없는 참조」로 막힌다.

    ★★★**owner 를 명시로 허용한다** (Codex). `else "prop"` 로 두면 `L##`
    (location)·outlook·모르는 owner 가 **prop 참조로 오분류**된다.
    ★★★2026-09-01 두 가지를 고쳤다 (Codex 재현) —
      ①**owner 를 kind 로 그대로 내지 않는다.** `reference_owner_of` 는
        갈래(`location`·`outlook`)를 주는데 `required_refs` 의 kind 어휘는
        `character`/`character_outlook`/`prop`/`background` 다. 그대로 내면
        **없는 kind** 가 실려 하류 계약과 어긋난다. 갈래→kind 는
        `reference_kind_of_owner` 한 곳이 안다.
      ②**정책 문이 맡은 갈래만** 받는다. 장소·장소부분은 배경 묶음이,
        아웃룩은 인물 아웃룩 자리가 **이미 붙인다** — 여기서 또 required 로
        올리면 같은 것이 두 벌이 된다.
    안 받되 **조용히 버리지도 않는다**: `unsupported_owner` 로 낸다.
    """
    out: List[Dict[str, Any]] = []
    unsupported: List[str] = []
    if not forced:
        return out
    have = {(str(r.get("kind")), str(r.get("id"))) for r in (already or ())}
    # ★★공개 카드 경로는 `visible_entities` 를 **문자열 목록**으로 넘긴다.
    #  dict 만 가정하면 실제 경로에서 아무것도 안 걸린다 — 조립 함수를 직접
    #  불러 재는 시험은 그 차이를 못 본다.
    # ★★★실측 2026-09-03 (run 45075f79441e · attempt 03:07): HITL 0 로 소품 여섯이 전부 강제되자, **보이는 것이
    #  하나도 없는** 샷(S4_Shot4)에 `visible` 이 빈 집합이라 문이 열려 여섯을 다 요구했고 PRO-13 이 두 번 서서
    #  scene_detail 이 partial 로 끝났다. 「모른다」(None)와 「없다」([])는 다르다 — 목록이 왔으면 **비어 있어도**
    #  그 목록으로 가른다. None 일 때만 옛 뜻(문 없음)이다.
    gate = visible_entities is not None
    visible = set()
    for e in (visible_entities or ()):
        if isinstance(e, dict):
            visible.add(str(e.get("short_id") or e.get("id") or ""))
        else:
            visible.add(str(e))
    # ★갈래는 **계약**이 정한다 — 여기서 접두를 다시 적으면 두 벌이 된다.
    #  ★글자로 비교하지 않는다: `LP01` 이 `"L"` 로도 시작하고 `Pfoo` 도
    #   `"P"` 로 시작한다.
    from app.modules.pipeline.grounding_entity_contract import (
        ENFORCE_BY_POLICY, enforcement_gate_of, reference_kind_of_owner,
        reference_owner_of)

    for sid in sorted({str(x) for x in forced if str(x).strip()}):
        owner = reference_owner_of(sid)
        # ★신원이 아니거나(`Pfoo`), 정책 문이 아닌 갈래면 안 받는다
        if owner is None or enforcement_gate_of(owner) != ENFORCE_BY_POLICY:
            unsupported.append(sid)
            continue
        kind = reference_kind_of_owner(owner)
        if kind is None:
            unsupported.append(sid)
            continue
        if gate and sid not in visible:
            continue
        if (kind, sid) in have:
            continue
        out.append({"kind": kind, "id": sid, "policy": "required",
                    "reason": "grounding: 조사가 필요하다고 확정됐다"})
        have.add((kind, sid))
    if unsupported:
        logger.warning(
            "research_forced_refs: 정책 문 밖 owner 라 여기서는 참조를 안 "
            "만든다 %s — 장소·장소부분은 배경 묶음이, 아웃룩은 인물 아웃룩 "
            "자리가 붙인다 (`REFERENCE_ENFORCEMENT_BY_OWNER`)",
            sorted(unsupported))
    return out


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,
    used_outlook_pairs: Optional[Set[Tuple[str, str]]] = None,
    state_variant_chars: Optional[Set[str]] = None,
    # Area B (2026-05-13): render_contracts 가 prop required_refs 영역 책임.
    # old args (visible_entity_details / shot_description / representative_moment /
    # t2i_prompts) 모두 제거. backward-compat sentinel 도 폐기.
    render_contracts: List[Dict[str, Any]],
    # FINDING 9 W2 (Cat2): subject_reference_policy normalized map
    # (base subject_id → SubjectReferencePolicy). 명시적 {} = no policy override.
    policy_map: Dict[str, SubjectReferencePolicy],
    # ★조사가 강제한 short_id. 비면 **아무것도 안 더한다**(legacy 비회귀).
    research_forced_short_ids: Optional[Set[str]] = None,
) -> 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).

    Area B (2026-05-13): prop required_refs 영역은 render_contracts 인자에서
    파생 (required_refs_from_render_contracts via). story_critical noun
    matching / shot text matching 모두 폐기. render_contracts 는 명시적 []
    valid, None 이면 R2-B4 fail-fast.

    2026-05-10 — 24-shot deterministic ref-contract fail fix:
    - ``used_outlook_pairs`` (Optional[Set[(cid, oid)]]) — variation 들이 실제
      사용한 (character_id, outlook_id) set. 지정되면 outlook_pairs ∩
      used_outlook_pairs 만 character_outlook required 에 박음. None 이면
      legacy 동작 (모든 outlook_pairs union — pre-narrow path 호환). RPC 가
      후보 union 으로 over-require 하던 결함 fix.
    - ``state_variant_chars`` (Optional[Set[character_id]]) — staging subject_state
      가 immobilized (dead/unconscious/severely_injured) 인 character_id set. 그
      character 의 character_outlook required 는 제외. resolver 가 character_state
      ref 로 attach 하므로 character_outlook 만족 X (validator P2). None 이면 legacy.
      (실제 derivation = detail_steps._detect_state_variant_chars, Area #2 W5.)
    """
    # R2-B4: None vs [] fail-fast.
    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."
            ),
        )
    if render_contracts is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_asset_requirements: render_contracts is None — upstream "
                "producer did not populate. Use explicit [] for intentionally empty."
            ),
        )
    # FINDING 9 W2 (Cat2): policy_map None vs {} fail-fast (siblings 일관).
    if policy_map is None:
        raise AppError(
            code="step.contract_violation",
            message=(
                "build_asset_requirements: policy_map is None — upstream "
                "producer did not populate. Use explicit {} for no policy override."
            ),
        )
    _state_variant: Set[str] = state_variant_chars if state_variant_chars else set()
    required: List[Dict[str, Any]] = []
    # FINDING 9 W2 (Cat2): base_id_required character ref dedup (한 cid 가
    # outlook_pairs 에 복수 등장 시 중복 emit 방지, loop 순서 보존).
    _emitted_char_bases: Set[str] = set()
    for pair in outlook_pairs:  # R2-B4: no `or []`
        # R5-B1 (G4.3): None vs non-dict pair fail-fast — silent `or {}` 제거.
        if not isinstance(pair, dict):
            raise AppError(
                code="step.contract_violation",
                message=(
                    "build_asset_requirements: outlook_pairs entry is "
                    f"{type(pair).__name__} (expected dict)"
                ),
            )
        cid = pair.get("character_id") or ""
        oid = pair.get("outlook_id") or ""
        if not (cid and oid):
            continue
        # 2026-05-10 narrow: used_outlook_pairs 지정 시 그 set 안에 있어야 박음.
        if used_outlook_pairs is not None and (cid, oid) not in used_outlook_pairs:
            continue
        # FINDING C W1 (Category B): policy 를 state_variant / O00 exclusion
        # 이전에 resolve. base_id_required → {kind:character} emit 은
        # state_variant / O00 와 무관 (resolver section 2.5 base attach 가 SOT,
        # validator step 3b/6 가 character kind honor). state_variant / O00
        # exclusion 은 character_outlook 위조 방지용 (resolver 가 각각
        # character_state / O00 base ref 로 attach → character_outlook strict
        # 만족 X, validator P2) → id_and_outlook_required 분기에만 적용.
        _policy = get_subject_reference_policy_or_default(
            policy_map, cid, where="build_asset_requirements",
        )
        if _policy.policy == "base_id_required":
            # W4b 가 bare C## 사용 — character_outlook 위조 금지, resolver/
            # validator 가 base ref honor. FINDING 9 W2 (Cat2) dedup 보존.
            if cid not in _emitted_char_bases:
                required.append({
                    "kind": "character",
                    "id": cid,
                    "policy": "required",
                })
                _emitted_char_bases.add(cid)
            continue
        if _policy.policy == "generic_descriptor_allowed":
            continue  # character / character_outlook required 둘 다 emit 안 함
        # id_and_outlook_required (default / 누락) — character_outlook required.
        # 2026-05-10 state_variant 제외: subject_state immobilized (dead/
        # unconscious/severely_injured) 인물의 outlook 은 character_state ref 로
        # attach 되므로 character_outlook required 에서 빼야 validator 정합
        # (S12_Shot6 결함 fix).
        if cid in _state_variant:
            continue
        # 2026-05-10 (Fix D1) — Null Outlook (O00) 제외: resolver 가 character
        # base ref 로 attach (scene_reference_service 의 O00 분기, ("character",
        # char_sid) attach). validator 의 character_outlook strict check 가 base
        # 만족 X (D5 P2 — character_outlook 위조 금지). S7/S17 deterministic fix.
        if oid == "O00":
            continue
        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",
        })
    # Area B (2026-05-13): prop required_refs = render_contracts 파생.
    # noun-list / shot text matching 폐기. visual_identity preserve contract 만.
    required.extend(required_refs_from_render_contracts(render_contracts))
    # ★★★GROUNDING-V2 §2-4b — **조사가 필요하다고 확정된 것은 참조를 만든다**.
    #  정책 manifest 에서 `reference_required` 로 올려도 여기까지 안 오면
    #  아무 일도 안 일어난다 — 실제 보호·생성은 이 `required_refs` 만 본다
    #  (Codex). owner 별로 **기존 SOT 에 합성**한다:
    #    C## → `character` base ref   ·   그 밖(P## 등) → prop 계약 ref
    required.extend(research_forced_refs(
        research_forced_short_ids, required,
        visible_entities=visible_entities))
    # Patch A (Codex MINOR 1 fix) — prop required 가 있으면 close_framing /
    # background_mode_on=False 분기와 무관하게 readiness_policy=block_if_missing
    # 으로 promote. asset readiness manifest semantics 일관 — prop 자체는 framing
    # 과 독립 (사진/액자/문서 prop 은 close-up 에서도 ref 필수).
    _has_prop_required = any(r.get("kind") == "prop" for r in required)
    if _has_prop_required:
        readiness = READINESS_BLOCK
    elif 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",
        ],
    }


# =========================================================================
# Hash + canonicalize
# =========================================================================

def _sort_string_list(items: List[Any]) -> List[Any]:
    """deterministic string sort with None-tolerant key."""
    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:
        # R5-B1 (G4.3): non-dict entry fail-fast — silent `or {}` 제거.
        if not isinstance(d, dict):
            raise AppError(
                code="step.contract_violation",
                message=(
                    "_sort_dict_list: list entry is "
                    f"{type(d).__name__} (expected dict)"
                ),
            )
        return "|".join(str(d.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)
      - render_contracts (dict list, key: contract_id)  # Area B (2026-05-13)
      - render_strategy.frame_spatial_contract.constraints (dict list,
        key: constraint_id)  # 2026-05-14 — area-frame-spatial-contract
    """
    # R5-B1 (G4.3): None / non-dict card fail-fast — silent `or {}` 제거.
    if not isinstance(card, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                "canonicalize_render_prompt_card: card is "
                f"{type(card).__name__} (expected dict)"
            ),
        )
    payload = _copy_module.deepcopy(card)
    # 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"])

    # Area B (2026-05-13): render_contracts list ordering 안정화.
    rc = payload.get("render_contracts")
    if isinstance(rc, list):
        payload["render_contracts"] = _sort_dict_list(
            rc, key_fields=["contract_id"],
        )

    # Area Frame Spatial Contract (2026-05-14) — constraints sort by
    # constraint_id (LLM emit 순서 무관 hash 안정성 보장). nullable safe.
    rs = payload.get("render_strategy")
    if isinstance(rs, dict):
        fsc = rs.get("frame_spatial_contract")
        if isinstance(fsc, dict) and isinstance(fsc.get("constraints"), list):
            fsc["constraints"] = sorted(
                fsc["constraints"],
                key=lambda c: c.get("constraint_id", "") if isinstance(c, dict) else "",
            )

    return payload


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

    Rules (spec §4 + Open Q4 + R1-I3 + R1-I11 + R2-B1):
      - canonicalize_render_prompt_card() 로 list 정렬 + _card_metadata +
        render_prompt_card_hash 제외.
      - sort_keys=True  → dict key order 무관.
      - ensure_ascii=False → 한국어 / 일본어 description 무손실 hash.
      - separators=(",", ":") → 공백 제거 (canonical compact form).
      - hash 자체는 hash payload 에 포함하지 않음 (canonicalize 가 제거).
    """
    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]


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

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

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

    R2-I2 결정 근거: G4.2 의 `compute_card_hash()` 가 envelope 전체 hash —
    G4.3 canary 는 id_policy 변경에만 sensitive 해야 하므로 partial hash 별도.
    `json` / `hashlib` 은 module top 의 import 영역에 이미 존재 (G4.1 carry).

    fail-fast: card 가 dict 가 아니거나 `id_policy` key 부재 / dict 아님이면
    AppError raise (silent fallback 금지 — `feedback_no_silent_fallback.md`
    carry — `card.get("id_policy", {})` 류 패턴 절대 금지).

    Args:
        card: a RenderPromptCard envelope dict (must have "id_policy" dict).

    Returns:
        16-char lowercase hex sha256 prefix of canonicalized id_policy field.

    Raises:
        AppError(code="step.contract_violation"): card 가 dict 아니거나
            "id_policy" key 부재 또는 dict 아님.
    """
    if not isinstance(card, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"compute_id_policy_snapshot_hash: card must be dict "
                f"(got {type(card).__name__})"
            ),
        )
    id_policy = card.get("id_policy")
    if not isinstance(id_policy, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                "compute_id_policy_snapshot_hash: card.id_policy missing or "
                f"not dict (got {type(id_policy).__name__})"
            ),
        )
    payload = json.dumps(id_policy, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]


def compute_continuity_snapshot_hash(card: Dict[str, Any]) -> str:
    """G4.4 Override O-12 carry — continuity_elements_used partial hash.

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

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

    Override O-12 결정 근거: G4.2 의 `compute_card_hash()` 가 envelope 전체 hash
    — G4.4 canary 는 continuity_elements_used 변경에만 sensitive 해야 하므로
    partial hash 별도. `_card_metadata` 는 envelope sibling 이므로 자연 제외 —
    추가 strip 불필요. G4.3 `compute_id_policy_snapshot_hash` 와 동일 패턴
    carry (검증 완료). `json` / `hashlib` 은 module top 의 import 영역에 이미
    존재 (G4.1 carry / G4.3 carry).

    spec §2.2 sub-shape — Override O-12 paired with O-6 namespace.

    fail-fast: card 가 dict 가 아니거나 `continuity_elements_used` key 부재 시
    AppError raise (silent fallback 금지 — `feedback_no_silent_fallback.md`
    carry / Override O-8 — `card.get(...)` 패턴 절대 금지).

    Args:
        card: a RenderPromptCard envelope dict (must have
              "continuity_elements_used" key).

    Returns:
        16-char lowercase hex sha256 prefix of canonicalized
        continuity_elements_used field.

    Raises:
        AppError(code="step.contract_violation"): card 가 dict 아니거나
            "continuity_elements_used" key 부재.
    """
    if not isinstance(card, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"compute_continuity_snapshot_hash: card must be dict "
                f"(got {type(card).__name__})"
            ),
        )
    if "continuity_elements_used" not in card:
        raise AppError(
            code="step.contract_violation",
            message=(
                "compute_continuity_snapshot_hash: card missing "
                "'continuity_elements_used'"
            ),
        )
    payload = json.dumps(
        card["continuity_elements_used"],
        sort_keys=True,
        ensure_ascii=False,
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]


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

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

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

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

    spec §5.2 + §5.3 + RO-7 + PR4-B4 + PR4-I2.

    fail-fast: card 가 dict 가 아니거나 `render_strategy` key 부재 시
    AppError raise (silent fallback 금지 — `feedback_no_silent_fallback.md`
    carry / Override O-8 / PR4-B1 — `card.get("render_strategy", {})` 패턴
    절대 금지).

    Args:
        card: a RenderPromptCard envelope dict (must have "render_strategy"
              key).

    Returns:
        16-char lowercase hex sha256 prefix of canonicalized render_strategy
        field.

    Raises:
        AppError(code="step.contract_violation"): card 가 dict 아니거나
            "render_strategy" key 부재.
    """
    if not isinstance(card, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"compute_render_strategy_snapshot_hash: card must be dict "
                f"(got {type(card).__name__})"
            ),
        )
    if "render_strategy" not in card:
        raise AppError(
            code="step.contract_violation",
            message=(
                "compute_render_strategy_snapshot_hash: card missing "
                "'render_strategy'"
            ),
        )
    payload = json.dumps(
        card["render_strategy"],
        sort_keys=True,
        ensure_ascii=False,
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]


# =========================================================================
# Shape validator
# =========================================================================

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 검증.

    G4.2 (2026-05-04, plan-R2-I3 + R2-I5):
      - 검증 대상은 `_REQUIRED_TOP_FIELDS` (lines 93-97) 의 **7 envelope contract
        fields**: schema_version / shot_key / render_strategy / id_policy /
        background_binding / continuity_elements_used / asset_requirements.
        spec/plan 초안에서 "5 field" 표현은 5 semantic field 의미였으나
        실제 validator 는 envelope 의 schema_version + shot_key 도 함께 검증
        — 정정된 표현은 "7 contract field".
      - `_card_metadata` (envelope sibling, G4.2 R1-B1) 는 free-form: extra-key
        허용, schema 강제 X (R2-I5). canonicalize_render_prompt_card() 가 hash
        입력에서 제외하므로 hash 영향 0.
    """
    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 isinstance(sk["scene_index"], bool)
        or not isinstance(sk["shot_index"], int)
        or isinstance(sk["shot_index"], bool)
    ):
        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}"
            ),
        )
    # G4.5a (RO-9 / PR4-I5 binding) — spatial_consistency 누락 strict.
    # PR-fix-iter-1-4 Option A: NO factor-out of `_assert_render_strategy_shape()`
    # — keep assert_card_shape() inline structure, insert spatial check
    # directly after the rs_mode enum check above.
    rs = card["render_strategy"]
    if "spatial_consistency" not in rs:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy missing "
                f"'spatial_consistency' (G4.5a PR4-B2) {where}"
            ),
        )
    _assert_spatial_consistency_shape(rs["spatial_consistency"], where)
    # Area Frame Spatial Contract (2026-05-14) — frame_spatial_contract
    # nullable shape check (object or None). 항상 sibling key 존재 보장 —
    # build_render_strategy 가 양 분기 모두 inject. validate_and_prepare 가
    # 이미 deep shape + cross-check 수행했으므로 본 inline 은 envelope-level
    # nullable/required shape strict (spatial_consistency 패턴 mirror).
    if "frame_spatial_contract" not in rs:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy missing "
                f"'frame_spatial_contract' (opt-in nullable field) {where}"
            ),
        )
    fsc = rs["frame_spatial_contract"]
    if fsc is not None:
        if not isinstance(fsc, dict):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_strategy.frame_spatial_contract must be dict "
                    f"or None, got {type(fsc).__name__} {where}"
                ),
            )
        if "reason" not in fsc or "constraints" not in fsc:
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_strategy.frame_spatial_contract missing reason "
                    f"or constraints {where}"
                ),
            )
        if not isinstance(fsc["constraints"], list):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_strategy.frame_spatial_contract.constraints "
                    f"must be list {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}"
                ),
            )

    # I1 (Wave 4 R4): 5 semantic field 내부 strict shape — opaque dict 만
    # 검증하던 옛 path 는 list-vs-dict / 누락 key / 잘못된 item shape 를
    # silent 로 통과시킴 (G3.2 assert_owned_sentinel_shape strict pattern mirror).
    _assert_id_policy_shape(card["id_policy"], where)
    _assert_background_binding_items_shape(card["background_binding"], where)
    _assert_continuity_elements_used_shape(card["continuity_elements_used"], where)
    _assert_asset_requirements_shape(card["asset_requirements"], where)

    # Area B (2026-05-13) — render_contracts envelope shape (list of dict).
    # 빈 list OK. 각 dict 의 jsonschema-level shape 는 validate_render_contracts
    # 책임 — 본 assert 는 envelope 자체의 list/dict 보장만 (Area C lesson §6
    # defense-in-depth — production shape deny-list).
    rc = card.get("render_contracts")
    if not isinstance(rc, list):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_contracts must be list "
                f"(got {type(rc).__name__}) {where}"
            ),
        )
    for i, c in enumerate(rc):
        if not isinstance(c, dict):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.render_contracts[{i}] must be dict "
                    f"(got {type(c).__name__}) {where}"
                ),
            )


# I1 strict shape helpers — 각 semantic field item-level shape 검증.

def _assert_str_list(v: Any, name: str, where: str) -> None:
    if not isinstance(v, list):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.{name} must be list "
                f"(got {type(v).__name__}) {where}"
            ),
        )
    for i, it in enumerate(v):
        if not isinstance(it, str):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.{name}[{i}] must be str "
                    f"(got {type(it).__name__}: {it!r}) {where}"
                ),
            )


def _assert_dict_list_with_keys(
    v: Any, name: str, required_keys: tuple, where: str,
) -> None:
    if not isinstance(v, list):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.{name} must be list "
                f"(got {type(v).__name__}) {where}"
            ),
        )
    for i, it in enumerate(v):
        if not isinstance(it, dict):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.{name}[{i}] must be dict "
                    f"(got {type(it).__name__}: {it!r}) {where}"
                ),
            )
        for k in required_keys:
            if k not in it:
                raise AppError(
                    code="step.contract_violation",
                    message=(
                        f"render_prompt_card.{name}[{i}] missing required "
                        f"key {k!r} (have keys {sorted(it.keys())}) {where}"
                    ),
                )


# G4.3 R2-B3 + R4-I5 carry: id_policy 5 신규 sub-field 의 required_keys
# contract — single source. role_hint_from_outfit / format_template_a/b /
# source_priority / scenario_dependency_ban / regional_consistency /
# token_count_range 는 free-form (optional, strict 검증 X).
_ID_POLICY_SUB_FIELD_REQUIRED_KEYS: Dict[str, Tuple[str, ...]] = {
    "face_identifiability_rule": (
        "use_entity_id_when",
        "common_noun_required_when",
        "id_use_summary",
        "rationale_summary",
    ),
    # Area #1 (2026-05-16): 폐기 2 entries. subject_reference_policy SOT
    # (list[dict]) 가 대체 — 별도 case 로 _assert_id_policy_shape() 본문에서 처리.
    "reproduction_surface_rule": (
        # Area C migration (2026-05-12): applies_to_surfaces (noun list) →
        # applies (bool derived from shot_staging.directionality_class).
        "applies",
        "id_use",
        "rationale_summary",
    ),
    "demographic_descriptor_policy": (
        "required_on_first_appearance",
        "applies_to_id_forms",
        "components",
    ),
}


def _assert_id_policy_shape(ip: Dict[str, Any], where: str) -> None:
    """id_policy strict shape — G4.1 base 2 field + 3 dict sub-fields + Area #1
    subject_reference_policy list[dict].

    G4.1 carry: allowed_base_entity_ids (list[str]) +
    allowed_outlook_pairs (list[dict] with character_id+outlook_id).

    dict sub-fields (`_ID_POLICY_SUB_FIELD_REQUIRED_KEYS` single source):
      - face_identifiability_rule: 4 keys
      - reproduction_surface_rule: 3 keys
      - demographic_descriptor_policy: 3 keys (components nested 검증 별도)
    + demographic_descriptor_policy.components 의 ethnicity / gender /
    age_band 가 non-empty list[str] 검증.

    Area #1 (2026-05-16): subject_reference_policy list[dict] — 별도 case
    (loop 와 분리). Full semantic validation (enum / duplicate / unknown
    subject) 은 상위 normalize_subject_reference_policy_items() 에서 끝남.
    여기서는 card-level structural validation 만 (defense in depth).

    free-form keys (strict 검증 X): role_hint_from_outfit /
    format_template_a / format_template_b / source_priority /
    scenario_dependency_ban / regional_consistency / token_count_range.
    """
    if "allowed_base_entity_ids" not in ip:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.id_policy missing "
                f"'allowed_base_entity_ids' {where}"
            ),
        )
    _assert_str_list(
        ip["allowed_base_entity_ids"],
        "id_policy.allowed_base_entity_ids", where,
    )
    if "allowed_outlook_pairs" not in ip:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.id_policy missing "
                f"'allowed_outlook_pairs' {where}"
            ),
        )
    _assert_dict_list_with_keys(
        ip["allowed_outlook_pairs"],
        "id_policy.allowed_outlook_pairs",
        required_keys=("character_id", "outlook_id"),
        where=where,
    )

    # G4.3 — 5 신규 sub-field present + dict + required_keys 검증.
    for sub_field_name, required_keys in _ID_POLICY_SUB_FIELD_REQUIRED_KEYS.items():
        if sub_field_name not in ip:
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.id_policy missing "
                    f"{sub_field_name!r} (G4.3 R2-B3) {where}"
                ),
            )
        sub_field = ip[sub_field_name]
        if not isinstance(sub_field, dict):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.id_policy.{sub_field_name} must be "
                    f"dict (got {type(sub_field).__name__}) {where}"
                ),
            )
        for k in required_keys:
            if k not in sub_field:
                raise AppError(
                    code="step.contract_violation",
                    message=(
                        f"render_prompt_card.id_policy.{sub_field_name} "
                        f"missing required key {k!r} (have keys "
                        f"{sorted(sub_field.keys())}) {where}"
                    ),
                )

    # Area #1 — subject_reference_policy: list[dict] structural validation.
    # _ID_POLICY_SUB_FIELD_REQUIRED_KEYS loop 는 dict sub-field 전용이므로
    # 별도 case. Full semantic validation 은 상위 normalize_*() 에서 이미 끝남.
    if "subject_reference_policy" not in ip:
        raise AppError(
            code="step.contract_violation.id_policy.field_missing",
            message=(
                f"render_prompt_card.id_policy missing "
                f"'subject_reference_policy' (Area #1) {where}"
            ),
        )
    _assert_dict_list_with_keys(
        ip["subject_reference_policy"],
        "id_policy.subject_reference_policy",
        required_keys=("subject_id", "policy_type", "policy", "reason"),
        where=where,
    )

    # Area C migration 2026-05-12 — explicit deny-list for
    # reproduction_surface_rule legacy/debug keys. Defense-in-depth on top
    # of the gate `test_reproduction_surface_rule_shape.py`.
    #   - `applies_to_surfaces`: legacy noun-list field. SOT migrated to
    #     `applies` bool derived from shot_staging.directionality_class.
    #   - `source`: debug field — must live in `_card_metadata`, not the
    #     RPC contract surface.
    repro_rule = ip["reproduction_surface_rule"]
    if "applies_to_surfaces" in repro_rule:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.id_policy.reproduction_surface_rule "
                f"must not contain 'applies_to_surfaces' field "
                f"(Area C migration regression — SOT is "
                f"shot_staging.directionality_class) {where}"
            ),
        )
    if "source" in repro_rule:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.id_policy.reproduction_surface_rule "
                f"must not contain 'source' debug field "
                f"(must live in _card_metadata) {where}"
            ),
        )

    # G4.3 — demographic_descriptor_policy.components nested 검증.
    # ethnicity / gender / age_band 모두 non-empty list[str] 강제.
    ddp = ip["demographic_descriptor_policy"]
    comp = ddp.get("components")
    if not isinstance(comp, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.id_policy.demographic_descriptor_policy."
                f"components must be dict (got {type(comp).__name__}) {where}"
            ),
        )
    for comp_key in ("ethnicity", "gender", "age_band"):
        if comp_key not in comp:
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.id_policy.demographic_descriptor_policy."
                    f"components missing {comp_key!r} {where}"
                ),
            )
        _assert_str_list(
            comp[comp_key],
            f"id_policy.demographic_descriptor_policy.components.{comp_key}",
            where,
        )
        if len(comp[comp_key]) == 0:
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.id_policy.demographic_descriptor_policy."
                    f"components.{comp_key} must be non-empty list {where}"
                ),
            )


def _assert_background_binding_items_shape(
    bb: Dict[str, Any], where: str,
) -> None:
    """background_binding.owned_objects (list[str])."""
    if "owned_objects" not in bb:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.background_binding missing "
                f"'owned_objects' {where}"
            ),
        )
    _assert_str_list(
        bb["owned_objects"], "background_binding.owned_objects", where,
    )


# R1-I2 10-field producer fields per fixed_element entry.
# Area #4 (2026-05-18): element_scope enum required (Codex iter 1 I3 fix —
# 9-field → 10-field cascade). producer (scene_consistency v7) emit + downstream
# card layer strict shape validation 둘 다 의무.
_FIXED_ELEMENT_REQUIRED_KEYS = (
    "element_id", "element_type", "character_name", "description",
    "applies_to_shots", "element_scope",
    "source_facts", "visual_inferences", "creative_decisions", "confidence",
)
_PREVIOUS_SHOT_REF_REQUIRED_KEYS = ("scene_index", "shot_index", "ref_usage")
_FORWARD_ZOOM_REQUIRED_KEYS = ("scene_index", "shot_index", "description")


def _assert_ref_usage_constraints_shape(
    ru: Dict[str, Any], where: str,
) -> None:
    """G4.4 nested helper — ref_usage_constraints strict shape validator
    (Override O-5 superset).

    spec §2.2 sub-shape: ref_usage_constraints 는 dict with 3 sub-keys
    (zoom_in_detail / exact_background / atmosphere_reference). 각 sub-key
    는 dict with 4-5 required keys per spec §2.2.

    Override O-5: strict required-keys subset (`EXPECTED <= returned_keys`) —
    extra keys 허용 (G4.5 forward-compat).
    """
    if not isinstance(ru, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used."
                f"ref_usage_constraints must be dict "
                f"(got {type(ru).__name__}) {where}"
            ),
        )
    # Top-level 3 sub-keys superset.
    expected_top = set(_CONTINUITY_REF_USAGE_TOP_REQUIRED_KEYS)
    returned_top = set(ru.keys())
    if not (expected_top <= returned_top):
        missing = sorted(expected_top - returned_top)
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used."
                f"ref_usage_constraints missing required sub-keys "
                f"{missing} (have {sorted(returned_top)}) {where}"
            ),
        )
    # Per-sub-key nested required_keys superset.
    nested_specs = {
        "zoom_in_detail": _CONTINUITY_REF_USAGE_ZOOM_REQUIRED_KEYS,
        "exact_background": _CONTINUITY_REF_USAGE_EXACT_REQUIRED_KEYS,
        "atmosphere_reference": _CONTINUITY_REF_USAGE_ATMOSPHERE_REQUIRED_KEYS,
    }
    for sub_key, required in nested_specs.items():
        nested = ru[sub_key]
        if not isinstance(nested, dict):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.continuity_elements_used."
                    f"ref_usage_constraints.{sub_key} must be dict "
                    f"(got {type(nested).__name__}) {where}"
                ),
            )
        expected = set(required)
        returned = set(nested.keys())
        if not (expected <= returned):
            missing = sorted(expected - returned)
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.continuity_elements_used."
                    f"ref_usage_constraints.{sub_key} missing required keys "
                    f"{missing} (have keys {sorted(returned)}) {where}"
                ),
            )


def _assert_continuity_elements_used_shape(
    ce: Dict[str, Any], where: str,
) -> None:
    """continuity_elements_used.fixed_elements (R1-I2 10-field per item — Area #4 element_scope) +
    previous_shot_refs (scene_index/shot_index/ref_usage) +
    forward_zoom_targets (scene_index/shot_index/description).

    G4.4 (2026-05-05) — Override O-5 + O-6 carry: 3 new sibling policy dicts
    strict 검증 추가 (cross_shot_id_substitution_rule / ref_usage_constraints
    / view_consistency). Override O-5 strict required-keys subset
    (`EXPECTED <= returned_keys`). extra keys 허용 (G4.5 forward-compat).

    spec §2.2 sub-shape — Override O-7 paired-string substring 검증은 builder
    에서 hardcoded literal 로 보장; assert helper 는 key presence 만 검증.

    framing_scope_options 만 list equality (정확 2 entry sequence 강제) —
    view_consistency 의 다른 free-form key 와 차별. mixing_forbidden 은 bool
    True (NOT bool int subclass — G3.2 / G4.3 trap carry).
    """
    # G4.1 base 3 field 검증 (기존 carry).
    for f in ("fixed_elements", "previous_shot_refs", "forward_zoom_targets"):
        if f not in ce:
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.continuity_elements_used missing "
                    f"{f!r} {where}"
                ),
            )
    _assert_dict_list_with_keys(
        ce["fixed_elements"],
        "continuity_elements_used.fixed_elements",
        required_keys=_FIXED_ELEMENT_REQUIRED_KEYS,
        where=where,
    )
    _assert_dict_list_with_keys(
        ce["previous_shot_refs"],
        "continuity_elements_used.previous_shot_refs",
        required_keys=_PREVIOUS_SHOT_REF_REQUIRED_KEYS,
        where=where,
    )
    _assert_dict_list_with_keys(
        ce["forward_zoom_targets"],
        "continuity_elements_used.forward_zoom_targets",
        required_keys=_FORWARD_ZOOM_REQUIRED_KEYS,
        where=where,
    )

    # G4.4 신규 3 new sibling policy dicts 검증 (Override O-5 strict superset).
    # 1. cross_shot_id_substitution_rule — top-level dict with 6 required keys.
    if "cross_shot_id_substitution_rule" not in ce:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used missing "
                f"'cross_shot_id_substitution_rule' (G4.4 Override O-6) "
                f"{where}"
            ),
        )
    csr = ce["cross_shot_id_substitution_rule"]
    if not isinstance(csr, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used."
                f"cross_shot_id_substitution_rule must be dict "
                f"(got {type(csr).__name__}) {where}"
            ),
        )
    expected_csr = set(_CONTINUITY_CROSS_SHOT_ID_SUB_REQUIRED_KEYS)
    returned_csr = set(csr.keys())
    if not (expected_csr <= returned_csr):
        missing = sorted(expected_csr - returned_csr)
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used."
                f"cross_shot_id_substitution_rule missing required keys "
                f"{missing} (have keys {sorted(returned_csr)}) {where}"
            ),
        )

    # 2. ref_usage_constraints — top-level dict with 3 sub-keys (nested helper).
    if "ref_usage_constraints" not in ce:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used missing "
                f"'ref_usage_constraints' (G4.4 Override O-6) {where}"
            ),
        )
    _assert_ref_usage_constraints_shape(ce["ref_usage_constraints"], where)

    # 3. view_consistency — top-level dict with 9 required keys.
    if "view_consistency" not in ce:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used missing "
                f"'view_consistency' (G4.4 Override O-6) {where}"
            ),
        )
    vc = ce["view_consistency"]
    if not isinstance(vc, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used."
                f"view_consistency must be dict "
                f"(got {type(vc).__name__}) {where}"
            ),
        )
    expected_vc = set(_CONTINUITY_VIEW_CONSISTENCY_REQUIRED_KEYS)
    returned_vc = set(vc.keys())
    if not (expected_vc <= returned_vc):
        missing = sorted(expected_vc - returned_vc)
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used."
                f"view_consistency missing required keys "
                f"{missing} (have keys {sorted(returned_vc)}) {where}"
            ),
        )
    # framing_scope_options must equal exact ground-truth list
    # (extra-strict — list equality, NOT set superset).
    fso = vc["framing_scope_options"]
    expected_fso = list(_CONTINUITY_FRAMING_SCOPE_OPTIONS)
    if fso != expected_fso:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used."
                f"view_consistency.framing_scope_options must be exactly "
                f"{expected_fso} (got {fso!r}) {where}"
            ),
        )
    # mixing_forbidden must be bool True (NOT 1, NOT bool int subclass —
    # G3.2 / G4.3 trap carry).
    mf = vc["mixing_forbidden"]
    if mf is not True or type(mf) is not bool:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used."
                f"view_consistency.mixing_forbidden must be bool True "
                f"(got type={type(mf).__name__} value={mf!r}) {where}"
            ),
        )

    # G4.4 constraints: G4.1 base 2 → G4.4 7 strings (extended).
    if "constraints" not in ce:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.continuity_elements_used missing "
                f"'constraints' {where}"
            ),
        )
    _assert_str_list(
        ce["constraints"],
        "continuity_elements_used.constraints",
        where,
    )


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

    Nested call chain (RO-9 / PR4-I5 binding — assert_card_shape inline
    structure preserved per PR-fix-iter-1-4):
      `assert_card_shape() inline rs_mode region` →
      `_assert_spatial_consistency_shape()` →
      `_assert_primary_framing_rule_shape()` (further nested, NOT sibling).

    spec §2.2 sub-shape: 5 sub-key + nested validation:
      - close_framing_rules (4 sub-key incl subject_reference_policy_cross_ref
        Area #1 paired-string)
      - wide_medium_rules (4 sub-key incl subject_reference_policy_cross_ref
        Area #1 paired-string)

    PR4-B3 binding: cross-card refs ONLY here (close_framing_rules +
    wide_medium_rules), NOT in camera_frame_rule (validated separately in
    `_assert_spatial_consistency_shape`).

    Override O-30 carry (G4.4): strict required-keys subset
    (`EXPECTED <= returned_keys`, set superset, NOT equality). extra keys 허용
    (G4.5b/c forward-compat).

    Area #1 paired-string substring: `subject_reference_policy_cross_ref` 가
    `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL` substring 포함만 검증 —
    정확 literal equality 아님.
    """
    if not isinstance(pf, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy.spatial_consistency."
                f"primary_framing_rule must be dict "
                f"(got {type(pf).__name__}) {where}"
            ),
        )
    pf_returned = set(pf.keys())
    if not (_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS <= pf_returned):
        missing = sorted(
            _SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS - pf_returned
        )
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy.spatial_consistency."
                f"primary_framing_rule missing required keys {missing} "
                f"(have {sorted(pf_returned)}) {where}"
            ),
        )

    # close_framing_rules — 4 sub-key (incl subject_reference_policy_cross_ref, RO-6 /
    # PR4-B3).
    cfr = pf["close_framing_rules"]
    if not isinstance(cfr, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy.spatial_consistency."
                f"primary_framing_rule.close_framing_rules must be dict "
                f"(got {type(cfr).__name__}) {where}"
            ),
        )
    # ★걷힌 자리면 sentinel 모양만 본다 (2026-08-26 Codex BLOCK-1).
    if _is_scoped_out(cfr):
        _assert_scoped_out_shape(cfr, "primary_framing_rule.close_framing_rules", where)
    else:
        cfr_returned = set(cfr.keys())
        if not (_SPATIAL_CLOSE_FRAMING_RULES_REQUIRED_SUBKEYS <= cfr_returned):
            missing = sorted(
                _SPATIAL_CLOSE_FRAMING_RULES_REQUIRED_SUBKEYS - cfr_returned
            )
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.render_strategy.spatial_consistency."
                    f"primary_framing_rule.close_framing_rules missing required "
                    f"sub-keys {missing} (have {sorted(cfr_returned)}) {where}"
                ),
            )
        # Area #1 — subject_reference_policy_cross_ref paired-string (RO-6 binding
        # — substring carry of `_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL`).
        cfr_xref = cfr["subject_reference_policy_cross_ref"]
        if not isinstance(cfr_xref, str):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.render_strategy.spatial_consistency."
                    f"primary_framing_rule.close_framing_rules."
                    f"subject_reference_policy_cross_ref must be str "
                    f"(got {type(cfr_xref).__name__}) {where}"
                ),
            )
        if _CONTINUITY_ID_POLICY_CROSS_REF_LITERAL not in cfr_xref:
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.render_strategy.spatial_consistency."
                    f"primary_framing_rule.close_framing_rules."
                    f"subject_reference_policy_cross_ref must contain substring "
                    f"{_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL!r} (RO-6 "
                    f"paired-string) {where}"
                ),
            )

    # wide_medium_rules — 4 sub-key (incl subject_reference_policy_cross_ref,
    # Area #1 rename).
    wmr = pf["wide_medium_rules"]
    if not isinstance(wmr, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy.spatial_consistency."
                f"primary_framing_rule.wide_medium_rules must be dict "
                f"(got {type(wmr).__name__}) {where}"
            ),
        )
    # ★걷힌 자리면 sentinel 모양만 본다 (2026-08-26 Codex BLOCK-1).
    if _is_scoped_out(wmr):
        _assert_scoped_out_shape(wmr, "primary_framing_rule.wide_medium_rules", where)
    else:
        wmr_returned = set(wmr.keys())
        if not (_SPATIAL_WIDE_MEDIUM_RULES_REQUIRED_SUBKEYS <= wmr_returned):
            missing = sorted(
                _SPATIAL_WIDE_MEDIUM_RULES_REQUIRED_SUBKEYS - wmr_returned
            )
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.render_strategy.spatial_consistency."
                    f"primary_framing_rule.wide_medium_rules missing required "
                    f"sub-keys {missing} (have {sorted(wmr_returned)}) {where}"
                ),
            )
        wmr_xref = wmr["subject_reference_policy_cross_ref"]
        if not isinstance(wmr_xref, str):
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.render_strategy.spatial_consistency."
                    f"primary_framing_rule.wide_medium_rules."
                    f"subject_reference_policy_cross_ref must be str "
                    f"(got {type(wmr_xref).__name__}) {where}"
                ),
            )
        if _CONTINUITY_ID_POLICY_CROSS_REF_LITERAL not in wmr_xref:
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.render_strategy.spatial_consistency."
                    f"primary_framing_rule.wide_medium_rules."
                    f"subject_reference_policy_cross_ref must contain substring "
                    f"{_CONTINUITY_ID_POLICY_CROSS_REF_LITERAL!r} (RO-6 "
                    f"paired-string) {where}"
                ),
            )


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

    Nested call chain (RO-9 / PR4-I5):
      `assert_card_shape() inline rs_mode region` →
      `_assert_spatial_consistency_shape(card)` →
      `_assert_primary_framing_rule_shape(sc["primary_framing_rule"])`
      (further nested, NOT sibling — PR4-I5 disambiguation).

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

    PR4-B3 binding: cross-card refs (`subject_reference_policy_cross_ref`) are
    validated inside `_assert_primary_framing_rule_shape()`, NOT here in
    `camera_frame_rule`.

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

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

    # fg_bg_shared_anchor_rule (6 keys).
    fb = sc["fg_bg_shared_anchor_rule"]
    if not isinstance(fb, dict):
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.render_strategy.spatial_consistency."
                f"fg_bg_shared_anchor_rule must be dict "
                f"(got {type(fb).__name__}) {where}"
            ),
        )
    # ★걷힌 자리면 sentinel 모양만 본다 (2026-08-26 Codex BLOCK-1).
    if _is_scoped_out(fb):
        _assert_scoped_out_shape(fb, "spatial_consistency.fg_bg_shared_anchor_rule", where)
    else:
        fb_returned = set(fb.keys())
        if not (_SPATIAL_FG_BG_SHARED_ANCHOR_RULE_REQUIRED_KEYS <= fb_returned):
            missing = sorted(
                _SPATIAL_FG_BG_SHARED_ANCHOR_RULE_REQUIRED_KEYS - fb_returned
            )
            raise AppError(
                code="step.contract_violation",
                message=(
                    f"render_prompt_card.render_strategy.spatial_consistency."
                    f"fg_bg_shared_anchor_rule missing required keys {missing} "
                    f"(have {sorted(fb_returned)}) {where}"
                ),
            )

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


_REQUIRED_REF_KEYS = ("kind", "id", "policy")
_FORBIDDEN_REF_KEYS = ("kind", "reason")


def _assert_asset_requirements_shape(ar: Dict[str, Any], where: str) -> None:
    """asset_requirements.required_refs (kind/id/policy) +
    forbidden_refs (kind/reason)."""
    if "required_refs" not in ar:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.asset_requirements missing "
                f"'required_refs' {where}"
            ),
        )
    _assert_dict_list_with_keys(
        ar["required_refs"],
        "asset_requirements.required_refs",
        required_keys=_REQUIRED_REF_KEYS,
        where=where,
    )
    if "forbidden_refs" not in ar:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"render_prompt_card.asset_requirements missing "
                f"'forbidden_refs' {where}"
            ),
        )
    _assert_dict_list_with_keys(
        ar["forbidden_refs"],
        "asset_requirements.forbidden_refs",
        required_keys=_FORBIDDEN_REF_KEYS,
        where=where,
    )


# =========================================================================
# Entry point
# =========================================================================

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]],
    used_outlook_pairs: Optional[Set[Tuple[str, str]]] = None,
    state_variant_chars: Optional[Set[str]] = None,
    # Area B (2026-05-13): render_contracts 가 prop required_refs 영역 책임.
    # visible_entity_details 는 producer 진입 — None default → 빈 list.
    # shot_description / representative_moment / t2i_prompts 모두 제거.
    visible_entity_details: Optional[List[Dict[str, Any]]] = None,
    # C10 Phase 1 — entity_canon short_id → name. screen-presence detector 가
    # character_angles[].character / pov_character (이름) 를 resolve 하는 데
    # 필요. None = legacy/test caller (detector 가 graceful no-op).
    name_by_short_id: Optional[Dict[str, str]] = None,
    # Phase 2 — episode_reference_policy manifest (schema_version 1).
    # text_only 판정 character subject 의 identity policy 를 결정론적으로
    # generic_descriptor_allowed 로 다운그레이드.
    # None = overlay 없음 (noop).
    episode_reference_policy: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """Single entry point — _analyze_one() 가 한 번 호출.

    각 builder 는 독립적이므로 순서 무관. 호출 직후 assert_card_shape 로
    self-check.

    2026-05-10 — used_outlook_pairs / state_variant_chars 두 인자는
    asset_requirements 의 character_outlook required_refs narrow 용 (24 shot
    deterministic ref-contract fail fix). LLM input 용 wide RPC build 시 두 인자
    모두 None (legacy union), cp 저장용 narrow RPC build 시 LLM 응답 후 산출한
    값 전달. id_policy.allowed_outlook_pairs 는 그대로 전체 후보 union 보존
    (LLM 가 outfit 선택 시 후보 인지 필요).

    Area B (2026-05-13): visible_entity_details 는 build_render_contracts
    producer 진입점. None → 빈 list (legacy/test fixture 호환). 명시적 []
    valid. 폐기된 노운 매칭 필터의 shot text 인자 모두 제거.
    """
    _ved: List[Dict[str, Any]] = (
        list(visible_entity_details) if visible_entity_details is not None else []
    )
    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,
        # Area Frame Spatial Contract (2026-05-14) — visible_entities (SID list)
        # 를 build_render_strategy 에 전달해 frame_spatial_contract 의
        # character/prop target_id cross-check (validate_and_prepare).
        visible_entities=visible_entities,
    )
    key_bg_elements = _extract_key_bg_elements_or_raise(staging, shot_info)

    # Area #1 (2026-05-16) — subject_reference_policy normalization (helper SOT).
    # staging 분기:
    #   - staging is None + shot_info.staging_not_applicable=True
    #       → raw_items=None (graceful empty)
    #   - staging dict + field missing → caller AppError ".field_missing"
    #   - staging dict + field present → helper full validation
    where_srp = f"render_prompt_card:s{scene_index}.shot{shot_index}"
    if staging is None:
        raw_srp_items = None
    elif "subject_reference_policy" not in staging:
        raise AppError(
            code="step.contract_violation.subject_reference_policy.field_missing",
            message=(
                "shot_staging v12 missing required field "
                f"'subject_reference_policy' {where_srp}"
            ),
        )
    else:
        raw_srp_items = staging["subject_reference_policy"]
    visible_bases = derive_visible_subject_ids(visible_entities)
    # FINDING 6 W4a — consumer-boundary normalization: shot_director.visible
    # SOT 기준으로 non-shot-visible subject 의 policy item 을 drop
    # (depicted-but-not-physically-present subject). malformed 는 보존 →
    # normalize_subject_reference_policy_items 가 fail-fast.
    raw_srp_items = filter_subject_reference_policy_to_visible(
        raw_srp_items, visible_bases, where=where_srp,
    )
    # C10 Phase 1 — screen-presence reconciliation (consumer-first 실험).
    # visible 후보인데 non-empty character_angles 에 부재 + non-POV +
    # camera_direction off-screen 산문 → identity policy 를
    # generic_descriptor_allowed 로 결정론적 다운그레이드. 빌더 내부에서
    # 실행하므로 producer / verify / recompute 경로가 hash-동일.
    if staging is not None:
        from app.modules.pipeline.shot_visibility import (
            detect_offscreen_referenced_subjects,
        )
        _offscreen_ref = detect_offscreen_referenced_subjects(
            visible_ids=sorted(visible_bases),
            camera_direction=staging.get("camera_direction") or "",
            character_angles=staging.get("character_angles") or [],
            id_to_name=name_by_short_id or {},
            pov_character=staging.get("pov_character"),
        )
        if _offscreen_ref:
            raw_srp_items = apply_screen_presence_downgrade(
                raw_srp_items, _offscreen_ref, where=where_srp,
            )
    # Phase 2 — episode_reference_policy overlay: manifest 가 text_only 로
    # 판정한 character subject 의 identity policy 를 generic_descriptor_allowed
    # 로 결정론적 다운그레이드. screen_presence downgrade 와 동일 메커니즘 —
    # builder 내부 실행이므로 producer / verify recompute hash 동일.
    if episode_reference_policy is not None:
        from app.core.episode_reference_policy import extract_text_only_subjects
        _text_only = {
            sid: r for sid, r in
            extract_text_only_subjects(episode_reference_policy).items()
            if sid in visible_bases
        }
        if _text_only:
            raw_srp_items = apply_episode_reference_policy_downgrade(
                raw_srp_items, _text_only, where=where_srp,
            )
    policy_map = normalize_subject_reference_policy_items(
        raw_srp_items,
        visible_subject_ids=visible_bases,
        where=where_srp,
    )
    policy_array = serialize_subject_reference_policy_map(policy_map)

    card["id_policy"] = build_id_policy(
        visible_entities=visible_entities, outlook_pairs=outlook_pairs,
        perception_mode=perception_mode,
        key_bg_elements=key_bg_elements,
        subject_reference_policies=policy_array,
    )
    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,
    )
    # Area B (2026-05-13): render_contracts build + validate.
    render_contracts = build_render_contracts(
        visible_entities=visible_entities,
        visible_entity_details=_ved,
        current_scene_index=int(scene_index),
        current_shot_index=int(shot_index),
    )
    validate_render_contracts(
        render_contracts,
        scene_index=int(scene_index),
        shot_index=int(shot_index),
    )
    card["render_contracts"] = render_contracts
    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,
        used_outlook_pairs=used_outlook_pairs,
        state_variant_chars=state_variant_chars,
        render_contracts=render_contracts,
        policy_map=policy_map,
        # ★정책 manifest 가 **올린** 것을 그대로 실어 보낸다 — 여기서 안 넘기면
        #  manifest 안에서만 바뀌고 실제 참조에는 아무 일도 안 일어난다.
        research_forced_short_ids=_research_forced_from_policy(
            episode_reference_policy),
    )
    # G4.2 R1-B1 / R2-B1: _card_metadata envelope-sibling (top-level only).
    # canonicalize_render_prompt_card() 가 _HASH_EXCLUDED_TOP_KEYS 로 pop —
    # hash 영향 0. assert_card_shape() 는 _REQUIRED_TOP_FIELDS 만 검증
    # (R2-I5 free-form, _card_metadata extra-key 허용).
    #
    # rule_X_lifted 의미: "이 card 의 background_binding.constraints 가
    # v17 prompt 의 Rule X prose 를 대체했는가". close-framing 미발생 모드
    # (not_applicable / background_ref_attached) 는 rule_e_lifted=False —
    # Rule E (close-skip) prose 가 v17 prompt 에 다른 모드용으로 남아있고,
    # 이 shot 의 card constraints 로 lift 되지는 않았다는 뜻.
    bb_mode = card["background_binding"]["mode"]
    if bb_mode in (BG_MODE_OFF, BG_MODE_SKIPPED_CLOSE):
        rule_e_lifted = True
    elif bb_mode in (BG_MODE_NOT_APPLICABLE, BG_MODE_REF_ATTACHED):
        rule_e_lifted = False
    else:
        raise AppError(
            code="step.contract_violation",
            message=(
                f"build_render_prompt_card: background_binding.mode {bb_mode!r} "
                f"is not a recognized mode for _card_metadata.lift_status assembly "
                f"(scene={scene_index}, shot={shot_index})"
            ),
        )
    # G4.3 R3-B1 carry — id_policy lift 완료. 4 신규 lift_status key + 4 신규
    # rule_source key 추가. id_policy 는 perception_mode 무관 모든 shot 에 동일
    # 적용 (spec §2.2 matrix — single row).
    # G4.4 (2026-05-05) carry — Override O-9 정확 명: continuity 4 신규
    # lift_status key + 3 신규 rule_source key 추가. continuity_elements_used
    # 는 ref_usage 무관 모든 shot 에 동일 적용 (spec §2.2 matrix single row).
    card["_card_metadata"] = {
        "lift_status": {
            # G4.2 carry (commit 14d14cb).
            "rule_a_lifted": True,
            "rule_c_lifted": True,
            "rule_e_lifted": rule_e_lifted,
            # G4.3 carry (commit 51d6c5e).
            "rule_h_lifted": True,
            "id_policy_composite_lifted": True,
            "id_policy_close_framing_face_lifted": True,
            "id_policy_reproduction_surface_lifted": True,
            # G4.4 신규 4 키 (Override O-9 정확 명 — abbrev 금지).
            "continuity_cross_shot_id_substitution_lifted": True,
            "continuity_ref_usage_constraints_lifted": True,
            "continuity_view_consistency_lifted": True,
            "continuity_constraints_extended": True,
            # G4.5a 신규 4 키 (정확 명 — abbrev 금지, drift 시 G4.5
            # lift_status enumerate test fail). spec §2.2 _card_metadata
            # matrix carry. spatial_consistency 는 ref_usage / mode 무관
            # 모든 shot 에 True (matrix single row).
            "spatial_camera_frame_lifted": True,
            "spatial_fg_bg_shared_anchor_lifted": True,
            "spatial_primary_framing_lifted": True,
            "spatial_constraints_extended": True,
        },
        "rule_source": {
            # G4.2 carry.
            "camera_rule": "A",
            "owned_rule": "C",
            "close_skip_rule": "E",
            # G4.3 carry — v17 line range 표기 hardcoded (시나리오 의존 0).
            "demographic_rule": "H",
            "composite_id_rule": "C## 사용 규칙 — 참조 이미지 연동 (v17:32-64)",
            "close_framing_face_rule": "극단 클로즈업 표현 (v17:66-85)",
            # R5-I1 (G4.3 iter1): plan/spec 정합 — `_card_metadata.rule_source` 는
            # 별도 namespace 이므로 top-level `id_policy.reproduction_surface_rule`
            # 와 collision 없음. 키명 통일.
            "reproduction_surface_rule": (
                "사진·포스터·화면·거울 속 인물 규칙 (v17:133-146)"
            ),
            # G4.4 신규 3 rule_source key (Trap #12 namespace OK — v19 prompt
            # source). 시나리오 의존 0.
            "cross_shot_id_substitution_rule": (
                "v20/scene_detail/system.md#continuity-section"
            ),
            "ref_usage_rule": (
                "v20/scene_detail/system.md#continuity-section"
            ),
            "view_consistency_rule": (
                "v20/scene_detail/system.md#continuity-section"
            ),
            # G4.5a 신규 3 rule_source key — v20 prompt source pointer.
            # 시나리오 의존 0 (Trap #9 — 작품 고유명사 없음).
            "camera_frame_rule": (
                "v20/scene_detail/system.md#spatial-consistency"
            ),
            "fg_bg_shared_anchor_rule": (
                "v20/scene_detail/system.md#spatial-consistency"
            ),
            "primary_framing_rule": (
                "v20/scene_detail/system.md#spatial-consistency"
            ),
        },
    }
    assert_card_shape(
        card,
        where=f"build_render_prompt_card(s{scene_index}_sh{shot_index})",
    )
    return card
