# C10 Screen Presence Reconciliation — Phase 0/1 Implementation Plan

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

**Goal:** S15_Shot5 류의 `shot_director.visible_entity_ids` ↔ `shot_staging.character_angles` 발산을, 화면 밖으로 연출된 인물의 identity policy 를 `generic_descriptor_allowed` 로 결정론적 다운그레이드하여 `scene_detail` contract violation(`base_id_missing`) 없이 해소한다.

**Architecture:** 새 schema·enum·prompt-pack 없이 기존 구조 신호만 사용하는 consumer-first 실험. (1) `shot_visibility.py` 에 순수 detector 함수 1개, (2) `subject_reference_policy.py` 에 순수 policy-array 변환 helper 1개, (3) `build_render_prompt_card()` 내부 — `filter_subject_reference_policy_to_visible()` 와 `normalize_subject_reference_policy_items()` 사이 — 에서 둘을 호출. detector 가 카드 빌더 안에서 돌기 때문에 producer / verify / recompute 경로가 모두 같은 카드(=같은 hash)를 만든다. detector 는 `character_angles` 항목 부재 + non-POV + `OFFSCREEN_RE` 매치를 hard gate 로 쓰고 `gaze_direction_kind=="off_screen"` 은 절대 가시성 신호로 쓰지 않는다.

**Tech Stack:** Python 3.12, pytest, SQLAlchemy(미사용 — 순수 함수), 기존 `subject_reference_policy` / `visible_entities_validator` / `render_prompt_card` 모듈.

설계 근거 문서: `docs/c10-screen-presence/index.html`, `phase-0-1-plan.html`, `c10-roadmap.html`.

---

## Phase 0 — Contract (설계 문서로 이미 충족, 코드 없음)

Phase 0 산출물 = `docs/c10-screen-presence/` 3개 HTML + 본 플랜. 아래 계약이 LOCKED:

- **visible 의미**: `shot_director.visible_entity_ids` 는 shot-level 후보 집합이지 "반드시 그릴 대상"이 아니다.
- **in-frame 신호**: `shot_staging.character_angles[].character` membership 만이 현재 유일한 구조적 in-frame 신호.
- **gaze 신호 금지**: `gaze_direction_kind=="off_screen"` 는 시선 방향이지 인물 가시성이 아니다 (schema v15 명시). detector 에서 사용 금지.
- **정책**: `generic_descriptor_allowed` 가 off-screen/referenced 정책. 새 `no_id_required` enum 만들지 않는다.
- **이름 근접**: OFFSCREEN_RE 근처 canonical name 등장은 confidence/provenance 일 뿐 hard gate 아니다.
- **빈 character_angles**: `character_angles==[]` 면 다운그레이드하지 않는다 (producer 가 필드를 안 채운 것일 수 있음).
- **Phase 1 범위**: policy reconciliation 만. shot_selection / shot_staging prompt / scene_detail prompt 변경 없음.

---

## Pre-verified Facts (W0 — 본 플랜 작성 중 read-only 조사 완료)

엔지니어는 아래를 사실로 간주하고 시작한다. 재조사 불필요.

1. **검증기 동작** — `backend/app/core/subject_reference_policy.py:46-56`: `generic_descriptor_allowed` = `IdUsageRule(base_required=False, outlook_required=False, outlook_forbidden=True)`. `backend/app/core/visible_entities_validator.py:469-489` Source 1 forward 검사는 `rule.base_required` 가 True 일 때만 `base_id_missing` raise → 다운그레이드하면 crash 분기를 안 탄다.

2. **카드 빌드 → LLM 순서** — `backend/app/core/steps/detail_steps.py:2890` 에서 `build_render_prompt_card()` 가 LLM 호출(`:2987`) **전에** 카드를 만들고 `:2901~` 에서 LLM user_prompt 에 주입. 같은 카드를 validator 가 검사 → 빌더 안 다운그레이드면 LLM·validator 가 같은 정책을 본다.

3. **`build_render_prompt_card()` 시그니처** — `backend/app/core/steps/render_prompt_card.py:3451-3476`: keyword-only. `staging`, `visible_entities` 는 받지만 **`name_by_short_id` 는 받지 않는다**. detector 는 `character_angles[].character`(이름)·`pov_character`(이름)를 이름으로 봐야 하므로 이 파라미터 추가가 W2 의 핵심.

4. **정책 정규화 위치** — `render_prompt_card.py:3526-3539`: `visible_bases` 계산(`:3526`) → `filter_subject_reference_policy_to_visible()`(`:3531-3533`) → `normalize_subject_reference_policy_items()`(`:3534-3538`) → `serialize`(`:3539`). 다운그레이드 삽입 지점 = `:3533` 와 `:3534` 사이. 그 뒤 `build_id_policy(subject_reference_policies=policy_array)`(`:3545`) 와 `build_asset_requirements(policy_map=policy_map)`(`:3576`) 가 모두 reconciled 정책을 본다.

5. **카드 입력 단일 chokepoint** — 모든 카드 빌드는 `detail_steps.py` 의 `_collect_card_inputs()`(`:752`)→`_derive_card_inputs_from_ctx()`(`:564`, ctx 경로) 또는 legacy fallback(`:806-843`)을 거친다. 호출처 4곳(`:909` recompute, `:1501` user-edited reuse, `:1857` verify, `:2868` `_analyze_one`)이 전부 이 helper 의 dict 를 `{k:v for k,v in ... if k!="ctx"}` 로 strip 후 splat. 따라서 `name_by_short_id` 를 이 두 helper 의 반환 dict 에 1번씩만 추가하면 4 호출처 전부 자동 전달된다. `redo-shot service` 는 `_analyze_one()` 을 거치므로 별도 작업 없음.

6. **`SceneAnalysisContext.name_by_short_id`** — `backend/app/core/dto/scene_analysis.py:131`: `Dict[str,str] = field(default_factory=dict)`. `SceneContextLoader` 가 `scene_context_loader.py:71` 에서 채운다 (entity_canon character rows).

7. **scene_detail 프롬프트** — `prompts/_base/scene_detail/33.202605201902/system.md:40,47,433`: 이미 `generic_descriptor_allowed` → "ID 없이 generic descriptor", "고유명사 단독 사용 금지" 를 명시. **Phase 1 에 프롬프트 변경 불필요.** G6(Source 2)의 결론: 다운그레이드 후 LLM 이 그래도 고유명("수리영")을 ID 없이 쓰면 `visible_entities_validator` Source 2(`:530-557`)가 raise 하는 것이 정상(LLM 위반). Phase 1 이 고칠 대상 아님.

8. **import 방향** — `shot_visibility.py` 는 `app.core.gaze_direction` + stdlib 만 import. `render_prompt_card.py` 가 `shot_visibility` 를 import 해도 순환 없음. 단 안전을 위해 본 플랜은 detector import 를 `build_render_prompt_card()` 함수 내부 inline import 로 한다 (codebase 의 detail_steps 패턴과 일치).

9. **S15_Shot5 실데이터** (checkpoint JSON, project `8d56bc5d`):
   - shot_director: `visible_entity_ids=["C02","C01"]`, `excluded_offscreen_entity_ids=[]`.
   - shot_staging: `camera_direction` 에 `"the off-screen direction where 수리영 has run"` 포함 / `character_angles=[{character:"혜수", gaze_direction_kind:"off_screen", ...}]` (C01 부재) / `subject_reference_policy=[]` / `pov_character=""`.
   - C01=수리영, C02=혜수.

---

## File Structure

| File | 역할 | 변경 |
|---|---|---|
| `backend/app/modules/pipeline/shot_visibility.py` | off-screen-referenced detector (순수 함수) | Modify — `detect_offscreen_referenced_subjects()` 추가, `__all__` 갱신 |
| `backend/app/core/subject_reference_policy.py` | policy-array 다운그레이드 변환 (순수 함수) | Modify — `apply_screen_presence_downgrade()` 추가 |
| `backend/app/core/steps/render_prompt_card.py` | 카드 빌더 — reconciliation chokepoint | Modify — `build_render_prompt_card()` 에 `name_by_short_id` 파라미터 + reconciliation 블록 |
| `backend/app/core/steps/detail_steps.py` | 카드 입력 helper | Modify — `_derive_card_inputs_from_ctx()` + `_collect_card_inputs()` 에 `name_by_short_id` threading |
| `backend/tests/unit/test_screen_presence_reconciliation.py` | Phase 1 전체 테스트 (D1-D7, T2, G1/G4/G7/G8/G10/G11) | Create |

---

## Task 1: Screen-presence detector

화면 밖으로 연출된 visible 후보 인물을 결정론적으로 식별하는 순수 함수.

**Files:**
- Modify: `backend/app/modules/pipeline/shot_visibility.py` (`detect_offscreen_drift_proximity_diagnostic` 정의 끝 직후, `__all__` 앞 — 현재 `:474` 근처)
- Modify: `backend/app/modules/pipeline/shot_visibility.py` `__all__` 리스트 (현재 `:476`)
- Test: `backend/tests/unit/test_screen_presence_reconciliation.py` (Create)

- [ ] **Step 1: Write the failing detector tests**

`backend/tests/unit/test_screen_presence_reconciliation.py` 신규 생성:

```python
"""C10 Phase 1 — screen-presence reconciliation tests.

Detector (Task 1) + downgrade helper (Task 2) + card wiring (Task 3).
순수 함수 — DB/LLM/FS 없음.
"""
from __future__ import annotations

from app.modules.pipeline.shot_visibility import (
    detect_offscreen_referenced_subjects,
)

# S15_Shot5 실데이터 기반 fixture (project 8d56bc5d). C01=수리영, C02=혜수.
_S15_CAM = (
    "From the bus stop at chest height, the camera holds in a static medium "
    "frame after the pan back, keeping 혜수 fixed on the left third while the "
    "empty coastal road stretches away behind her into fog. Her cupped hands "
    "and open mouth point toward the off-screen direction where 수리영 has run, "
    "making the absence feel watched rather than resolved."
)
_S15_ANGLES = [
    {"character": "혜수", "angle": "profile_right", "body_pose": "hands cupped",
     "gaze_direction_kind": "off_screen", "gaze_target_id": None,
     "subject_state": "alive"},
]
_ID_TO_NAME = {"C01": "수리영", "C02": "혜수"}


class TestDetector:
    def test_d1_structural_offscreen_positive(self) -> None:
        """D1: visible C01, character_angles 부재, non-POV, OFFSCREEN_RE 매치 → emit."""
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C01", "C02"],
            camera_direction=_S15_CAM,
            character_angles=_S15_ANGLES,
            id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert "C01" in out
        assert "C02" not in out  # 혜수는 character_angles 에 있음 (in-frame)

    def test_d2_gaze_offscreen_not_a_signal(self) -> None:
        """D2: character_angles 에 있는 인물은 gaze 가 off_screen 이어도 emit 안 함."""
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C02"],
            camera_direction=_S15_CAM,
            character_angles=_S15_ANGLES,  # 혜수 gaze=off_screen 이지만 in-frame
            id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert out == {}

    def test_d3_pov_guard(self) -> None:
        """D3: character_angles 부재라도 POV 인물이면 emit 안 함."""
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C01", "C02"],
            camera_direction=_S15_CAM,
            character_angles=_S15_ANGLES,
            id_to_name=_ID_TO_NAME,
            pov_character="수리영",
        )
        assert "C01" not in out

    def test_d4_empty_character_angles_noop(self) -> None:
        """D4: character_angles=[] → 아무도 emit 안 함."""
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C01", "C02"],
            camera_direction=_S15_CAM,
            character_angles=[],
            id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert out == {}

    def test_d5_no_offscreen_phrase_noop(self) -> None:
        """D5: camera_direction 에 OFFSCREEN_RE 매치 없음 → emit 없음."""
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C01", "C02"],
            camera_direction="A calm medium shot of 혜수 on the road.",
            character_angles=_S15_ANGLES,
            id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert out == {}

    def test_d6_in_frame_member_not_emitted(self) -> None:
        """D6: character_angles 에 back_to_camera 로 존재하면 emit 안 함."""
        angles = _S15_ANGLES + [
            {"character": "수리영", "angle": "back_to_camera",
             "body_pose": "walking away", "gaze_direction_kind": "distant",
             "gaze_target_id": None, "subject_state": "alive"},
        ]
        out = detect_offscreen_referenced_subjects(
            visible_ids=["C01", "C02"],
            camera_direction=_S15_CAM,
            character_angles=angles,
            id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert "C01" not in out

    def test_d7_confidence_named_vs_structural(self) -> None:
        """D7: camera_direction 에 이름 있으면 confidence=named, 없으면 structural."""
        named = detect_offscreen_referenced_subjects(
            visible_ids=["C01"], camera_direction=_S15_CAM,
            character_angles=_S15_ANGLES, id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert "confidence=named" in named["C01"]
        structural = detect_offscreen_referenced_subjects(
            visible_ids=["C01"],
            camera_direction="Her hands point toward the off-screen road.",
            character_angles=_S15_ANGLES, id_to_name=_ID_TO_NAME,
            pov_character="",
        )
        assert "confidence=structural" in structural["C01"]
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `cd backend && python -m pytest tests/unit/test_screen_presence_reconciliation.py::TestDetector -v`
Expected: FAIL — `ImportError: cannot import name 'detect_offscreen_referenced_subjects'`

- [ ] **Step 3: Implement the detector**

`backend/app/modules/pipeline/shot_visibility.py` — `detect_offscreen_drift_proximity_diagnostic()` 함수 정의가 끝나는 줄(현재 `return drift` + 빈 줄, `__all__` 직전) 바로 위에 추가:

```python
def detect_offscreen_referenced_subjects(
    visible_ids: Sequence[str],
    camera_direction: str,
    character_angles: Sequence[Dict[str, Any]],
    id_to_name: Mapping[str, str],
    pov_character: Optional[str],
) -> Dict[str, str]:
    """C10 Phase 1 — visible 후보 인물 중 후행 staging 이 화면 밖/부재로
    연출한 인물을 결정론적으로 식별.

    Hard gate (전부 필요):
      1. C## 가 visible character 후보.
      2. character_angles 가 non-empty (producer 가 필드를 채웠음).
      3. 그 인물의 canonical name 이 character_angles[].character 에 없음.
      4. 그 인물이 POV character 가 아님.
      5. camera_direction 에 OFFSCREEN_RE 매치 존재.

    `gaze_direction_kind == "off_screen"` 은 의도적으로 사용하지 않음 — schema
    상 시선 방향이지 인물 가시성 결정이 아니다 (in-frame 인물도 off_screen
    gaze 가능).

    Returns:
        {subject_id: reason}. reason 에 confidence 태그("named" = canonical
        name 이 camera_direction 에 등장 / "structural" = 구조 신호만) 를
        provenance 로 포함. 다운그레이드 동작에는 영향 없음.
    """
    result: Dict[str, str] = {}
    if not camera_direction or not character_angles:
        return result
    if not OFFSCREEN_RE.search(camera_direction):
        return result

    angle_names: Set[str] = set()
    for ca in character_angles:
        if isinstance(ca, dict):
            ch = (ca.get("character") or "").strip()
            if ch:
                angle_names.add(ch)

    pov = (pov_character or "").strip()

    for sid in visible_ids:
        if not (isinstance(sid, str) and sid.startswith("C")):
            continue
        name = (id_to_name.get(sid) or "").strip()
        if not name:
            # 이름이 없으면 character_angles membership 을 검증할 수 없음 → skip.
            continue
        if sid == pov or name == pov:
            continue
        if name in angle_names:
            continue
        confidence = "named" if name in camera_direction else "structural"
        result[sid] = (
            "screen_presence_reconciliation: visible candidate absent from "
            "character_angles, non-POV, camera_direction off-screen reference "
            f"(confidence={confidence})"
        )
    return result
```

같은 파일 `__all__` 리스트(`OFFSCREEN_RE` 등이 들어있는 리스트)에 항목 추가:

```python
    "detect_offscreen_referenced_subjects",
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `cd backend && python -m pytest tests/unit/test_screen_presence_reconciliation.py::TestDetector -v`
Expected: PASS — 7 passed

- [ ] **Step 5: Commit**

```bash
git add backend/app/modules/pipeline/shot_visibility.py backend/tests/unit/test_screen_presence_reconciliation.py
git commit -m "$(cat <<'EOF'
C10 Phase1 W1: screen-presence off-screen-referenced detector

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

---

## Task 2: Policy downgrade helper

detector 결과를 받아 `subject_reference_policy` 배열에 `generic_descriptor_allowed` 를 inject/override 하는 순수 함수.

**Files:**
- Modify: `backend/app/core/subject_reference_policy.py` (`canonicalize_base_id_required_outlook_forms()` 정의 끝 직후 — `normalize_subject_reference_policy_items()` 앞)
- Test: `backend/tests/unit/test_screen_presence_reconciliation.py` (Append)

- [ ] **Step 1: Write the failing downgrade-helper tests**

`test_screen_presence_reconciliation.py` 상단 import 에 추가:

```python
from app.core.subject_reference_policy import apply_screen_presence_downgrade
```

같은 파일에 클래스 추가:

```python
class TestDowngradeHelper:
    _DETECTED = {"C01": "screen_presence_reconciliation: ... (confidence=named)"}

    def test_t2_1_inject_when_no_entry(self) -> None:
        """T2-1: 기존 entry 없음 → generic_descriptor_allowed inject."""
        out = apply_screen_presence_downgrade([], self._DETECTED, where="t")
        assert len(out) == 1
        assert out[0]["subject_id"] == "C01"
        assert out[0]["policy"] == "generic_descriptor_allowed"
        assert out[0]["policy_type"] == "identity_reference"
        assert out[0]["reason"].strip()

    def test_t2_2_downgrade_id_and_outlook_required(self) -> None:
        """T2-2: explicit id_and_outlook_required → generic_descriptor_allowed."""
        raw = [{"subject_id": "C01", "policy_type": "identity_reference",
                "policy": "id_and_outlook_required", "reason": "producer"}]
        out = apply_screen_presence_downgrade(raw, self._DETECTED, where="t")
        c01 = next(i for i in out if i["subject_id"] == "C01")
        assert c01["policy"] == "generic_descriptor_allowed"
        assert "id_and_outlook_required" in c01["reason"]  # provenance 보존

    def test_t2_3_keep_explicit_base_id_required(self) -> None:
        """T2-3 (G5): explicit base_id_required 는 보존."""
        raw = [{"subject_id": "C01", "policy_type": "identity_reference",
                "policy": "base_id_required", "reason": "producer"}]
        out = apply_screen_presence_downgrade(raw, self._DETECTED, where="t")
        c01 = next(i for i in out if i["subject_id"] == "C01")
        assert c01["policy"] == "base_id_required"

    def test_t2_4_keep_explicit_generic(self) -> None:
        """T2-4: 이미 generic_descriptor_allowed 면 그대로."""
        raw = [{"subject_id": "C01", "policy_type": "identity_reference",
                "policy": "generic_descriptor_allowed", "reason": "producer"}]
        out = apply_screen_presence_downgrade(raw, self._DETECTED, where="t")
        c01 = next(i for i in out if i["subject_id"] == "C01")
        assert c01["policy"] == "generic_descriptor_allowed"
        assert c01["reason"] == "producer"  # 미변경

    def test_t2_5_non_list_passthrough(self) -> None:
        """T2-5: raw_items 가 list 아니면 그대로 반환 (normalize 가 fail-fast)."""
        assert apply_screen_presence_downgrade(None, self._DETECTED, where="t") is None

    def test_t2_6_no_detection_noop(self) -> None:
        """T2-6: detected 비어있으면 입력 그대로."""
        raw = [{"subject_id": "C02", "policy_type": "identity_reference",
                "policy": "id_and_outlook_required", "reason": "p"}]
        out = apply_screen_presence_downgrade(raw, {}, where="t")
        assert out == raw
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `cd backend && python -m pytest tests/unit/test_screen_presence_reconciliation.py::TestDowngradeHelper -v`
Expected: FAIL — `ImportError: cannot import name 'apply_screen_presence_downgrade'`

- [ ] **Step 3: Implement the downgrade helper**

`backend/app/core/subject_reference_policy.py` — `canonicalize_base_id_required_outlook_forms()` 함수 정의가 끝난 직후(다음 함수 `normalize_subject_reference_policy_items` 정의 앞)에 추가:

```python
def apply_screen_presence_downgrade(
    raw_items: Optional[list],
    offscreen_referenced: dict,
    *,
    where: str = "",
) -> Optional[list]:
    """C10 Phase 1 — screen-presence detector 가 off-screen-referenced 로
    플래그한 subject 의 identity policy 를 `generic_descriptor_allowed` 로
    inject/override.

    Override rule:
      - 기존 entry 없음            → generic_descriptor_allowed inject;
      - id_and_outlook_required    → generic_descriptor_allowed 다운그레이드
                                     (explicit producer 정책 override —
                                     reason 에 provenance 기록);
      - base_id_required           → 보존 (의도적 partial-frame 정책);
      - generic_descriptor_allowed → 보존 (이미 정합).

    `raw_items` 가 list 아니면 그대로 반환 — `normalize_subject_reference_
    policy_items` 가 fail-fast 처리. malformed dict item 도 보존.
    """
    if not isinstance(raw_items, list):
        return raw_items
    if not offscreen_referenced:
        return raw_items
    items = [dict(it) if isinstance(it, dict) else it for it in raw_items]
    by_base: dict = {}
    for it in items:
        if isinstance(it, dict) and isinstance(it.get("subject_id"), str):
            by_base.setdefault(it["subject_id"].split("O")[0], it)
    for sid, reason in sorted(offscreen_referenced.items()):
        base = sid.split("O")[0]
        existing = by_base.get(base)
        if existing is None:
            items.append({
                "subject_id": base,
                "policy_type": "identity_reference",
                "policy": "generic_descriptor_allowed",
                "reason": reason or "screen_presence_reconciliation",
            })
        elif existing.get("policy") == "id_and_outlook_required":
            existing["policy"] = "generic_descriptor_allowed"
            existing["reason"] = (
                f"{reason} | overrode explicit id_and_outlook_required"
            )
    return items
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `cd backend && python -m pytest tests/unit/test_screen_presence_reconciliation.py::TestDowngradeHelper -v`
Expected: PASS — 6 passed

- [ ] **Step 5: Commit**

```bash
git add backend/app/core/subject_reference_policy.py backend/tests/unit/test_screen_presence_reconciliation.py
git commit -m "$(cat <<'EOF'
C10 Phase1 W1: apply_screen_presence_downgrade policy-array helper

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

---

## Task 3: Name-map threading + builder reconciliation wiring

`name_by_short_id` 를 `build_render_prompt_card()` 까지 threading 하고, 빌더 내부에서 Task 1 detector + Task 2 helper 를 호출.

**Files:**
- Modify: `backend/app/core/steps/render_prompt_card.py` (`build_render_prompt_card` 시그니처 `:3475` / 정책 정규화 블록 `:3531-3534` / top import `:88-94`)
- Modify: `backend/app/core/steps/detail_steps.py` (`_derive_card_inputs_from_ctx` 반환 dict `:729-749` / `_collect_card_inputs` 시그니처 `:752-769`·legacy dict `:823-843`·overrides `:846-857`)
- Test: `backend/tests/unit/test_screen_presence_reconciliation.py` (Append)

- [ ] **Step 1: Write the failing card-level tests**

`test_screen_presence_reconciliation.py` 상단 import 에 추가:

```python
from app.core.steps.render_prompt_card import (
    build_render_prompt_card,
    compute_card_hash,
)
from app.core.steps.detail_steps import _collect_card_inputs
```

같은 파일에 helper + 클래스 추가. (`_build_card` 는 `test_render_prompt_card_hash.py:_full_card_strict` 패턴을 staging 경로용으로 확장한 것):

```python
def _build_card(*, visible_entities, staging, name_by_short_id, outlook_pairs):
    """staging 경로 build_render_prompt_card 호출 — Phase 1 카드 테스트용."""
    return build_render_prompt_card(
        scene_index=15, shot_index=5,
        seg={}, shot_info={"shot_index": 5, "camera_direction": "x"},
        visible_entities=visible_entities,
        outlook_pairs=outlook_pairs,
        perception_mode=None,
        staging=staging,
        bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
        is_close_framing=False, background_mode_on=False,
        fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
        name_by_short_id=name_by_short_id,
    )


def _s15_staging(*, subject_reference_policy=None, character_angles=None,
                 pov_character=""):
    return {
        "camera_direction": _S15_CAM,
        "framing_scale": "medium",
        "lighting_mood": "cold fog",
        "key_bg_elements": [],
        "pov_character": pov_character,
        "character_angles": _S15_ANGLES if character_angles is None
        else character_angles,
        "subject_reference_policy": [] if subject_reference_policy is None
        else subject_reference_policy,
    }


def _srp_array(card):
    return card["id_policy"]["subject_reference_policy"]


class TestCardReconciliation:
    def test_g1_s15_reproduction(self) -> None:
        """G1: S15 — C01 다운그레이드, C02(in-frame, gaze off_screen) 불변."""
        card = _build_card(
            visible_entities=["C01", "C02"],
            staging=_s15_staging(),
            name_by_short_id=_ID_TO_NAME,
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"},
                           {"character_id": "C02", "outlook_id": "O02"}],
        )
        srp = {e["subject_id"]: e["policy"] for e in _srp_array(card)}
        assert srp.get("C01") == "generic_descriptor_allowed"
        assert srp.get("C02") != "generic_descriptor_allowed"  # 미다운그레이드
        # G8: asset_requirements 에 C01 required ref 없음.
        req_ids = [r.get("id", "") for r
                   in card["asset_requirements"]["required_refs"]]
        assert not any(rid.startswith("C01") for rid in req_ids)

    def test_g4_in_frame_back_view_not_downgraded(self) -> None:
        """G4: C01 이 character_angles 에 back_to_camera 로 존재 → 미다운그레이드."""
        angles = _S15_ANGLES + [
            {"character": "수리영", "angle": "back_to_camera",
             "body_pose": "walking away", "gaze_direction_kind": "distant",
             "gaze_target_id": None, "subject_state": "alive"},
        ]
        card = _build_card(
            visible_entities=["C01", "C02"],
            staging=_s15_staging(character_angles=angles),
            name_by_short_id=_ID_TO_NAME,
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"},
                           {"character_id": "C02", "outlook_id": "O02"}],
        )
        srp = {e["subject_id"]: e["policy"] for e in _srp_array(card)}
        assert srp.get("C01") != "generic_descriptor_allowed"

    def test_g7_hash_stable(self) -> None:
        """G7: 같은 입력 → 같은 카드 hash (producer/verify 경로 정합)."""
        kw = dict(
            visible_entities=["C01", "C02"], staging=_s15_staging(),
            name_by_short_id=_ID_TO_NAME,
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"},
                           {"character_id": "C02", "outlook_id": "O02"}],
        )
        assert compute_card_hash(_build_card(**kw)) == compute_card_hash(
            _build_card(**kw))

    def test_g10_staging_none_noop(self) -> None:
        """G10: staging=None (not-applicable) → crash 없음, 다운그레이드 없음."""
        card = build_render_prompt_card(
            scene_index=1, shot_index=1, seg={},
            shot_info={"staging_not_applicable": True},
            visible_entities=["C01"],
            outlook_pairs=[{"character_id": "C01", "outlook_id": "O01"}],
            perception_mode=None, staging=None,
            bg_id=None, bg_owned=[], bg_camera_meta=None, bg_guide=None,
            is_close_framing=False, background_mode_on=False,
            fixed_elements=[], previous_shot_refs=[], forward_zoom_targets=[],
            name_by_short_id=_ID_TO_NAME,
        )
        assert _srp_array(card) == []

    def test_g11_name_map_threading_ctx_path(self) -> None:
        """G11: ctx-derived 경로(_derive_card_inputs_from_ctx)가 name_by_short_id
        를 빌더 입력에 전달 — production / verify recompute 의 실경로.
        verify hash drift 방지를 직접 검증 (legacy fallback 검증으로 불충분).

        SceneAnalysisContext 는 모든 필드 default → name_by_short_id 만 지정해
        구성 가능 (backend/app/core/dto/scene_analysis.py).
        """
        from app.core.dto.scene_analysis import SceneAnalysisContext
        ctx = SceneAnalysisContext(name_by_short_id=_ID_TO_NAME)
        inputs = _collect_card_inputs(
            ctx=ctx,  # 실 SceneAnalysisContext → _derive_card_inputs_from_ctx 경로
            seg={"scene_index": 15}, shot_info={"shot_index": 5},
        )
        assert inputs["name_by_short_id"] == _ID_TO_NAME

    def test_g11b_name_map_threading_legacy_path(self) -> None:
        """G11b: legacy fallback 경로(ctx 가 SceneAnalysisContext 아님)도 명시
        name_by_short_id kwarg 를 빌더 입력에 전달."""
        inputs = _collect_card_inputs(
            ctx=object(),  # opaque → legacy fallback 경로
            seg={"scene_index": 15}, shot_info={"shot_index": 5},
            name_by_short_id=_ID_TO_NAME,
        )
        assert inputs["name_by_short_id"] == _ID_TO_NAME
```

- [ ] **Step 2: Run the tests to verify they fail**

Run: `cd backend && python -m pytest tests/unit/test_screen_presence_reconciliation.py::TestCardReconciliation -v`
Expected: FAIL — `TypeError: build_render_prompt_card() got an unexpected keyword argument 'name_by_short_id'`

- [ ] **Step 3a: Add `name_by_short_id` to `build_render_prompt_card` signature**

`backend/app/core/steps/render_prompt_card.py:3475` — `visible_entity_details` 파라미터 다음 줄(`) -> Dict[str, Any]:` 앞)에 추가:

```python
    # 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,
```

- [ ] **Step 3b: Add `apply_screen_presence_downgrade` to the top import**

`backend/app/core/steps/render_prompt_card.py:88-94` — `from app.core.subject_reference_policy import (` 블록에 항목 추가 (알파벳/기존 순서 유지, 예: `filter_subject_reference_policy_to_visible` 다음 줄):

```python
    apply_screen_presence_downgrade,
```

- [ ] **Step 3c: Insert the reconciliation block in `build_render_prompt_card`**

`backend/app/core/steps/render_prompt_card.py` — `raw_srp_items = filter_subject_reference_policy_to_visible(...)` 호출이 끝나는 줄(현재 `:3533` `)` )과 `policy_map = normalize_subject_reference_policy_items(` (현재 `:3534`) 사이에 삽입:

```python
    # 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,
            )
```

- [ ] **Step 3d: Thread `name_by_short_id` through `_derive_card_inputs_from_ctx`**

`backend/app/core/steps/detail_steps.py` — `_derive_card_inputs_from_ctx()` 의 반환 dict(`:729-749`)에서 `"ctx": ctx,` 줄 바로 앞에 추가:

```python
        "name_by_short_id": getattr(ctx, "name_by_short_id", {}) or {},
```

- [ ] **Step 3e: Thread `name_by_short_id` through `_collect_card_inputs`**

`backend/app/core/steps/detail_steps.py` — `_collect_card_inputs()`:

(1) 시그니처 — `forward_zoom_targets_for_shot=None,` 다음 줄에 추가:

```python
    name_by_short_id=None,
```

(2) legacy fallback `derived` dict(`:823-843`) — `"ctx": None,` 줄 바로 앞에 추가:

```python
            "name_by_short_id": {},
```

(3) `overrides` dict(`:846-857`) — 마지막 항목 다음에 추가:

```python
        "name_by_short_id": name_by_short_id,
```

- [ ] **Step 4: Run the tests to verify they pass**

Run: `cd backend && python -m pytest tests/unit/test_screen_presence_reconciliation.py -v`
Expected: PASS — TestDetector(7) + TestDowngradeHelper(6) + TestCardReconciliation(6) = 19 passed

- [ ] **Step 5: Commit**

```bash
git add backend/app/core/steps/render_prompt_card.py backend/app/core/steps/detail_steps.py backend/tests/unit/test_screen_presence_reconciliation.py
git commit -m "$(cat <<'EOF'
C10 Phase1 W2: name_by_short_id threading + render_prompt_card reconciliation

build_render_prompt_card() 내부에서 screen-presence detector 를 호출해
off-screen-referenced subject 의 identity policy 를 generic_descriptor_allowed
로 결정론적 다운그레이드. name_by_short_id 를 _collect_card_inputs /
_derive_card_inputs_from_ctx 경유로 모든 카드 빌드 경로에 threading.

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

---

## Task 4: Regression + S15 E2E verification

코드 변경 없음 — 회귀 확인 + 실제 파이프라인 재현 검증.

**Files:** 없음 (검증만)

- [ ] **Step 1: Targeted regression suites**

Run:
```bash
cd backend && python -m pytest \
  tests/unit/test_screen_presence_reconciliation.py \
  tests/unit/test_render_prompt_card.py \
  tests/unit/test_render_prompt_card_hash.py \
  tests/unit/test_render_prompt_card_integration.py \
  tests/core/steps/test_render_prompt_card_helper.py \
  tests/unit/test_subject_reference_policy_helper.py \
  tests/unit/test_shot_visibility_drift_structured.py \
  tests/unit/test_shot_visibility_proximity_diagnostic.py \
  tests/core/test_shot_visibility_helpers.py \
  tests/core/test_visible_entities_validator.py \
  tests/integration/test_g4_1_card_consumer_wiring.py \
  tests/integration/test_finding8_scene_detail_card_verify_dependency_source.py \
  -v
```
Expected: PASS — 전부 통과. 실패 시 stash 로 baseline 비교(`git stash` → 동일 명령 → 비교)하여 pre-existing 여부 확인. 회귀면 수정.

- [ ] **Step 2: Broader scene_detail regression**

Run: `cd backend && python -m pytest tests/test_scene_detail_v3.py tests/core -q`
Expected: PASS (pre-existing 실패는 baseline 비교로 분리 — 회귀 0).

- [ ] **Step 3: S15 E2E re-run**

dev 백엔드 기동 후 project `8d56bc5d-89eb-4733-9890-cbec35dd358b` / episode `1458fcc5-fb7d-407c-aa45-bc41bf98bba7` 의 `scene_detail` 을 재실행한다. 카드는 `scene_detail` step 중 빌드되므로 **`scene_detail` 재실행이 필요** — 순수 image-phase resume 으로는 반영 안 됨(`run-all?category=all&mode=resume` 경로).

확인 항목:
- S15_Shot5 가 `step.scene_detail.contract_violation.subject_reference_policy.base_id_missing` 없이 완료.
- S15_Shot5 의 카드 `id_policy.subject_reference_policy` 에 C01 = `generic_descriptor_allowed` (reason 에 `screen_presence_reconciliation`).
- C01 관련 신규 Source 2(`contract_violation_entity_name_no_specific_id`) 위반 없음.

- [ ] **Step 4: 신규 다운그레이드 전수 집계 (false-positive 검증)**

이 기능은 카드의 `id_policy.subject_reference_policy` 를 직접 바꾸므로 로그 점검만으로는 불충분. E2E 후 `scene_detail` checkpoint 를 스캔해 `screen_presence_reconciliation` 가 들어간 모든 정책 entry 를 shot 단위로 전수 집계한다.

Run:
```bash
cd backend && python3 - <<'PY'
import json, pathlib
mf = pathlib.Path(
    "../projects/8d56bc5d-89eb-4733-9890-cbec35dd358b/checkpoints/episodes/"
    "1458fcc5-fb7d-407c-aa45-bc41bf98bba7/scene_detail/manifest.json"
)
m = json.loads(mf.read_text(encoding="utf-8"))
hits = []
def scan(node):
    if isinstance(node, dict):
        # render_prompt_card 는 shot_key + id_policy 를 동시 보유.
        if "shot_key" in node and "id_policy" in node:
            srp = (node.get("id_policy") or {}).get(
                "subject_reference_policy") or []
            for e in srp:
                if isinstance(e, dict) and "screen_presence_reconciliation" in (
                        e.get("reason") or ""):
                    hits.append((node.get("shot_key"), e.get("subject_id"),
                                 e.get("policy"), e.get("reason")))
        for v in node.values():
            scan(v)
    elif isinstance(node, list):
        for v in node:
            scan(v)
scan(m)
print(f"screen_presence_reconciliation downgrades: {len(hits)}")
for sk, sid, pol, reason in hits:
    print(f"  {sk} {sid} -> {pol} :: {reason}")
PY
```

판정:
- 최소 1건 = S15 (`shot_key={"scene_index":15,"shot_index":5}`) C01 → `generic_descriptor_allowed`, confidence=named 예상.
- S15 외 신규 다운그레이드가 있으면 **각 건의 해당 shot `shot_staging.camera_direction` + `character_angles` 를 직접 열어** 그 인물이 실제 화면 밖/부재 연출인지 확인. 정당하지 않은 건(in-frame 인물 오downgrade)이 1건이라도 있으면 detector gate 를 좁히고 W1 부터 재검토 — push 금지.

- [ ] **Step 5: Codex review + 결정**

- Codex 로 range review (`git log` 3 commit) → APPROVED 시 push.
- `c10-roadmap.html` 의 Branch A/B 기준으로 판단: false-positive 0 + E2E clean → Branch A(Phase 1 에서 정지, telemetry 만). 반복 false +/- 발생 → Branch B(Phase 3 screen_presence schema 설계 착수).

---

## Self-Review

**1. Spec coverage** — `phase-0-1-plan.html` 의 G1-G11 매핑:
- G1 → `test_g1_s15_reproduction` (Task 3). G2 → `test_d2` (Task 1). G3 → `test_d3` (Task 1). G4 → `test_g4_in_frame_back_view_not_downgraded` (Task 3). G5 → `test_t2_3` (Task 2). G6 → Pre-verified Fact 7 + Task 4 Step 3 (코드 없음 — 프롬프트 이미 충족, validator raise 가 정상). G7 → `test_g7_hash_stable` (Task 3). G8 → `test_g1` 내 required_refs assert (Task 3). G9 → `test_d4` (Task 1). G10 → `test_g10_staging_none_noop` (Task 3). G11 → `test_g11_name_map_threading_ctx_path` (실 `SceneAnalysisContext` — production/verify 경로 직접 검증) + `test_g11b_name_map_threading_legacy_path` (Task 3). Detector 자체 커버리지 D1/D5/D6/D7 추가. **gap 없음.**
- Detector contract 6개 gate → Task 1 코드 + D1-D7. Override Rule 4행 → Task 2 코드 + T2-1~T2-4. Name Map Threading 표 → Task 3 Step 3a/3d/3e. Risk Controls 7행 → 각 G/D 테스트가 canary.

**2. Placeholder scan** — TBD/TODO/"적절히 처리" 없음. 모든 step 에 실제 코드/명령/기대출력 포함. Task 4 는 검증 task 라 코드 없음(명시).

**3. Type consistency** — `detect_offscreen_referenced_subjects(visible_ids, camera_direction, character_angles, id_to_name, pov_character) -> Dict[str,str]` Task 1 정의 ↔ Task 3 Step 3c 호출 일치. `apply_screen_presence_downgrade(raw_items, offscreen_referenced, *, where)` Task 2 정의 ↔ Task 3 Step 3c 호출 일치. `name_by_short_id` 파라미터명 Task 3 전체 일관. `_S15_CAM`/`_S15_ANGLES`/`_ID_TO_NAME` fixture 는 Task 1 에서 정의되어 Task 2/3 가 같은 파일에서 재사용.

**threading 검증** — `_collect_card_inputs` 의 ctx-derived 경로(`_derive_card_inputs_from_ctx`, = production/verify recompute 실경로)와 legacy fallback 경로 둘 다 직접 테스트(G11/G11b). `SceneAnalysisContext` 는 전 필드 default 라 `SceneAnalysisContext(name_by_short_id=...)` 로 구성 가능(`dto/scene_analysis.py:19-138` 확인). `_collect_card_inputs` 가 `name_by_short_id` override 를 `overrides` dict 경유로 적용하므로 legacy/ctx 양쪽 모두 동작.

---

## Revision Log

- **2026-05-22 — NEEDS_REVISION_NARROW_1 반영** (Codex 리뷰):
  1. **BLOCKING** — G11 이 ctx-derived 경로(verify hash drift 핵심 경로)를 미검증 → `test_g11_name_map_threading_ctx_path` 로 실 `SceneAnalysisContext` 직접 검증 추가, legacy 경로는 G11b 로 분리.
  2. `apply_screen_presence_downgrade` 반환 타입 `-> list` → `-> Optional[list]` (비-list/None 통과 정합, 형제 `filter_subject_reference_policy_to_visible` 와 일치).
  3. Task 4 false-positive 확인을 "로그 점검" → checkpoint 전수 집계 절차(Step 4, `screen_presence_reconciliation` reason 스캔 스크립트)로 명시.
