# Area Frame Spatial Contract — `render_strategy.frame_spatial_contract` (design)

- Date: 2026-05-14
- Predecessor: Area D-next-min (`5f3da38`, 5-commit stack — `scene_reference_service` consumer cascade + keep_elements environment/static_prop only)
- Successors: `area-character-pose-state-reference`, `area-information-surface`, `area-large-prop-vehicle-identity`, `area-id-policy-framing-extension`
- Related memo: [[next_session_screen_blocking]] (entry point)

---

## 1. Non-negotiables (terminology / scope lock)

본 patch 본문 + plan + implementation 단계에서 다음 5개는 변경 금지.

1. **이름 = `frame_spatial_contract`** — `screen_blocking` / `spatial_contract` 와 혼용 금지. 본 spec / plan / commit message / code identifier / prompt text / test name 모두 `frame_spatial_contract` 단일 표기.
2. **Scope = frame-space only** — 방 전체 구조, pose continuity, surface 앞뒤, identity/framing rule, vehicle identity 는 본 patch out-of-scope (별도 area).
3. **Default = null** — 모든 shot 에 emit 금지. 필요한 shot 만, `constraints` 최대 3 개. `frame_spatial_contract: null` 이 정상 기본값.
4. **Echo field 위치 = variation item 내부** — `t2i_variations[].applied_frame_spatial_constraint_ids`. scene top-level 아님.
5. **Implementation 위치 = `backend/app/modules/pipeline/shot_staging.py` 의 retry loop** — `backend/app/core/steps/shot_staging_step.py` 는 wrapper 라 변경 X. 새 validator 는 기존 orientation pattern (try 외부 raise) 과 같은 위치.

---

## 2. 본질 / 원칙

### 2.1 본질

`shot_staging` schema v9 의 화면 좌표 SOT 부재를 해결하는 patch.

현재 `camera_direction` (NL string, 2-3 문장 English) 가 화면 zone / depth / vector 를 묻고, `character_angles[].angle` enum 은 카메라 대비 방향만 다루며, `key_bg_elements[].directionality_class` 는 surface 분류만 다룬다. 화면 어디에 무엇이 있고 무엇이 어느 방향으로 향하는지의 **구조화된 SOT** 는 부재.

`scene_detail` Rule F/G/J 가 `render_prompt_card._build_spatial_consistency_dict` (line 593) 로 lift 됐으나 RO-9 invariant "builder-static, 입력 무관 항상 동일 dict" — shot-specific data 부재.

본 patch 는 `shot_staging` LLM 이 필요한 shot 에만 (opt-in, gated) `frame_spatial_contract` 를 emit 하여, `scene_detail` 이 첫 문장으로 강제 반영하도록 한다. 구조화된 enum-only SOT, code-deterministic id, per-variation echo 강제 — open-world NL parsing 완전 회피.

### 2.2 원칙

- **Opt-in minimal contract**: contract 는 화면 전체 layout 이 아니다. 필요한 shot 에서, 필요한 target 만, 필요한 좌표만 고정하는 최소 제약.
- **Structured SOT, no NL parsing**: zone / depth / gesture 모두 closed enum. 자연어 instruction phrase 본 spec 의 LLM output 에 0.
- **LLM produces structured data, code assigns ids and validates**: shot_staging LLM 은 의미 결정 (emit/skip, reason, target, zone/depth/gesture), code 는 deterministic id 부여 + cross-check + echo validation 담당.
- **scene_detail owns wording**: contract 의 wording 은 scene_detail LLM 책임. shot_staging 은 instruction NL 을 만들지 않는다.
- **All variations equally bound**: 한 shot 의 contract 는 1개. variation 마다 다른 wording / camera_effect 허용, 그러나 spatial constraint 는 모든 variation 에 동일 반영.
- **No silent fallback**: contract 부재 (null) 는 명시적 정상 상태. invalid contract 는 retry → 소진 시 fail-fast.

본 영역의 모든 의미적 결정은 [[feedback_llm_based_judgment]] 4 gate (Semantic Regex Ban / Prompt Closed-List Ban / Structured SOT Required / No Silent Fallback) 와 정합.

---

## 3. Scope

### 3.1 In-scope

본 patch 가 cover 하는 9-point routing matrix 항목 ([[next_session_screen_blocking]] §"9-point routing matrix" carry):

| # | Problem | Cover 정도 | 사용되는 field |
|---|---|---|---|
| #5 | 공간/문/움직임 방향 | **완전** (v1 핵심) | `movement_direction` / `points_to_anchor` / `looks_to_anchor` + gesture_action |
| #3 | 집 내부 가구/장치 | 부분 (필수 anchor 위치만) | `required_background_position` + constraint(target_kind=background) |
| #9 | 배경/가구 ↔ 인물 mismatch | 부분 (공유 anchor + fg/bg relation 만) | `shared_space_relation` + 2~3 constraint |
| #7 | 인물 확대 시 다른 사람 침입 | 부분 (primary subject 위치/격리 힌트만) | `primary_subject_isolation` + constraint(target_kind=character) |
| #2 | 탈것/큰 물체 | 부분 (화면 위치만) | constraint(target_kind=prop). identity/ref 는 별도 area |
| #4 | 문서/폰/사진/지도 앞뒤 | 부분 (화면 위치만) | constraint(target_kind=prop). surface 앞뒤 는 별도 area |
| #8 | 몸 이상한 자세/잘림 | 부분 (depth/screen zone 만) | constraint.depth_plane. body posture 는 character_angles.body_pose |

### 3.2 Out-of-scope (별도 area defer)

다음은 본 patch 가 다루지 **않는다**. 구현/리뷰/테스트에서 scope creep 금지.

- **#1 캐릭터 자세 일관성**: pose/state reference — `area-character-pose-state-reference` defer
- **#2 탈것/큰 물체 identity/ref**: visual_identity 확장 — `area-large-prop-vehicle-identity` (Area B 확장) defer
- **#4 문서/폰/사진/지도 앞뒤**: surface front/back/information — `area-information-surface` defer
- **#6 얼굴/몸 ID 튀어나옴**: id_policy / body_part_focus_rule / visible validator — `area-id-policy-framing-extension` defer
- **#8 body pose correctness**: character_angles.body_pose 확장 — defer
- **방 전체 구조 (room/world floorplan)**: 본 patch v1 + v2 모두 다루지 않음

### 3.3 Scope statement (verbatim)

> This area does not solve full scene geography, pose continuity, identity continuity, information-surface orientation, or body-part ID policy. It only adds an opt-in, minimal frame-space contract for required screen placement and direction in shots where spatial misunderstanding materially harms the image.

본 문장은 spec / plan / Codex review prompt / commit message 에 verbatim carry.

### 3.4 D-next-min 직교 책임 경계

- **D-next-min** (`5f3da38`): previous-shot reference 의 비인물 keep/remove 정리 (`environment/static_prop` only) — `scene_reference_service.classify` 영역
- **frame_spatial_contract**: 현재 shot 의 화면 좌표/방향 SOT — `shot_staging` + `scene_detail` 영역
- 두 area 는 직교. scene_detail prompt 안에서 두 input (keep_elements + frame_spatial_contract) 을 별개 section 으로 받아 wording 통합.

---

## 4. Architecture (4 layer)

```
L1 shot_staging LLM (Producer, 의미 판단)
  • schema v10: v9 + frame_spatial_contract (object/null) optional field
  • prompt v10: 6 reason trigger 예시 + counter-example
  • enum strict (provider/schema layer 1차 차단)
  • 기존 retry loop 안 post-validation (try 외부, orientation pattern mirror)
                    ↓
L2 render_prompt_card (Storage + Inject, code deterministic)
  • frame_spatial_contract carry → render_strategy.frame_spatial_contract
  • constraint_id deterministic 부여 (fsc_001 / fsc_002 / fsc_003)
  • target_id 가 C##/P## 구조면 visible_entities cross-check
  • background label 은 non-empty string 검증만 (semantic matching 없음)
  • _assert_card_shape() inline nullable shape check
  • canonicalize_render_prompt_card() 안 constraints sort by constraint_id
                    ↓
L3 scene_detail LLM (Consumer, wording)
  • prompt v23: "if frame_spatial_contract is present, the first sentence
    of t2i_prompt of EVERY t2i_variations[] item must reflect required
    screen position/depth/gesture from each constraint"
  • LLM wording 자유 (다른 rule 과 통합)
  • output: t2i_variations[].applied_frame_spatial_constraint_ids echo
                    ↓
L4 scene_detail post-validation (code deterministic)
  • per-variation echo set == injected set 정확 일치 (hard, 1회 retry 통합)
  • label/zone phrase presence (v1 soft warning only)
```

**Layer 책임 경계**:

- **L1 (LLM 의미 판단)**: emit/skip 결정, 6 reason 선택, target 의 screen_zone/depth_plane/gesture_action 결정
- **L2 (code deterministic)**: id 부여, structured target_id cross-check, RenderPromptCard inject
- **L3 (LLM wording)**: t2i_prompt 첫 문장 작성, applied id echo
- **L4 (code deterministic)**: echo set 매칭 hard, phrase diagnostic soft

---

## 5. Schema 명세

### 5.1 frame_spatial_contract shape

**중요**: producer (shot_staging LLM) 의 output shape 과 RenderPromptCard 단계의 storage shape 은 **`constraint_id` 유무로 다르다**. constraint_id 는 LLM 이 emit 하지 않는다 — render_prompt_card 의 `build_render_strategy()` 가 §5.4 sort key 로 정렬 후 deterministic 부여.

#### 5.1.1 Producer shape (shot_staging LLM output, schema v10)

```json
{
  "frame_spatial_contract": {
    "reason": "points_to_anchor",
    "constraints": [
      {
        "target_kind": "character",
        "target_id": "C02",
        "label": "B",
        "screen_zone": "lower_right",
        "depth_plane": "foreground",
        "gesture_action": "points_to",
        "gesture_target_label": "entrance door"
      },
      {
        "target_kind": "background",
        "target_id": "",
        "label": "entrance door",
        "screen_zone": "upper_center",
        "depth_plane": "background",
        "gesture_action": "none",
        "gesture_target_label": ""
      }
    ]
  }
}
```

`constraint_id` field 는 producer schema (`prompts/_base/shot_staging/10.<ts>/schema.json`) 에 **정의되지 않는다** — LLM 이 emit 하지 않는 field 는 schema 에 포함 안 함.

#### 5.1.2 RenderPromptCard shape (render_strategy.frame_spatial_contract, build 후)

```json
{
  "render_strategy": {
    "frame_spatial_contract": {
      "reason": "points_to_anchor",
      "constraints": [
        {
          "constraint_id": "fsc_001",
          "target_kind": "character",
          "target_id": "C02",
          "label": "B",
          "screen_zone": "lower_right",
          "depth_plane": "foreground",
          "gesture_action": "points_to",
          "gesture_target_label": "entrance door"
        },
        {
          "constraint_id": "fsc_002",
          "target_kind": "background",
          "target_id": "",
          "label": "entrance door",
          "screen_zone": "upper_center",
          "depth_plane": "background",
          "gesture_action": "none",
          "gesture_target_label": ""
        }
      ]
    }
  }
}
```

`constraint_id` 는 §5.4 sort key 로 정렬 후 `build_render_strategy()` 가 deterministic 부여 — LLM 의 emit 순서 무관, hash 안정성 보장.

#### 5.1.3 null case (양 단계 공통)

```json
{
  "frame_spatial_contract": null
}
```

### 5.2 Field 명세

| Field | Type | Producer | 설명 |
|---|---|---|---|
| `reason` | enum (6) | shot_staging LLM | 단일 trigger. 복수 trigger 시 가장 image-failure 위험 큰 1개 선택. |
| `constraints` | array (1~3) | shot_staging LLM | 최소 제약. opt-in. |
| `constraints[].constraint_id` | string ("fsc_NNN") | **code (render_prompt_card)** | LLM 생성 X. `build_render_prompt_card()` 가 sort 후 deterministic 부여. |
| `constraints[].target_kind` | enum (3) | shot_staging LLM | `character` / `prop` / `background` |
| `constraints[].target_id` | string | shot_staging LLM | character/prop → `C##` / `P##` (정확한 정규식은 기존 entity_canon naming 패턴 일관성 유지 — carry note §12). background → empty string. |
| `constraints[].label` | string (non-empty) | shot_staging LLM | 사람 읽기용. background 의 경우 free-text (semantic match 없음). |
| `constraints[].screen_zone` | enum (9) | shot_staging LLM | 3x3 grid |
| `constraints[].depth_plane` | enum (3) | shot_staging LLM | foreground / midground / background |
| `constraints[].gesture_action` | enum (5) | shot_staging LLM | `none` / `points_to` / `reaches_for` / `looks_toward` / `moves_toward` |
| `constraints[].gesture_target_label` | string | shot_staging LLM | **`gesture_action != "none"` → non-empty 필수**. **`gesture_action == "none"` → empty string `""`**. v1 free-text, v2 anchor id 도입 가능. |

### 5.3 Enum 명세

**reason** (6 values, 단일 enum):

| Value | Trigger 의도 |
|---|---|
| `movement_direction` | 인물/물체의 이동 방향이 화면 의미에 핵심 |
| `points_to_anchor` | 인물이 특정 anchor 를 가리키는 동작이 핵심 |
| `looks_to_anchor` | 인물의 시선이 특정 anchor 로 향함이 핵심 |
| `shared_space_relation` | 두 인물의 fg/bg 공간 관계가 화면에 보여야 함 |
| `required_background_position` | 배경/소품의 화면 위치가 의미에 핵심 |
| `primary_subject_isolation` | close-up 에서 primary 인물만 보이는 것이 핵심 |

reason 단일성 (verbatim):

> reason is a single primary trigger. If multiple triggers apply, choose the one whose failure would most materially damage the image. Do not use reason arrays in v1.

**target_kind** (3 values): `character` / `prop` / `background`

**screen_zone** (9 values, 3x3 grid):

```
upper_left    upper_center    upper_right
middle_left   middle_center   middle_right
lower_left    lower_center    lower_right
```

**depth_plane** (3 values): `foreground` / `midground` / `background`

**gesture_action** (5 values): `none` / `points_to` / `reaches_for` / `looks_toward` / `moves_toward`

### 5.4 constraint_id 명세 (verbatim + sort key)

> constraint_id is not produced by the shot_staging LLM. `build_render_prompt_card()` assigns deterministic card-local IDs (`fsc_001`, `fsc_002`, ...) after validating and sorting the constraints. scene_detail echoes these IDs in `applied_frame_spatial_constraint_ids`.

**Sort key (pre-id assignment)**:

```python
sort_key = (
    target_kind,          # "background" < "character" < "prop" (alphabetical)
    target_id,            # background 은 "" empty string 으로 sort 참여
    label,                # 사람 읽기용
    screen_zone,
    depth_plane,
    gesture_action,
    gesture_target_label,
)
```

7-tuple lexicographic ordering. LLM 의 emit 순서 무관 — 같은 7-tuple 의 두 constraint 는 같은 의미 중복 emit 으로 간주, `validate_and_prepare` 가 `AppError(render_prompt_card.fsc_invalid, reason="duplicate_constraint")` raise.

sort 후 index 순서대로 `fsc_001` / `fsc_002` / `fsc_003` 부여.

### 5.5 scene_detail schema 변경 (v23)

`t2i_variations[]` item 안 신규 required field. schema required 이므로 contract 유무 무관 **항상 array** (생략 불가, 단 null contract 시 빈 array).

#### 5.5.1 Contract 있는 shot

```json
{
  "t2i_variations": [
    {
      "variant_label": "var_1",
      "t2i_prompt": "...",
      "applied_frame_spatial_constraint_ids": ["fsc_001", "fsc_002"]
    }
  ]
}
```

#### 5.5.2 null contract shot (echo array 는 빈 list)

```json
{
  "t2i_variations": [
    {
      "variant_label": "var_1",
      "t2i_prompt": "...",
      "applied_frame_spatial_constraint_ids": []
    }
  ]
}
```

**echo semantics**:

- contract 가 있는 shot: **모든** variation 의 echo set 이 injected `{fsc_001, fsc_002, ...}` set 과 **정확 일치**. variation 별 partial echo 허용 안 함.
- null contract shot: **모든** variation 의 echo array 가 **빈 list `[]`**. 빈 list 외 값은 `validate_echoes` 가 `AppError(echo_mismatch, detail.injected_ids=[], echoed_ids=[...])` raise.

---

## 6. Operating rules (9 운영규칙)

1. **opt-in**: `frame_spatial_contract` 의 기본값은 `null`. 모든 shot 에 emit 금지.
2. **shot_staging 이 emit 결정**: 6 reason trigger 매칭 시에만.
3. **최소 target**: emit 해도 visible 전체 아님. 1~3 constraint.
4. **character 자동 포함 금지**.
5. **prop/background 자동 포함 금지**.
6. **screen_zone 도 필요한 경우만** (constraint 안에는 항상 있지만 emit 자체가 opt-in).
7. **depth_plane 도 필요한 경우만** (5번과 동일).
8. **scene_detail 강제 반영**: contract 있는 shot 만 모든 variation 의 첫 문장에 반영.
9. **contract 없으면 기존 path**: `camera_direction` 중심 wording.

> **명확화**: 운영규칙 6/7 의 "필요한 경우만" 은 **constraint 자체의 emit 결정 차원 (opt-in)** 을 의미. constraint 가 emit 되는 경우 `screen_zone` / `depth_plane` / `gesture_action` 은 §5.2 명세대로 모두 required — 항상 enum 값 채워야 함. 빈 string / null 값 금지 (gesture_action 의 명시적 `"none"` 은 enum 값으로 인정).

---

## 7. Components (11 file)

| # | 파일 | 변경 |
|---|---|---|
| 1 | `prompts/_base/shot_staging/10.<YYYYMMDDHHmm>/schema.json` | 신규 — v9 + `frame_spatial_contract` (object/null). 디렉토리 명명 = `<버전>.<YYYYMMDDHHmm>` (CLAUDE.md "버전 형식" 정합). |
| 2 | `prompts/_base/shot_staging/10.<YYYYMMDDHHmm>/system.md` | 신규 — v9 + `## Frame Spatial Contract` 섹션 (gating + 6 reason trigger + counter-example) |
| 3 | `backend/app/modules/pipeline/shot_staging.py` | retry loop (`:203~`) 안 post-validation 추가. orientation pattern mirror — try 외부 raise. |
| 4 | `backend/app/core/step_manifest.py` | shot_staging entry (`:587~599`) 에 `schema_version` 추가 + scene_detail (`:704`) 8→9 |
| 5 | `backend/app/core/version_registry.py` | shot_staging "2.2.0"→"2.3.0" + scene_detail_composer "1.22.0"→"1.23.0" |
| 6 | `backend/app/core/frame_spatial_contract.py` (신규) | helper module — 5 함수 + 4 상수 (§9 참조) |
| 7 | `backend/app/core/steps/render_prompt_card.py` | (a) `build_render_strategy()` inject (`:883~`) (b) `_assert_card_shape()` 안 inline nullable shape (`:2578~`) (c) `canonicalize_render_prompt_card()` 안 constraints sort by constraint_id (`:2222~`) |
| 8 | `prompts/_base/scene_detail/23.<YYYYMMDDHHmm>/system.md` | 신규 — v22 + consumer section. 디렉토리 명명 = `<버전>.<YYYYMMDDHHmm>` (CLAUDE.md 정합). |
| 9 | `prompts/_base/scene_detail/23.<YYYYMMDDHHmm>/detail_schema.json` | 신규 — v22 + each `t2i_variations[]` item 안 `applied_frame_spatial_constraint_ids: [string]` required |
| 10 | `backend/app/core/steps/detail_steps.py` | `SCENE_DETAIL_SCHEMA_VERSION` 8→9 (`:110`) + `SCENE_DETAIL_PROMPT_VERSION = "23.<YYYYMMDDHHmm>"` (`:111`) + `_check_prompts` correction retry path 에 echo 검증 통합 (`:2568~`) |
| 11 | `backend/tests/` | 신규 파일 2개 + 기존 파일 추가 (§10 참조) |

**제외 확정**:

- `backend/app/core/steps/shot_staging_step.py` — wrapper, 변경 없음
- `backend/app/core/errors.py` — `AppError(code=...)` 단일 표면, 새 typed class 없음
- alembic migration — cp JSON only, DB column 변경 없음
- scene_image_pipeline / scene_image_service / redo-shot — t2i_prompt 만 carry, frame_spatial_contract 자체 데이터 미참조

---

## 8. Data flow

### 8.1 STEP 1: shot_staging (`shot_staging.py:203~` retry loop)

```
batch retry loop (attempt 1..MAX_ATTEMPTS=3):

  try:
    result = call_structured(prompt v10, schema v10)
    # provider/schema layer: enum strict (reason/target_kind/screen_zone/
    # depth_plane/gesture_action) + maxItems 3 등 1차 차단.
    # producer schema 는 constraint_id field 를 정의하지 않음 — code-assigned (§5.4).
  except Exception:
    failed_batches += 1
    batch_failed_at_call = True
    break  # provider/call_structured exception 은 retry X (기존 패턴)

  # try 외부 (orientation pattern mirror)
  batch_shots = result["shots"]
  orientation_violations = _find_orientation_violations(batch_shots)
  fsc_violations         = find_frame_spatial_contract_violations(batch_shots)
  # find_frame_spatial_contract_violations 의 케이스 (§9.1):
  #   - null OK (gating)
  #   - constraints 1~3 (object 인 경우)
  #   - per-constraint shape: target_kind / target_id / label / screen_zone /
  #     depth_plane / gesture_action / gesture_target_label
  #   - target_kind+target_id shape (character/prop → C##/P## / background → "")
  #   - gesture_action != "none" → gesture_target_label non-empty 필수
  #   - gesture_action == "none" → gesture_target_label "" 필수
  #   - enum consistency defensive second-check

  if (orientation_violations or fsc_violations) and attempt < MAX_ATTEMPTS:
    append retry hints for both violation types
    continue

  if (orientation_violations or fsc_violations) and attempts exhausted:
    if orientation_violations and not fsc_violations:
      raise ShotStagingOrientationError(...)  # 기존 typed error 보존
    elif fsc_violations and not orientation_violations:
      raise AppError(code="shot_staging.frame_spatial_contract_invalid",
                     details={"fsc_violations": ...})
    else:  # both → fsc code 로 통합 (custom error 묻히는 위험 회피)
      raise AppError(code="shot_staging.frame_spatial_contract_invalid",
                     details={"orientation_violations": ...,
                              "fsc_violations": ...})
```

### 8.2 STEP 2: render_prompt_card (`build_render_strategy`, per shot)

```
shot_staging cp 의 frame_spatial_contract 읽기

contract_validated = frame_spatial_contract.validate_and_prepare(
                       contract,
                       visible_entities,
                     )
  ↓
  - null → None 반환
  - shape validate (reason/constraints/per-constraint shape)
  - constraint_id 부여: sort 후 fsc_001..fsc_003 deterministic
  - cross-check:
    * target_kind="character" & target_id matches C## → visible_entities.characters 멤버십
    * target_kind="prop"      & target_id matches P## → visible_entities.props 멤버십
    * target_kind="background" → target_id empty + label non-empty string
  - invalid → AppError(render_prompt_card.fsc_invalid) 또는 fsc_cross_check_failed
  - return validated dict 또는 None

render_strategy.frame_spatial_contract = contract_validated

_assert_card_shape() 안 inline shape check:
  - "frame_spatial_contract" key 존재 (nullable OK)
  - if not None: reason / constraints 구조 strict

canonicalize_render_prompt_card():
  - render_strategy.frame_spatial_contract.constraints sort by constraint_id
  - hash 안정성 보장 (LLM emit 순서 무관)
```

### 8.3 STEP 3: scene_detail LLM (per shot, parallel)

```
RenderPromptCard 입력 + prompt v23

prompt v23 consumer section:
  "if render_strategy.frame_spatial_contract is present, the first sentence
   of t2i_prompt of EVERY t2i_variations[] item must reflect required
   screen position/depth/gesture from each constraint. echo applied_
   frame_spatial_constraint_ids in each variation."

LLM emit:
  t2i_variations: [
    { variant_label: "var_1", t2i_prompt: "...",
      applied_frame_spatial_constraint_ids: ["fsc_001","fsc_002"] },
    { variant_label: "var_2", t2i_prompt: "...",
      applied_frame_spatial_constraint_ids: ["fsc_001","fsc_002"] },
  ]
```

### 8.4 STEP 4: scene_detail post-validation (`detail_steps.py:2568~` retry path 통합)

```
result = call_structured(scene_detail v23)  # 1차

remove_ids = _check_prompts(result)   # 기존 path
echo_violations = frame_spatial_contract.validate_echoes(card, result)  # 신규
# validate_echoes 동작:
#   - card.render_strategy.frame_spatial_contract == None
#       → injected_ids = []. 각 variation 의 echo == [] 확인.
#         echo != [] → mismatch violation 반환.
#   - card.render_strategy.frame_spatial_contract != None
#       → injected_ids = {fsc_001, fsc_002, ...}. 각 variation 의 echo set
#         과 정확 일치 확인. 불일치 → mismatch violation 반환.

if remove_ids or echo_violations:
  retry_prompt = build retry hint (둘 다 합쳐서)
  result = call_structured(retry)  # 1회 retry

  post_remove = _check_prompts(result)
  if post_remove:
    graceful fix (variant 치환 / 강제 제거)  # 기존 path

  post_echo = frame_spatial_contract.validate_echoes(card, result)
  if post_echo:
    raise AppError(code="scene_detail.frame_spatial_contract_echo_mismatch",
                   details={variant_label, injected_ids, echoed_ids,
                            missing, extra})
    # graceful fix 불가능 — LLM 의 실제 반영 누락이 본질

# phrase diagnostic (soft, raise 없음)
for variation in result["t2i_variations"]:
  diagnostic = frame_spatial_contract.phrase_diagnostic(
                 variation["t2i_prompt"], constraints)
  for d in diagnostic:
    log.warning("frame_spatial_contract phrase diagnostic: %s", d)
```

### 8.5 STEP 5+: downstream (scene_image_pipeline 등)

`t2i_prompt` 만 carry — `frame_spatial_contract` 자체 데이터 미참조. 변경 없음.

### 8.6 Hash 영향

- `frame_spatial_contract` 는 `render_strategy` 안 → `render_prompt_card_hash` 에 포함
- shot_staging cp 변경 시 render_prompt_card_hash drift → scene_detail cp invalidation (preflight 자동)
- `canonicalize_render_prompt_card()` 의 constraint_id sort 로 LLM emit 순서 무관한 hash 안정성 보장

---

## 9. `frame_spatial_contract.py` helper module

### 9.1 함수 (5개)

| 함수 | caller | 반환 | 동작 |
|---|---|---|---|
| `find_frame_spatial_contract_violations(batch_shots)` | `shot_staging.py` (producer) | `list[dict]` | violations list 반환. raise X. retry hint 형성용. |
| `validate_and_prepare(contract, visible_entities)` | `render_prompt_card.py` (consumer) | `dict \| None` | shape validate + constraint_id assign + visible_entities cross-check. invalid 시 raise AppError. |
| `validate_echoes(card, result)` | `detail_steps.py` (post-validation) | `list[dict]` | per-variation echo set 검증. violations list 반환. raise X (caller 가 retry 판단). |
| `phrase_diagnostic(t2i_prompt, constraints)` | `detail_steps.py` (soft) | `list[dict]` | diagnostic list. raise X. v1 soft warning. |
| `_format_retry_hint(fsc_violations)` | `shot_staging.py` | `str` | retry hint formatter (orientation pattern mirror). |

### 9.2 상수

```python
ZONE_PHRASES: dict[str, list[str]] = {
    "upper_left":    ["upper-left",    "top-left",     "upper left"],
    "upper_center":  ["upper-center",  "top-center",   "upper center"],
    "upper_right":   ["upper-right",   "top-right",    "upper right"],
    "middle_left":   ["middle-left",   "center-left",  "middle left",  "left side"],
    "middle_center": ["middle-center", "center",       "middle"],
    "middle_right":  ["middle-right",  "center-right", "middle right", "right side"],
    "lower_left":    ["lower-left",    "bottom-left",  "lower left"],
    "lower_center":  ["lower-center",  "bottom-center","lower center"],
    "lower_right":   ["lower-right",   "bottom-right", "lower right"],
}

DEPTH_PHRASES: dict[str, list[str]] = {
    "foreground": ["foreground", "front"],
    "midground":  ["midground",  "middle ground"],
    "background": ["background", "back"],
}

_FSC_ID_PREFIX = "fsc_"
_FSC_MAX_CONSTRAINTS = 3
```

---

## 10. Error handling

### 10.1 Error code (4 신규 + 1 기존)

| Code | Layer | 발생 시점 | Detail |
|---|---|---|---|
| `shot_staging.frame_spatial_contract_invalid` | producer | retry 소진 + fsc violation | `batch_num`, `attempts`, `fsc_violations`, (optional) `orientation_violations` |
| `render_prompt_card.fsc_invalid` | consumer | shape / target_kind-id mismatch | `scene_index`, `shot_index`, `constraint_id`, `target_kind`, `target_id_actual`, `reason` |
| `render_prompt_card.fsc_cross_check_failed` | consumer | visible_entities 매칭 실패 | `scene_index`, `shot_index`, `constraint_id`, `target_kind`, `target_id`, `expected_in`, `reason` |
| `scene_detail.frame_spatial_contract_echo_mismatch` | post-validation | retry 후에도 echo set mismatch | `scene_index`, `shot_index`, `variant_label`, `injected_ids`, `echoed_ids`, `missing`, `extra` |
| `step.contract_violation` (기존) | consumer | `_assert_card_shape()` envelope 위반 | `where`, `card path` |

### 10.2 Retry policy

| Step | Violation | Retry? | Max attempts | 소진 시 |
|---|---|---|---|---|
| shot_staging | provider/call_structured exception | ❌ break | — | `failed_batches += 1`, batch skip (기존) |
| shot_staging | orientation only | ✅ retry hint | 3 | `ShotStagingOrientationError` (기존) |
| shot_staging | fsc only | ✅ retry hint | 3 | `AppError(fsc_invalid)` |
| shot_staging | both | ✅ retry hint (둘 다) | 3 | `AppError(fsc_invalid)` (detail 에 orientation 도 포함) |
| render_prompt_card | shape / cross-check | ❌ retry X | — | `AppError` — fail-fast |
| scene_detail | echo mismatch | ✅ retry 1회 (`_check_prompts` correction retry path 와 통합) | 2 total (initial + 1 retry) | `AppError(echo_mismatch)` |
| scene_detail | phrase diagnostic | ❌ retry X (v1 soft) | — | `log.warning` only |

### 10.3 Recovery 가이드

- `shot_staging.fsc_invalid`: 운영자가 `shot_staging` step force re-run (LLM 재호출, max 3 attempts 재시도).
- `render_prompt_card.fsc_*`: shot_staging cp 가 visible_entities 와 정합 깨짐 → `shot_staging` step force re-run.
- `scene_detail.echo_mismatch`: prompt v23 / schema v9 정합 확인 후 `scene_detail` step force re-run. 1회 retry 후에도 mismatch 면 graceful fix 없음 (LLM 의 실제 반영 누락이 본질).

---

## 11. Testing

### 11.1 핵심 위험 5개

1. shot_staging 의 잘못된 frame_spatial_contract → retry 동작
2. render_prompt_card 가 contract carry + id 부여 + visible cross-check
3. scene_detail 모든 variation echo + 누락 시 1회 retry
4. hash/canonicalize 안정성
5. version/schema/prompt pack 의 stale cp 차단

### 11.2 신규 파일 (2개)

```
backend/tests/unit/test_frame_spatial_contract.py
backend/tests/pipeline/test_shot_staging_frame_spatial_contract.py
```

### 11.3 기존 파일 추가

```
backend/tests/unit/test_render_prompt_card.py
backend/tests/unit/test_render_prompt_card_hash.py
backend/tests/unit/test_scene_detail_runner_retry.py
backend/tests/test_prompt_versions.py
```

§11.4 Group E 5 tests 모두 `test_prompt_versions.py` 한 파일로 통합 (prompt version + version_registry + step_manifest schema_version + detail_steps constants). step_manifest 검증도 prompt-versioning 의 일부로 흡수.

### 11.4 Group 명세 (총 30~32 tests)

**Group A — helper unit (12~13 tests, `test_frame_spatial_contract.py`)**:

- null contract → valid
- valid 1~3 constraints → pass
- > 3 constraints → violation
- invalid enum / empty label / wrong target_id shape → violation
- `gesture_action != "none"` + `gesture_target_label = ""` → violation
- `gesture_action == "none"` + `gesture_target_label != ""` → violation
- `validate_and_prepare` 가 `fsc_001` deterministic 부여
- character/prop target_id 가 visible_entities 에 없으면 raise
- background 는 target_id empty + label 만 검사
- contract 있는 케이스: all variation echo set == injected set pass
- null contract 케이스: 각 variation echo 가 `[]` → pass / non-empty → violation
- missing / extra echo → violation list
- phrase diagnostic 은 warning data 반환만, raise 안 함
- constants 9 zone / 3 depth / max 3

**Group B — shot_staging retry (4 tests, `test_shot_staging_frame_spatial_contract.py`)**:

- 1st fsc violation → 2nd valid → success
- 3회 fsc violation → `AppError(fsc_invalid)`
- orientation only 소진 → 기존 `ShotStagingOrientationError` 유지
- provider exception → 기존 failed batch break 유지

**Group C — render_prompt_card (5 tests, `test_render_prompt_card.py` + `test_render_prompt_card_hash.py`)**:

- staging 의 contract 가 `render_strategy.frame_spatial_contract` 로 들어감
- constraint_id 부여
- cross-check fail
- null contract 도 card shape 통과
- canonicalize sort 후 hash 안정

**Group D — scene_detail retry (5 tests, `test_scene_detail_runner_retry.py`)**:

- 1차 echo missing → retry prompt 생성 → 2차 echo OK → pass
- 2차도 echo mismatch → `AppError(echo_mismatch)`
- 모든 variations 가 동일 injected set 을 echo
- null contract shot 의 각 variation echo 가 `[]` → pass / non-empty → mismatch raise
- phrase diagnostic warning 은 raise 안 함

**Group E — prompt/version gate (5 tests, 기존 prompt/version test 흡수)**:

- shot_staging v10 schema 에 `frame_spatial_contract` 있음
- scene_detail v23 schema 의 `t2i_variations[].applied_frame_spatial_constraint_ids` 있음
- step_manifest: shot_staging `schema_version` 추가, scene_detail 9
- detail_steps: `SCENE_DETAIL_SCHEMA_VERSION` 9 + `SCENE_DETAIL_PROMPT_VERSION` "23.<ts>"
- version_registry: shot_staging "2.3.0" / scene_detail_composer "1.23.0"

### 11.5 제외 (scope 보호)

다음은 본 patch test 범위에서 명시적으로 제외:

- 9-point cover/defer regression test (문서/spec 검증으로 충분, 코드 테스트로 만들면 brittle)
- 9 zone × 모든 phrase variant exhaustive test (constants key count + 대표 매칭만)
- LLM mock fixture 로 6 reason 각각 시뮬레이션 (prompt grep 으로 reason enum 존재만)
- D-next-min 직교 테스트 (scene_reference_service 안 건드림 → affected test run 으로 충분)
- prompt_loader latest picking (기존 prompt_loader test 존재)

---

## 12. Open carry notes (implementation 단계 결정)

- **ShotStagingOrientationError 와의 architectural 정합성**: 본 spec 은 `AppError(code=...)` 단일 표면이지만, retry loop 의 try 외부 raise 패턴은 typed exception (orientation 처럼) 도 자연스러움. implementation 단계에서 typed subclass 채택 여부 재검토 가능. spec 본문은 `AppError(code=...)` 로 표기.
- **C## / P## 정규식 형식**: 본 spec 은 `C##` / `P##` 표기. 정확한 정규식 (2-digit fixed 인지 longer ID 허용인지) 은 기존 entity_canon / character_canon naming 패턴 일관성 유지 — implementation 단계에서 결정. cross-check helper (`validate_and_prepare`) 가 그 정규식 사용.
- **Retry hint wording**: `shot_staging.py:_format_retry_hint` 의 fsc-version mirror 작성. 사용자 친화 한국어 hint, orientation hint 와 동일 format. implementation 시 fsc 의 6 reason / 5 enum 위반별 맞춤 메시지.
- **gesture_target_label v2 anchor id 도입**: v1 은 free-text label. v2 에서 `gesture_target_constraint_id` 같은 cross-ref 도입 가능. background label 의 canonical id 부재 문제 해결 시.
- **phrase diagnostic v2 hard 승격**: v1 은 soft warning. v2 에서 hard validator 로 승격 결정은 후속 area. Korean phrase variant 추가 가능성도 후속.
- **6 reason enum 확장**: v1 은 6 trigger. 운영 중 새 trigger 발견 시 schema migration + prompt v11 으로 확장. v1 은 6 으로 fix.

---

## 13. References

### 13.1 Related memos

- [[next_session_screen_blocking]] — 본 area 진입점
- [[session_20260515_area_d_next_min_clean_rebuild]] — D-next-min closure (직교 책임 경계)
- [[feedback_llm_based_judgment]] — 4 gate (Semantic Regex Ban / Prompt Closed-List Ban / Structured SOT Required / No Silent Fallback)
- [[feedback_subagent_model_opus]] — subagent dispatch model=opus

### 13.2 Code paths (변경 위치)

- `backend/app/modules/pipeline/shot_staging.py:203~` — retry loop
- `backend/app/modules/pipeline/shot_staging.py:225` — try 외부 raise 패턴
- `backend/app/core/step_manifest.py:587~599` — shot_staging entry
- `backend/app/core/step_manifest.py:704` — scene_detail schema_version
- `backend/app/core/steps/detail_steps.py:110~111` — SCENE_DETAIL_SCHEMA_VERSION / PROMPT_VERSION
- `backend/app/core/steps/detail_steps.py:2568~` — `_check_prompts` correction retry path
- `backend/app/core/steps/render_prompt_card.py:593~` — `_build_spatial_consistency_dict` (builder-static 직전 contrast)
- `backend/app/core/steps/render_prompt_card.py:883~` — `build_render_strategy`
- `backend/app/core/steps/render_prompt_card.py:2222~` — `canonicalize_render_prompt_card`
- `backend/app/core/steps/render_prompt_card.py:2578~` — `_assert_card_shape` inline render_strategy check

### 13.3 Existing patterns (mirror)

- `_find_orientation_violations` + `_format_retry_hint` (`shot_staging.py:25~`, `:45~`) — fsc validator 가 mirror
- `ShotStagingOrientationError` (`errors.py:154`) — fsc 의 typed exception 대안 (carry note §12)
- `_check_prompts` correction retry (`detail_steps.py:2568~`) — fsc echo 검증이 통합
