# Framing Scale Enum SOT v1 — Shot-Level Frame Scale + Close Ref Matrix

Date: 2026-05-15
Status: Draft
Area: `framing-scale-enum-sot-v1`
Audit reference: `docs/visual-reliability-audit/2026-05-14-semantic-string-routing-debt-audit/00-index.md` §8.1 (2) — Second Implementation Candidate

## 0. Scope (one-line)

shot_staging schema v11 에 `framing_scale` enum required field 도입 + 새 helper module + 전체 regex/canary keyword/LLM keyword inject/shape validator cascade 전수 제거 + close × ref_usage fail-fast matrix. 단 #16/#17/L-5/body_part_focus 는 explicitly deferred.

## 1. Background

### 1.1 결함

현재 production routing 의 close framing 판정 = `_CLOSE_FRAMING_RE` regex (영어 ECU/MCU/CU/close-up 패턴 + 한국어 클로즈업/손가락이 alternation). 동일 정의 가 `detail_steps.py:104-109` + `scene_generation_coordinator.py:76-81` 두 곳에 중복 존재하며 "양 layer 판정 mismatch 시 합성에 wide ref 들어가는데 prompt 는 close 가정 → '허공 얼굴' 결함 재발" comment 명시.

추가 결함:
- `_INSERT_HINT_RE` (render_prompt_card.py:546-557) lazy compile + insert 분기 — 동일 패턴.
- `_SPATIAL_FRAMING_CLOSE_KEYWORDS` (render_prompt_card.py:416-426) 9-entry canary keyword.
- `_SPATIAL_FRAMING_WIDE_KEYWORDS` (render_prompt_card.py:432-438) 5-entry canary.
- `framing_scale_keywords` LLM-facing field inject (render_prompt_card.py:749-772, 11+5 entry) + `scene_detail/system.md:155` 의 동일 instruction.
- 4-way drift = production regex / canary 9-entry / LLM-facing 11-entry / prompt instruction. RO-5 strict binding comment 가 "분리 strict" 명시하지만 production regex 변경 시 dual-sync 의무.

### 1.2 본 area 의 ROI

audit `00-index.md` §8.1 (2) — First Implementation Candidate (Prompt Hygiene v1, commit `91d1dfd`) 닫힘 이후의 **second structural prerequisite**. close × ref_usage attach policy (routing map §6) 의 prerequisite.

본 area closure 후 unblock:
- close × ref_usage attach policy spec 진입 (routing map §6 prerequisite 충족)
- #16/#17/L-5 의 follow-up area 진입 (body_part_focus enum + scene_consistency element_scope)

### 1.3 4 강제 gate 적용

본 spec 은 `feedback_llm_based_judgment` 의 4 gate 자가 검증 의무:
- Gate 1 — Semantic Regex Ban (open-world visual classifier regex 신규 0 + 기존 polish)
- Gate 3 — Structured SOT Required (LLM emit enum field + code 는 enum consume 만)
- Gate 4 — No Silent Fallback (missing/invalid 시 fail-fast, regex fallback 0)

## 2. Goals + Non-Goals

### 2.1 Goals

1. shot_staging schema v11 에 `framing_scale: enum["close","medium","wide","insert"]` required field 추가.
2. 새 helper module `backend/app/core/framing_scale.py` 신설 — 단일 production reader.
3. 모든 production regex / canary keyword / LLM-facing keyword inject / shape validator cascade 전수 제거 (grep residue 0 기준).
4. consumer 4 file (render_prompt_card / coordinator / detail_steps / scene_reference_service) helper read 전환.
5. close × ref_usage fail-fast matrix: `close + zoom_in_detail = allow / close + (exact_background | atmosphere_reference | "") = RefContractError` (best_prev_bytes 존재 + attach 후보 있을 때만 enforce).
6. coordinator 2 site broad except 의 `RefContractError` reraise + 그 외 `Exception` fallback 보존.
7. scene_detail v24 prompt: `framing_scale_keywords` instruction 폐기 + "card enum 소비" rewrite.
8. step_manifest + version_registry 양쪽 version bump.

### 2.2 Non-Goals (Explicit Defer)

| 항목 | Defer 사유 | 다음 area |
|---|---|---|
| #16 `_FACE_CLOSE_UP_PATTERNS` 완전 제거 | shot-level framing_scale 만으로 face/eye vs body/object close-up 구분 X. body_part_focus enum prerequisite. | Area body_part_focus |
| #17 scene_consistency `_ELEMENT_ID_CLOSE_REGEX` + `_DESCRIPTION_CLOSE_KEYWORDS` | element-level scope. shot-level framing_scale 로 대체 X. element_scope enum 별도 필요. | **✓ closed C-4 (Area #4, 2026-05-18)** — element_scope enum SOT v7, helper trio 폐기, render_prompt_card 9→10 strict shape. 본 routing 충족. |
| L-5 `close_framing_face_phrasing` 6+4 inject 완전 폐기 | body_part_focus + framing_scale 양쪽 SOT 필요. | Area body_part_focus |
| body_part_focus / focus_scope 신설 | #16 / L-5 prerequisite. | Area body_part_focus |
| representation_binding enum (perception_mode) | 다른 area (#18 + L-6). | Area perception_mode |
| shot_visibility drift only | 다른 area (#3 + L-1). | Area shot_visibility |
| ref-contract phantom guard | 다른 area (#4). | Area phantom_guard |
| t2i_review mutation redesign | 다른 area (#10 + L-3). | Area t2i_review |
| visual_world_rules SOT 일원화 | 다른 area (#6 + D-1~D-4 + ADD-6~11). | Area visual_world_rules |

### 2.3 Closed Area Do-Not-Touch (5)

본 spec 진입 시 절대 변경 X:

1. `backend/app/core/keep_elements.py` — Area D-next-min closed (commit `5f3da38`). `KEEP_ELEMENT_KINDS = {environment, static_prop}` 외 확장 금지.
2. `backend/app/core/frame_spatial_contract.py` — Area frame-spatial-contract closed (commit `2885f8c`). soft phrase diagnostic 유지, hard 승격 금지.
3. `backend/app/modules/semantic_contract_router.py` — Patch B-min closed. `IMMOBILIZED_GAZE` frozenset 외 확장 금지.
4. `_FACE_CLOSE_UP_PATTERNS` (visible_entities_validator.py:139-145) — L-5 defer.
5. 새 `_NOUNS / _TOKENS / _KEYWORDS / _PHRASES` open-world classifier 식별자 추가 절대 금지 (gate 1 자가 검증).

## 3. Architecture (4 layer)

### 3.1 Layer A — Producer (shot_staging v11)

- 새 prompt dir `prompts/_base/shot_staging/11.<TS>/` (system.md + schema.json full-pack from v10)
- schema v11 = 기존 properties + 신규 `framing_scale` required enum
- LLM = gemini-pro (기존 동일, model 변경 X)
- system.md = framing_scale 판단 기준 **개념 설명** (closed keyword list 금지 — `feedback_llm_based_judgment` gate 1/2 자가 검증)

### 3.2 Layer B — Helper (`backend/app/core/framing_scale.py` 신설)

단일 production reader. 모든 consumer 가 이 helper 만 호출.

```python
"""Framing scale enum SOT — sole production reader for shot_staging.framing_scale.

No regex fallback. Missing/invalid framing_scale → AppError (gate 4).
"""

from typing import Optional, Mapping

from app.core.errors import AppError

FRAMING_CLOSE = "close"
FRAMING_MEDIUM = "medium"
FRAMING_WIDE = "wide"
FRAMING_INSERT = "insert"

VALID_FRAMING_SCALES = frozenset({
    FRAMING_CLOSE,
    FRAMING_MEDIUM,
    FRAMING_WIDE,
    FRAMING_INSERT,
})


def get_framing_scale_or_raise(
    staging: Optional[Mapping[str, object]],
    *,
    where: str,
) -> str:
    """Read framing_scale enum from shot_staging output.

    Args:
        staging: shot_staging step manifest entry (single shot dict). None or
            non-dict → AppError.
        where: caller context for error message (e.g. "render_prompt_card.derive",
            "coordinator.batch.close_check", "scene_reference_service.matrix").

    Returns:
        One of {"close", "medium", "wide", "insert"}.

    Raises:
        AppError(code="shot_staging.framing_scale_missing") — staging not dict
            or framing_scale key absent.
        AppError(code="shot_staging.framing_scale_invalid") — value outside
            VALID_FRAMING_SCALES.

    No silent fallback — no `staging.get("framing_scale", "medium")` style allowed
    in any consumer.
    """
    if not isinstance(staging, Mapping):
        raise AppError(
            code="shot_staging.framing_scale_missing",
            message=(
                f"framing_scale read failed at {where}: staging is not a mapping "
                f"(got {type(staging).__name__})"
            ),
            status_code=422,
        )
    value = staging.get("framing_scale")
    if value is None:
        raise AppError(
            code="shot_staging.framing_scale_missing",
            message=(
                f"framing_scale read failed at {where}: key absent. Legacy "
                f"shot_staging cp likely — force re-run shot_staging step."
            ),
            status_code=422,
        )
    if value not in VALID_FRAMING_SCALES:
        raise AppError(
            code="shot_staging.framing_scale_invalid",
            message=(
                f"framing_scale invalid at {where}: got {value!r}, "
                f"expected one of {sorted(VALID_FRAMING_SCALES)}"
            ),
            status_code=422,
        )
    return value
```

### 3.3 Layer C — Consumers (4 file + canary)

전수 callsite (grep residue 0 기준):

| File | 변경 항목 |
|---|---|
| `backend/app/core/steps/render_prompt_card.py` | `_derive_framing_scale(camera_direction)` 함수 폐기 (`:560-570`) → `_resolve_framing_scale_for_render(staging, shot_info)` 신설 (staging_not_applicable mode 분기 보존, 4.5 참조) / `_CLOSE_FRAMING_RE` function-local import 삭제 (`:562`) / `_INSERT_HINT_RE` + `_get_insert_hint_re()` + 사용처 삭제 (`:546-557, :566`) / `_SPATIAL_FRAMING_CLOSE_KEYWORDS` 삭제 (`:416-426`) / `_SPATIAL_FRAMING_WIDE_KEYWORDS` 삭제 (`:432-438`) / `framing_scale_keywords` field inject 삭제 (`:749-772`) / validator constants 정리: `_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS` 의 `framing_scale_keywords` 제거 (`:475`) + `_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS` 폐기 (`:482-484`) + `_assert_primary_framing_rule_shape` 내부 cascade 제거 (`:3282-3324` 정도) |
| `backend/app/services/scene_generation_coordinator.py` | `_CLOSE_FRAMING_RE` 정의 + 인접 comment 삭제 (`:71-81`) / **close read 3 site** `_is_close_framing = bool(_cam_dir and _CLOSE_FRAMING_RE.search(_cam_dir))` 모두 helper read 로 전환 (`:352`, `:641`, `:1267`) / **broad except reraise 2 site** 의 `except RefContractError: raise` 추가 (`:393`, `:678` 만 — `:1267` 인근 `validate_attached_refs(...)` 호출 (`:1282-1286`) 에는 coordinator-level broad except 없음, validate_attached_refs 가 RefContractError 직접 propagate) |
| `backend/app/core/steps/detail_steps.py` | `_CLOSE_FRAMING_RE` 정의 + comment 삭제 (`:97-109`) / 6 callsite `_CLOSE_FRAMING_RE.search(...)` 모두 helper read 로 전환 (`:158`, `:577`, `:1286`, `:1735`, `:2376`, `:2912`) — 각 callsite 의 staging 변수 access path 따라 helper 호출 |
| `backend/app/services/scene_reference_service.py` | `build_prev_shot_background_ref` (`:692-864`) 안에서 `if not best_prev_bytes: return None` early return (`:725`) 보존 + ref_usage read 직후 (`:796-799`) close × ref_usage matrix check: `framing_scale = get_framing_scale_or_raise(staging, where="scene_reference_service.matrix")` → `if framing_scale == FRAMING_CLOSE and ref_usage != "zoom_in_detail": raise RefContractError(...)` |
| canary `tests/.../g4_5a_primary_framing*.py` (정확 path test 발견 시 명시) | `_SPATIAL_FRAMING_CLOSE_KEYWORDS` import / 사용 제거 → staging.framing_scale read |

### 3.4 Layer D — Prompt (scene_detail v24)

- 새 dir `prompts/_base/scene_detail/24.<TS>/` (system.md + 필요한 부속 file full-pack from v23)
- system.md `:155` `primary_framing_rule.framing_scale_keywords` instruction 삭제 + "framing_scale 은 `[RenderPromptCard v1].render_strategy.framing_scale` enum (`close / medium / wide / insert`) 만 소비. keyword 분류 금지" 명시
- `close_framing_rules` (`:156`) + `close_framing_face_phrasing` 보존 (L-5 / `close_framing_rules.body_part_focus_cross_ref` cross-ref 는 다음 area)
- `wide_medium_rules` (`:157`) 보존
- LLM = gemini-pro 또는 gpt (model 변경 X)

## 4. Detailed Change Spec (per file + line)

### 4.1 New file: `backend/app/core/framing_scale.py`

(3.2 참조. 새 module 신설, import 의존 = `app.core.errors.AppError` 만.)

### 4.2 `prompts/_base/shot_staging/11.<TS>/schema.json`

기존 v10 properties 보존 + `framing_scale` field 추가:

```json
{
  "type": "object",
  "properties": {
    "shots": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "scene_index": {"type": "integer"},
          "shot_index": {"type": "integer"},
          "framing_scale": {
            "type": "string",
            "enum": ["close", "medium", "wide", "insert"],
            "description": "Primary framing scale of this shot. close = primary subject (face/hand/object) fills most of the frame. medium = upper body or mid-distance subject with some surrounding context. wide = full body or environment/layout visible. insert = isolated detail without surrounding context. Judge by camera distance and primary subject visibility (meaning, not vocabulary)."
          },
          "perspective": {...},
          "pov_character": {...},
          "perception_mode": {...},
          "camera_direction": {...},
          ...
        },
        "required": [
          "scene_index", "shot_index", "framing_scale",
          "perspective", "pov_character", "perception_mode",
          "camera_direction", "lighting_mood", "character_angles",
          "key_bg_elements", "frame_spatial_contract"
        ],
        "additionalProperties": false
      }
    }
  },
  "required": ["shots"],
  "additionalProperties": false
}
```

### 4.3 `prompts/_base/shot_staging/11.<TS>/system.md`

v10 system.md full-pack copy + framing_scale 판단 기준 section 추가. **closed keyword list 금지** — 의미 정의만:

```markdown
## framing_scale (필수)

각 shot 의 primary framing scale 을 emit. enum 4 값:

- `close` — primary subject (face/hand/object) 가 frame 의 대부분을 차지. 배경/주변 entity 는 partial/soft/absent.
- `medium` — primary subject 의 상반신 또는 mid-distance. 배경 일부 visible (some surrounding context).
- `wide` — primary subject 의 full body 또는 environment/layout visible.
- `insert` — isolated detail without surrounding context. 단일 object 또는 partial element 만.

판단 기준 = camera 거리 + primary subject visibility (의미 기준). **특정 어휘 / camera 약어 / phrase list 기준 분류 금지** — gate 1/2 자가 검증.
```

### 4.4 `backend/app/core/step_manifest.py:599`

```python
"shot_staging": {
    ...
    "schema_version": 3,  # 2026-05-15: framing_scale required enum 추가 (framing_scale enum SOT v1). 기존 cp default 2 → mismatch → invalidation.
},
```

### 4.5 `backend/app/core/version_registry.py`

Line 33:
```python
"shot_staging": "2.4.0",  # 2026-05-15 — v11 prompt + schema_version 3: framing_scale required enum (framing_scale enum SOT v1)
```

Line 122:
```python
"shot_staging": {
    "prompt_dependency": "shot_staging/v11",
    "updated_at": "2026-05-15",
},
```

Line 34:
```python
"scene_detail_composer": "1.24.0",  # 2026-05-15 — framing_scale enum SOT v1: v24 prompt (framing_scale_keywords 폐기, card enum 소비 rewrite). schema_version 9 유지.
```

Line 126:
```python
"scene_detail_composer": {
    "prompt_dependency": "scene_detail/v24",
    "updated_at": "2026-05-15",
},
```

### 4.6 `backend/app/core/steps/render_prompt_card.py`

전체 변경:

1. **삭제** (`:546-557`): `_INSERT_HINT_RE` 변수 + `_get_insert_hint_re()` 함수.
2. **삭제** (`:416-426`): `_SPATIAL_FRAMING_CLOSE_KEYWORDS` tuple + comment.
3. **삭제** (`:432-438`): `_SPATIAL_FRAMING_WIDE_KEYWORDS` tuple + comment.
4. **변경** (`:560-570`): `_derive_framing_scale(camera_direction)` 폐기 → `_resolve_framing_scale_for_render(staging, shot_info)` 신설:
   ```python
   def _resolve_framing_scale_for_render(
       staging: Optional[Dict[str, Any]],
       shot_info: Optional[Dict[str, Any]],
   ) -> str:
       """staging_not_applicable mode 분기 + helper read.

       staging is None + shot_info.staging_not_applicable=True → FRAMING_MEDIUM
       (deterministic sentinel; helper raise 회피).
       그 외 → get_framing_scale_or_raise(staging, where="render_prompt_card.derive").
       """
       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.derive"
       )
   ```
5. **변경** (`:961`): `staging_not_applicable` mode 안 `_derive_framing_scale(shot_cam)` → `_resolve_framing_scale_for_render(staging, shot_info)`.
6. **변경** (`:984`): direct mode 안 `_derive_framing_scale(cam_dir)` → `_resolve_framing_scale_for_render(staging, shot_info)`.
7. **삭제** (`:749-772`): `framing_scale_keywords` field inject (`build_spatial_consistency_dict` 내부).
8. **변경** (`:473-480`): `_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS` 에서 `"framing_scale_keywords"` 제거 — 결과 5 keys: `{applies_when, close_framing_rules, wide_medium_rules, self_check_steps, rationale_summary}`.
9. **삭제** (`:482-484`): `_SPATIAL_FRAMING_SCALE_KEYWORDS_REQUIRED_SUBKEYS` 폐기.
10. **변경** (`:3282-3324`): `_assert_primary_framing_rule_shape` 내부 framing_scale_keywords nested validation 제거 (RequiredKeysCheck + sub-shape check + close 11-entry check + wide 5-entry check 모두 제거). `applies_when` + `close_framing_rules` + `wide_medium_rules` + `self_check_steps` + `rationale_summary` cascade 보존.
11. **변경** (`:740-748`): `primary_framing_rule.applies_when` 의 docstring 안 `framing_scale_keywords` 참조 모두 "framing_scale enum (close/medium/wide/insert)" 로 rewrite.

### 4.7 `backend/app/services/scene_generation_coordinator.py`

1. **삭제** (`:71-81`): `_CLOSE_FRAMING_RE` 정의 + 인접 comment.
2. **변경 — close read 3 site** (`:352`, `:641`, `:1267`): 3 callsite 의 `_is_close_framing = bool(_cam_dir and _CLOSE_FRAMING_RE.search(_cam_dir))` → `_is_close_framing = get_framing_scale_or_raise(_stg or staging, where="coordinator.<context>") == FRAMING_CLOSE`. 각 callsite 의 staging 변수명 (`staging` / `_stg` / `ctx.get("staging")`) + context name (single / batch / validator_pre_check) 정확히 매핑. RefContractError import = `from app.core.ref_contract_validator import RefContractError`.
3. **변경 — broad except reraise 2 site** (`:392-401` single + `:668-683` batch):
   ```python
   try:
       _prev_shot_ref = reference_svc.build_prev_shot_background_ref(...)
   except RefContractError:
       raise
   except Exception as exc:
       logger.warning(...)
       _prev_shot_ref = None
   ```
4. **변경 X — `:1267` 인근 broad except 없음**: `:1267` close read 직후 `:1260` `from app.core.ref_contract_validator import validate_attached_refs` → `:1282-1286` `validate_attached_refs(...)` call 진행. coordinator-level broad except 없음 (validate_attached_refs 가 RefContractError 직접 propagate). 즉 close read 변경 (#2) 만 적용, except 변경 X.

### 4.8 `backend/app/core/steps/detail_steps.py`

1. **삭제** (`:97-109`): `_CLOSE_FRAMING_RE` 정의 + comment.
2. **변경** (`:158`, `:577`, `:1286`, `:1735`, `:2376`, `:2912`): 6 callsite 의 `_CLOSE_FRAMING_RE.search(...)` → helper read. 각 callsite 의 staging access path 정확히 매핑:
   - `:158` `is_close_framing = bool(camera_direction) and bool(_CLOSE_FRAMING_RE.search(camera_direction))` → `is_close_framing = get_framing_scale_or_raise(staging_dict, where="detail_steps.<context>") == FRAMING_CLOSE` (staging_dict access 확인 의무)
   - 동일 패턴 5 callsite 추가
3. **변경** (`:467`, `:1448`, `:1733`): 3 comment 안 `_CLOSE_FRAMING_RE` reference rewrite ("framing_scale enum SOT").
4. **변경** (`:115`): `SCENE_DETAIL_PROMPT_VERSION = "24.<TS>"`.
5. **변경** (`:114`): `SCENE_DETAIL_SCHEMA_VERSION = 9` 유지 (schema 변경 없음 — comment update only).

### 4.9 `backend/app/services/scene_reference_service.py`

`build_prev_shot_background_ref` (`:692-864`) 변경:

1. **보존** (`:725`): `if not best_prev_bytes: return None` early return 그대로 유지.
2. **추가** (`:796` 직후, ref_usage read 직후):
   ```python
   # close × ref_usage matrix v1 — Gate 4 (No Silent Fallback).
   # framing_scale=close + ref_usage != zoom_in_detail → RefContractError.
   # best_prev_bytes 존재 + attach 후보 있을 때만 enforce (early return 후).
   from app.core.framing_scale import (
       FRAMING_CLOSE,
       get_framing_scale_or_raise,
   )
   from app.core.ref_contract_validator import RefContractError

   framing_scale = get_framing_scale_or_raise(
       staging,
       where=(
           f"scene_reference_service.matrix "
           f"S{still_data.get('scene_index')}_Shot{still_data.get('shot_index')}"
       ),
   )
   if framing_scale == FRAMING_CLOSE and ref_usage != "zoom_in_detail":
       raise RefContractError(
           f"close_ref_usage_violation: close framing requires "
           f"ref_usage='zoom_in_detail' but got {ref_usage!r} "
           f"(S{still_data.get('scene_index')}_"
           f"Shot{still_data.get('shot_index')}). Allowed: close + "
           f"zoom_in_detail only."
       )
   ```

   **RefContractError constructor**: `__init__(self, detail: str)` 1-arg only — `code="ref_contract.violation"` 고정. 신규 code 도입 X. violation 종류는 `detail` message 의 `"close_ref_usage_violation: "` prefix 로 식별. AppError(status_code=422) 자동 inherit.
3. **보존**: 기존 ref_usage 분기 (`:802-863`) 그대로. zoom_in_detail / atmosphere_reference / exact_background / else 분기 변경 0.

### 4.10 `prompts/_base/scene_detail/24.<TS>/system.md`

v23 system.md full-pack copy + `:155` rewrite:

기존:
```
- `primary_framing_rule.framing_scale_keywords`: close 판정 키워드 = `close-up`, `CU`, `MCU`, `ECU`, `XCU`, `extreme close-up`, `클로즈업`, `손가락이`, `손이`, `눈이`, `얼굴이` (총 11 entries; freeze-frame `정지 컷` 류는 무관). wide = `wide shot`, `establishing`, `aerial`, `전경`, `전신`. 그 외 medium default.
```

신규:
```
- `primary_framing_rule.framing_scale_source`: framing_scale 은 `[RenderPromptCard v1].render_strategy.framing_scale` enum (`close / medium / wide / insert`) 만 소비. shot_staging 이 emit. **keyword 분류 / 패턴 매칭 / phrase list 기반 추론 금지** — production 의 단일 SOT.
```

다른 instruction (`close_framing_rules` `:156` / `wide_medium_rules` `:157` / `close_framing_face_phrasing` 등) 보존 — body_part_focus enum 도입 시 다음 area 에서 정리.

### 4.11 Canary G4.5a primary_framing tests

`tests/` 안 canary test (정확 path = plan task 단계에서 grep):
- `_SPATIAL_FRAMING_CLOSE_KEYWORDS` import / 사용 제거 → staging.framing_scale read
- canary scope filter = `framing_scale == FRAMING_CLOSE` shot 만 (production enum 정렬)
- canary fixture 의 staging dict 에 framing_scale field 추가 (test data update)

## 5. Data Flow

```
shot_staging step (gemini-pro, v11)
  ↓ LLM emit per shot
  manifest data.shots[i].framing_scale ∈ {close, medium, wide, insert}
  ↓
scene_context_loader (cp invalidation if schema_version mismatch — 2 → 3)
  ↓
render_prompt_card.build_render_prompt_card(staging, shot_info, ...)
  ↓ _resolve_framing_scale_for_render(staging, shot_info)
  ↓ staging_not_applicable=True + staging is None → FRAMING_MEDIUM (sentinel)
  ↓ else → get_framing_scale_or_raise(staging, where=...)
  ↓ AppError if missing/invalid
  card.render_strategy.framing_scale = enum value
  ↓
scene_generation_coordinator (single + batch + 1267-site)
  ↓ is_close = get_framing_scale_or_raise(staging, where=...) == FRAMING_CLOSE
  ↓ if chain_bg + close → skip + prev_shot_ref try
  ↓ try:
    scene_reference_service.build_prev_shot_background_ref(...)
      ↓ if not best_prev_bytes: return None (early return)
      ↓ ref_usage = dep_info["ref_usage"]
      ↓ framing_scale = get_framing_scale_or_raise(staging, where=...)
      ↓ if CLOSE and ref_usage != "zoom_in_detail": raise RefContractError
      ↓ else label 분기 (zoom_in_detail / atmosphere_reference / exact_background / else)
  ↓ except RefContractError: raise   ← fail-fast (gate 4)
  ↓ except Exception: entity-only fallback (legacy 보존)
  ↓
scene_detail prompt (v24)
  ↓ card.render_strategy.framing_scale enum 소비 (keyword 분류 X)
  ↓ T2I prompt emit
```

## 6. Error Handling

### 6.1 신규 AppError codes

| code | status | trigger |
|---|---|---|
| `shot_staging.framing_scale_missing` | 422 | staging 이 mapping 아님 또는 framing_scale key 부재 |
| `shot_staging.framing_scale_invalid` | 422 | 값이 VALID_FRAMING_SCALES 밖 |

각 error message 에 caller `where` context 포함 (e.g. `"render_prompt_card.derive"`, `"coordinator.batch.close_check"`, `"scene_reference_service.matrix S5_Shot2"`).

### 6.2 RefContractError (close × ref_usage)

기존 `RefContractError(detail: str)` 1-arg constructor 그대로 사용. 신규 code 도입 X — code 고정 `"ref_contract.violation"` 보존. violation 종류는 detail message 의 `"close_ref_usage_violation: "` prefix 로 식별:

```
code: ref_contract.violation  (기존, 고정)
detail (= message): close_ref_usage_violation: close framing requires ref_usage='zoom_in_detail' but got '{ref_usage}' (S{scene}_Shot{shot}). Allowed: close + zoom_in_detail only.
status_code: 422  (AppError inherit)
```

**RefContractError class 확장 안 함** — `ref_contract_validator.py:33-44` 의 constructor 1-arg shape 그대로 보존. class 확장은 audit `01-` #4 (ref-contract phantom guard replacement) area 와 분리 의무.

import path: `from app.core.ref_contract_validator import RefContractError` (NOT `app.core.errors`).

### 6.3 Coordinator broad except 패턴 (2 site)

```python
try:
    _prev_shot_ref = reference_svc.build_prev_shot_background_ref(...)
except RefContractError:
    raise  # fail-fast (gate 4)
except Exception as exc:
    logger.warning(
        "Scene %d Shot %d: prev_shot_ref build failed (%s) — entity-only fallback",
        still_data.get("scene_index", 0),
        still_data.get("shot_index", 0),
        exc,
    )
    _prev_shot_ref = None
```

`RefContractError` 만 reraise, 그 외 `Exception` 은 기존 entity-only fallback 보존.

## 7. Versioning + Manifest Sync

### 7.1 shot_staging

| Location | Before | After |
|---|---|---|
| `step_manifest.py:599` | `"schema_version": 2` | `"schema_version": 3` |
| `version_registry.py:33` | `"shot_staging": "2.3.0"` | `"shot_staging": "2.4.0"` |
| `version_registry.py:121-123` | `"shot_staging": {"prompt_dependency": "shot_staging/v10", "updated_at": "2026-05-14"}` | `"shot_staging": {"prompt_dependency": "shot_staging/v11", "updated_at": "2026-05-15"}` |
| prompt dir | `prompts/_base/shot_staging/10.202605141617/` | `prompts/_base/shot_staging/11.<TS>/` (new dir, v10 보존) |

### 7.2 scene_detail_composer

| Location | Before | After |
|---|---|---|
| `detail_steps.py:115` | `SCENE_DETAIL_PROMPT_VERSION = "23.202605141758"` | `SCENE_DETAIL_PROMPT_VERSION = "24.<TS>"` |
| `detail_steps.py:114` | `SCENE_DETAIL_SCHEMA_VERSION = 9` | `SCENE_DETAIL_SCHEMA_VERSION = 9` (unchanged — schema 변경 없음) |
| `version_registry.py:34` | `"scene_detail_composer": "1.23.0"` | `"scene_detail_composer": "1.24.0"` |
| `version_registry.py:125-127` | `"scene_detail_composer": {"prompt_dependency": "scene_detail/v23", ...}` | `"scene_detail_composer": {"prompt_dependency": "scene_detail/v24", "updated_at": "2026-05-15"}` |
| prompt dir | `prompts/_base/scene_detail/23.202605141758/` | `prompts/_base/scene_detail/24.<TS>/` (new dir, v23 보존) |

### 7.3 cp invalidation 검증 의무

- shot_staging cp 의 schema_version 2 → 3 mismatch → step_runner 자동 invalidation. 운영 cp force re-run 의무.
- scene_detail cp 의 PROMPT_VERSION 변경 → config_hash mismatch → 자동 invalidation. 운영 cp force re-run 의무.

## 8. Testing Strategy

### 8.1 Unit tests (신규)

| Test | 검증 |
|---|---|
| `test_framing_scale_helper.py::test_get_framing_scale_or_raise_valid` | 4 enum 값 각 정상 반환 |
| `test_framing_scale_helper.py::test_get_framing_scale_or_raise_missing_staging_dict` | staging=None / non-dict → AppError `framing_scale_missing` |
| `test_framing_scale_helper.py::test_get_framing_scale_or_raise_missing_key` | staging={} → AppError `framing_scale_missing` |
| `test_framing_scale_helper.py::test_get_framing_scale_or_raise_invalid_value` | staging={"framing_scale": "huge"} → AppError `framing_scale_invalid` |
| `test_framing_scale_helper.py::test_where_context_in_message` | where 매개변수 가 error message 에 포함 |
| `test_scene_reference_service_close_ref_usage_matrix.py::test_close_zoom_in_detail_allow` | close + zoom_in_detail → no raise, label 생성 정상 |
| `test_scene_reference_service_close_ref_usage_matrix.py::test_close_exact_background_raises` | close + exact_background → RefContractError |
| `test_scene_reference_service_close_ref_usage_matrix.py::test_close_atmosphere_reference_raises` | close + atmosphere_reference → RefContractError |
| `test_scene_reference_service_close_ref_usage_matrix.py::test_close_empty_ref_usage_raises` | close + "" → RefContractError |
| `test_scene_reference_service_close_ref_usage_matrix.py::test_close_no_prev_bytes_no_check` | close + best_prev_bytes=None → early return, matrix not enforced |
| `test_scene_reference_service_close_ref_usage_matrix.py::test_medium_any_ref_usage_allow` | medium + any ref_usage → no raise |
| `test_coordinator_broad_except_reraise.py::test_single_reraise_ref_contract_error` | scene_reference_service 가 RefContractError → coordinator 가 reraise (2 broad except site `:393`, `:678` 모두) |
| `test_coordinator_broad_except_reraise.py::test_single_fallback_other_exception` | scene_reference_service 가 ValueError → coordinator 가 entity-only fallback (regression) |
| `test_render_prompt_card_framing_scale.py::test_resolve_staging_not_applicable_returns_medium` | staging=None + staging_not_applicable=True → FRAMING_MEDIUM (sentinel) |
| `test_render_prompt_card_framing_scale.py::test_resolve_normal_uses_helper` | staging={"framing_scale":"close"} → close |
| `test_render_prompt_card_framing_scale.py::test_resolve_staging_missing_no_sentinel_raises` | staging=None + staging_not_applicable=False → AppError (gate 4) |

### 8.2 Existing test 수정 대상

| Test | 변경 |
|---|---|
| `_derive_framing_scale(camera_direction)` regex unit test | signature 변경 → `_resolve_framing_scale_for_render(staging, shot_info)` 기준 rewrite |
| `_CLOSE_FRAMING_RE` import/behavior test | 정의/import 제거 검증으로 전환 (grep residue 0 assertion) |
| G4.5a `_SPATIAL_FRAMING_CLOSE_KEYWORDS` 기반 canary | production enum read 로 전환 (`framing_scale == FRAMING_CLOSE` filter) |
| `framing_scale_keywords.close/wide` entry count integration test | field 제거 검증 (`framing_scale_keywords` key 부재 assertion) 또는 폐기 |
| `_SPATIAL_PRIMARY_FRAMING_RULE_REQUIRED_KEYS` 6 keys 검증 test | 5 keys 로 수정 (`framing_scale_keywords` 제외) |
| `_assert_primary_framing_rule_shape` cascade test | framing_scale_keywords nested validation 제거 후 회귀 |

### 8.3 Integration / Canary

- G4.5a primary_framing canary (production enum read) — 4 enum 분포 verified
- legacy shot_staging cp force re-run synthetic: schema_version mismatch invalidation + missing framing_scale → AppError fail-fast
- canary fixture: staging dict 에 framing_scale field 추가 (test data update)

### 8.4 Regression

- focused test (framing_scale + render_prompt_card + coordinator + scene_reference_service + detail_steps): PASS
- full regression: unrelated B1~B5 외 0 fail
- shape validator cascade test PASS (validator constants 정리 후 회귀)

## 9. Acceptance Criteria (4 gate 자가 검증)

### Gate 1 — Semantic Regex Ban

- `git diff` 에서 새 `_NOUNS / _TOKENS / _KEYWORDS / _PHRASES / _RE` 식별자 추가 = **0**
- `grep -nE "_CLOSE_FRAMING_RE|_INSERT_HINT_RE|_SPATIAL_FRAMING_(CLOSE|WIDE)_KEYWORDS" backend/app/ --include='*.py'` = **0 hits** (residue 검증)
- 모든 callsite 제거 verified (detail_steps 6 + coordinator 3 + render_prompt_card 1 + canary)

### Gate 3 — Structured SOT Required

- shot_staging schema v11 의 `framing_scale` = enum required (additionalProperties: false, required list 포함)
- 모든 consumer (4 file + canary) 가 `get_framing_scale_or_raise` 또는 `_resolve_framing_scale_for_render` 호출
- 직접 `staging.get("framing_scale", "medium")` 류 fallback = **0**
- `_resolve_framing_scale_for_render` 의 staging_not_applicable sentinel branch 외 다른 fallback = **0**

### Gate 4 — No Silent Fallback

- legacy shot_staging cp missing framing_scale = `AppError(code="shot_staging.framing_scale_missing")` fail-fast verified
- enum 밖 값 = `AppError(code="shot_staging.framing_scale_invalid")` fail-fast verified
- close × ref_usage 위반 = `RefContractError` raise (code 고정 `ref_contract.violation`, detail message prefix `"close_ref_usage_violation: "`) fail-fast verified
- coordinator broad except **2 site** (`:393`, `:678`) 모두 `RefContractError` reraise verified — `:1267` 인근은 validate_attached_refs 가 직접 propagate (coordinator-level broad except 없음)
- regex fallback / keyword fallback = **0**

### Gate 2 (Prompt Closed-List Ban) — Boundary 자가 검증

- 새 shot_staging v11 system.md + scene_detail v24 system.md 에 closed keyword list = **0**
- 의미 정의 + `non-exhaustive examples, do not classify by this list` 형 예외 명시 (필요 시)

## 10. Closure Criteria

본 area 가 닫혔다고 간주되는 조건:

- [ ] 4 gate 자가 검증 PASS (Gate 1/2/3/4)
- [ ] focused test (framing_scale helper + 4 consumer + canary) PASS
- [ ] full regression: unrelated B1~B5 외 0 fail
- [ ] shape validator cascade test PASS (validator constants 정리 후 회귀)
- [ ] staging_not_applicable mode synthetic test PASS
- [ ] close × ref_usage matrix 4 case + best_prev_bytes None 5 unit test PASS
- [ ] coordinator broad except 2 site (`:393`, `:678`) reraise synthetic test PASS
- [ ] legacy shot_staging cp force re-run synthetic test PASS
- [ ] G4.5a primary_framing canary PASS (production enum read)
- [ ] Codex review APPROVED 또는 APPROVED_WITH_MINOR
- [ ] push origin/main 완료
- [ ] routing map `docs/superpowers/specs/2026-05-14-visual-reliability-routing-map.md` §6 prerequisite 충족 표시 update

## 11. Out-of-Scope (Explicit Defer + 사유)

§2.2 표 참조. 본 spec 진입 시 절대 변경 X.

추가 명시:

- **`_FACE_CLOSE_UP_PATTERNS` 폐기 X** — `visible_entities_validator.py:139-145` 보존. L-5 의 prerequisite (body_part_focus enum) 미완성. 본 area 진입 시 발견 시 grep residue allowed.
- **`close_framing_face_phrasing` 6+4 inject 보존** — `render_prompt_card.py:131-146` + `scene_detail/system.md:45-46` 변경 X.
- **`scene_consistency_step._ELEMENT_ID_CLOSE_REGEX` + `_DESCRIPTION_CLOSE_KEYWORDS` 보존** — `scene_consistency_step.py:65-80` 변경 X.
- **`shot_staging.frame_spatial_contract` 변경 X** — Area frame-spatial-contract closed.
- **`perception_mode` enum 통일 X** — 다른 area (#18 + L-6).

## 12. Risks + Open Questions (PENDING)

### 12.1 Risk

| Risk | Mitigation |
|---|---|
| legacy shot_staging cp 의 production force re-run 누락 | step_runner 의 schema_version 2 → 3 mismatch auto-invalidation 신뢰. 단 force migration command (`run shot_staging --force`) 운영 runbook 명시 |
| canary fixture 의 staging dict 정합 | test data update (framing_scale field 추가) — fixture sweep 전수 |
| validator cascade 의 hidden test fail (golden fixture) | plan task 단계에서 grep residue 검사 + test fixture 동기 |
| `_resolve_framing_scale_for_render` 의 import 위치 (function-local vs module-top) | function-local import 채택 (`render_prompt_card.py` 가 이미 frame_spatial_contract 등 다른 helper 도 function-local — circular import 회피) |

### 12.2 Open Questions (PENDING)

| Question | Resolution path |
|---|---|
| shot_staging module 의 production DB row 존재 (prompt_loader DB-first override) | `04-db-row-verification.md` §6.1 PENDING. production DB 측정 의무 — 별도 area 또는 spec 진입 시 PENDING 명시 |
| canary test 정확 path (`g4_5a_primary_framing*.py`) | plan task 단계에서 grep |
| scene_detail v24 의 fixture path 변경 영향 (snapshot test) | plan task 단계에서 fixture diff 검토 |
| close × ref_usage matrix 의 best_prev_bytes None 외 edge case 가능성 | early return 보존 + close+non-zoom 조합만 enforce. 추가 edge 발견 시 plan task 단계에서 unit test 보강 |

## 13. Related Documents

### Audit
- `docs/visual-reliability-audit/2026-05-14-semantic-string-routing-debt-audit/00-index.md` §7 + §8.1 (2)
- `docs/visual-reliability-audit/2026-05-14-semantic-string-routing-debt-audit/01-code-side-regex-audit.md` §4 #1 + #2
- `docs/visual-reliability-audit/2026-05-14-semantic-string-routing-debt-audit/03-llm-side-heuristic-audit.md` §4 L-4

### Routing Map
- `docs/superpowers/specs/2026-05-14-visual-reliability-routing-map.md` §6 — close × ref_usage attach policy (본 area 의 follow-up unblock)

### Closed Areas (Do-Not-Touch)
- `docs/superpowers/specs/2026-05-14-frame-spatial-contract-design.md` (commit `2885f8c`)
- `docs/superpowers/specs/2026-05-15-area-d-next-min-keep-elements-environment-static-prop-only.md` (commit `5f3da38`)
- `docs/superpowers/specs/2026-05-13-area-b-render-contracts-design.md` (commit `e400e9c`)

### Policy
- `~/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/feedback_llm_based_judgment.md` (external memory) — 4 gate
- `~/.claude/projects/-Users-manta-Documents-Projects-TheRoad-I1/memory/feedback_subagent_model_opus.md` (external memory) — subagent dispatch model=opus 강제

### Audit Carry
- B1~B5 unrelated regression backlog (`session_20260515_prompt_hygiene_v1_closure.md`) — 본 area 영향 외, 별도 처리.

## 14. Implementation Sequence (high-level, for plan task)

1. **Task 1** — `app/core/framing_scale.py` 신설 + unit test 5
2. **Task 2** — `prompts/_base/shot_staging/11.<TS>/` 신설 (schema + system.md) + manifest + version_registry sync
3. **Task 3** — `render_prompt_card.py` 변경 (helper + sentinel + validator cascade 정리 + LLM inject 제거 + regex 제거)
4. **Task 4** — `detail_steps.py` 변경 (6 callsite + SCENE_DETAIL_PROMPT_VERSION) + canary fixture update
5. **Task 5** — `coordinator.py` 변경 (close read 3 callsite `:352/:641/:1267` + broad except reraise 2 site `:393/:678`; `:1267` 인근은 close read 만, validate_attached_refs propagate 경로)
6. **Task 6** — `scene_reference_service.py` 변경 (close × ref_usage matrix + RefContractError raise) + unit test
7. **Task 7** — `prompts/_base/scene_detail/24.<TS>/` 신설 (system.md rewrite) + version_registry sync
8. **Task 8** — canary G4.5a production enum read 전환 + fixture update
9. **Task 9** — 전수 grep residue 검증 (gate 1) + full regression + Codex review
10. **Task 10** — push + routing map §6 update

Plan task 단계 (`writing-plans` skill) 에서 task breakdown + risk + verification + 정확한 commit boundary 정의.
