# 실내 일반 샷 shared-model pose 가이드 (Wave5) 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:** 같은 씬·같은 배경 plate를 공유하는 실내 멀티샷 연속성 그룹에 default-deny 게이트를 통과한 bg-grounded 마네킹 pose 가이드를 파생·부착해 cross-shot 봉합(hand-float/구조물 발명/drift)을 줄인다.

**Architecture:** 결정론 plan 모듈(`indoor_shared_pose_plan.py`)이 그룹·신호·pose brief를 산출 → default-deny VLM judge가 attach 여부 결정 → guide service(`indoor_shared_pose_guide_service.py`)가 bg underlay 위에 마네킹을 등록하고 생성 후 guide QC로 2차 게이트 → coordinator가 flag ON+judge admit+QC pass일 때만 `indoor_pose_guide` ref를 부착. 모든 실패는 no-guide degrade(white-bg fallback 없음). 기존 immobilized(`registered_pose_guide_service`)·outdoor(`outdoor_site_layout`) 경로의 패턴을 차용하되 별도 모듈/role로 분리.

**Tech Stack:** Python 3 / SQLAlchemy / PostgreSQL / gpt-image-2(이미지 edit) / VLM(judge·guide QC) / pytest. 설계 SOT = `docs/superpowers/specs/2026-06-30-indoor-shared-pose-guide-design.md`.

## Global Constraints

- **LLM/VLM/T2I 출력은 deterministic 보장 X** — 결정론 함수(plan/judge평가/brief/QC판정/cache key/flag-off)만 TDD. 가이드 품질은 **canary 육안 + reroll variance**로 검증. PASS 카운트로 완성도 주장 금지.
- **flag `indoor_shared_pose_guide_enabled` default False** — OFF 시 byte-identical(config_hash + step diff 0).
- **no-guide baseline** — judge deny / QC fail / plate 부재 / 생성 실패 = 시각 가이드 없이 text+ref degrade. **white-bg fallback 없음.**
- **★★ 실내 마네킹 스케치는 항상 생성된 배경 plate(underlay) 기반 등록 (사용자 절대 강조 2026-06-30)** — 흰배경/blank canvas에 스케치 후 합성하는 경로 **절대 금지**. 이유: 배경을 모르고 만든 스케치는 배경과 구도가 모순되어(예: 인물이 없는 가구에 앉음, 손이 허공에 뜸) 이상한 샷 이미지가 나온다. 따라서 guide 생성은 **반드시 `env_bg_bytes`(그룹 실제 bg plate)를 underlay로 깔고 그 위에 마네킹을 등록**한다. `env_bg_bytes` 없으면 guide 생성 **skip → no-guide degrade**(흰배경으로라도 그리는 fallback 0). 이 제약을 어기는 코드 경로를 만들지 않는다.
- **★★★ 시나리오 의존성 절대 0 (사용자 반복 강조 "비슷한 것도 없어야 해")** — 코드·프롬프트(guide edit / judge / QC) 어디에도 작품 고유명사·특정 방/장소명·소품명·캐릭터명·장면 문구·literal lexicon·예시 토큰이 **비슷한 것조차 0**. 그룹핑=구조키(scene_index/bg_id), 신호=enum/카운트, pose=구조화 FSC/staging 필드만. **★프롬프트에 FSC `label`/`gesture_target_label`/엔티티 이름/ID 절대 미주입** — `build_pose_brief`가 slot=spatial descriptor(foreground/background figure)·gesture=generic phrase("reach toward a nearby object")로만 변환(원문 label 차단). judge/QC 프롬프트도 name-free generic. 글자/substring/조사/term-boundary 의미판정·literal matcher 0. **Wave 완료 보고에 "specific scenario leakage 없음" 별도 증명 섹션 필수**(T10). 위반 비슷한 것도 발견 시 즉시 제거.
- **이름/엔티티 ID/텍스트 라벨 누출 0** — brief·프롬프트는 spatial descriptor만(foreground/background figure).
- **기존 모듈 삭제 금지 / 프롬프트 덮어쓰기 금지** — 신규 모듈·신규 프롬프트 버전 디렉토리.
- **venv** = `backend/.venv/bin/python`. **DB** = PostgreSQL `theroad`(PGPASSWORD=theroad_dev_2026). 테스트 = `backend/.venv/bin/python -m pytest`.
- **커밋** = 사용자 자율 위임(persist-all). 각 task 끝 커밋, 메시지 끝에 `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.

---

## File Structure

- **Create** `backend/app/modules/pipeline/indoor_shared_pose_plan.py` — 순수 결정론: `complexity_signals`(per-shot), `candidate_groups`(그룹핑+group signals), `build_pose_brief`(FSC→staging 계층 brief). 의미판정 0, DB/LLM 0.
- **Create** `backend/app/services/indoor_shared_pose_guide_service.py` — `build_indoor_pose_guide`(gate→cache→underlay→edit→QC→atomic), `evaluate_guide_qc`(VLM dict→pass/fail), `guide_cache_key`. registered_pose_guide_service 패턴 차용·별도.
- **Create** `backend/app/core/steps/indoor_pose_guide_judge.py` — `evaluate_indoor_pose_guide_judge`(verdict dict→(attach, deny_reason)) + `has_grounded_evidence` 차용(indoor 전용·분리).
- **Create** `backend/app/core/steps/indoor_shared_pose_guide_context.py` — `build_indoor_shared_pose_context`(coordinator main thread, episode당 1회: candidate→judge→admitted+guide precompute group당 1회→immutable ctx) + `attach_indoor_pose_guide_ref`(worker: ctx lookup + 4-list mutate + cache IO만, DB/LLM/VLM 0). ★Codex 정렬: worker 내 judge/QC/DB 금지.
- **Modify** `backend/app/core/config.py` — flag 3개 추가(`indoor_shared_pose_guide_enabled`/`indoor_shared_pose_guide_judge_enabled`/`indoor_shared_pose_guide_model`).
- **Modify** `backend/app/services/scene_generation_coordinator.py` — episode 이미지 단계 시작 시 `build_indoor_shared_pose_context` 호출(main thread) + `build_scene_attached_refs`/`_generate_scene_in_loop`에서 `attach_indoor_pose_guide_ref` 호출(immobilized attach 패턴과 동일 위치).
- **Modify** `backend/app/services/prompt_service.py`(또는 REF_ROLE 정의부) — `indoor_pose_guide` role을 REF_ROLE_VALUES에 **별도** 추가. registered/outdoor guide render branch와 **섞지 않음**.
- **Create** 프롬프트 버전 디렉토리 — guide edit 프롬프트 + judge 프롬프트/스키마(indoor 전용) + QC 프롬프트/스키마.
- **Create** `backend/tests/pipeline/test_indoor_shared_pose_plan.py`, `backend/tests/services/test_indoor_shared_pose_guide_service.py`, `backend/tests/steps/test_indoor_pose_guide_judge.py`, attach byte-identical 테스트.
- **Driver(커밋 금지)** `scratchpad/wave5_canary/*` — canary 육안.

---

## Task 1: complexity_signals (per-shot 결정론 신호)

**Files:**
- Create: `backend/app/modules/pipeline/indoor_shared_pose_plan.py`
- Test: `backend/tests/pipeline/test_indoor_shared_pose_plan.py`

**Interfaces:**
- Consumes: shot dict = `{"scene_index":int, "shot_index":int, "frame_spatial_contract":{"reason":str,"constraints":[{"target_kind","target_id","label","screen_zone","depth_plane","gesture_action","gesture_target_label"}]}, "camera_direction":str, "shot_type":str}` (shot_staging 출력 형태).
- Produces: `complexity_signals(shot: Dict) -> Dict[str,Any]` 반환 `{"fsc_constraint_count":int, "depth_plane_bucket":int, "entity_count_bucket":str, "entity_count":int, "gesture_present":bool, "framing":str}`. `entity_count_bucket∈{none,single,multiple}` (FSC constraints 중 target_kind=="character" 고유 target 수 기준). `depth_plane_bucket`=고유 depth_plane 값 수. `framing`=`shot_type` 또는 `camera_direction`에서 enum 정규화(wide/medium/close/insert/unknown — 텍스트 substring 매칭 아님, 정확 enum 비교; shot_type이 이 enum 값이면 사용, 아니면 unknown).

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

```python
# backend/tests/pipeline/test_indoor_shared_pose_plan.py
from app.modules.pipeline.indoor_shared_pose_plan import complexity_signals

def _shot(scene, shot, constraints, framing="medium", camera_direction=""):
    return {"scene_index": scene, "shot_index": shot,
            "shot_type": framing, "camera_direction": camera_direction,
            "frame_spatial_contract": {"reason": "blocking", "constraints": constraints}}

def test_complexity_signals_multi_character_multi_depth_gesture():
    s = _shot(1, 1, [
        {"target_kind":"character","target_id":"C01","label":"woman","screen_zone":"left","depth_plane":"foreground","gesture_action":"reach","gesture_target_label":"cup"},
        {"target_kind":"character","target_id":"C02","label":"man","screen_zone":"right","depth_plane":"background","gesture_action":"none","gesture_target_label":""},
        {"target_kind":"prop","target_id":"P01","label":"cup","screen_zone":"center","depth_plane":"foreground","gesture_action":"none","gesture_target_label":""},
    ], framing="medium")
    sig = complexity_signals(s)
    assert sig["fsc_constraint_count"] == 3
    assert sig["entity_count"] == 2          # C01, C02
    assert sig["entity_count_bucket"] == "multiple"
    assert sig["depth_plane_bucket"] == 2    # foreground, background
    assert sig["gesture_present"] is True
    assert sig["framing"] == "medium"

def test_complexity_signals_null_fsc_is_empty():
    s = {"scene_index":1,"shot_index":2,"shot_type":"insert","camera_direction":"","frame_spatial_contract":None}
    sig = complexity_signals(s)
    assert sig["fsc_constraint_count"] == 0
    assert sig["entity_count_bucket"] == "none"
    assert sig["depth_plane_bucket"] == 0
    assert sig["gesture_present"] is False
    assert sig["framing"] == "insert"
```

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

Run: `backend/.venv/bin/python -m pytest backend/tests/pipeline/test_indoor_shared_pose_plan.py -v`
Expected: FAIL (ImportError: cannot import name 'complexity_signals')

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

```python
# backend/app/modules/pipeline/indoor_shared_pose_plan.py
"""실내 일반 샷 shared-model pose 가이드 — 결정론 plan (Wave5).

candidate_groups / complexity_signals / build_pose_brief. 의미판정·글자패턴 0:
그룹핑=구조키(scene_index/bg_id), 신호=enum/카운트, brief=구조화 FSC/staging 필드.
"""
from typing import Any, Dict, List, Set, Tuple

ShotKey = Tuple[int, int]
_FRAMING_ENUM = ("wide", "medium", "close", "insert")


def _normalize_framing(shot: Dict[str, Any]) -> str:
    st = str(shot.get("shot_type") or "").strip().lower()
    if st in _FRAMING_ENUM:
        return st
    cd = str(shot.get("camera_direction") or "").strip().lower()
    return cd if cd in _FRAMING_ENUM else "unknown"


def complexity_signals(shot: Dict[str, Any]) -> Dict[str, Any]:
    fsc = shot.get("frame_spatial_contract")
    constraints = (fsc or {}).get("constraints") or [] if isinstance(fsc, dict) else []
    char_ids: Set[str] = set()
    depth_vals: Set[str] = set()
    gesture_present = False
    for c in constraints:
        if not isinstance(c, dict):
            continue
        if c.get("target_kind") == "character" and str(c.get("target_id") or "").strip():
            char_ids.add(str(c["target_id"]))
        dp = str(c.get("depth_plane") or "").strip()
        if dp:
            depth_vals.add(dp)
        if str(c.get("gesture_action") or "none") != "none":
            gesture_present = True
    n = len(char_ids)
    bucket = "none" if n == 0 else ("single" if n == 1 else "multiple")
    return {
        "fsc_constraint_count": len(constraints),
        "depth_plane_bucket": len(depth_vals),
        "entity_count": n,
        "entity_count_bucket": bucket,
        "gesture_present": gesture_present,
        "framing": _normalize_framing(shot),
    }
```

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

Run: `backend/.venv/bin/python -m pytest backend/tests/pipeline/test_indoor_shared_pose_plan.py -v`
Expected: PASS (2 passed)

- [ ] **Step 5: 커밋**

```bash
git add backend/app/modules/pipeline/indoor_shared_pose_plan.py backend/tests/pipeline/test_indoor_shared_pose_plan.py
git commit -m "feat(wave5): indoor pose plan complexity_signals (결정론)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```

---

## Task 2: candidate_groups (그룹핑 + group signals)

**Files:**
- Modify: `backend/app/modules/pipeline/indoor_shared_pose_plan.py`
- Test: `backend/tests/pipeline/test_indoor_shared_pose_plan.py`

**Interfaces:**
- Consumes: `complexity_signals` (Task 1).
- Produces: `candidate_groups(*, selected_keys: List[ShotKey], bg_id_by_shot: Dict[ShotKey,str], shot_by_key: Dict[ShotKey,Dict], zoom_member_keys: Set[ShotKey]) -> List[Dict[str,Any]]`. 각 그룹 = `{"scene_index":int, "bg_id":str, "member_keys":List[ShotKey], "anchor_key":ShotKey, "signals_by_shot":Dict[str,Dict], "group_signals":{"camera_framing_variation":bool,"repeated_targets":bool,"multi_depth":bool}, "zoom_members":List[ShotKey]}`. 키=`(scene_index, bg_id)`, **selected 2+** 만, bg_id 빈 문자열은 그룹 제외. `signals_by_shot` 키 = `"{si}_{shi}"`. anchor=shot_index 최소.

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

```python
from app.modules.pipeline.indoor_shared_pose_plan import candidate_groups

def test_candidate_groups_same_scene_same_bg_two_shots():
    keys = [(1,1),(1,2),(1,3)]
    bg = {(1,1):"bgA", (1,2):"bgA", (1,3):"bgB"}   # sh3 다른 plate
    shots = {
        (1,1): _shot(1,1,[{"target_kind":"character","target_id":"C01","label":"w","screen_zone":"left","depth_plane":"foreground","gesture_action":"none","gesture_target_label":""}], framing="wide"),
        (1,2): _shot(1,2,[{"target_kind":"character","target_id":"C01","label":"w","screen_zone":"center","depth_plane":"background","gesture_action":"none","gesture_target_label":""}], framing="close"),
        (1,3): _shot(1,3,[], framing="insert"),
    }
    groups = candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots, zoom_member_keys=set())
    assert len(groups) == 1                       # (1,bgA) 2샷만, bgB 1샷 제외
    g = groups[0]
    assert g["scene_index"] == 1 and g["bg_id"] == "bgA"
    assert g["member_keys"] == [(1,1),(1,2)]
    assert g["anchor_key"] == (1,1)
    assert g["group_signals"]["camera_framing_variation"] is True   # wide vs close
    assert g["group_signals"]["repeated_targets"] is True           # C01 다른 zone/depth
    assert g["group_signals"]["multi_depth"] is True                # foreground+background

def test_candidate_groups_cross_scene_not_merged():
    keys = [(1,1),(2,1)]
    bg = {(1,1):"bgA",(2,1):"bgA"}                 # 같은 plate라도 다른 scene
    shots = {(1,1):_shot(1,1,[]), (2,1):_shot(2,1,[])}
    groups = candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots, zoom_member_keys=set())
    assert groups == []                            # scene 경계 안 넘김 → 각 1샷

def test_candidate_groups_empty_bg_id_excluded():
    keys = [(1,1),(1,2)]
    bg = {(1,1):"", (1,2):""}
    shots = {(1,1):_shot(1,1,[]), (1,2):_shot(1,2,[])}
    assert candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots, zoom_member_keys=set()) == []

def test_candidate_groups_zoom_member_flagged_not_excluded():
    keys = [(1,1),(1,2)]
    bg = {(1,1):"bgA",(1,2):"bgA"}
    shots = {(1,1):_shot(1,1,[]), (1,2):_shot(1,2,[])}
    groups = candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots, zoom_member_keys={(1,2)})
    assert len(groups) == 1
    assert groups[0]["zoom_members"] == [(1,2)]    # 표시만, 제외 안 함
```

- [ ] **Step 2: 실패 확인** — Run 위 파일 -v / Expected: FAIL (ImportError candidate_groups)

- [ ] **Step 3: 최소 구현** (같은 파일에 append)

```python
def _group_signals(member_keys: List[ShotKey], signals_by_shot: Dict[str, Dict],
                   shot_by_key: Dict[ShotKey, Dict]) -> Dict[str, bool]:
    framings = {signals_by_shot[f"{si}_{shi}"]["framing"] for si, shi in member_keys}
    coarse = {"wide", "medium"}
    fine = {"close", "insert"}
    framing_var = (len(framings) >= 2) and bool(framings & coarse) and bool(framings & fine) \
        or len(framings - {"unknown"}) >= 2
    multi_depth = any(signals_by_shot[f"{si}_{shi}"]["depth_plane_bucket"] >= 2
                      for si, shi in member_keys)
    # repeated_targets: 같은 character target_id 가 둘 이상 멤버에서 다른 zone 또는 depth 로 등장
    seen: Dict[str, Set[Tuple[str, str]]] = {}
    for si, shi in member_keys:
        fsc = (shot_by_key.get((si, shi)) or {}).get("frame_spatial_contract") or {}
        for c in (fsc.get("constraints") or []):
            if isinstance(c, dict) and c.get("target_kind") in ("character", "prop"):
                tid = str(c.get("target_id") or "").strip()
                if tid:
                    seen.setdefault(tid, set()).add(
                        (str(c.get("screen_zone") or ""), str(c.get("depth_plane") or "")))
    repeated = any(len(v) >= 2 for v in seen.values())
    return {"camera_framing_variation": bool(framing_var),
            "repeated_targets": bool(repeated), "multi_depth": bool(multi_depth)}


def candidate_groups(*, selected_keys, bg_id_by_shot, shot_by_key, zoom_member_keys):
    by_group: Dict[Tuple[int, str], List[ShotKey]] = {}
    for si, shi in selected_keys:
        bg_id = str(bg_id_by_shot.get((si, shi)) or "").strip()
        if not bg_id:
            continue
        by_group.setdefault((si, bg_id), []).append((si, shi))
    out: List[Dict[str, Any]] = []
    for (si, bg_id), members in sorted(by_group.items(), key=lambda kv: (kv[0][0], kv[0][1])):
        members = sorted(members, key=lambda k: k[1])
        if len(members) < 2:
            continue
        signals_by_shot = {f"{m_si}_{m_shi}": complexity_signals(shot_by_key[(m_si, m_shi)])
                           for m_si, m_shi in members}
        out.append({
            "scene_index": si, "bg_id": bg_id,
            "member_keys": members, "anchor_key": members[0],
            "signals_by_shot": signals_by_shot,
            "group_signals": _group_signals(members, signals_by_shot, shot_by_key),
            "zoom_members": [k for k in members if k in zoom_member_keys],
        })
    return out
```

- [ ] **Step 4: 통과 확인** — Run -v / Expected: PASS (6 passed 누적)

- [ ] **Step 5: 커밋** — `git commit -m "feat(wave5): candidate_groups 그룹핑+group signals (결정론)\n\nCo-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"`

---

## Task 3: build_pose_brief (FSC→staging 계층, 이름누출 0)

**Files:**
- Modify: `backend/app/modules/pipeline/indoor_shared_pose_plan.py`
- Test: `backend/tests/pipeline/test_indoor_shared_pose_plan.py`

**Interfaces:**
- Produces: `build_pose_brief(shot: Dict, *, max_figures: int = 2) -> Dict[str,Any]` 반환 `{"figures":[{"slot":str,"screen_zone":str,"depth_plane":str,"gesture":str|None,"pose":str|None,"facing":str|None}], "support_clause":str, "framing":str, "contact_locked":bool, "skipped_reason":str|None, "field_diagnostics":Dict[str,int]}`. `slot`=spatial descriptor("foreground figure"/"background figure"/"left figure" 등 zone·depth 파생, **이름/ID 0**). `gesture`는 `gesture_action!=none` AND target_kind∈{prop,character} 일 때만 generic phrase, 아니면 None(발명 금지). `support_clause`=항상 generic("each figure must be supported by a visible surface beneath it; nothing floats unsupported"). character target>2 이면 `contact_locked=False` + 상위 2 figure만(나머지 skip). gesture가 다인물 상호접촉(gesture_target_label이 character)이면 `contact_locked=False`.
  - **★Codex 정렬 — character_angles robust 조건부**: shot에 `character_angles`가 있고 figure의 character와 매칭되면 `pose`(=`body_pose` if available)·`facing`(=`gaze_direction_kind`/`gaze_target_id` 기반 방향, **target id는 미주입**·left/right/toward-camera 등 generic만)을 보강. **필드(`body_pose`/`subject_state`/`gaze_*`)가 없으면 그냥 생략**(FSC+framing만). 필드 부재는 `field_diagnostics`에 count(예: `{"body_pose_missing":N}`)로 남기되 **deny 사유 아님**. `subject_state`는 일반 실내샷에 항상 있다고 가정 금지 — support/contact는 끝까지 generic clause로만.

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

```python
from app.modules.pipeline.indoor_shared_pose_plan import build_pose_brief

def test_build_pose_brief_no_name_leak_and_generic_clause():
    s = _shot(1,1,[
        {"target_kind":"character","target_id":"C01","label":"민숙","screen_zone":"left","depth_plane":"foreground","gesture_action":"reach","gesture_target_label":"cup"},
        {"target_kind":"prop","target_id":"P01","label":"커피잔","screen_zone":"center","depth_plane":"foreground","gesture_action":"none","gesture_target_label":""},
    ])
    b = build_pose_brief(s)
    flat = str(b)
    assert "C01" not in flat and "민숙" not in flat and "P01" not in flat   # 이름/ID 누출 0
    assert "supported by a visible surface" in b["support_clause"]
    assert b["figures"][0]["gesture"] is not None                          # reach + prop target → 약하게
    assert b["contact_locked"] is True

def test_build_pose_brief_no_gesture_invention():
    s = _shot(1,1,[{"target_kind":"character","target_id":"C01","label":"w","screen_zone":"center","depth_plane":"midground","gesture_action":"none","gesture_target_label":""}])
    b = build_pose_brief(s)
    assert b["figures"][0]["gesture"] is None        # gesture none → 발명 안 함

def test_build_pose_brief_caps_two_figures_contact_unlocked():
    cons = [{"target_kind":"character","target_id":f"C0{i}","label":f"p{i}","screen_zone":"center","depth_plane":"midground","gesture_action":"none","gesture_target_label":""} for i in range(1,4)]
    b = build_pose_brief(_shot(1,1,cons), max_figures=2)
    assert len(b["figures"]) == 2
    assert b["contact_locked"] is False              # 3+ character → contact not locked
```

- [ ] **Step 2: 실패 확인** — Expected: FAIL (ImportError build_pose_brief)

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

```python
_SUPPORT_CLAUSE = ("each figure must be supported by a visible surface beneath it; "
                   "nothing floats or hovers unsupported in mid-air")


def _slot_descriptor(zone: str, depth: str) -> str:
    depth = (depth or "").strip().lower()
    if depth in ("foreground", "background"):
        return f"{depth} figure"
    zone = (zone or "").strip().lower()
    return f"{zone} figure" if zone in ("left", "right", "center") else "figure"


def build_pose_brief(shot: Dict[str, Any], *, max_figures: int = 2) -> Dict[str, Any]:
    fsc = shot.get("frame_spatial_contract")
    constraints = (fsc or {}).get("constraints") or [] if isinstance(fsc, dict) else []
    char_cons = [c for c in constraints
                 if isinstance(c, dict) and c.get("target_kind") == "character"
                 and str(c.get("target_id") or "").strip()]
    contact_locked = True
    if len(char_cons) > max_figures:
        contact_locked = False
        char_cons = char_cons[:max_figures]
    figures = []
    for c in char_cons:
        ga = str(c.get("gesture_action") or "none")
        gtl = str(c.get("gesture_target_label") or "").strip()
        gesture = None
        if ga != "none":
            # 다인물 상호접촉(target 이 character)면 contact 미확정
            tgt_is_char = any(
                str(o.get("label") or "").strip() == gtl and o.get("target_kind") == "character"
                for o in constraints if isinstance(o, dict))
            if tgt_is_char:
                contact_locked = False
            gesture = f"{ga} toward a nearby object"  # generic, label/ID 미주입
        figures.append({
            "slot": _slot_descriptor(c.get("screen_zone"), c.get("depth_plane")),
            "screen_zone": str(c.get("screen_zone") or ""),
            "depth_plane": str(c.get("depth_plane") or ""),
            "gesture": gesture,
        })
    skipped = None if figures else "no_character_target"
    return {"figures": figures, "support_clause": _SUPPORT_CLAUSE,
            "framing": _normalize_framing(shot), "contact_locked": contact_locked,
            "skipped_reason": skipped}
```

- [ ] **Step 4: 통과 확인** — Expected: PASS (9 passed 누적)

- [ ] **Step 5: 커밋** — `feat(wave5): build_pose_brief FSC→staging 계층 (이름누출0, 결정론)`

---

## Task 4: default-deny judge 평가 함수

**Files:**
- Create: `backend/app/core/steps/indoor_pose_guide_judge.py`
- Test: `backend/tests/steps/test_indoor_pose_guide_judge.py`

**Interfaces:**
- Produces: `has_grounded_evidence(evidence) -> bool` (outdoor `_has_grounded_evidence` 차용: list 중 dict이고 shot_key/source_field/quote 전부 strip non-empty 1개+). `evaluate_indoor_pose_guide_judge(verdict: Dict) -> Tuple[bool, str|None]` — attach = `needs_indoor_pose_guide` True AND `decision_type∈{cross_shot_continuity,both}` AND `confidence∈{medium,high}` AND grounded evidence. 그 외 (False, deny_reason). deny_reason∈{judge_failed, judge_no_need, judge_not_cross_shot, judge_low_confidence, judge_missing_evidence}.
- **★Codex 정렬 — judge provider 출력 schema(indoor 전용 프롬프트/스키마 디렉토리, outdoor 재사용 금지)**: `needs_indoor_pose_guide:bool` / `decision_type: cross_shot_continuity|single_shot_complexity|both|no_guide` / `confidence: low|medium|high` / `evidence:[{shot_key,source_field,quote}]` / `reasons:enum[]` / `guide_scope:str` / `risk_notes:str` / `deny_reason:str`. evaluate 함수는 이 중 **attach gate만 결정론 평가**(VLM 호출 아님). `single_shot_complexity`/`no_guide` decision은 deny(diagnostic only). provider(VLM 호출)는 T8 `build_indoor_group_ctx`에서 `judge_fn`으로 주입(DI). evidence는 quote만으론 부족 — shot_key/source_field/quote **모두** non-empty 요구(outdoor 기보유 가드).

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

```python
# backend/tests/steps/test_indoor_pose_guide_judge.py
from app.core.steps.indoor_pose_guide_judge import evaluate_indoor_pose_guide_judge

_EV = [{"shot_key":"1_1","source_field":"frame_spatial_contract","quote":"two figures, foreground/background"}]

def test_judge_attach_when_all_conditions_met():
    v = {"needs_indoor_pose_guide":True,"decision_type":"cross_shot_continuity","confidence":"high","evidence":_EV}
    assert evaluate_indoor_pose_guide_judge(v) == (True, None)

def test_judge_deny_no_need():
    assert evaluate_indoor_pose_guide_judge({"needs_indoor_pose_guide":False}) == (False, "judge_no_need")

def test_judge_deny_single_shot_decision():
    v = {"needs_indoor_pose_guide":True,"decision_type":"single_shot_complexity","confidence":"high","evidence":_EV}
    assert evaluate_indoor_pose_guide_judge(v) == (False, "judge_not_cross_shot")

def test_judge_deny_low_confidence():
    v = {"needs_indoor_pose_guide":True,"decision_type":"both","confidence":"low","evidence":_EV}
    assert evaluate_indoor_pose_guide_judge(v) == (False, "judge_low_confidence")

def test_judge_deny_empty_evidence():
    v = {"needs_indoor_pose_guide":True,"decision_type":"both","confidence":"high","evidence":[{"shot_key":"","source_field":"","quote":""}]}
    assert evaluate_indoor_pose_guide_judge(v) == (False, "judge_missing_evidence")

def test_judge_deny_malformed():
    assert evaluate_indoor_pose_guide_judge({}) == (False, "judge_failed")
```

- [ ] **Step 2: 실패 확인** — Run: `backend/.venv/bin/python -m pytest backend/tests/steps/test_indoor_pose_guide_judge.py -v` / Expected: FAIL (ImportError)

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

```python
# backend/app/core/steps/indoor_pose_guide_judge.py
"""실내 pose 가이드 default-deny judge 평가 (Wave5). outdoor 패턴 차용·분리."""
from typing import Any, Dict, Optional, Tuple


def has_grounded_evidence(evidence: Any) -> bool:
    if not isinstance(evidence, list):
        return False
    for e in evidence:
        if (isinstance(e, dict)
                and str(e.get("shot_key") or "").strip()
                and str(e.get("source_field") or "").strip()
                and str(e.get("quote") or "").strip()):
            return True
    return False


def evaluate_indoor_pose_guide_judge(verdict: Any) -> Tuple[bool, Optional[str]]:
    if not isinstance(verdict, dict) or not verdict:
        return False, "judge_failed"
    if not verdict.get("needs_indoor_pose_guide"):
        return False, "judge_no_need"
    if verdict.get("decision_type") not in ("cross_shot_continuity", "both"):
        return False, "judge_not_cross_shot"
    if verdict.get("confidence") not in ("medium", "high"):
        return False, "judge_low_confidence"
    if not has_grounded_evidence(verdict.get("evidence")):
        return False, "judge_missing_evidence"
    return True, None
```

- [ ] **Step 4: 통과 확인** — Expected: PASS (6 passed)

- [ ] **Step 5: 커밋** — `feat(wave5): default-deny indoor pose guide judge 평가 (결정론)`

---

## Task 5: guide service — gate / cache key / no-guide 분기 + 생성 인터페이스

**Files:**
- Create: `backend/app/services/indoor_shared_pose_guide_service.py`
- Create: 프롬프트 버전 디렉토리 (guide edit 프롬프트) — Task 9 canary 전까지 문자열 상수로 보유 후 분리 가능.
- Test: `backend/tests/services/test_indoor_shared_pose_guide_service.py`

**Interfaces:**
- Consumes: `build_pose_brief` (Task 3), `registered_pose_guide_service.make_registration_underlay` (차용).
- Produces: `guide_cache_key(*, scene_index:int, bg_id:str, member_keys:List, pose_brief:Dict, prompt_version:str, model:str, bg_asset_hash:str) -> str` (입력 바뀌면 mismatch). `build_indoor_pose_guide(*, group_id:str, pose_brief:Dict, env_bg_bytes:bytes|None, bg_key:str, cache_dir:Path, qc_fn:Callable|None=None, openai_client=None, model:str="gpt-image-2", force:bool=False) -> Tuple[bytes|None, Dict]`. gate: `pose_brief["figures"]` 비었거나 `env_bg_bytes` 없으면 (None, diag{status:skipped, reason}). 생성 성공 후 `qc_fn` 있으면 호출, fail이면 (None, diag{status:qc_failed}). per-key lock + cache 재확인(멱등). **white-bg fallback 없음** = 모든 실패 None.

- [ ] **Step 1: 실패 테스트 작성** (실제 gpt-image-2 호출 없이 gate/cache/QC 분기만 — 생성 함수 주입)

```python
# backend/tests/services/test_indoor_shared_pose_guide_service.py
from pathlib import Path
from app.services.indoor_shared_pose_guide_service import guide_cache_key, build_indoor_pose_guide

_BRIEF = {"figures":[{"slot":"foreground figure","screen_zone":"left","depth_plane":"foreground","gesture":None}],
          "support_clause":"...","framing":"wide","contact_locked":True,"skipped_reason":None}

def test_cache_key_changes_with_inputs():
    k1 = guide_cache_key(scene_index=1,bg_id="bgA",member_keys=[(1,1),(1,2)],pose_brief=_BRIEF,prompt_version="1",model="gpt-image-2",bg_asset_hash="h1")
    k2 = guide_cache_key(scene_index=1,bg_id="bgA",member_keys=[(1,1),(1,2)],pose_brief=_BRIEF,prompt_version="1",model="gpt-image-2",bg_asset_hash="h2")
    assert k1 != k2

def test_gate_no_plate_returns_none(tmp_path):
    png, diag = build_indoor_pose_guide(group_id="g1",pose_brief=_BRIEF,env_bg_bytes=None,bg_key="bgA",cache_dir=tmp_path)
    assert png is None and diag["reason"] == "no_environment_bg_plate"

def test_gate_no_figures_returns_none(tmp_path):
    empty = {**_BRIEF, "figures":[]}
    png, diag = build_indoor_pose_guide(group_id="g1",pose_brief=empty,env_bg_bytes=b"x",bg_key="bgA",cache_dir=tmp_path)
    assert png is None and diag["reason"] == "no_figures"

def test_qc_fail_returns_none(tmp_path, monkeypatch):
    # 생성은 성공(가짜 PNG), qc_fn 이 fail → no-guide
    import app.services.indoor_shared_pose_guide_service as svc
    monkeypatch.setattr(svc, "_generate_guide_png", lambda **kw: b"\x89PNG_fake")
    png, diag = build_indoor_pose_guide(group_id="g1",pose_brief=_BRIEF,env_bg_bytes=b"x",bg_key="bgA",
                                        cache_dir=tmp_path, qc_fn=lambda png: (False, "photoreal_person"))
    assert png is None and diag["status"] == "qc_failed" and diag["qc_reason"] == "photoreal_person"
```

- [ ] **Step 2: 실패 확인** — Expected: FAIL (ImportError)

- [ ] **Step 3: 최소 구현** — `guide_cache_key`(sha256 over sorted json), `build_indoor_pose_guide`(gate→lock→cache→`_generate_guide_png`(underlay+edit, 실호출은 canary에서)→qc_fn→atomic write). `_generate_guide_png`는 실제 gpt-image-2 edit 호출(registered_pose_guide_service 패턴: make_registration_underlay + edit). 프롬프트=guide label "pose/placement/support registration only; bg plate is environment SOT; identity/clothing from refs; do not copy mannequin or marks" + figures slot/zone/depth + support_clause + framing. 마네킹 line-art, multi-figure(≤2 detailed, 3+ silhouette), no text/marks/photoreal. (정확한 프롬프트 문자열은 canary 육안으로 튜닝 — Global Constraint.)

```python
# 핵심 골격 (생성 호출부는 canary 튜닝 대상)
import hashlib, json, threading
from io import BytesIO
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple

_LOCKS: Dict[str, threading.Lock] = {}
_LOCKS_GUARD = threading.Lock()


def _lock_for(key: str) -> threading.Lock:
    with _LOCKS_GUARD:
        lk = _LOCKS.get(key)
        if lk is None:
            lk = threading.Lock(); _LOCKS[key] = lk
        return lk


def guide_cache_key(*, scene_index, bg_id, member_keys, pose_brief, prompt_version, model, bg_asset_hash) -> str:
    payload = {"scene_index": scene_index, "bg_id": bg_id,
               "member_keys": sorted([list(k) for k in member_keys]),
               "pose_brief": pose_brief, "prompt_version": prompt_version,
               "model": model, "bg_asset_hash": bg_asset_hash}
    return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()[:24]


def _generate_guide_png(*, pose_brief, env_bg_bytes, model, openai_client) -> bytes:
    """gpt-image-2 edit: underlay + 마네킹 등록. 실호출 — canary 에서 프롬프트 튜닝."""
    from app.services.registered_pose_guide_service import make_registration_underlay
    underlay = make_registration_underlay(env_bg_bytes)
    prompt = _build_guide_prompt(pose_brief)        # 아래 helper (canary 튜닝)
    # ... registered_pose_guide_service._resolve_openai_client + images.edit 패턴 ...
    raise NotImplementedError  # Step 3 에서 edit 호출 구현


def build_indoor_pose_guide(*, group_id, pose_brief, env_bg_bytes, bg_key, cache_dir,
                            qc_fn: Optional[Callable[[bytes], Tuple[bool, Optional[str]]]] = None,
                            openai_client=None, model="gpt-image-2", force=False):
    diag: Dict[str, Any] = {"group_id": group_id, "bg_key": bg_key}
    if not (pose_brief.get("figures")):
        diag.update(status="skipped", reason="no_figures"); return None, diag
    if not env_bg_bytes:
        diag.update(status="skipped", reason="no_environment_bg_plate"); return None, diag
    # cache/lock 생략 가능 — figures hash 기반 path
    png = _generate_guide_png(pose_brief=pose_brief, env_bg_bytes=env_bg_bytes,
                              model=model, openai_client=openai_client)
    if qc_fn is not None:
        ok, reason = qc_fn(png)
        if not ok:
            diag.update(status="qc_failed", qc_reason=reason); return None, diag
    diag.update(status="generated")
    return png, diag
```

(Step 3 구현 시 `_generate_guide_png`의 edit 호출·cache/lock·atomic write를 registered_pose_guide_service에서 패턴 복사. 테스트는 `_generate_guide_png`를 monkeypatch.)

- [ ] **Step 4: 통과 확인** — Expected: PASS (4 passed)

- [ ] **Step 5: 커밋** — `feat(wave5): indoor pose guide service gate/cache/no-guide 분기 (결정론)`

---

## Task 6: guide QC 판정 함수

**Files:**
- Modify: `backend/app/services/indoor_shared_pose_guide_service.py`
- Test: `backend/tests/services/test_indoor_shared_pose_guide_service.py`

**Interfaces:**
- Produces: `evaluate_guide_qc(verdict: Dict, *, expected_figures: int) -> Tuple[bool, str|None]`. pass = `layout_preserved` True AND `photoreal_person` False AND `clothing_or_face` False AND `text_or_marker_leakage` False AND `environment_redraw` False AND `mannequin_count` in [expected_figures-1, expected_figures+1]. 위반 시 (False, 첫 위반 reason). (VLM 응답 dict → 결정론 판정.)

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

```python
from app.services.indoor_shared_pose_guide_service import evaluate_guide_qc

_GOOD = {"layout_preserved":True,"photoreal_person":False,"clothing_or_face":False,"text_or_marker_leakage":False,"environment_redraw":False,"mannequin_count":2}

def test_qc_pass():
    assert evaluate_guide_qc(_GOOD, expected_figures=2) == (True, None)

def test_qc_fail_photoreal():
    assert evaluate_guide_qc({**_GOOD,"photoreal_person":True}, expected_figures=2) == (False, "photoreal_person")

def test_qc_fail_leakage():
    assert evaluate_guide_qc({**_GOOD,"text_or_marker_leakage":True}, expected_figures=2) == (False, "text_or_marker_leakage")

def test_qc_fail_count_off():
    assert evaluate_guide_qc({**_GOOD,"mannequin_count":5}, expected_figures=2) == (False, "mannequin_count_mismatch")

def test_qc_count_within_tolerance():
    assert evaluate_guide_qc({**_GOOD,"mannequin_count":3}, expected_figures=2) == (True, None)
```

- [ ] **Step 2: 실패 확인** — Expected: FAIL (ImportError)

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

```python
def evaluate_guide_qc(verdict: Any, *, expected_figures: int) -> Tuple[bool, Optional[str]]:
    if not isinstance(verdict, dict) or not verdict:
        return False, "qc_failed"
    if not verdict.get("layout_preserved"):
        return False, "layout_not_preserved"
    if verdict.get("photoreal_person"):
        return False, "photoreal_person"
    if verdict.get("clothing_or_face"):
        return False, "clothing_or_face"
    if verdict.get("text_or_marker_leakage"):
        return False, "text_or_marker_leakage"
    if verdict.get("environment_redraw"):
        return False, "environment_redraw"
    cnt = verdict.get("mannequin_count")
    if not isinstance(cnt, int) or abs(cnt - expected_figures) > 1:
        return False, "mannequin_count_mismatch"
    return True, None
```

- [ ] **Step 4: 통과 확인** — Expected: PASS (5 passed)

- [ ] **Step 5: 커밋** — `feat(wave5): guide QC 판정 (생성후 2차 게이트, 결정론)`

---

## Task 7: attach_indoor_pose_guide_ref (worker lookup-only, flag OFF byte-identical)

**Files:**
- Create: `backend/app/core/steps/indoor_shared_pose_guide_context.py` (`attach_indoor_pose_guide_ref` 부분 — `build_indoor_shared_pose_context`는 Task 8).
- Test: `backend/tests/steps/test_indoor_pose_guide_attach.py`

**★Codex 정렬 — worker 제약**: attach는 **worker thread**에서 호출되므로 DB/LLM/VLM/생성 호출 0. **이미 precompute된** guide bytes를 `indoor_pose_ctx`에서 **lookup**만 하고 4-list를 mutate한다. guide 생성·judge·QC는 전부 Task 8(coordinator main thread, episode당 1회)에서 끝나 있다.

**Interfaces:**
- Consumes: `indoor_pose_ctx` (Task 8 `build_indoor_shared_pose_context` 반환) = `{"guide_by_shot":Dict[ShotKey,{"png":bytes,"group_id":str,"visible_focus":str}], "diagnostics":[...]}`.
- Produces: `attach_indoor_pose_guide_ref(labeled_refs, ref_roles, ref_role_metadata, attached_meta, *, scene_index, shot_index, indoor_pose_ctx) -> bool`. flag `indoor_shared_pose_guide_enabled` OFF → **즉시 return False, 4-list 불변(byte-identical)**. ON + `guide_by_shot[(si,shi)]` 존재 → `(INDOOR_POSE_GUIDE_LABEL, png)` append, role=`indoor_pose_guide`, metadata={group_id, attach_reason}. 해당 샷 guide 없으면(미admit/QC fail/생성실패) no-op return False. **worker 내 생성/judge/QC/DB 0.**

- [ ] **Step 1: 실패 테스트 작성** (flag OFF 불변 + flag ON lookup)

```python
# backend/tests/steps/test_indoor_pose_guide_attach.py
from app.core.steps.indoor_shared_pose_guide_context import attach_indoor_pose_guide_ref

def test_attach_flag_off_is_noop(monkeypatch):
    from app.core import config
    monkeypatch.setattr(config.settings, "indoor_shared_pose_guide_enabled", False, raising=False)
    refs, roles, meta, am = [("x",b"1")], ["x"], [{}], [("x","1")]
    ctx = {"guide_by_shot": {(1,1): {"png": b"g", "group_id":"g1", "visible_focus":""}}, "diagnostics": []}
    changed = attach_indoor_pose_guide_ref(refs, roles, meta, am, scene_index=1, shot_index=1, indoor_pose_ctx=ctx)
    assert changed is False
    assert refs == [("x",b"1")] and roles == ["x"] and len(meta) == 1 and am == [("x","1")]

def test_attach_flag_on_appends_precomputed_guide(monkeypatch):
    from app.core import config
    monkeypatch.setattr(config.settings, "indoor_shared_pose_guide_enabled", True, raising=False)
    refs, roles, meta, am = [], [], [], []
    ctx = {"guide_by_shot": {(1,1): {"png": b"g", "group_id":"g1", "visible_focus":""}}, "diagnostics": []}
    changed = attach_indoor_pose_guide_ref(refs, roles, meta, am, scene_index=1, shot_index=1, indoor_pose_ctx=ctx)
    assert changed is True and roles == ["indoor_pose_guide"] and refs[0][1] == b"g"

def test_attach_flag_on_no_guide_for_shot_is_noop(monkeypatch):
    from app.core import config
    monkeypatch.setattr(config.settings, "indoor_shared_pose_guide_enabled", True, raising=False)
    refs, roles, meta, am = [], [], [], []
    ctx = {"guide_by_shot": {}, "diagnostics": []}
    changed = attach_indoor_pose_guide_ref(refs, roles, meta, am, scene_index=9, shot_index=9, indoor_pose_ctx=ctx)
    assert changed is False and refs == []
```

- [ ] **Step 2: 실패 확인** — Run: `backend/.venv/bin/python -m pytest backend/tests/steps/test_indoor_pose_guide_attach.py -v` / Expected: FAIL (ImportError)

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

```python
# backend/app/core/steps/indoor_shared_pose_guide_context.py (attach 부분)
from typing import Any, Dict, List
INDOOR_POSE_GUIDE_LABEL = "[INDOOR POSE GUIDE]"

def attach_indoor_pose_guide_ref(labeled_refs, ref_roles, ref_role_metadata, attached_meta,
                                 *, scene_index, shot_index, indoor_pose_ctx) -> bool:
    from app.core.config import settings
    if not bool(getattr(settings, "indoor_shared_pose_guide_enabled", False)):
        return False
    entry = (indoor_pose_ctx or {}).get("guide_by_shot", {}).get((scene_index, shot_index))
    if not entry or not entry.get("png"):
        return False
    labeled_refs.append((INDOOR_POSE_GUIDE_LABEL, entry["png"]))
    ref_roles.append("indoor_pose_guide")
    ref_role_metadata.append({"group_id": entry.get("group_id"),
                              "visible_focus": entry.get("visible_focus", "")})
    attached_meta.append(("indoor_pose_guide", entry.get("group_id")))
    return True
```

- [ ] **Step 4: 통과 확인** — Expected: PASS (3 passed)

- [ ] **Step 5: 커밋** — `feat(wave5): attach_indoor_pose_guide_ref worker lookup-only (flag OFF byte-identical)`

---

## Task 8: build_indoor_shared_pose_context (main thread precompute) + config + REF_ROLE + persist-all

**Files:**
- Modify: `backend/app/core/config.py` — `indoor_shared_pose_guide_enabled: bool = False`, `indoor_shared_pose_guide_judge_enabled: bool = False`, `indoor_shared_pose_guide_model: str = "gpt-image-2"`.
- Modify: `backend/app/core/steps/indoor_shared_pose_guide_context.py` — `build_indoor_shared_pose_context` 추가.
- Modify: `backend/app/services/scene_generation_coordinator.py` — episode 이미지 단계 시작 시 main thread에서 1회 호출, 반환 ctx를 worker attach에 전달.
- Modify: `backend/app/services/prompt_service.py`(REF_ROLE 정의부) — `indoor_pose_guide`를 `REF_ROLE_VALUES`에 별도 추가(render branch 안 섞음).
- Modify: guide service `_generate_guide_png`/underlay에 `capture_artifact`/capture role 배선(`indoor_pose_guide`/`indoor_pose_underlay`, disposition accepted / rejected[QC fail] / diagnostic[judge deny], default None=byte-identical 패턴 Wave1~2a 동일).
- Test: config 로드 + `build_indoor_shared_pose_context` 결정론 + ref_contract 방어.

**★Codex 정렬 — main thread 1회 precompute**: 이 함수는 **coordinator main thread에서 episode당 1회** 실행한다(worker 아님). candidate_groups(결정론) → judge_fn(VLM, admit 그룹만) → admit 그룹의 **각 멤버 샷에 대해 guide PNG+QC를 group당 1회 precompute**(bg bytes 있을 때만; ★Global Constraint: underlay 필수, bg 없으면 skip) → `guide_by_shot` immutable map. worker는 이 map을 lookup만(Task 7).

**Interfaces:**
- Consumes: `candidate_groups`(T2), `evaluate_indoor_pose_guide_judge`(T4), `build_pose_brief`(T3), `build_indoor_pose_guide`+`evaluate_guide_qc`(T5·T6), judge provider(VLM, DI), `background_chain_bg_map`(bg bytes/bg_id SOT).
- Produces: `build_indoor_shared_pose_context(*, selected_keys, bg_id_by_shot, shot_by_key, zoom_member_keys, background_chain_bg_map, cache_dir, judge_fn, guide_fn, qc_fn, judge_enabled, openai_client=None) -> Dict` = `{"guide_by_shot":Dict[ShotKey,{"png","group_id","visible_focus"}], "diagnostics":[{group_id, status, reason?}...]}`. judge_enabled=False → admit 0(candidate diagnostic만, guide_by_shot 빈). judge admit + guide_fn 성공 + qc_fn pass인 멤버만 guide_by_shot에 등록. deny/QC fail/bg 부재는 diagnostics(캔버스 노출용). `judge_fn`/`guide_fn`/`qc_fn`은 DI(테스트 override).

- [ ] **Step 1: 실패 테스트 작성** — admit+guide+QC pass 멤버만 guide_by_shot 등록, judge_enabled=False면 빈, QC fail은 diagnostics. + ref_contract 방어(`indoor_pose_guide`가 required character/background/prop 충족 안 함).

```python
from app.core.steps.indoor_shared_pose_guide_context import build_indoor_shared_pose_context

def _admit_judge(group_payload, **kw):
    return {"needs_indoor_pose_guide":True,"decision_type":"cross_shot_continuity","confidence":"high",
            "evidence":[{"shot_key":"1_1","source_field":"fsc","quote":"two figures"}]}
def _guide_ok(**kw): return b"\x89PNG_guide"
def _qc_ok(png): return (True, None)
def _qc_fail(png): return (False, "photoreal_person")

_BGMAP = {"1_1":{"bg_id":"bgA","image_bytes":b"plate"}, "1_2":{"bg_id":"bgA","image_bytes":b"plate"}}

def _grp_shots():
    return {(1,1):_shot(1,1,[{"target_kind":"character","target_id":"C01","label":"w","screen_zone":"left","depth_plane":"foreground","gesture_action":"none","gesture_target_label":""}],framing="wide"),
            (1,2):_shot(1,2,[{"target_kind":"character","target_id":"C01","label":"w","screen_zone":"center","depth_plane":"background","gesture_action":"none","gesture_target_label":""}],framing="close")}

def test_context_registers_guide_for_admitted_qc_pass(tmp_path):
    ctx = build_indoor_shared_pose_context(selected_keys=[(1,1),(1,2)],bg_id_by_shot={(1,1):"bgA",(1,2):"bgA"},
        shot_by_key=_grp_shots(),zoom_member_keys=set(),background_chain_bg_map=_BGMAP,cache_dir=tmp_path,
        judge_fn=_admit_judge,guide_fn=_guide_ok,qc_fn=_qc_ok,judge_enabled=True)
    assert (1,1) in ctx["guide_by_shot"] and ctx["guide_by_shot"][(1,1)]["png"] == b"\x89PNG_guide"

def test_context_qc_fail_no_guide_but_diagnostic(tmp_path):
    ctx = build_indoor_shared_pose_context(selected_keys=[(1,1),(1,2)],bg_id_by_shot={(1,1):"bgA",(1,2):"bgA"},
        shot_by_key=_grp_shots(),zoom_member_keys=set(),background_chain_bg_map=_BGMAP,cache_dir=tmp_path,
        judge_fn=_admit_judge,guide_fn=_guide_ok,qc_fn=_qc_fail,judge_enabled=True)
    assert ctx["guide_by_shot"] == {}
    assert any(d.get("reason") == "photoreal_person" for d in ctx["diagnostics"])

def test_context_judge_disabled_admits_none(tmp_path):
    ctx = build_indoor_shared_pose_context(selected_keys=[(1,1),(1,2)],bg_id_by_shot={(1,1):"bgA",(1,2):"bgA"},
        shot_by_key=_grp_shots(),zoom_member_keys=set(),background_chain_bg_map=_BGMAP,cache_dir=tmp_path,
        judge_fn=_admit_judge,guide_fn=_guide_ok,qc_fn=_qc_ok,judge_enabled=False)
    assert ctx["guide_by_shot"] == {}
```

```python
# ref_contract 방어 — backend/tests/steps/test_indoor_pose_guide_role.py
def test_indoor_pose_guide_role_does_not_satisfy_required_refs():
    # indoor_pose_guide 가 character/background/prop required ref 를 충족한다고
    # 오인되지 않아야 한다(role 분리). ref_contract_validator 가 indoor_pose_guide
    # 를 required slot 으로 카운트하지 않음을 단언.
    from app.services.prompt_service import REF_ROLE_VALUES
    assert "indoor_pose_guide" in REF_ROLE_VALUES
    # required ref kinds(character/character_outlook/character_state/prop/background/chain_bg)
    # 집합에 indoor_pose_guide 미포함.
```

- [ ] **Step 2: 실패 확인** — Expected: FAIL

- [ ] **Step 3: 최소 구현** — config flag 3개 + `build_indoor_shared_pose_context`(candidate_groups → judge_enabled시 그룹별 judge_fn → `evaluate_indoor_pose_guide_judge` → admit 멤버별 `build_pose_brief`→`guide_fn`(bg bytes 필수)→`qc_fn`→pass면 guide_by_shot 등록, 그 외 diagnostics) + REF_ROLE_VALUES 등록(render branch 분리) + capture role 배선(default None byte-identical).

- [ ] **Step 4: 통과 확인** — Expected: PASS + 전체 회귀 `backend/.venv/bin/python -m pytest backend/tests/pipeline/test_indoor_shared_pose_plan.py backend/tests/services/test_indoor_shared_pose_guide_service.py backend/tests/steps/ -q`

- [ ] **Step 5: 커밋** — `feat(wave5): main-thread precompute context + config + REF_ROLE 등록 + persist-all role`

---

## Task 9: canary 육안 (가이드 품질 — TDD 불가, 드라이버 커밋 금지)

**Files:**
- Create(커밋 금지): `scratchpad/wave5_canary/drive.py` — 실 데이터(금월도 등)에서 same-scene same-bg 멀티샷 그룹 발굴 → guide ON vs no-guide A/B 생성 → 갤러리.

**이 task는 테스트 통과가 아니라 육안 판정이 deliverable.**

- [ ] **Step 1:** 실 데이터에서 candidate_groups 발동 그룹 1+ 발굴(DB 쿼리 + plan 함수). 없으면 `not_applicable` 기록(실패 아님 — Wave4 교훈).
- [ ] **Step 2:** flag ON으로 그룹 멤버 샷 guide 생성(gpt-image-2 edit, $10 미만 자율). guide QC 결과 기록.
- [ ] **Step 3:** 같은 샷 no-guide 최종 still A/B 생성(nb2 i2i). reroll 2~3.
- [ ] **Step 4:** 갤러리 `python -m http.server 8898 --bind 0.0.0.0`. 육안 판정: cross-shot 일관(같은 가구/지지면)·hand-float 감소·마네킹 leakage 0(가이드에만)·환경 발명 0·multi-char placement 합리. reroll variance 확인.
- [ ] **Step 5:** findings 기록(scratchpad, 커밋 금지). 커밋할 코드 없음(드라이버는 절대 커밋 금지).

---

## Task 10: 시나리오 누출 0 증명 + Codex 리뷰 + 최종 정리

- [ ] **Step 1: ★시나리오 의존성 0 증명 (사용자 절대 강조)** — 신규 코드·프롬프트(guide edit/judge/QC) 전수 점검: 작품 고유명사·방/장소명·소품명·캐릭터명·장면 문구·예시 토큰·literal lexicon 0. 특히 guide 생성 프롬프트에 전달되는 입력이 `build_pose_brief` 출력(slot/gesture generic, label·ID 0)뿐임을 확인. 프롬프트 파일에 시나리오 단어 grep 0. **결과를 보고에 "specific scenario leakage 없음" 섹션으로 명시.**
- [ ] **Step 2:** 전체 결정론 회귀 green 확인 + flag OFF byte-identical 확인(config_hash diff 0).
- [ ] **Step 3:** Codex(%1) 수평 리뷰 요청(설계 합의 대비 구현 정합 + main 코드 변경 점검 + 시나리오 누출 점검). 답신 %0.
- [ ] **Step 4:** 리뷰 반영 → 커밋. (Codex는 main 코드 직접 수정 금지 — 가이드/리뷰만.)
- [ ] **Step 5:** 메모리 갱신(Wave5 완료 상태) + 사용자 push GO 질문.

---

## Acceptance Criteria (Codex 합의)

구현 완료 판정 기준 — 결정론은 테스트로, 나머지는 canary/DB 검증으로:

- **AC1 (flag OFF byte-identical)**: `indoor_shared_pose_guide_enabled=False`에서 attach·config_hash·step 출력 byte-identical(Task 7 테스트 + config_hash diff 0).
- **AC2 (intermediate row 노출 + 캔버스 표시)**: admitted group 1개당 `indoor_pose_underlay`/`indoor_pose_guide` intermediate row 생성. rejected(judge deny)/QC-fail guide도 **diagnostic row 또는 diagnostic metadata**로 캔버스에서 원인(deny_reason/qc_reason) 확인 가능(Task 8 capture role + disposition). **★사용자 요구(2026-06-30): 갤러리에서 보이는 모든 중간 과정(underlay/guide/거부본)이 production UI pipeline canvas(Wave3)에도 영속화·표시되어야 함.** 배선 시 2가지 필수: ① `_generate_guide_png`의 underlay capture role을 **`indoor_pose_underlay`로 별도 구분**(현재 registered_pose_underlay 공유 → indoor 전용 분리) + guide는 `indoor_pose_guide`. ② pipeline canvas 프론트(types/nodeStyle)에 `indoor_pose_guide`/`indoor_pose_underlay` role **색/필터 인식 추가**(미등록 role은 캔버스에서 누락·무색 위험). guide의 `input_image_ids`=[bg plate asset]로 lineage 엣지 연결.
- **AC3 (no-guide fallback 무손상)**: judge deny/QC fail/plate 부재 시 기존 scene prompt/ref 경로 **그대로 유지**(white-bg fallback 없음, 4-list 불변).
- **AC4 (role 충돌 없음)**: guide attach 시 final prompt에 `indoor_pose_guide` role이 명확히 표시되고 character/background refs와 역할 충돌 없음(label에서 pose/placement only 분리).
- **AC5 (canary A/B)**: no-guide vs guide에서 지지면/인물 위치 안정성 개선 + 마네킹/라벨/환경 재그림 leakage 0(Task 9 육안).

---

## Self-Review

**1. Spec coverage** (설계 §3~10 대비):
- §4 결정론 plan(그룹/신호/brief) → Task 1·2·3 ✓
- §5 default-deny judge → Task 4 + Task 8(group_ctx admit) ✓
- §6.1 가이드 생성 + multi-char 제한 → Task 5(gate/생성) + Task 3(max_figures 2, contact_locked) ✓
- §6.2 guide QC → Task 6 ✓
- §6.3 cache key → Task 5 ✓
- §7 attach + flag OFF byte-identical → Task 7 ✓
- §8 persist-all role → Task 8 ✓
- §9 검증(결정론 TDD + canary 육안) → Task 1~8 TDD + Task 9 canary ✓
- §10 제약 준수 → Global Constraints + Task 7 flag-off 테스트 ✓

**2. Placeholder scan:** Task 5 `_generate_guide_png` edit 호출·프롬프트 문자열은 "canary 튜닝 대상"으로 명시(LLM/T2I=결정론 불가, Global Constraint). 그 외 결정론 함수는 완전 코드 제공. ✓ (의도적 — 메모리 절대규칙: T2I 프롬프트는 TDD 아닌 육안 튜닝.)

**3. Type consistency:** `complexity_signals`(Task1)→`candidate_groups`(Task2 signals_by_shot)→`build_pose_brief`(Task3 figures/contact_locked)→`build_indoor_pose_guide`(Task5 pose_brief)→`evaluate_guide_qc`(Task6 expected_figures=len(figures))→`attach_indoor_pose_guide_ref`(Task7)→`build_indoor_group_ctx`(Task8). `evaluate_indoor_pose_guide_judge`(Task4) verdict 키(needs_indoor_pose_guide/decision_type/confidence/evidence) ↔ Task8 judge_fn 반환 일치 ✓. ShotKey=(int,int) 일관 ✓.

**주의(구현 시 확인)**: Task 7·8의 정확한 attach site와 `bg_id_by_shot`/`shot_by_key` 데이터 소스(어느 checkpoint)는 codegraph로 `attach_registered_pose_guide_ref` 호출부 + `background_chain_bg_map` 빌드부 + shot_staging map 로더를 확인해 확정(설계 §3 재사용 패턴). character_angles의 body_pose/subject_state 필드 실재 여부는 shot_staging 스키마 확인 후 brief 계층에 조건부 반영.
