# lane 마네킹 콘티 → 배경 i2i → 엔티티 승격 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:** lane(map_marker) 샷의 이미지 체인을 `맵 → i2i 마커 맵 → 마네킹
콘티(마커 맵 1장 참조) → 배경 i2i(참조 0) → 엔티티`로 전환하고, 방향
정합 결함 3건을 같은 wave 에서 봉합한다.

**Architecture:** 신규 스텝을 만들지 않는다. 기존 bgfirst 체인에 typed
권위 종류 `lane_conti_only` 를 추가해 Step1 참조를 콘티 1장으로 줄이고,
사라지는 장소 외형 권위를 place spec + world facts **텍스트**로 대체
한다. 콘티 스타일은 sketch 팩 v14(마네킹), 카메라 대면 관계는 geometry
팩 v4 의 선언 필드 + 결정론 검증으로 잠근다.

**Tech Stack:** Python 3 / FastAPI / SQLAlchemy / pytest, gpt-image-2
(배경 i2i), nb2 Gemini(스케치·엔티티), 파일 기반 프롬프트 팩
(`prompts/_base/<module>/<version>/<stem>.md`)

**Spec:** `docs/superpowers/specs/2026-07-26-lane-mannequin-conti-bg-entity-design.md`
(Codex APPROVED_TO_IMPLEMENTATION_PLAN)

## Global Constraints

- **LLM 에 넘기는 원문은 절대 자르지 않는다** (`[:400]` 등 금지).
- **프롬프트 팩 파일 덮어쓰기 금지** — 항상 새 버전 디렉터리.
  버전 형식 `<selector>.YYYYMMDDHHmm` (예: `14.202607261530`).
- **팩 발행 = selector 배선까지가 한 단위.** 팩만 만들고 step 상수를
  올리지 않으면 사문화된다.
- **소비된 팩 재수정 금지** — 변경은 신규 버전 디렉터리.
- **시나리오 의존 코딩 금지** — 특정 장소·인물·대사 문구를 코드나
  프롬프트에 넣지 않는다. fixture 는 `SAMPLE_FIXTURE_*` 로 격리.
- **글자/substring 으로 의미를 판정하지 않는다.** 이 계획의 substring
  사용은 단 하나 — LLM 이 제시한 인용이 원문에 실재하는지 확인하는
  provenance 게이트(기존 `_check_evidence` 와 동형)뿐이다.
- **코드로 이미지에 그리지 않는다** (PIL 오버레이 금지).
- **커밋·푸시는 사용자 지시 전까지 하지 않는다.** 각 Task 의 커밋
  스텝은 로컬 커밋까지만이고, `git push` 는 이 계획에 없다.
- **비-lane 경로는 byte-identical** — 프롬프트 조립 결과·참조 순서·
  라벨·지문이 변하면 안 된다. 새 인자는 전부 default 로 기존 동작 유지.
- 테스트 실행 위치는 항상 `backend/` (`cd backend && python -m pytest ...`).

---

## File Structure

**신규 (코드)**
- 없음. 모든 코드 변경은 기존 4파일 안에서 이루어진다.

**신규 (프롬프트 팩 디렉터리)**
- `prompts/_base/outdoor_marker_geometry/4.<ts>/` — `system.md`,
  `user_template.md` (v3 사본 + 대면 관계 계약)
- `prompts/_base/marker_map_sketch/14.<ts>/` — v13 전 스템 사본 +
  `mannequin_frame.md` 신설, `plate_look.md` 제외
- `prompts/_base/still_recipe/<n>.<ts>/` — 기존 팩 사본 +
  `bg_fill_head.md`, `bg_fill_tail.md`, `stage_head_mannequin.md` 신설

**수정 (코드)**
| 파일 | 책임 |
|---|---|
| `backend/app/modules/pipeline/outdoor_marker_map.py` | geometry 스키마·검증·방향 문구 직렬화 (T1·T3) |
| `backend/app/core/steps/shot_conti_light_step.py` | lane 콘티 저작 배선·팩 selector·config hash·CP entry (T2·T4·T5) |
| `backend/app/modules/pipeline/still_recipe.py` | bgfirst 프롬프트 조립·참조 조립·lineage 순수 로직 (T6·T7·T8) |
| `backend/app/services/still_recipe_service.py` | 권위 해석·Step1/Step2 실행·CP 로드 (T5·T6·T7·T8) |

**수정 (테스트)**
| 파일 | 대상 |
|---|---|
| `backend/tests/pipeline/test_outdoor_marker_map.py` | T1·T3 |
| `backend/tests/core/test_shot_conti_light_lane.py` | T2·T4·T5 |
| `backend/tests/unit/test_still_recipe_bgfirst.py` | T6·T7·T8 |

---

### Task 1: geometry 카메라 대면 관계 — 스키마 + 결정론 검증

카메라 대면 관계(뒷모습/정면/측면)를 저작 LLM 이 **선언 필드**로 내고,
코드가 좌표와 대조한다. 프롬프트 문구만으로는 틀린 facing 을 잡을 수
없다 — 실측: SHOT TEXT 가 `뒷모습`인데 저작 facing 의 카메라 축 성분이
`dep=-0.211`(부호가 오히려 카메라 쪽)이었고 `_SCREEN_EPS=0.25` 미달로
문구가 침묵했다.

**Files:**
- Modify: `backend/app/modules/pipeline/outdoor_lane_plan.py:120-123`
  (`_ws_normalize` → 공개 이름 승격)
- Modify: `backend/app/modules/pipeline/outdoor_marker_map.py:51-54`
  (팩 집합), `:79-140` (스키마), `:283-302` (validator 시그니처)
- Test: `backend/tests/pipeline/test_outdoor_marker_map.py`

**Interfaces:**
- Consumes: 없음 (첫 Task)
- Produces:
  - `CAMERA_FACING_RELATIONS: Tuple[str, ...]` =
    `("toward_camera", "away_from_camera", "profile", "unspecified")`
  - `_FACING_RELATION_GEOMETRY_PACKS: Set[str]` = `{"4"}`
  - `build_marker_geometry_schema(..., include_facing_relation: bool = False)`
  - `facing_camera_depth(cam: Dict, pl: Dict) -> Optional[float]`
  - `validate_marker_geometry(geometry, *, require_anchors=False, require_view=False, require_facing_relation: bool = False, shot_text: str = "") -> List[str]`
  - `outdoor_lane_plan.ws_normalize(text: str) -> str` (공개 별칭)

- [ ] **Step 1: `_ws_normalize` 를 공개 이름으로 승격**

`outdoor_marker_map.py` 가 재사용해야 하는데 사설 이름 교차 import 는
피한다. `outdoor_lane_plan.py` 는 모듈 레벨에 프로젝트 import 가 없어
순환 위험이 없다(확인됨).

`backend/app/modules/pipeline/outdoor_lane_plan.py:120-123` 를 이렇게
바꾼다:

```python
def ws_normalize(text: str) -> str:
    """연속 whitespace(개행 포함)→단일 공백. 원문 줄바꿈을 LLM 이 공백으로
    인용하는 정상 케이스(5회차 실측 2건) 허용 — 의미 판단 없음.

    2026-07-26: outdoor_marker_map 의 camera_facing evidence provenance 도
    같은 정규화를 쓴다 — 사설 이름 교차 import 를 피해 공개로 승격.
    """
    return re.sub(r"\s+", " ", text).strip()


# 기존 호출부·테스트 호환 별칭 (동일 객체)
_ws_normalize = ws_normalize
```

- [ ] **Step 2: 승격이 기존 동작을 깨지 않는지 확인**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_lane_plan.py -q`
Expected: PASS (기존 통과 수 유지 — 별칭이 같은 객체라 동작 불변)

- [ ] **Step 3: 실패하는 테스트를 쓴다 — 스키마 + 3분할 경계값 + provenance**

`backend/tests/pipeline/test_outdoor_marker_map.py` 끝에 추가:

```python
SAMPLE_FIXTURE_SHOT_TEXT = (
    "한쪽 발이 공중에 뜬 달리기 자세인 인물 B의 뒷모습을 배경으로, "
    "두 손을 입가에 모으고 입을 크게 벌린 인물 A의 상반신."
)


def _fixture_geometry(relation="unspecified", evidence="",
                      facing=(0.88, 0.52)):
    """카메라가 (0.64,0.72)에서 (0.56,0.575)를 보는 기준 배치.

    fwd 는 위-왼쪽을 향한다 — facing 을 바꿔 dep 부호를 조절한다.
    """
    return {
        "camera": {
            "origin": {"x": 0.64, "y": 0.72},
            "look_target": {"x": 0.56, "y": 0.575},
            "origin_anchor_en": "On the roadway in front of the site.",
            "look_target_anchor_en": "Inside the site by the sign.",
            "view_left": {"x": 0.45, "y": 0.51},
            "view_right": {"x": 0.83, "y": 0.56},
            "view_left_anchor_en": "Inside the site left of the shelter.",
            "view_right_anchor_en": "Inside the site at its right edge.",
        },
        "entity_placements": [{
            "slot": "E1",
            "subject_en": "running figure with one foot airborne",
            "x": 0.78, "y": 0.55,
            "anchor_en": "Inside the right side of the site.",
            "facing": {"x": facing[0], "y": facing[1]},
            "facing_anchor_en": "Farther right inside the site.",
            "camera_facing_relation": relation,
            "camera_facing_evidence": evidence,
        }],
        "rationale_ko": "픽스처 배치.",
    }


def test_facing_relation_schema_is_pack_gated():
    from app.modules.pipeline.outdoor_marker_map import (
        CAMERA_FACING_RELATIONS,
        _FACING_RELATION_GEOMETRY_PACKS,
        build_marker_geometry_schema,
    )

    assert _FACING_RELATION_GEOMETRY_PACKS == {"4"}
    off = build_marker_geometry_schema(
        include_anchors=True, include_view=True)
    pl_off = off["properties"]["entity_placements"]["items"]
    assert "camera_facing_relation" not in pl_off["properties"]

    on = build_marker_geometry_schema(
        include_anchors=True, include_view=True,
        include_facing_relation=True)
    pl_on = on["properties"]["entity_placements"]["items"]
    assert pl_on["properties"]["camera_facing_relation"]["enum"] == list(
        CAMERA_FACING_RELATIONS)
    assert pl_on["properties"]["camera_facing_evidence"] == {
        "type": "string"}
    for key in ("camera_facing_relation", "camera_facing_evidence"):
        assert key in pl_on["required"]


def test_facing_camera_depth_sign():
    from app.modules.pipeline.outdoor_marker_map import facing_camera_depth

    g = _fixture_geometry()
    cam, pl = g["camera"], g["entity_placements"][0]
    dep = facing_camera_depth(cam, pl)
    # facing 이 오른쪽-위: 카메라 축 성분이 살짝 카메라 쪽(음수)
    assert dep is not None and -0.25 < dep < 0


def test_facing_relation_intervals_are_mutually_exclusive():
    """away / toward / profile 은 하나의 eps 로 완전 비중첩이어야 한다.

    dep=0.1, eps=0.25 는 'dep>0'(away) 과 'abs(dep)<=eps'(profile) 를
    동시에 만족하던 구간 — away 는 반드시 거부돼야 한다.
    """
    from app.modules.pipeline.outdoor_marker_map import (
        _SCREEN_EPS,
        validate_marker_geometry,
    )

    def _v(relation, dep_target):
        # facing 을 카메라 축 방향으로 dep_target 만큼 기울인 픽스처를
        # 만드는 대신, 대표 좌표 3종으로 부호·크기 구간을 덮는다.
        g = _fixture_geometry(relation=relation, evidence="뒷모습",
                              facing=dep_target)
        return validate_marker_geometry(
            g, require_anchors=True, require_view=True,
            require_facing_relation=True,
            shot_text=SAMPLE_FIXTURE_SHOT_TEXT)

    assert _SCREEN_EPS == 0.25
    # (0.70, 0.20) → facing 이 카메라 반대쪽으로 크게: dep > +eps
    assert _v("away_from_camera", (0.70, 0.20)) == []
    assert _v("profile", (0.70, 0.20)) != []
    # (0.88, 0.52) → dep 가 0 근처: profile 만 통과
    assert _v("profile", (0.88, 0.52)) == []
    assert _v("away_from_camera", (0.88, 0.52)) != []
    assert _v("toward_camera", (0.88, 0.52)) != []
    # (0.80, 0.95) → facing 이 카메라 쪽으로 크게: dep < -eps
    assert _v("toward_camera", (0.80, 0.95)) == []
    assert _v("profile", (0.80, 0.95)) != []


def test_facing_relation_evidence_provenance_both_ways():
    from app.modules.pipeline.outdoor_marker_map import (
        validate_marker_geometry,
    )

    def _v(relation, evidence, facing=(0.70, 0.20)):
        return validate_marker_geometry(
            _fixture_geometry(relation=relation, evidence=evidence,
                              facing=facing),
            require_anchors=True, require_view=True,
            require_facing_relation=True,
            shot_text=SAMPLE_FIXTURE_SHOT_TEXT)

    # 명시 relation + 원문에 실재하는 인용 = 통과
    assert _v("away_from_camera", "뒷모습") == []
    # 명시 relation + evidence 누락 = 거부
    assert any("evidence" in v for v in _v("away_from_camera", ""))
    # 명시 relation + 원문에 없는 인용(발명) = 거부
    assert any("원문에 없음" in v
               for v in _v("away_from_camera", "정면을 향해 선다"))
    # unspecified + evidence 존재 = 거부 (근거 없는 선언 세탁 차단)
    assert any("unspecified" in v for v in _v("unspecified", "뒷모습"))
    # unspecified + 빈 evidence = 통과 (기하 검사 대상 아님)
    assert _v("unspecified", "", facing=(0.88, 0.52)) == []
```

- [ ] **Step 4: 테스트가 실패하는지 확인**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_marker_map.py -q -k "facing_relation or facing_camera_depth"`
Expected: FAIL — `ImportError: cannot import name 'CAMERA_FACING_RELATIONS'`

- [ ] **Step 5: 상수·팩 집합 추가**

`outdoor_marker_map.py` 의 `_VIEW_GEOMETRY_PACKS = {"3"}` (`:54`) 바로
아래에 추가:

```python
# v4 (2026-07-26): SHOT TEXT 가 말한 카메라 대면 관계를 선언 필드로 올려
# 좌표와 결정론 대조한다. 프롬프트 문구·rationale 만으로는 틀린 facing 을
# validator 가 잡을 수 없다 — 실측(S15sh5): SHOT TEXT 가 '뒷모습'인데
# 저작 facing 의 카메라 축 성분이 dep=-0.211(부호가 오히려 카메라 쪽)
# 이었고 _SCREEN_EPS 미달로 심도 문구가 침묵해 은폐됐다.
CAMERA_FACING_RELATIONS = (
    "toward_camera", "away_from_camera", "profile", "unspecified")
_FACING_RELATION_GEOMETRY_PACKS = {"4"}
```

- [ ] **Step 6: 스키마에 게이트된 필드 추가**

`build_marker_geometry_schema` 시그니처(`:79-82`)를 바꾼다:

```python
def build_marker_geometry_schema(
    max_slots: int = MAX_SLOTS, *, include_anchors: bool = False,
    include_view: bool = False, include_facing_relation: bool = False,
) -> Dict[str, Any]:
```

docstring 끝에 한 줄 추가:

```python
    include_facing_relation (v4+): 카메라 대면 관계 선언 필드 2개를
    required 로 강제. False=기존 byte-identical.
```

`if include_view:` 블록 **다음**(`:133` `cam_req = cam_req + [...]` 뒤,
`placement = {` 앞)에 추가:

```python
    if include_facing_relation:
        # 관계는 enum, 근거는 SHOT TEXT 원문 인용 — unspecified 는 빈
        # 문자열이어야 한다(validator 가 양방향 검증).
        placement_props["camera_facing_relation"] = {
            "enum": list(CAMERA_FACING_RELATIONS)}
        placement_props["camera_facing_evidence"] = {"type": "string"}
        placement_req = placement_req + [
            "camera_facing_relation", "camera_facing_evidence"]
```

- [ ] **Step 7: `facing_camera_depth` 추가**

`outdoor_marker_map.py` 의 `_facing_screen_text` 정의(`:837`) **앞**에
추가한다 (`_screen_basis` 는 `:800` 에 이미 있다):

```python
def facing_camera_depth(
    cam: Dict[str, Any], pl: Dict[str, Any],
) -> Optional[float]:
    """피사체 facing 의 카메라 축 성분 (-1..+1).

    양수 = 카메라에서 멀어지는 쪽(카메라는 등을 본다),
    음수 = 카메라 쪽으로 도는 쪽(카메라는 앞을 본다),
    0 근처 = 카메라 축에 직각(측면).

    좌표가 없거나 facing 이 자기 위치와 같으면 None — 순수 벡터 연산이며
    의미 판단은 하지 않는다.
    """
    basis = _screen_basis(cam)
    if basis is None or not isinstance(pl.get("facing"), dict):
        return None
    fwd, _right = basis
    try:
        vec = (pl["facing"]["x"] - pl["x"], pl["facing"]["y"] - pl["y"])
    except (KeyError, TypeError):
        return None
    norm = math.hypot(*vec)
    if norm < 1e-9:
        return None
    return (vec[0] / norm) * fwd[0] + (vec[1] / norm) * fwd[1]
```

- [ ] **Step 8: 대면 관계 검증 헬퍼 추가**

바로 아래에 추가:

```python
def _check_facing_relation(
    cam: Dict[str, Any], pl: Dict[str, Any], label: str, shot_text: str,
) -> List[str]:
    """선언된 대면 관계 ↔ 좌표 정합 + 근거 provenance (v4).

    구간은 하나의 named epsilon(_SCREEN_EPS)으로 **완전 비중첩**이다:
      away_from_camera : dep > +eps
      toward_camera    : dep < -eps
      profile          : abs(dep) <= eps
    겹치게 정의하면(예: away 를 dep>0 으로) eps=0.25·dep=0.1 이 away 와
    profile 양쪽을 통과해 보증이 무너진다.

    evidence 는 필드 존재로 통과시키지 않는다 — 명시 relation 이면 SHOT
    TEXT 원문에 실재하는 인용이어야 하고, unspecified 면 비어 있어야
    한다. substring 으로 의미를 판정하는 것이 아니라 LLM 이 제시한 인용의
    진위만 확인하는 결정론 게이트다(outdoor_lane_plan._check_evidence 와
    동형).
    """
    from app.modules.pipeline.outdoor_lane_plan import ws_normalize

    rel = pl.get("camera_facing_relation")
    if rel not in CAMERA_FACING_RELATIONS:
        return [f"{label} camera_facing_relation 이 계약 값 아님: {rel!r}"]
    ev_raw = pl.get("camera_facing_evidence")
    if not isinstance(ev_raw, str):
        return [f"{label} camera_facing_evidence 가 문자열 아님"]
    ev = ws_normalize(ev_raw)
    out: List[str] = []
    if rel == "unspecified":
        if ev:
            out.append(
                f"{label} camera_facing_relation=unspecified 인데 "
                "camera_facing_evidence 가 비어 있지 않음 — 근거만 있고 "
                "선언이 없는 상태 금지"
            )
        return out
    if not ev:
        out.append(
            f"{label} camera_facing_relation={rel} 인데 "
            "camera_facing_evidence(SHOT TEXT 원문 인용) 누락"
        )
    elif ev not in ws_normalize(shot_text or ""):
        out.append(
            f"{label} camera_facing_evidence 인용이 SHOT TEXT 원문에 없음 "
            f"— 원문 그대로 인용할 것: {ev!r}"
        )
    dep = facing_camera_depth(cam, pl)
    if dep is None:
        out.append(
            f"{label} facing 으로 카메라 축 성분을 계산할 수 없음 "
            "(facing/좌표 결손 또는 자기 위치와 동일)"
        )
        return out
    ok = {
        "away_from_camera": dep > _SCREEN_EPS,
        "toward_camera": dep < -_SCREEN_EPS,
        "profile": abs(dep) <= _SCREEN_EPS,
    }[rel]
    if not ok:
        out.append(
            f"{label} camera_facing_relation={rel} 인데 facing 좌표의 "
            f"카메라 축 성분이 {dep:.3f} — 관계를 만족하지 않는다 "
            f"(away>{_SCREEN_EPS}, toward<-{_SCREEN_EPS}, "
            f"profile |dep|<={_SCREEN_EPS}). SHOT TEXT 가 말한 대면 "
            "관계대로 facing 점을 옮길 것"
        )
    return out
```

- [ ] **Step 9: validator 에 배선**

`validate_marker_geometry` 시그니처(`:283-286`)를 바꾼다:

```python
def validate_marker_geometry(
    geometry: Any, *, require_anchors: bool = False,
    require_view: bool = False, require_facing_relation: bool = False,
    shot_text: str = "",
) -> List[str]:
```

docstring 끝(`:301` `(쐐기 개폐·look_target 포함·facing 자기겹침)는 검증한다.`
뒤)에 추가:

```python
    require_facing_relation (v4+): per-placement 카메라 대면 관계 선언과
    좌표의 정합 + 근거 인용 provenance 를 필수로 검증. shot_text 는 그
    샷의 SHOT TEXT 원문(인용 대조용) — require_facing_relation=True 인데
    미공급이면 모든 명시 relation 이 거부된다(fail-closed).
```

placement 루프에서 anchor/view 검증이 끝나는 지점 뒤에 per-placement
호출을 추가한다. 카메라 dict 를 이미 확보한 뒤여야 하므로, 함수 말미의
반환 직전에 다음 블록을 넣는다:

```python
    if require_facing_relation:
        cam = geometry.get("camera")
        if not isinstance(cam, dict):
            violations.append(
                "camera 가 없어 카메라 대면 관계를 검증할 수 없음")
        else:
            for idx, pl in enumerate(placements):
                if not isinstance(pl, dict):
                    continue
                label = f"placement[{idx}]({pl.get('slot') or '?'})"
                violations.extend(
                    _check_facing_relation(cam, pl, label, shot_text))
```

- [ ] **Step 10: 테스트 통과 확인**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_marker_map.py -q`
Expected: PASS — 신규 4개 포함, 기존 통과 수 감소 없음

- [ ] **Step 11: 회귀 스윕**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_lane_plan.py tests/core/test_shot_conti_light_lane.py -q`
Expected: PASS (default 인자라 기존 경로 불변)

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

```bash
git add backend/app/modules/pipeline/outdoor_marker_map.py \
        backend/app/modules/pipeline/outdoor_lane_plan.py \
        backend/tests/pipeline/test_outdoor_marker_map.py
git commit -m "feat(lane): geometry 카메라 대면 관계 — 선언 필드+비중첩 3분할 검증+인용 provenance

SHOT TEXT 가 말한 뒷모습/정면/측면을 camera_facing_relation 으로 선언받아
좌표와 결정론 대조한다. 하나의 eps 로 away>+eps / toward<-eps /
profile |dep|<=eps 완전 비중첩. evidence 는 SHOT TEXT normalized substring
양방향 검증(명시=인용 필수, unspecified=빈 문자열 필수).

실측 근거: S15sh5 는 SHOT TEXT 가 '뒷모습'인데 dep=-0.211 로 부호가 반대
였고 eps 미달로 심도 문구가 침묵해 은폐됐다."
```

---

### Task 2: geometry 팩 v4 발행 + selector 상향

**Files:**
- Create: `prompts/_base/outdoor_marker_geometry/4.<ts>/system.md`,
  `prompts/_base/outdoor_marker_geometry/4.<ts>/user_template.md`
- Modify: `backend/app/modules/pipeline/outdoor_marker_map.py:51-54`
  (`_ANCHOR_GEOMETRY_PACKS`/`_VIEW_GEOMETRY_PACKS` 에 `"4"` 편입),
  `:513-516` (스키마 게이트), `:548-550` (validator 게이트)
- Modify: `backend/app/core/steps/shot_conti_light_step.py:37`
  (`LANE_GEOMETRY_PACK_VERSION`), `:1151` 부근 (shot_text 공급)
- Test: `backend/tests/pipeline/test_outdoor_marker_map.py`

**Interfaces:**
- Consumes: T1 의 `_FACING_RELATION_GEOMETRY_PACKS`,
  `validate_marker_geometry(..., require_facing_relation, shot_text)`,
  `build_marker_geometry_schema(..., include_facing_relation)`
- Produces: `LANE_GEOMETRY_PACK_VERSION = "4"`;
  `run_marker_geometry(..., shot_text=...)` 가 대면 관계까지 검증한 뒤
  반환

- [ ] **Step 1: v4 디렉터리를 v3 사본으로 만든다**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1
TS=$(date +%Y%m%d%H%M)
cp -r prompts/_base/outdoor_marker_geometry/3.202607251321 \
      prompts/_base/outdoor_marker_geometry/4.$TS
ls prompts/_base/outdoor_marker_geometry/4.$TS
```
Expected: `system.md  user_template.md`

- [ ] **Step 2: v4 `system.md` 에 대면 관계 계약을 넣는다**

`prompts/_base/outdoor_marker_geometry/4.<ts>/system.md` 의 facing 문단
(`Finally author which way this subject is turned at this moment:` 로
시작해 `it always lies clearly away from it.` 로 끝나는 부분) 바로
뒤에 다음을 삽입한다:

```
   Then declare how this subject faces the CAMERA you author in (2),
   as camera_facing_relation:
     away_from_camera - the camera sees this subject from behind
     toward_camera    - the camera sees this subject from the front
     profile          - the subject is side-on to the camera
     unspecified      - the shot text does not say
   If the shot text states this for a subject (seen from behind, back
   turned, facing the lens, in profile, ...), you MUST declare the
   matching relation and place the facing point so the geometry agrees
   with it: for away_from_camera the facing point lies FARTHER from the
   camera origin than the subject does; for toward_camera it lies
   CLOSER; for profile it lies roughly sideways, neither farther nor
   nearer. A declared relation that the coordinates contradict is
   rejected and you will be asked to fix it.
   With any relation other than unspecified, also author
   camera_facing_evidence: the exact words from the shot text that say
   it, quoted verbatim in the original language. Do not paraphrase and
   do not invent a quote - it is checked against the shot text. When
   the relation is unspecified, camera_facing_evidence must be an empty
   string.
```

- [ ] **Step 3: 실패하는 테스트를 쓴다 — 팩 게이트**

`backend/tests/pipeline/test_outdoor_marker_map.py` 에 추가:

```python
def test_geometry_pack_v4_gates_facing_relation():
    from app.modules.pipeline.outdoor_marker_map import (
        _ANCHOR_GEOMETRY_PACKS,
        _FACING_RELATION_GEOMETRY_PACKS,
        _VIEW_GEOMETRY_PACKS,
        resolve_prompt_version,
    )

    # v4 는 v3 의 anchor·view 계약을 승계하고 대면 관계를 추가한다
    assert "4" in _ANCHOR_GEOMETRY_PACKS
    assert "4" in _VIEW_GEOMETRY_PACKS
    assert _FACING_RELATION_GEOMETRY_PACKS == {"4"}
    # v4 팩 디렉터리가 실재해야 selector 가 해석된다
    assert resolve_prompt_version("4").startswith("4.")


def test_geometry_pack_v4_system_declares_relation_contract():
    from app.modules.prompt_loader import load_prompt
    from app.modules.pipeline.outdoor_marker_map import (
        _MODULE,
        resolve_prompt_version,
    )

    sys_txt = load_prompt(
        _MODULE, "system", version=resolve_prompt_version("4"))
    for token in ("camera_facing_relation", "camera_facing_evidence",
                  "away_from_camera", "toward_camera", "profile",
                  "unspecified"):
        assert token in sys_txt
    # 발명 인용 금지 계약이 명문화돼 있어야 한다
    assert "checked against the shot text" in sys_txt
```

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

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_marker_map.py -q -k "pack_v4"`
Expected: FAIL — `assert "4" in _ANCHOR_GEOMETRY_PACKS`

- [ ] **Step 5: 팩 집합에 v4 를 편입한다**

`outdoor_marker_map.py:51-54`:

```python
_ANCHOR_GEOMETRY_PACKS = {"2", "3", "4"}
```
```python
_VIEW_GEOMETRY_PACKS = {"3", "4"}
```

- [ ] **Step 6: 저작 루프에 게이트를 배선한다**

`outdoor_marker_map.py:513-516` 를 바꾼다:

```python
    _anchored = prompt_version in _ANCHOR_GEOMETRY_PACKS
    _viewed = prompt_version in _VIEW_GEOMETRY_PACKS
    _related = prompt_version in _FACING_RELATION_GEOMETRY_PACKS
    schema = build_marker_geometry_schema(
        include_anchors=_anchored, include_view=_viewed,
        include_facing_relation=_related)
```

`:548-550` 의 검증 호출을 바꾼다:

```python
        violations = validate_marker_geometry(
            result or {}, require_anchors=_anchored,
            require_view=_viewed, require_facing_relation=_related,
            shot_text=shot_text_for_relation)
```

`run_marker_geometry` 시그니처에 키워드 인자를 추가한다(default 로 기존
호출 불변):

```python
    shot_text_for_relation: str = "",
```

docstring 에 한 줄:

```python
    shot_text_for_relation (v4+): camera_facing_evidence 인용 대조용 SHOT
    TEXT 원문. v4 팩인데 미공급이면 모든 명시 relation 이 거부된다
    (fail-closed — 근거 없는 선언 통과 금지).
```

- [ ] **Step 7: 호출부에서 shot_text 를 공급하고 selector 를 올린다**

`backend/app/core/steps/shot_conti_light_step.py:37`:

```python
LANE_GEOMETRY_PACK_VERSION = "4"
```

`:1151` 부근의 `run_marker_geometry(...)` 호출에
`prompt_version=LANE_GEOMETRY_PACK_VERSION` 과 나란히 추가한다.
샷 설명은 같은 블록에서 스케치 프롬프트에도 넘기는 값이라 동일 표현을
쓴다(`:1233` 의 `shot_desc=vshot.get("description") or ""`):

```python
                    shot_text_for_relation=vshot.get("description") or "",
```

- [ ] **Step 8: 테스트 통과 확인**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_marker_map.py tests/core/test_shot_conti_light_lane.py -q`
Expected: PASS

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

```bash
git add prompts/_base/outdoor_marker_geometry \
        backend/app/modules/pipeline/outdoor_marker_map.py \
        backend/app/core/steps/shot_conti_light_step.py \
        backend/tests/pipeline/test_outdoor_marker_map.py
git commit -m "feat(lane): outdoor_marker_geometry 팩 v4 — 카메라 대면 관계 저작 계약+selector 배선

팩 발행과 selector 상향을 같은 커밋에 둔다(팩만 저작하고 상수를 올리지
않으면 사문화되는 실측 교훈 — canon v3·lane_plan v4)."
```

---

### Task 3: 방향 문구 — 앞/뒤 명시 + 선언 relation 렌더

프롬프트 문구 `"back toward the camera"` 는 부사("다시 카메라 쪽으로")와
신체부위("등을 카메라로")로 갈리는 중의성이 있다. 마네킹은 얼굴이 없어
앞/뒤 명시가 특히 중요하다. 그리고 T1·T2 로 선언 relation 이 좌표와
정합함이 보장되므로, 심도 문구는 eps 추론이 아니라 **검증 통과한
선언값**에서 렌더한다.

**Files:**
- Modify: `backend/app/modules/pipeline/outdoor_marker_map.py:837-865`
  (`_facing_screen_text`), `:889-` (`build_geometry_text_lines`)
- Test: `backend/tests/pipeline/test_outdoor_marker_map.py`

**Interfaces:**
- Consumes: T1 의 `facing_camera_depth`, `CAMERA_FACING_RELATIONS`
- Produces: `_facing_screen_text(cam, pl)` 가 선언 relation 을 우선
  사용하고 `unspecified`/부재 시 기존 eps 경로를 유지

- [ ] **Step 1: 실패하는 테스트를 쓴다**

```python
def test_facing_text_states_front_or_back_explicitly():
    from app.modules.pipeline.outdoor_marker_map import (
        _facing_screen_text,
    )

    g = _fixture_geometry(facing=(0.80, 0.95))  # dep < -eps
    txt = _facing_screen_text(g["camera"], g["entity_placements"][0])
    assert "from the front" in txt
    assert "back toward the camera" not in txt  # 중의성 어구 제거

    g2 = _fixture_geometry(facing=(0.70, 0.20))  # dep > +eps
    txt2 = _facing_screen_text(g2["camera"], g2["entity_placements"][0])
    assert "from behind" in txt2


def test_facing_text_prefers_declared_relation():
    """선언 relation 이 있으면 eps 추론이 아니라 그것을 문장화한다."""
    from app.modules.pipeline.outdoor_marker_map import (
        _facing_screen_text,
    )

    # dep 는 0 근처(eps 미달)지만 선언은 profile — 침묵하지 않고 서술
    g = _fixture_geometry(relation="profile", evidence="뒷모습",
                          facing=(0.88, 0.52))
    txt = _facing_screen_text(g["camera"], g["entity_placements"][0])
    assert "side-on to the camera" in txt

    # unspecified 는 기존 eps 경로 — dep 미달이면 심도 문구 없음
    g2 = _fixture_geometry(relation="unspecified", facing=(0.88, 0.52))
    txt2 = _facing_screen_text(g2["camera"], g2["entity_placements"][0])
    assert "side-on to the camera" not in txt2
    assert "from behind" not in txt2 and "from the front" not in txt2
```

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

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_marker_map.py -q -k "facing_text"`
Expected: FAIL — `assert "from the front" in txt`

- [ ] **Step 3: `_facing_screen_text` 를 고친다**

`outdoor_marker_map.py:853-865` 의 `depth` 계산부터 반환까지를 교체한다:

```python
    horiz = (
        "toward the RIGHT of the frame" if lat > _SCREEN_EPS
        else "toward the LEFT of the frame" if lat < -_SCREEN_EPS
        else ""
    )
    # v14/v4 (2026-07-26): 앞/뒤를 명시한다. 구 문구 "back toward the
    # camera" 는 부사("다시 카메라 쪽으로")와 신체부위("등을 카메라로")로
    # 갈려 읽혔다 — 마네킹은 얼굴이 없어 몸통 방향이 유일한 신호라
    # 앞/뒤 오독이 곧 반전이다.
    rel = pl.get("camera_facing_relation")
    if rel in ("toward_camera", "away_from_camera", "profile"):
        # 선언값은 validator 가 좌표와 정합을 확인한 뒤에만 도달한다
        # (Task 1) — eps 추론보다 이것이 권위다.
        depth = _FACING_RELATION_PHRASES[rel]
    elif dep > _SCREEN_EPS:
        depth = (
            "away from the camera, into the depth of the shot, so the "
            "camera sees this subject from behind"
        )
    elif dep < -_SCREEN_EPS:
        depth = (
            "toward the camera, so the camera sees this subject from "
            "the front"
        )
    else:
        depth = ""
    if horiz and depth:
        return f"{horiz} and {depth}"
    return horiz or depth or "across the frame"
```

`_SCREEN_EPS` 정의(`:797`) 아래에 매핑을 추가한다:

```python
# 선언 relation → 화면 서술 (렌더링 전용 매핑, 의미 판단 아님)
_FACING_RELATION_PHRASES = {
    "away_from_camera": (
        "away from the camera, into the depth of the shot, so the camera "
        "sees this subject from behind"
    ),
    "toward_camera": (
        "toward the camera, so the camera sees this subject from the front"
    ),
    "profile": (
        "side-on to the camera, neither toward it nor away from it"
    ),
}
```

- [ ] **Step 4: 테스트 통과 확인**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_marker_map.py -q`
Expected: PASS

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

```bash
git add backend/app/modules/pipeline/outdoor_marker_map.py \
        backend/tests/pipeline/test_outdoor_marker_map.py
git commit -m "fix(lane): 방향 문구 앞/뒤 명시 + 선언 relation 우선 렌더

'back toward the camera' 중의성 제거(부사 vs 신체부위) — 마네킹은 얼굴이
없어 몸통 방향이 유일한 신호라 앞/뒤 오독이 곧 반전이다. 선언 relation 은
validator 가 좌표 정합을 확인한 값이므로 eps 추론보다 권위."
```

---

### Task 4: sketch 팩 v14 — 마네킹 + 관할 절

**Files:**
- Create: `prompts/_base/marker_map_sketch/14.<ts>/` (v13 사본,
  `plate_look.md` 제외, `mannequin_frame.md` 신설,
  `sketch_head.md`·`staging_geometry_head.md` 관할 문구 보강)
- Modify: `backend/app/modules/pipeline/outdoor_marker_map.py:689-720`
  (팩 집합), `:1199-1244` (스타일 스템 분기)
- Modify: `backend/app/core/steps/shot_conti_light_step.py:59`
- Test: `backend/tests/pipeline/test_outdoor_marker_map.py`,
  `backend/tests/core/test_shot_conti_light_lane.py`

**Interfaces:**
- Consumes: T3 의 방향 문구
- Produces: `_MANNEQUIN_SKETCH_PACKS: Set[str]` = `{"14"}`;
  `LANE_SKETCH_PACK_VERSION = "14"`; v14 는 `_PLATE_LOOK_PACKS` 밖

- [ ] **Step 1: v14 디렉터리를 만든다 (plate_look 제외)**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1
TS=$(date +%Y%m%d%H%M)
cp -r prompts/_base/marker_map_sketch/13.202607260218 \
      prompts/_base/marker_map_sketch/14.$TS
rm prompts/_base/marker_map_sketch/14.$TS/plate_look.md
rm prompts/_base/marker_map_sketch/14.$TS/light_frame.md
ls prompts/_base/marker_map_sketch/14.$TS
```
Expected: `leakage_judge.md  marker_annotate.md  marker_check_judge.md
naturalism_clause.md  no_marker.md  no_text.md  sketch_head.md
staging_geometry_head.md`

- [ ] **Step 2: `mannequin_frame.md` 를 쓴다**

`prompts/_base/marker_map_sketch/14.<ts>/mannequin_frame.md` (실증된
M2 문구):

```
STYLE — MANNEQUIN BLOCKING SKETCH: thin uniform dark outlines on plain
paper, nothing else. Draw every person as a PLAIN ARTIST'S MANNEQUIN:
a featureless oval head with NO face, NO hair, NO clothing and no
costume detail — simple tapered limbs and torso, like a wooden posing
figure. Because the head has no face and no hair, the ONLY thing that
shows which way a figure is turned is the BODY ITSELF: the direction
the chest, hips and feet point, and the way the head-oval sits on the
shoulders. Aim each mannequin's chest and feet at the spot the staging
text says that person faces, and let the arms carry the action. A
mannequin turned to the wrong side is the single worst error in this
drawing. Environment is reduced to a few placement lines (ground line,
key landmark silhouettes) — just enough to read positions and depth.
```

- [ ] **Step 3: 관할 문구를 보강한다 (결함 ①②)**

`prompts/_base/marker_map_sketch/14.<ts>/staging_geometry_head.md` 의
끝에 다음 두 단락을 추가한다:

```
Where the CAMERA DIRECTION text below also describes horizontal
placement (which side of the frame someone occupies, foreground left,
background right), treat that as background reading only: for anything
about left, right and where a subject sits in this frame, the staging
above wins. CAMERA DIRECTION governs the vertical angle, elevation,
tilt and framing.

For whether a subject faces the camera or is turned away from it, each
subject line above already states it in screen terms and the SHOT TEXT
is the source it was derived from — draw exactly what the subject line
says, and never mirror it.
```

- [ ] **Step 4: 실패하는 테스트를 쓴다**

`backend/tests/pipeline/test_outdoor_marker_map.py`:

```python
def test_sketch_pack_v14_is_mannequin_and_has_no_plate_look():
    from app.modules.pipeline.outdoor_marker_map import (
        _GEOMETRY_TEXT_PACKS,
        _MANNEQUIN_SKETCH_PACKS,
        _NATURALISM_SKETCH_PACKS,
        _PLATE_LOOK_PACKS,
        _VIEW_SKETCH_PACKS,
    )

    assert _MANNEQUIN_SKETCH_PACKS == {"14"}
    # 사진 참조 계약이 없는 팩 — 첨부 시 기존 가드가 거부한다
    assert "14" not in _PLATE_LOOK_PACKS
    # v13 의 나머지 계약은 승계
    for s in (_GEOMETRY_TEXT_PACKS, _NATURALISM_SKETCH_PACKS,
              _VIEW_SKETCH_PACKS):
        assert "14" in s


def test_sketch_v14_prompt_uses_mannequin_style_and_rejects_plate():
    import pytest
    from app.modules.pipeline.outdoor_marker_map import (
        build_marker_sketch_prompt,
    )

    g = _fixture_geometry(relation="away_from_camera", evidence="뒷모습",
                          facing=(0.70, 0.20))
    p = build_marker_sketch_prompt(
        shot_desc=SAMPLE_FIXTURE_SHOT_TEXT,
        place_text="SAMPLE_FIXTURE_PLACE",
        geometry=g, pose_clauses=[], carried_en="",
        camera_direction_en="Eye level, tightening on the upper body.",
        seed_look_attached=False, plate_attached=False,
        prompt_version="14")
    assert "MANNEQUIN BLOCKING SKETCH" in p
    assert "LIGHT STORYBOARD FRAME" not in p
    # 결함 ① 관할: 수평은 staging 우선
    assert "the staging above wins" in p
    # 사진 조항이 없는 팩에 사진 첨부 = 거부
    with pytest.raises(ValueError):
        build_marker_sketch_prompt(
            shot_desc=SAMPLE_FIXTURE_SHOT_TEXT,
            place_text="SAMPLE_FIXTURE_PLACE",
            geometry=g, pose_clauses=[], carried_en="",
            camera_direction_en="", seed_look_attached=False,
            plate_attached=True, prompt_version="14")
```

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

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_marker_map.py -q -k "v14"`
Expected: FAIL — `ImportError: cannot import name '_MANNEQUIN_SKETCH_PACKS'`

- [ ] **Step 6: 팩 집합을 갱신한다**

`outdoor_marker_map.py:689-720` 영역:

```python
_GEOMETRY_TEXT_PACKS = {
```
에 `"14"` 를 추가하고, 나머지도 갱신한다:

```python
_NATURALISM_SKETCH_PACKS = {"8", "9", "10", "11", "12", "13", "14"}
```
```python
_VIEW_SKETCH_PACKS = {"9", "10", "11", "12", "13", "14"}
```
```python
# v14 는 사진 참조 계약이 없다 — 확정 흐름(2026-07-26 사용자)에서 콘티
# 참조는 마커 맵 1장뿐이다. 집합에 넣지 않는 것이 곧 가드다(:1192).
_PLATE_LOOK_PACKS = {"9", "10", "11", "12", "13"}
```
```python
_ANCHOR_SKETCH_PACKS = {"6", "7", "8", "9", "10", "11", "12", "13", "14"}
```

그리고 마네킹 팩 집합을 새로 정의한다(`_PLATE_LOOK_PACKS` 아래):

```python
# 마네킹 스타일 팩 — 인물을 목각 인형으로만 그린다. 이 산출은 배경/엔티티
# 단계의 마네킹 교체 계약과 **짝으로만** 유효하다. 짝이 없는 경로로 흘러
# 가면 마네킹이 최종 스틸까지 유출되므로 Task 5 가 3층으로 봉한다.
_MANNEQUIN_SKETCH_PACKS = {"14"}
```

- [ ] **Step 7: 스타일 스템 분기를 배선한다**

`outdoor_marker_map.py:1199-1210` 의 `parts = [...]` 에서 스타일 스템
이름을 팩에 따라 고른다:

```python
    resolved = resolve_sketch_pack_version(prompt_version)
    _style_stem = (
        "mannequin_frame"
        if prompt_version in _MANNEQUIN_SKETCH_PACKS
        else "light_frame"
    )
    if prompt_version in _GEOMETRY_TEXT_PACKS:
        parts = [
            load_prompt(SKETCH_PACK_MODULE, "sketch_head",
                        version=resolved).strip(),
            load_prompt(SKETCH_PACK_MODULE, _style_stem,
                        version=resolved).strip(),
```

같은 함수 아래쪽 비-geometry-text 경로(`:1250-1256`)의
`"light_frame"` 도 `_style_stem` 으로 바꾼다.

- [ ] **Step 8: selector 를 올린다**

`backend/app/core/steps/shot_conti_light_step.py:59`:

```python
LANE_SKETCH_PACK_VERSION = "14"
```

- [ ] **Step 9: 테스트 통과 확인**

Run: `cd backend && python -m pytest tests/pipeline/test_outdoor_marker_map.py tests/core/test_shot_conti_light_lane.py -q`
Expected: PASS. `plate_look_on` 이 False 가 되어 `ref_paths` 가 1장으로
줄어드는 것을 확인하는 기존 테스트가 있으면 기대값을 함께 갱신한다.

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

```bash
git add prompts/_base/marker_map_sketch \
        backend/app/modules/pipeline/outdoor_marker_map.py \
        backend/app/core/steps/shot_conti_light_step.py \
        backend/tests/pipeline/test_outdoor_marker_map.py \
        backend/tests/core/test_shot_conti_light_lane.py
git commit -m "feat(lane): marker_map_sketch 팩 v14 — 마네킹 콘티+사진 참조 제거+관할 절

3변형 격리 실측(마네킹+사진O=정확 / 마네킹+사진X=정확 / 세밀인물+사진X=
반대)으로 방향 반전 원인이 렌더 스타일임을 확정했다. v14 를
_PLATE_LOOK_PACKS 에서 제외해 사진 첨부가 기존 가드로 거부되게 한다.
결함 ① 관할(수평=staging 우선) 명문화."
```

---

### Task 5: 마네킹 유출 3층 가드

v14 콘티는 배경/엔티티의 마네킹 교체 계약과 **짝으로만** 유효하다.
`lane_chain` 은 `still_bgfirst_enabled` · `still_bgfirst_full_enabled` ·
`still_lane_prev_bgfirst_enabled` 세 플래그에 걸려 있고 기본값이 전부
False 다(`config.py:725,733,741`). 그리고 `_config_hash` 는 앞의 둘만
접는다(`:105-117`, `:219-226` 실측) — v14 CP 를 만든 뒤 lane-prev 만
내리면 CP 가 재사용되면서 `lane_chain=False` 로 마네킹이 legacy 경로에
들어간다.

**Files:**
- Modify: `backend/app/core/steps/shot_conti_light_step.py:100-117`
  (`_config_hash`), `:219-226` (게이트 헬퍼), `:1300-1319` (CP entry)
- Modify: `backend/app/services/still_recipe_service.py:228-249`
  (콘티 팩 검사부 — lane 무조건 검사 추가)
- Test: `backend/tests/core/test_shot_conti_light_lane.py`,
  `backend/tests/unit/test_still_recipe_bgfirst.py`

**Interfaces:**
- Consumes: T4 의 `_MANNEQUIN_SKETCH_PACKS`, `LANE_SKETCH_PACK_VERSION`
- Produces:
  - `ShotContiLightStep._mannequin_chain_ready() -> bool`
  - `MANNEQUIN_CHAIN_CONTRACT_VERSION: str` = `"1"`
  - CP lane entry 신규 필드: `lane_sketch_pack`, `lane_geometry_pack`,
    `mannequin_chain_contract` (resolved 값)
  - 서비스 헬퍼 `_require_mannequin_chain(lane_entries) -> None`

- [ ] **Step 1: 실패하는 테스트를 쓴다 — producer 거부 + hash + entry**

`backend/tests/core/test_shot_conti_light_lane.py`:

```python
def test_mannequin_pack_requires_all_three_chain_flags(monkeypatch):
    from app.core.config import settings
    from app.core.steps.shot_conti_light_step import ShotContiLightStep

    for on in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
               "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, on, True, raising=False)
    assert ShotContiLightStep._mannequin_chain_ready() is True

    # 어느 하나라도 꺼지면 마네킹 팩은 준비되지 않았다
    for off in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
                "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, off, False, raising=False)
        assert ShotContiLightStep._mannequin_chain_ready() is False
        monkeypatch.setattr(settings, off, True, raising=False)


def test_config_hash_folds_lane_prev_flag(monkeypatch):
    """lane-prev 플래그가 hash 에 접혀야 플래그만 내린 CP 재사용이 막힌다."""
    from app.core.config import settings
    from app.core.steps.shot_conti_light_step import ShotContiLightStep

    step = ShotContiLightStep.__new__(ShotContiLightStep)
    step.project_config = {}
    for on in ("still_bgfirst_enabled", "still_bgfirst_full_enabled",
               "still_lane_prev_bgfirst_enabled"):
        monkeypatch.setattr(settings, on, True, raising=False)
    h_on = step._config_hash()
    monkeypatch.setattr(
        settings, "still_lane_prev_bgfirst_enabled", False, raising=False)
    h_off = step._config_hash()
    assert h_on != h_off
```

`backend/tests/unit/test_still_recipe_bgfirst.py`:

```python
def test_service_rejects_mannequin_cp_when_chain_flag_off():
    """소비자 검사는 lane_chain 분기 **밖**에서 무조건 돌아야 한다."""
    import pytest
    from app.core.errors import AppError
    from app.services.still_recipe_service import (
        _require_mannequin_chain,
    )

    entries = {"S1sh1": {"lane": "map_marker", "status": "ok",
                         "lane_sketch_pack": "14.202607261530"}}
    # 체인 준비 안 됨 → fail-closed
    with pytest.raises(AppError) as ei:
        _require_mannequin_chain(entries, chain_ready=False)
    assert "mannequin" in str(ei.value.code)
    # 체인 준비됨 → 통과
    _require_mannequin_chain(entries, chain_ready=True)
    # 구 v13 콘티는 마네킹 검사 대상이 아니다
    _require_mannequin_chain(
        {"S1sh1": {"lane": "map_marker", "status": "ok",
                   "lane_sketch_pack": "13.202607260218"}},
        chain_ready=False)
```

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

Run: `cd backend && python -m pytest tests/core/test_shot_conti_light_lane.py tests/unit/test_still_recipe_bgfirst.py -q -k "mannequin or lane_prev_flag"`
Expected: FAIL — `AttributeError: _mannequin_chain_ready`

- [ ] **Step 3: 계약 상수와 게이트 헬퍼를 추가한다**

`shot_conti_light_step.py` 의 `LANE_SKETCH_PACK_VERSION` 아래:

```python
# 마네킹 콘티 ↔ 배경/엔티티 마네킹 교체 계약의 짝 버전. 어느 한쪽만
# 바뀌면 CP 를 무효화해야 하므로 config hash 에 접힌다.
MANNEQUIN_CHAIN_CONTRACT_VERSION = "1"
```

`_no_plate_conti_on` 옆에 staticmethod 를 추가한다:

```python
    @staticmethod
    def _mannequin_chain_ready() -> bool:
        """마네킹 콘티를 만들어도 되는 조건 — 하류 교체 계약이 살아 있나.

        마네킹 산출은 배경 i2i·엔티티 단계의 교체 계약과 짝으로만
        유효하다. 세 플래그 중 하나라도 꺼지면 서비스가 lane_chain=False
        로 계산해 마네킹을 legacy 조립 참조로 넣고, 그 경로엔 교체 계약이
        없어 마네킹이 최종 스틸까지 유출된다(2026-07-26 설계 실측).
        """
        from app.core.config import settings

        return all(
            bool(getattr(settings, name, False))
            for name in (
                "still_bgfirst_enabled",
                "still_bgfirst_full_enabled",
                "still_lane_prev_bgfirst_enabled",
            )
        )
```

- [ ] **Step 4: config hash 에 3플래그와 계약을 접는다**

`shot_conti_light_step.py:115-116` 의 `_no_plate_conti_on()` 블록
**뒤**에 추가한다:

```python
        # 2026-07-26: 마네킹 팩은 하류 교체 계약과 짝이다 — lane-prev
        # 플래그까지 접지 않으면 v14 CP 를 만든 뒤 그 플래그만 내려
        # CP 재사용+legacy 경로 유출이 가능하다(구 hash 는 bgfirst/full
        # 만 접었다).
        if LANE_SKETCH_PACK_VERSION in _mannequin_sketch_packs():
            payload["lane_mannequin_chain"] = {
                "contract": MANNEQUIN_CHAIN_CONTRACT_VERSION,
                "bgfirst": bool(getattr(
                    settings, "still_bgfirst_enabled", False)),
                "bgfirst_full": bool(getattr(
                    settings, "still_bgfirst_full_enabled", False)),
                "lane_prev": bool(getattr(
                    settings, "still_lane_prev_bgfirst_enabled", False)),
            }
```

같은 파일 상단 import 근처에 헬퍼를 둔다(모듈 로드 순환 방지를 위한
지연 import):

```python
def _mannequin_sketch_packs():
    from app.modules.pipeline.outdoor_marker_map import (
        _MANNEQUIN_SKETCH_PACKS,
    )

    return _MANNEQUIN_SKETCH_PACKS
```

- [ ] **Step 5: producer 가드를 넣는다**

lane 콘티 루프 진입 전(`run_shot_conti_light` 의 lane 분기 시작 지점,
`shot_conti_light_step.py:890` 근처 `geometry_sys` 로딩 앞)에 추가:

```python
        if (LANE_SKETCH_PACK_VERSION in _mannequin_sketch_packs()
                and not self._mannequin_chain_ready()):
            from app.core.errors import AppError

            raise AppError(
                code="step.config.lane_mannequin_chain_off",
                message=(
                    f"lane sketch 팩 v{LANE_SKETCH_PACK_VERSION}(마네킹)"
                    "인데 still_bgfirst_enabled/"
                    "still_bgfirst_full_enabled/"
                    "still_lane_prev_bgfirst_enabled 가 모두 ON 이 아님 "
                    "— 마네킹 콘티는 배경/엔티티 교체 계약과 짝으로만 "
                    "유효하다 (마네킹 유출 방지, fail-closed)"
                ),
                status_code=422,
            )
```

- [ ] **Step 6: CP entry 에 resolved 팩을 영속한다**

`shot_conti_light_step.py:1300-1312` 의 `entry.update(status="ok", ...)`
에 3필드를 추가한다:

```python
            entry.update(
                status="ok",
                image_path=str(sketch_path),
                base_map_path=base_map,
                base_map_asset_id=base_asset_id,
                marker_map_path=str(marker_map_path),
                marker_check=marker_check,
                marker_prompt=_marker_prompt,
                geometry=geometry,
                geometry_attempts=g_out["attempts"],
                leakage=verdict,
                prompt=sketch_prompt,
                # 2026-07-26: 소비자(still_recipe_service)가 팩 계약을
                # exact revalidate 할 수 있도록 resolved 팩명을 entry 에
                # 영속한다 — 이전에는 lane_records sidecar 지문 extra
                # 에만 있어서 구 v13 콘티를 신 계약으로 조용히 소비할 수
                # 있었다.
                lane_sketch_pack=resolve_sketch_pack_version(
                    LANE_SKETCH_PACK_VERSION),
                lane_geometry_pack=resolve_prompt_version(
                    LANE_GEOMETRY_PACK_VERSION),
                mannequin_chain_contract=(
                    MANNEQUIN_CHAIN_CONTRACT_VERSION
                    if LANE_SKETCH_PACK_VERSION in _mannequin_sketch_packs()
                    else ""
                ),
            )
```

- [ ] **Step 7: 서비스 소비자 무조건 검사를 넣는다**

`still_recipe_service.py` 에 헬퍼를 추가한다(모듈 레벨, `_now` 근처):

```python
def _require_mannequin_chain(
    lane_entries: Dict[str, Any], *, chain_ready: bool,
) -> None:
    """마네킹 콘티 CP ↔ 체인 플래그 정합 — lane_chain 분기 **밖**에서
    무조건 호출한다.

    lane_chain=True 안에서만 검사하면 플래그를 내린 뒤 v14 CP 를 재사용
    하는 경로가 그대로 남는다(그때 lane_chain 은 False 라 검사 자체가
    실행되지 않는다).
    """
    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_marker_map import (
        _MANNEQUIN_SKETCH_PACKS,
    )

    if chain_ready:
        return
    for tag, entry in (lane_entries or {}).items():
        if not isinstance(entry, dict):
            continue
        pack = str(entry.get("lane_sketch_pack") or "")
        selector = pack.split(".", 1)[0]
        if selector in _MANNEQUIN_SKETCH_PACKS:
            raise AppError(
                code="still_recipe.lane_mannequin_chain_off",
                message=(
                    f"lane 콘티({tag})가 마네킹 팩 {pack} 로 생성됐는데 "
                    "bgfirst/bgfirst_full/lane_prev 플래그가 모두 ON 이 "
                    "아님 — 마네킹이 legacy 조립으로 유출된다 "
                    "(fail-closed)"
                ),
                status_code=422,
            )
```

호출 위치는 `lane_conti` 가 로드되는 `:310` **직후**다(실측: CP 키는
`lane_conti`, 서비스 지역변수도 `lane_conti`). `lane_chain` 은 `:1591`
에서 계산되므로 이 위치는 lane 분기보다 한참 앞이고, 마네킹 CP 는
플래그 상태와 무관하게 검사된다:

```python
    lane_conti: Dict[str, Any] = conti_data.get("lane_conti", {}) or {}
    # 2026-07-26: 마네킹 CP ↔ 체인 플래그 정합을 lane_chain 분기와 무관
    # 하게 여기서 무조건 검사한다(분기 안에서 하면 플래그를 내린 뒤
    # lane_chain=False 가 되어 검사 자체가 실행되지 않는다).
    _require_mannequin_chain(
        lane_conti,
        chain_ready=bool(
            getattr(settings, "still_bgfirst_enabled", False))
        and bool(getattr(settings, "still_bgfirst_full_enabled", False))
        and bool(getattr(
            settings, "still_lane_prev_bgfirst_enabled", False)),
    )
```

- [ ] **Step 8: 테스트 통과 확인**

Run: `cd backend && python -m pytest tests/core/test_shot_conti_light_lane.py tests/unit/test_still_recipe_bgfirst.py -q`
Expected: PASS

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

```bash
git add backend/app/core/steps/shot_conti_light_step.py \
        backend/app/services/still_recipe_service.py \
        backend/tests/core/test_shot_conti_light_lane.py \
        backend/tests/unit/test_still_recipe_bgfirst.py
git commit -m "fix(lane): 마네킹 유출 3층 가드 — producer 거부/hash 접기/소비자 무조건 검사

_config_hash 가 still_lane_prev_bgfirst_enabled 를 접지 않아, v14 CP 생성
후 그 플래그만 내리면 CP 재사용+lane_chain=False 로 마네킹이 legacy 조립
참조로 흘러 최종 스틸까지 유출될 수 있었다. 소비자 검사는 lane_chain 분기
밖에서 무조건 돈다 — 안에서만 하면 같은 구멍이 남는다."
```

---

### Task 6: `lane_conti_only` 권위 — Step1 참조 0 + lineage 정정

**Files:**
- Modify: `backend/app/modules/pipeline/still_recipe.py:1437-1468`
  (`bgfirst_winner_lineage`), `:1505-1539` (`bgfirst_require_input_ids`)
- Modify: `backend/app/services/still_recipe_service.py:693-704`
  (`_run_bgfirst_bg` 시그니처), `:745-766` (지문), `:785-790` (참조),
  `:841-852` (input_ids), `:2229-2311` (권위 해석),
  `:2362-2398` (refs_b 조립 순서), `:2463-2467` (lineage 호출)
- Test: `backend/tests/unit/test_still_recipe_bgfirst.py`

**Interfaces:**
- Consumes: T5 의 소비자 검사
- Produces:
  - `LANE_CONTI_ONLY = "lane_conti_only"` (`still_recipe.py` 상수)
  - `bgfirst_require_input_ids(..., authority_kind: str = "plate")`
  - `bgfirst_winner_lineage(..., conti_attached: bool = True)`
  - `_run_bgfirst_bg(tag, still_id, conti_path, plate_path=None, ...)`

- [ ] **Step 1: 실패하는 테스트를 쓴다**

```python
def test_lane_conti_only_input_ids_exempts_plate_only_in_that_mode():
    import pytest
    from app.modules.pipeline.still_recipe import (
        LANE_CONTI_ONLY,
        bgfirst_require_input_ids,
    )

    ids = bgfirst_require_input_ids(
        conti_asset_id="conti-uuid", plate_asset_id=None,
        plate_path=None, authority_kind=LANE_CONTI_ONLY)
    assert ids == ["conti-uuid"]

    # 콘티 UUID 는 여전히 필수
    with pytest.raises(ValueError):
        bgfirst_require_input_ids(
            conti_asset_id=None, plate_asset_id=None, plate_path=None,
            authority_kind=LANE_CONTI_ONLY)

    # 다른 kind 에서는 면제 없음 (기존 fail-closed 유지)
    with pytest.raises(ValueError):
        bgfirst_require_input_ids(
            conti_asset_id="conti-uuid", plate_asset_id=None,
            plate_path=None, authority_kind="plate")


def test_lane_conti_only_final_lineage_has_no_conti_edge():
    import pytest
    from app.modules.pipeline.still_recipe import (
        LANE_CONTI_ONLY,
        bgfirst_winner_lineage,
    )

    # 확정 흐름: Step2 참조에 콘티가 없다 → final direct edge 도 없다
    assert bgfirst_winner_lineage(
        chain_won=True, authority_kind=LANE_CONTI_ONLY,
        structure_seed_attached=False,
        conti_attached=False) == ["bgfirst_bg"]
    # 콘티를 실제로 첨부하는 기존 체인은 그대로
    assert bgfirst_winner_lineage(
        chain_won=True, authority_kind="plate",
        structure_seed_attached=False) == ["conti", "bgfirst_bg"]
    # lane_conti_only + 무콘티 승은 도달 불가 조합 → fail-closed
    with pytest.raises(ValueError):
        bgfirst_winner_lineage(
            chain_won=False, authority_kind=LANE_CONTI_ONLY,
            structure_seed_attached=False, conti_attached=False)
```

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

Run: `cd backend && python -m pytest tests/unit/test_still_recipe_bgfirst.py -q -k "lane_conti_only"`
Expected: FAIL — `ImportError: cannot import name 'LANE_CONTI_ONLY'`

- [ ] **Step 3: 상수와 순수 로직을 고친다**

`still_recipe.py` 에 상수를 추가한다(`bgfirst_winner_lineage` 앞):

```python
# lane(map_marker) 확정 흐름의 위치 권위 종류 — 외부 사진 0, 콘티 자체가
# 유일한 Step1 입력이다. generic "none" 대신 typed 로 두어 "권위 없음"이
# 다른 경로로 번지지 않게 한다.
LANE_CONTI_ONLY = "lane_conti_only"
```

`bgfirst_require_input_ids` 를 바꾼다:

```python
def bgfirst_require_input_ids(
    *,
    conti_asset_id: Optional[str],
    plate_asset_id: Optional[str],
    plate_path: Any,
    seed_asset_id: Optional[str] = None,
    seed_attached: bool = False,
    authority_kind: str = "plate",
) -> List[str]:
```

docstring 끝에 추가:

```python
    authority_kind=LANE_CONTI_ONLY (2026-07-26 확정 흐름): 외부 사진이
    아예 없는 lane 체인 — 플레이트 UUID 요구를 **이 모드에서만** 면제
    하고 [conti] 만 반환한다. 콘티 UUID 는 여전히 필수(불완전 lineage 를
    성공 record 로 영속 금지 원칙 유지). 다른 kind 는 기존 fail-closed.
```

본문의 plate 검사를 조건부로 만든다:

```python
    if not conti_asset_id:
        raise ValueError("bgfirst 콘티 asset 미해결 — fail-closed")
    if authority_kind == LANE_CONTI_ONLY:
        if plate_asset_id or plate_path is not None:
            raise ValueError(
                f"{LANE_CONTI_ONLY} 인데 플레이트 입력이 공급됨 "
                f"({plate_path}) — 참조 0 계약 위반 (fail-closed)"
            )
        out = [conti_asset_id]
    else:
        if not plate_asset_id:
            raise ValueError(
                f"bgfirst 플레이트 asset 미해결({plate_path}) — 불완전 "
                "lineage 영속 금지 (fail-closed)"
            )
        out = [conti_asset_id, plate_asset_id]
    if seed_attached:
        ...
```

`bgfirst_winner_lineage` 를 바꾼다:

```python
def bgfirst_winner_lineage(
    *,
    chain_won: bool,
    authority_kind: str,
    structure_seed_attached: bool,
    conti_attached: bool = True,
) -> List[str]:
```

docstring 끝에 추가:

```python
    conti_attached (2026-07-26): 체인 승 final 이 콘티를 **실제로** 참조
    하는지. 확정 흐름의 lane 체인은 Step2 참조에서 콘티를 빼므로
    False — 이때 direct role 은 bgfirst_bg 뿐이고 콘티 UUID 는 중간
    bgfirst_bg 의 input edge 에만 남는다(conti → bgfirst_bg → final).
    True 로 두면 존재하지 않는 direct edge 가 생긴다.
```

allowlist 와 반환을 바꾼다:

```python
    if authority_kind not in (
        "plate", "groupbg", "seed_bg", "prev", "canon_master",
        LANE_CONTI_ONLY,
    ):
        raise ValueError(f"unknown authority_kind {authority_kind!r}")
    if authority_kind == LANE_CONTI_ONLY and not chain_won:
        raise ValueError(
            f"{LANE_CONTI_ONLY} 는 체인 단독 경로 — chain_won=False 는 "
            "도달 불가 조합 (거짓 lineage 대신 fail-closed)"
        )
    if chain_won:
        return ["conti", "bgfirst_bg"] if conti_attached else ["bgfirst_bg"]
```

- [ ] **Step 4: 순수 로직 테스트 통과 확인**

Run: `cd backend && python -m pytest tests/unit/test_still_recipe_bgfirst.py -q`
Expected: PASS

- [ ] **Step 5: `_run_bgfirst_bg` 를 참조 0 을 표현할 수 있게 고친다**

`still_recipe_service.py:693-704` 시그니처:

```python
    def _run_bgfirst_bg(
        tag: str,
        still_id: str,
        conti_path: Path,
        plate_path: Optional[Path],
        bg_prompt: str,
        conti_asset_id: Optional[str],
        plate_asset_id_override: Optional[str] = None,
        seed_path: Optional[Path] = None,
        seed_asset_id: Optional[str] = None,
        authority_kind: str = "plate",
    ) -> Tuple[Path, str]:
```

docstring 끝에 추가:

```python
        2026-07-26 확정 흐름: authority_kind=LANE_CONTI_ONLY 면
        plate_path=None 이고 참조는 [콘티] 1장이다 — 외부 사진 없이
        콘티 자체를 편집해 배경을 입힌다(장소 사실은 텍스트 권위).
```

지문 refs(`:747`):

```python
        _fp_refs = [("conti", conti_path)]
        if plate_path is not None:
            _fp_refs.append(("plate", plate_path))
```

참조 목록(`:789-790`):

```python
                        ref_paths=(
                            [conti_path]
                            + ([plate_path] if plate_path is not None
                               else [])
                            + ([seed_path] if seed_path is not None
                               else [])
                        ),
```

input_ids(`:841-852`):

```python
        _plate_aid = None
        if plate_path is not None:
            _plate_aid = plate_asset_id_override or (
                (map_conti.get(tag) or {}).get("asset_id")
                if (map_conti.get(tag) or {}).get("plate_path")
                == str(plate_path) else None
            ) or _plate_asset_id(plate_path)
        input_ids = bgfirst_require_input_ids(
            conti_asset_id=conti_asset_id,
            plate_asset_id=_plate_aid,
            plate_path=plate_path,
            seed_asset_id=seed_asset_id,
            seed_attached=seed_path is not None,
            authority_kind=authority_kind,
        )
```

- [ ] **Step 6: 권위 해석에서 lane 을 typed mode 로 바꾼다**

`still_recipe_service.py:2244-2260` 의 `elif plate is None and lane_chain:`
블록 전체를 교체한다:

```python
                    elif plate is None and lane_chain:
                        # 2026-07-26 사용자 확정: lane 콘티는 마커 맵
                        # 1장만 보고 마네킹으로 그려진다. 배경은 그 콘티
                        # 자체를 i2i 로 편집해 입히고 외부 사진은 쓰지
                        # 않는다(사전 배경 플레이트 금지 — 미리 만든 빈
                        # 배경과 콘티 구도가 어긋나 구조물이 겹쳐 보이던
                        # 실측). 장소 사실은 텍스트 권위(§4.3).
                        if seed_bg_path is not None:
                            raise ValueError(
                                f"{LANE_CONTI_ONLY} 샷에 seed_bg 가 함께 "
                                "공급됨 — 단일 권위 계약 위반 "
                                "(fail-closed)"
                            )
                        _authority_kind = LANE_CONTI_ONLY
                        _authority_path = None
                        _authority_aid = None
```

`:2306-2311` 의 권위 결손 검사를 조건부로 만든다:

```python
                if _authority_kind != LANE_CONTI_ONLY and (
                    _authority_path is None
                    or not _authority_path.is_file()
                ):
                    raise ValueError(
                        f"BGFIRST2 샷 LOCATION 권위 결손({_authority_path},"
                        f" kind={_authority_kind}) — 재투영 불가 "
                        "(fail-closed)"
                    )
```

`LANE_CONTI_ONLY` 를 이 블록에서 쓰려면 상단 import 목록에 추가한다
(`:2218-2224` 의 `from app.modules.pipeline.still_recipe import (...)`):

```python
                    LANE_CONTI_ONLY,
```

- [ ] **Step 7: `_chain_only` 판정을 B 조립 앞으로 옮긴다**

`still_recipe_service.py:2362-2398` 를 재배열한다. `refs_chain` 조립
직후에 `_chain_only` 를 정하고, B 후보는 `_chain_only` 가 아닐 때만
만든다:

```python
                refs_chain = build_bgfirst_refs(
                    bg=bg_path, conti=None if lane_chain else conti,
                    char_refs=char_refs, prop_refs=prop_refs,
                )
                # lane 체인은 2택1이 없다(사용자 확정) — B 후보를 조립할
                # 이유가 없고, LANE_CONTI_ONLY 는 plate·seed_bg 가 모두
                # None 이라 build_ab_branch_refs 가 "A/B 는 LOCATION
                # 권위 필수" ValueError 로 샷을 죽인다(still_recipe.py:737).
                # 그래서 판정을 B 조립보다 **앞**에 둔다.
                _chain_only = lane_chain
                refs_b: Any = None
                if not _chain_only:
                    if _authority_kind == "prev":
                        refs_b = refs
                    else:
                        _, refs_b = build_ab_branch_refs(
                            plate=(
                                _authority_path
                                if _authority_kind in (
                                    "plate", "groupbg", "canon_master")
                                else None
                            ),
                            conti=conti,
                            char_refs=char_refs, prop_refs=prop_refs,
                            structure_seed=structure_seed_path,
                            seed_bg=(
                                seed_bg_path
                                if _authority_kind == "seed_bg" else None
                            ),
                        )
```

기존 `_chain_only = lane_chain` 행(`:2398`)은 제거한다(위로 올렸다).

- [ ] **Step 8: lineage 호출에 `conti_attached` 를 넘긴다**

`still_recipe_service.py:2463-2467`:

```python
                for _role in bgfirst_winner_lineage(
                    chain_won=_chain_won,
                    authority_kind=_authority_kind,
                    structure_seed_attached=_chain_seed_path is not None,
                    conti_attached=not lane_chain,
                ):
```

- [ ] **Step 9: 스윕**

Run: `cd backend && python -m pytest tests/unit/test_still_recipe_bgfirst.py tests/unit/test_still_recipe.py tests/core/test_shot_conti_light_lane.py -q`
Expected: PASS

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

```bash
git add backend/app/modules/pipeline/still_recipe.py \
        backend/app/services/still_recipe_service.py \
        backend/tests/unit/test_still_recipe_bgfirst.py
git commit -m "feat(lane): lane_conti_only 권위 — Step1 참조 0 + direct lineage 정정

콘티 자체만 편집해 배경을 입히므로 Step1 참조는 1장이다. Step2 에서 콘티를
빼는 만큼 final direct lineage 는 [bgfirst_bg] 뿐이고 콘티는 중간 bg 의
input edge 로만 남는다(거짓 direct edge 방지).

_chain_only 판정을 B 후보 조립 앞으로 옮긴다 — LANE_CONTI_ONLY 는 plate·
seed_bg 가 모두 None 이라 build_ab_branch_refs 가 ValueError 로 샷을
죽였다(현행 canon_master 는 plate 슬롯에 사진이 들어가 우회됐던 것)."
```

---

### Task 7: 장소·world 텍스트 배선 + `bg_fill` 팩

참조 이미지 0 으로 사라지는 장소 외형 권위를 텍스트로 대체한다.
현재 서비스는 `outdoor_place_spec` CP 를 로드하지 않고 world 는
`classify.world_anchor_en` 한 줄뿐이며(`:188-189`)
`build_bgfirst_bg_prompt` 에는 인자 자체가 없다 — **배선 없이는 효과가
0** 이다.

**Files:**
- Create: `prompts/_base/still_recipe/<n>.<ts>/bg_fill_head.md`,
  `bg_fill_tail.md` (기존 팩 사본 디렉터리 안에)
- Modify: `backend/app/modules/pipeline/still_recipe.py:1094-1126`
  (`build_bgfirst_bg_prompt`), 신규 `build_place_facts_block`
- Modify: `backend/app/services/still_recipe_service.py` (CP 2종 로드,
  `:2328-2348` 프롬프트 호출)
- Test: `backend/tests/unit/test_still_recipe_bgfirst.py`

**Interfaces:**
- Consumes: T6 의 `LANE_CONTI_ONLY`,
  `core.world_context.build_world_facts_block`
- Produces:
  - `build_place_facts_block(spec: Dict[str, Any]) -> str`
  - `BGFIRST_LANE_PROMPT_VERSION: str`,
    `BGFIRST_LANE_CONTRACT_VERSION: str`
  - `build_bgfirst_bg_prompt(..., place_facts_block: str = "", world_facts_block: str = "", lane_fill: bool = False)`
  - 서비스 헬퍼 `_lane_place_facts(group_key) -> str` (fail-closed)

- [ ] **Step 1: 실패하는 테스트를 쓴다 — 절제 계약**

```python
SAMPLE_FIXTURE_SPEC = {
    "layout_narration_en": "The site is a paved waiting area beside a "
                           "vehicle roadway, with a shoreline behind it.",
    "zone_labels_en": ["Waiting Area"],
    "items": [
        {"code": "B1", "kind": "shelter",
         "name_en": "roofed waiting shelter",
         "placement_en": "Stands at the middle of the waiting area.",
         "inferred": False, "temporal_scope": "persistent_site"},
        {"code": "B2", "kind": "sign",
         "name_en": "fixed identification sign",
         "placement_en": "Beside the roadway edge.",
         "inferred": False, "temporal_scope": "persistent_site"},
    ],
    "excluded_transient_elements": [
        {"name_en": "temporary police control line",
         "reason_en": "installed for the investigation"},
    ],
}


def test_place_facts_block_is_restrained():
    from app.modules.pipeline.still_recipe import build_place_facts_block

    block = build_place_facts_block(SAMPLE_FIXTURE_SPEC)
    # 주입: narration + kind/name
    assert "paved waiting area" in block
    assert "roofed waiting shelter" in block
    assert "fixed identification sign" in block
    # 미주입: 배치 서술(콘티와 이중 권위), 코드, 제외 목록(프라이밍)
    assert "Stands at the middle" not in block
    assert "Beside the roadway edge" not in block
    assert "B1" not in block and "B2" not in block
    assert "police" not in block
    assert "Waiting Area" not in block  # zone 은 narration 과 중복


def test_bg_fill_prompt_requires_place_and_world_facts():
    import pytest
    from app.modules.pipeline.still_recipe import (
        BGFIRST_LANE_PROMPT_VERSION,
        build_bgfirst_bg_prompt,
    )

    p = build_bgfirst_bg_prompt(
        shot_desc="SAMPLE_FIXTURE_SHOT",
        place_text="SAMPLE_FIXTURE_PLACE",
        time_of_day_en="night",
        place_facts_block="- shelter: roofed waiting shelter",
        world_facts_block="- Region (real-world reference): SAMPLE",
        lane_fill=True,
        prompt_version=BGFIRST_LANE_PROMPT_VERSION)
    assert "roofed waiting shelter" in p
    assert "Region (real-world reference)" in p
    # 마네킹 보존 계약이 실려야 한다
    assert "mannequin" in p.lower()
    # 발명 유도 문구는 없어야 한다
    assert "such a place really has" not in p

    # lane_fill 인데 사실 블록이 비면 fail-closed
    with pytest.raises(ValueError):
        build_bgfirst_bg_prompt(
            shot_desc="S", place_text="P", time_of_day_en="night",
            place_facts_block="", world_facts_block="- Region: X",
            lane_fill=True, prompt_version=BGFIRST_LANE_PROMPT_VERSION)
    with pytest.raises(ValueError):
        build_bgfirst_bg_prompt(
            shot_desc="S", place_text="P", time_of_day_en="night",
            place_facts_block="- shelter: x", world_facts_block="",
            lane_fill=True, prompt_version=BGFIRST_LANE_PROMPT_VERSION)


def test_non_lane_bg_prompt_is_byte_identical():
    """기존 경로는 새 인자 default 로 조립 결과가 변하지 않는다."""
    from app.modules.pipeline.still_recipe import (
        BGFIRST_PROMPT_VERSION,
        build_bgfirst_bg_prompt,
    )

    kw = dict(shot_desc="S", place_text="P", time_of_day_en="night",
              prompt_version=BGFIRST_PROMPT_VERSION)
    assert build_bgfirst_bg_prompt(**kw) == build_bgfirst_bg_prompt(
        **kw, place_facts_block="", world_facts_block="", lane_fill=False)
```

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

Run: `cd backend && python -m pytest tests/unit/test_still_recipe_bgfirst.py -q -k "place_facts or bg_fill or byte_identical"`
Expected: FAIL — `ImportError: cannot import name 'build_place_facts_block'`

- [ ] **Step 3: 팩 디렉터리와 스템을 만든다**

```bash
cd /Users/manta/Documents/Projects/TheRoad-I1
TS=$(date +%Y%m%d%H%M)
# 현행 bgfirst full 팩 디렉터리를 확인해 그 사본으로 만든다
ls prompts/_base/still_recipe | sort -V | tail -3
```

가장 높은 selector 를 `N` 이라 하면 `N+1` 로 사본을 만든다:

```bash
cp -r prompts/_base/still_recipe/<N>.<old_ts> \
      prompts/_base/still_recipe/<N+1>.$TS
```

`prompts/_base/still_recipe/<N+1>.<ts>/bg_fill_head.md`:

```
Turn the attached storyboard sketch into a photorealistic film still of
its LOCATION ONLY, keeping the figures exactly as they are.

KEEP EXACTLY: the camera framing, the horizon, and where every element
sits in the frame. Each mannequin figure stays a plain grey featureless
mannequin standing in the very same spot, at the same size, in the same
pose, turned the same way — do not turn them into people, do not move,
rotate, mirror or re-pose them, do not add or remove figures.

BUILD PHOTOREALISTICALLY: everything that is not a figure — ground,
surfacing, structures, vegetation, sky, water, distance. The bare lines
of the sketch are a layout guide; replace them with the real materials,
depth and lighting of the place described below.
```

`prompts/_base/still_recipe/<N+1>.<ts>/bg_fill_tail.md`:

```
THINGS THAT LIVE AT THIS PLACE (what they are, not where they go): the
list above names what permanently belongs to this site. Where each one
sits in this frame is decided by the attached sketch alone — do not add
or reposition anything on the strength of the list, build only what the
sketch already has lines for, and do not invent facilities that are not
listed.

No text, no captions, no diagram symbols, no markers anywhere in the
image.
```

- [ ] **Step 4: `build_place_facts_block` 을 구현한다**

`still_recipe.py` 에 추가:

```python
def build_place_facts_block(spec: Dict[str, Any]) -> str:
    """장소 사실 — 절제 블록 (2026-07-26 사용자: "너무 디테일하게 넣으면
    안 돼").

    싣는 것: layout_narration_en 1단락 + items 의 kind/name_en.
    빼는 것:
      - placement_en — 배치 권위는 첨부 콘티의 선이다. 텍스트로 또 주면
        이중 권위가 되어 구도가 흔들린다.
      - code/evidence/inferred/zone_labels_en — 감사 필드이거나 narration
        과 중복.
      - excluded_transient_elements — 금지 대상 열거는 오히려 프라이밍
        이다(outdoor_marker_map.build_geometry_text_lines 의 구조 토큰
        비노출과 같은 계열).

    코드는 데이터 직렬화만 한다 — 의미 판단 없음.
    """
    if not isinstance(spec, dict):
        return ""
    lines: List[str] = []
    narration = str(spec.get("layout_narration_en") or "").strip()
    if narration:
        lines.append(narration)
    seen = set()
    for it in spec.get("items") or []:
        if not isinstance(it, dict):
            continue
        kind = str(it.get("kind") or "").strip()
        name = str(it.get("name_en") or "").strip()
        if not name or name in seen:
            continue
        seen.add(name)
        lines.append(f"- {kind}: {name}" if kind else f"- {name}")
    return "\n".join(lines)
```

- [ ] **Step 5: lane 전용 버전 상수와 프롬프트 조립을 고친다**

`still_recipe.py` 의 기존 `BGFIRST_PROMPT_VERSION` /
`BGFIRST_FULL_PROMPT_VERSION` 정의 옆에 추가:

```python
# lane(map_marker) 확정 흐름 전용 — v7(비 full)·v11(full) 과 역할이
# 갈라져 있어 한 상수에 세 역할을 섞지 않는다.
BGFIRST_LANE_PROMPT_VERSION = "<N+1>"
BGFIRST_LANE_CONTRACT_VERSION = "1"
```

`build_bgfirst_bg_prompt` 를 바꾼다:

```python
def build_bgfirst_bg_prompt(
    *,
    shot_desc: str,
    place_text: str,
    time_of_day_en: str,
    camera_frame_en: str = "",
    lighting_mood_en: str = "",
    place_facts_block: str = "",
    world_facts_block: str = "",
    lane_fill: bool = False,
    prompt_version: str = BGFIRST_PROMPT_VERSION,
) -> str:
```

docstring 끝에 추가:

```python
    lane_fill (2026-07-26 확정 흐름): 참조 이미지 0 으로 콘티 자체를
    편집하는 lane 경로. head/tail 스템이 bg_fill_* 로 바뀌고 장소·world
    사실 블록이 **필수**가 된다 — 둘 중 하나라도 비면 무국적 배경을
    조용히 만들게 되므로 ValueError(fail-closed). 기본값 False 는 기존
    조립과 byte-identical.
```

본문:

```python
    resolved = resolve_prompt_version(prompt_version)
    if lane_fill:
        if not place_facts_block.strip():
            raise ValueError(
                "lane_fill 인데 place_facts_block 이 비어 있음 — 장소 "
                "사실 없이 배경을 만들지 않는다 (fail-closed)"
            )
        if not world_facts_block.strip():
            raise ValueError(
                "lane_fill 인데 world_facts_block 이 비어 있음 — 지역·"
                "시대 없이 배경을 만들지 않는다 (fail-closed)"
            )
        parts = [
            load_prompt(_MODULE, "bg_fill_head", version=resolved).strip(),
            f"SHOT TEXT this background must serve (Korean): {shot_desc}",
            f"LOCATION (lock): {place_text}",
            f"TIME OF DAY (lock): {time_of_day_en}.",
        ]
    else:
        parts = [
            load_prompt(
                _MODULE, "bg_reproject_head", version=resolved).strip(),
            f"SHOT TEXT this background must serve (Korean): {shot_desc}",
            f"LOCATION (lock): {place_text}",
            f"TIME OF DAY (lock): {time_of_day_en}.",
        ]
    if camera_frame_en:
        parts.append(camera_frame_en)
    if lighting_mood_en:
        parts.append(lighting_mood_en)
    if lane_fill:
        parts.append("THINGS AT THIS PLACE:\n" + place_facts_block)
        parts.append(
            "WORLD FACTS (creator-confirmed — always true):\n"
            + world_facts_block)
        parts.append(
            load_prompt(_MODULE, "bg_fill_tail", version=resolved).strip())
    else:
        parts.append(
            load_prompt(
                _MODULE, "bg_reproject_tail", version=resolved).strip())
    return "\n\n".join(parts)
```

- [ ] **Step 6: 서비스에서 CP 2종을 로드하고 fail-closed 조회를 넣는다**

`still_recipe_service.py` 의 다른 `_load_cp` 호출들 근처(`:250` 부근
`background_share_plan` 로드 옆)에 추가한다:

```python
    # 2026-07-26 확정 흐름: 참조 0 배경의 장소·world 권위는 텍스트다.
    # lane ON 일 때 런당 1회 로드해 Step1 프롬프트에 싣는다.
    _place_spec_groups: Dict[str, Any] = (
        _load_cp(projects_dir, project_id, episode_id,
                 "outdoor_place_spec").get("data", {}) or {}
    ).get("groups", {}) or {}
    _vwr_cp = _load_cp(
        projects_dir, project_id, episode_id, "visual_world_rules")
    _lane_world_block = build_world_facts_block(_vwr_cp)

    def _lane_place_facts(group_key: str) -> str:
        """lane 샷의 장소 사실 블록 — 결손이면 fail-closed.

        성공 entry 는 {spec, attempts, outdoor_loc_ids, scene_indices}
        이고 `status` 키가 **없다**(실패 entry 만 error 를 갖는다,
        outdoor_place_spec_step.py:228-233). canon CP 는 status 를 갖는
        다른 shape 라 혼동하지 말 것.
        """
        entry = (_place_spec_groups or {}).get(group_key)
        if not isinstance(entry, dict) or entry.get("error"):
            raise ValueError(
                f"lane 배경: place spec 그룹({group_key!r}) 결손/실패 "
                f"({(entry or {}).get('error')!r}) — 장소 사실 없이 "
                "배경 생성 금지 (fail-closed)"
            )
        spec = entry.get("spec")
        if not isinstance(spec, dict):
            raise ValueError(
                f"lane 배경: place spec 그룹({group_key!r}) 의 spec 이 "
                "dict 아님 — fail-closed"
            )
        block = build_place_facts_block(spec)
        if not block.strip():
            raise ValueError(
                f"lane 배경: place spec 그룹({group_key!r}) 에서 장소 "
                "사실 블록이 비었음 — fail-closed"
            )
        return block
```

상단 import 에 추가한다:

```python
from app.core.world_context import build_world_facts_block
from app.modules.pipeline.still_recipe import build_place_facts_block
```

(모듈 상단 import 가 순환을 만들면 `run_still_recipe_generation` 안의
지연 import 로 옮긴다 — 이 파일은 지연 import 관례를 쓴다.)

- [ ] **Step 7: Step1 프롬프트 호출을 lane 분기로 바꾼다**

`still_recipe_service.py:2328-2348` 의 `bg_prompt = build_bgfirst_bg_prompt(...)`
호출에 lane 인자를 넘긴다:

```python
                _lane_fill = _authority_kind == LANE_CONTI_ONLY
                bg_prompt = build_bgfirst_bg_prompt(
                    shot_desc=shot_desc_by_id.get(still_id)
                    or s.get("still_frame_prompt") or "",
                    place_text=(
                        cls.get("place_en")
                        or classify_scenes.get(str(si), {}).get("place_en")
                        or location_by_scene.get(si, "")
                    ),
                    time_of_day_en=(
                        classify_scenes.get(str(si), {})
                        .get("time_of_day_en") or ""
                    ),
                    camera_frame_en=_bg_cam,
                    lighting_mood_en=lighting_mood_en,
                    place_facts_block=(
                        _lane_place_facts(lane_entry.get("group_id") or "")
                        if _lane_fill else ""
                    ),
                    world_facts_block=(
                        _lane_world_block if _lane_fill else ""
                    ),
                    lane_fill=_lane_fill,
                    prompt_version=(
                        BGFIRST_LANE_PROMPT_VERSION if _lane_fill
                        else _BGF_FULL_PACK_SEL if bgfirst_full_on
                        else _BGFIRST_PACK
                    ),
                )
```

`BGFIRST_LANE_PROMPT_VERSION` 을 같은 블록 import 에 추가한다.

`group_id` 는 실측으로 확인된 필드다 — lane CP 의 map_marker entry 는
`{asset_id, base_map_asset_id, base_map_path, geometry,
geometry_attempts, group_id, image_path, lane, leakage, prompt,
segment_id, sketch_attempts, status}` 를 갖고 `group_id` 값은
place spec 의 그룹 키와 같은 형식(예: `bg_rooftop_residence`)이다.
`group_id` 가 비면 `_lane_place_facts("")` 가 그룹 부재로 fail-closed
하므로 별도 가드는 필요 없다.

- [ ] **Step 8: 지문에 lane 계약을 접는다**

`still_recipe_service.py:496-506` 의 `extra_fingerprint` 블록에 추가:

```python
        if bool(getattr(settings, "still_lane_prev_bgfirst_enabled", False)):
            extra_fingerprint["bgfirst_lane_pack"] = recipe_pack_version(
                BGFIRST_LANE_PROMPT_VERSION)
            extra_fingerprint["bgfirst_lane_contract"] = (
                BGFIRST_LANE_CONTRACT_VERSION)
```

- [ ] **Step 9: 테스트 통과 확인**

Run: `cd backend && python -m pytest tests/unit/test_still_recipe_bgfirst.py tests/unit/test_still_recipe.py -q`
Expected: PASS

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

```bash
git add prompts/_base/still_recipe \
        backend/app/modules/pipeline/still_recipe.py \
        backend/app/services/still_recipe_service.py \
        backend/tests/unit/test_still_recipe_bgfirst.py
git commit -m "feat(lane): 배경 i2i 장소·world 텍스트 배선 + bg_fill 팩

참조 0 으로 사라지는 장소 외형 권위를 place spec 절제 블록(narration+
items kind/name)과 world facts 로 대체한다. placement_en·제외 목록·code 는
넣지 않는다(콘티와 이중 권위 회피 / 금지 대상 프라이밍 회피).

서비스는 outdoor_place_spec CP 를 로드조차 하지 않았고 world 는
classify.world_anchor_en 한 줄뿐이어서 배선 없이는 효과가 0 이었다.
성공 entry 는 status 키가 없으므로 spec dict + error 부재를 성공 조건으로
쓴다. 사실 블록이 비면 fail-closed."
```

---

### Task 8: 엔티티 단계 — 마네킹 교체 계약

**Files:**
- Modify: `backend/app/modules/pipeline/still_recipe.py:1129-1160`
  (`build_bgfirst_final_prompt`, `build_bgfirst_refs`)
- Create: `prompts/_base/still_recipe/<N+1>.<ts>/stage_head_mannequin.md`
- Modify: `backend/app/services/still_recipe_service.py:2399-2400`
- Test: `backend/tests/unit/test_still_recipe_bgfirst.py`

**Interfaces:**
- Consumes: T6 의 `LANE_CONTI_ONLY`, T7 의
  `BGFIRST_LANE_PROMPT_VERSION`
- Produces:
  - `build_bgfirst_refs(*, bg, conti: Optional[Path], ...)`
  - `build_bgfirst_final_prompt(base_prompt, prompt_version=..., mannequin: bool = False)`

- [ ] **Step 1: 실패하는 테스트를 쓴다**

```python
def test_bgfirst_refs_omits_sketch_slot_when_conti_is_none():
    from pathlib import Path
    from app.modules.pipeline.still_recipe import build_bgfirst_refs

    bg = Path("/tmp/SAMPLE_FIXTURE_bg.png")
    conti = Path("/tmp/SAMPLE_FIXTURE_conti.png")
    char = [("A", Path("/tmp/SAMPLE_FIXTURE_a.png"))]

    with_conti = build_bgfirst_refs(
        bg=bg, conti=conti, char_refs=char, prop_refs=[])
    without = build_bgfirst_refs(
        bg=bg, conti=None, char_refs=char, prop_refs=[])

    assert len(with_conti) - len(without) == 1
    assert [p for _l, p in without] == [bg, char[0][1]]
    # 비-lane 호출은 라벨·순서 불변
    assert [l for l, _p in with_conti][0] == [
        l for l, _p in without][0]


def test_mannequin_stage_head_replaces_figures():
    from app.modules.pipeline.still_recipe import (
        BGFIRST_LANE_PROMPT_VERSION,
        build_bgfirst_final_prompt,
    )

    p = build_bgfirst_final_prompt(
        "SAMPLE_FIXTURE_BASE_STILL_PROMPT",
        prompt_version=BGFIRST_LANE_PROMPT_VERSION, mannequin=True)
    low = p.lower()
    assert "mannequin" in low
    assert "never mirrored" in low or "not mirror" in low
    # base 스틸 프롬프트 전문이 유지돼야 한다(자세·조명 계약 유실 금지)
    assert "SAMPLE_FIXTURE_BASE_STILL_PROMPT" in p
```

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

Run: `cd backend && python -m pytest tests/unit/test_still_recipe_bgfirst.py -q -k "omits_sketch or mannequin_stage"`
Expected: FAIL — `TypeError` 또는 `mannequin` 미포함

- [ ] **Step 3: `stage_head_mannequin.md` 를 쓴다**

`prompts/_base/still_recipe/<N+1>.<ts>/stage_head_mannequin.md`:

```
Replace every grey mannequin in the FIRST attached image with the real
person the CHARACTER REFERENCE images show, and output a photorealistic
film still.

KEEP EXACTLY: the background of the first image, the camera framing,
and each mannequin's position, size, pose and the direction it is
turned. A person must stand where their mannequin stood, at the same
scale, facing the same way — never mirrored, never re-staged. No
mannequin, grey figure or sketch line may remain anywhere in the
output.
```

- [ ] **Step 4: `build_bgfirst_refs` 와 final prompt 를 고친다**

```python
def build_bgfirst_refs(
    *,
    bg: Path,
    conti: Optional[Path],
    char_refs: Sequence[Tuple[str, Any]],
    prop_refs: Sequence[Tuple[str, Any]],
    prompt_version: str = BGFIRST_PROMPT_VERSION,
    entity_label_version: str = "1",
) -> List[Tuple[str, Any]]:
```

docstring 에 추가:

```python
    conti=None (2026-07-26 확정 흐름): LAYOUT SKETCH 슬롯을 생략한다 —
    lane 체인의 배경본은 이미 마네킹 배치와 장소를 담고 있어 콘티가
    중복이고, 선 그림을 참조로 넣으면 스케치 선 잔류 위험이 있다.
    비-lane 호출(conti 실재)의 참조 순서·라벨은 불변이다.
```

본문에서 sketch 슬롯 append 를 조건부로 만든다 (기존 sketch 라벨
append 행을 `if conti is not None:` 아래로 들여쓴다).

```python
def build_bgfirst_final_prompt(
    base_prompt: str,
    prompt_version: str = BGFIRST_PROMPT_VERSION,
    mannequin: bool = False,
) -> str:
    """Step2 인물 삽입 프롬프트 — stage_head + base 스틸 전문.

    mannequin=True (2026-07-26 확정 흐름): 배경본의 회색 마네킹을 실제
    인물로 교체하는 계약 스템을 쓴다(위치·크기·자세·방향 유지, 미러
    금지, 마네킹/스케치 선 잔류 0). base 스틸 프롬프트 전문은 그대로
    유지한다 — 자세 정본·조명·표정 사실감 계약을 잃지 않는다.
    """
    resolved = resolve_prompt_version(prompt_version)
    stem = "stage_head_mannequin" if mannequin else "stage_head"
    return (
        load_prompt(_MODULE, stem, version=resolved).strip()
        + "\n\n"
        + base_prompt
    )
```

- [ ] **Step 5: 서비스 호출을 배선한다**

`still_recipe_service.py:2399-2400`:

```python
                _chain_prompt = build_bgfirst_final_prompt(
                    prompt_chain or prompt,
                    prompt_version=(
                        BGFIRST_LANE_PROMPT_VERSION if lane_chain
                        else _BGFIRST_PACK
                    ),
                    mannequin=lane_chain,
                )
```

- [ ] **Step 6: 테스트 통과 확인**

Run: `cd backend && python -m pytest tests/unit/test_still_recipe_bgfirst.py tests/unit/test_still_recipe.py -q`
Expected: PASS

- [ ] **Step 7: 전체 관련 스위트 스윕**

Run:
```bash
cd backend && python -m pytest \
  tests/pipeline/test_outdoor_marker_map.py \
  tests/pipeline/test_outdoor_lane_plan.py \
  tests/pipeline/test_outdoor_place_spec.py \
  tests/core/test_shot_conti_light_lane.py \
  tests/unit/test_still_recipe_bgfirst.py \
  tests/unit/test_still_recipe.py -q
```
Expected: PASS, 실패 0

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

```bash
git add prompts/_base/still_recipe \
        backend/app/modules/pipeline/still_recipe.py \
        backend/app/services/still_recipe_service.py \
        backend/tests/unit/test_still_recipe_bgfirst.py
git commit -m "feat(lane): 엔티티 단계 마네킹 교체 계약 + 콘티 참조 생략

배경본이 이미 마네킹 배치와 장소를 담고 있어 Step2 에 콘티는 중복이고 선
그림 참조는 스케치 선 잔류 위험이다. stage_head_mannequin 은 위치·크기·
자세·방향 유지와 미러 금지, 마네킹/선 잔류 0 을 계약한다. base 스틸
프롬프트 전문은 유지(자세·조명·표정 계약 유실 금지)."
```

---

### Task 9: 캔ary 실행 + 육안 acceptance

결정론 테스트는 계약만 증명한다. 콘티·배경·엔티티의 완성도는 실제
생성 산출을 눈으로 봐야 판정된다 — 이 Task 에 PASS/FAIL 을 테스트로
쓰지 않는다.

**Files:**
- Create: `<scratchpad>/canary_lane_mannequin.py` (실험 하네스,
  **커밋하지 않는다**)
- Create: `<scratchpad>/lane_mannequin_gallery/index.html`

**Interfaces:**
- Consumes: T1~T8 전부

- [ ] **Step 1: 플래그를 켜고 캔ary 하네스를 만든다**

기존 하네스를 사본으로 쓴다. 참고 원본:
`canary_lane_marker_v5.py`(이전 세션 scratchpad). 3플래그
(`STILL_BGFIRST_ENABLED`, `STILL_BGFIRST_FULL_ENABLED`,
`STILL_LANE_PREV_BGFIRST_ENABLED`) 와
`OUTDOOR_LANE_PLAN_ENABLED`/`OUTDOOR_LANE_PIPE_ENABLED` 를 모두 true 로
설정한다. 대상은 **같은 장소 그룹의 lane 샷 2개**(S15sh5 + 동일 그룹의
다른 lane 샷) — acceptance ⑥(그룹 외형 정합) 측정에 2샷이 필요하다.

- [ ] **Step 2: 캔ary 를 실행한다**

Run: `cd backend && python <scratchpad>/canary_lane_mannequin.py 2>&1 | tee <scratchpad>/canary_lane_mannequin.log`
Expected: 두 샷 모두 `맵 → 마커 맵 → 콘티 → 배경 → 엔티티` 5산출 생성,
실패 0

- [ ] **Step 3: 발행 프롬프트를 실측한다**

```bash
grep -c "MANNEQUIN BLOCKING SKETCH" <scratchpad>/*/lane_sketch_*.prompt.txt
grep -c "THINGS AT THIS PLACE" <scratchpad>/*/bgfirst_bg.prompt.txt
grep -c "grey mannequin" <scratchpad>/*/still_*.prompt.txt
grep -o "camera_facing_relation[^,]*" <scratchpad>/*/lane_records.json | head
```
Expected: 각 1 이상, relation 필드가 저작돼 있음

- [ ] **Step 4: 갤러리를 만들고 LAN IP 로 서브한다**

```bash
cd <scratchpad>/lane_mannequin_gallery
python3 -m http.server 8918 --bind 0.0.0.0 &
ipconfig getifaddr en0
```
갤러리 URL 은 반드시 `http://<LAN IP>:8918/` 형태로 제시한다
(localhost 금지).

- [ ] **Step 5: acceptance 6축을 기록한다**

| 축 | 판정 |
|---|---|
| ① 두 인물 방향이 SHOT TEXT·geometry 와 정합 | |
| ② 마네킹·스케치 선 잔류 0 | |
| ③ 장소 발명 0 (spec 목록 밖 시설 부재) | |
| ④ 배경 단계가 마네킹 위치·크기·자세·방향 보존 | |
| ⑤ 한국적 외형 유지 (world facts 배선 실효) | |
| ⑥ 같은 그룹 2샷의 장소 외형 정합 | |

⑥이 깨지면 그 실측이 그룹별 외형 브리프 도입의 근거다(후속 부채로
기록하고 이번 wave 에서 브리프를 만들지 않는다).

- [ ] **Step 6: 사용자 육안 → Codex 코드 리뷰 요청**

캔ary 산출을 사용자에게 제시하고, 동시에 T1~T8 의 누적 diff 를 Codex
에 코드 리뷰 요청한다(설계 리뷰는 이미 APPROVED). 두 결과를 반영한 뒤
production 슬라이스 재검증(TASK ③)으로 넘어간다.

- [ ] **Step 7: 실험 코드는 커밋하지 않는다**

Run: `git status --short | grep -c scratchpad`
Expected: `0` (scratchpad 는 저장소 밖)

---

## Self-Review

**1. 스펙 커버리지**

| 스펙 항목 | Task |
|---|---|
| §4.2(1) sketch v14·plate_look 제외·콘티 lineage=마커맵·CP entry 팩 영속 | T4·T5 |
| §4.2(2) `lane_conti_only`·plate optional·input_ids 면제·`_chain_only` 선판정 | T6 |
| §4.2(3) bg prompt 인자·lane 전용 상수·카디널리티 계약 | T7 |
| §4.3 절제 블록·성공 조건·world 배선·빈 블록 fail-closed | T7 |
| §4.4 팩 3 family + selector 상향 | T2·T4·T7 |
| §4.5 ① 관할 절 | T4 |
| §4.5 ② relation 스키마·비중첩 검증·evidence·선언 렌더 | T1·T2·T3 |
| §4.5 ③ 앞/뒤 명시 문구 | T3 |
| §5(0) 마네킹 유출 3층 | T5 |
| §5 lineage `["bgfirst_bg"]`·allowlist·chain_won 가드 | T6 |
| §6 결정론 유닛 | T1~T8 각 Step |
| §6 캔ary 6축 | T9 |

누락 없음.

**2. Placeholder 스캔** — `<ts>`·`<N+1>`·`<scratchpad>` 는 실행 시점에
확정되는 값이고 각 Task 의 Step 에 확정 명령(`date +%Y%m%d%H%M`,
`ls | sort -V | tail`)을 함께 뒀다. 그 밖의 TBD·"적절히 처리" 류는 없다.

**4. CP 키·필드 실측 반영** — 콘티 CP 의 lane 키는 `lane_contis` 가
아니라 **`lane_conti`** 이고 서비스는 `:310` 에서 그 이름으로 로드한다.
마네킹 소비자 검사는 그 로드 직후에 둔다(`lane_chain` 은 `:1591` 계산
이므로 충분히 앞이다). map_marker entry 의 장소 그룹 키는 **`group_id`**
로 확인됐다(값 예: `bg_rooftop_residence`).

**3. 타입 정합** — `LANE_CONTI_ONLY`(T6 정의 → T7·T8 사용),
`BGFIRST_LANE_PROMPT_VERSION`(T7 정의 → T8 사용),
`_MANNEQUIN_SKETCH_PACKS`(T4 정의 → T5 사용),
`facing_camera_depth`(T1 정의 → T3 사용),
`build_place_facts_block`(T7 정의 → 서비스 사용),
`ws_normalize`(T1 승격 → T1 사용) 모두 정의 Task 가 사용 Task 앞에 있고
이름이 일치한다.
