# Task 5 리포트 — 마네킹 유출 3층 가드

## 상태

완료. 커밋 `b4089b84`(로컬만, push 안 함).

## 구현한 3층

### 1층 — producer 거부 (`shot_conti_light_step.py`)

- `MANNEQUIN_CHAIN_CONTRACT_VERSION = "1"` (모듈 상수, `LANE_MARKER_ANNOTATE_MAX_ATTEMPTS` 아래)
- `_mannequin_sketch_packs()` 모듈 헬퍼 — `outdoor_marker_map._MANNEQUIN_SKETCH_PACKS` 지연 import
- `ShotContiLightStep._mannequin_chain_ready()` staticmethod — 3플래그 AND
- `_run_lane_conti` 의 lane 분기 진입부(`leakage_sys` 팩 프롬프트 로드 직전)에서
  마네킹 팩 + 체인 미준비 = `AppError(step.config.lane_mannequin_chain_off, 422)`

### 2층 — CP 무효화 (`_config_hash`)

`payload["lane_mannequin_chain"] = {contract, bgfirst, bgfirst_full, lane_prev}` 스탬프.

**브리프에서 의도적으로 벗어난 부분 ①**: 브리프 Step 4 는 `_no_plate_conti_on()`
블록 뒤(= 무조건 스탬프)를 지정했지만, `outdoor_lane_pipe_enabled` 블록 **안**에
넣었다. 이유:

- 무조건 스탬프는 lane 파이프를 쓰지 않는 **모든** 프로젝트의
  `shot_conti_light` config_hash 를 바꿔 CP 전량 무효화 → 이미지 생성 재렌더.
  이는 상위 하드 제약("non-lane 경로 byte-identical")과 이 함수가 4곳에서
  명시하는 "opt-in stamping 관례"를 동시에 위반한다.
- 목표는 그대로 달성된다: v14 CP 를 만들려면 lane pipe 가 ON 이어야 하고,
  그 상태에서 lane-prev 만 내리면 스탬프가 여전히 있으므로 hash 가 갈린다.
  lane pipe 를 내리면 lane 블록 자체가 빠져 hash 가 어차피 갈린다.
- 브리프 예시 테스트 `test_config_hash_folds_lane_prev_flag` 는
  `outdoor_lane_pipe_enabled=True` 를 함께 켜도록 수정해 반영했고,
  반대 방향(lane pipe OFF 면 hash 불변)을 잠그는 테스트를 추가했다.

### 3층 — CP entry 팩 영속 + 소비자 무조건 검사

- lane entry 신규 필드 `lane_sketch_pack` / `lane_geometry_pack` /
  `mannequin_chain_contract`.
  **브리프에서 벗어난 부분 ②**: 브리프는 `status="ok"` 신규 생성 경로만
  지정했지만, sidecar **reuse 경로**(`reused=True`)에도 같은 스탬프를 넣었다.
  넣지 않으면 resume 로 만들어진 CP entry 에 팩 필드가 없어 소비자 검사가
  그대로 뚫린다. 두 경로가 어긋나지 않도록 `_pack_stamp` dict 하나를 공유한다.
  (reuse 는 sidecar 지문에 `sketch_pack` 이 접혀 있어 항상 동일 팩이다.)
- resolver 확인: `resolve_sketch_pack_version` = `marker_map_sketch` 팩 맵,
  `resolve_prompt_version` = `outdoor_marker_geometry` 팩 맵. 둘 다
  `outdoor_marker_map` 의 함수이고 `_run_lane_conti` 스코프에 이미 import 되어
  있다(파일 :785-786 원본 기준). 짝이 맞다.
- `still_recipe_service._require_mannequin_chain(lane_entries, *, chain_ready)`
  모듈 헬퍼 + `lane_conti` 로드 **직후** 무조건 호출.

## 소비자 검사가 분기 밖에서 실제로 도는지 증명

두 가지로 증명했다.

1. **런타임 증명** (`test_run_still_recipe_raises_on_mannequin_cp_with_chain_off`)
   — 세 플래그를 전부 False(= `lane_chain` 이 False 로 계산되는 바로 그 상태)로
   두고, `lane_sketch_pack=14.202607261530` 인 v14 CP 를 tmp projects_dir 에
   써서 `run_still_recipe_generation` 을 실제로 호출한다.
   결과: `AppError(still_recipe.lane_mannequin_chain_off)` raise. 통과.

2. **역대조(negative control)** — 검사 호출을 no-op 로 바꿔 같은 테스트를 돌리면
   함수가 끝(:2885 progress.update)까지 관통한다. 즉 검사가 없으면 v14 마네킹
   CP 가 `lane_chain=False` 로 조용히 legacy 조립까지 흘러간다는 것을 실측했다.
   (검사 복구 후 재통과 확인.)

3. 보조로 소스 순서 잠금 테스트
   (`test_service_mannequin_check_runs_before_lane_chain_branch`) — 호출이
   `lane_chain = ` 계산보다 앞임을 줄머리 앵커로 assert.

## 테스트

```
.venv/bin/python -m pytest tests/core/test_shot_conti_light_lane.py \
  tests/unit/test_still_recipe_bgfirst.py \
  tests/pipeline/test_outdoor_marker_map.py -q
→ 117 passed in 0.40s
```

전체 스위트: `7598 passed / 64 failed` — 64 는 기존군(step 카운트 baseline,
manifest, floor_plan 실험 슬라이스 등)과 동수. 해당 파일 부분집합을
`git stash` 전/후로 각각 돌려 `24 failed / 664 passed` 동일 확인.

신규 테스트 8개:
- `test_mannequin_pack_requires_all_three_chain_flags`
- `test_lane_mannequin_producer_fail_closed`
- `test_lane_entry_persists_resolved_packs` (생성/reuse 양 경로)
- `test_config_hash_folds_lane_prev_flag` (+계약 버전 bump 민감도)
- `test_config_hash_mannequin_stamp_is_lane_opt_in`
- `test_service_rejects_mannequin_cp_when_chain_flag_off`
- `test_service_mannequin_check_runs_before_lane_chain_branch`
- `test_run_still_recipe_raises_on_mannequin_cp_with_chain_off`

기존 lane 테스트 하네스(`_run_lane`)는 체인 3플래그를 기본 ON 으로 patch 하도록
바꿨다(`chain_on=False` 로 가드 자체를 검증). v14 는 체인 전제이므로 하네스가
현행 계약을 반영하는 것이 맞고, 가드 뒤 동작을 보려는 기존 assert 는 그대로 산다.

## 남은 우려

1. **이 커밋 이전에 만들어진 v14 CP** 는 entry 에 `lane_sketch_pack` 이 없어
   소비자 검사(3층)가 잡지 못한다. 2층(hash)이 `lane_mannequin_chain` 스탬프
   추가로 그런 CP 를 무효화하지만, 그건 스텝 러너를 다시 탈 때 얘기다.
   still_recipe 를 단독으로 돌리는 부분 실행은 여전히 구 CP 를 소비할 수 있다.
   실무 영향은 작다(v14 는 직전 커밋에서 도입, 세 플래그가 전부 default False
   라 v14 CP 를 정상 생성한 이력이 사실상 없음). 필요하면 후속으로
   "lane entry 에 팩 스탬프 부재 = 재실행 요구" 를 별도 게이트로 올릴 수 있다.
2. **1층 가드는 lane 분기 전체에 걸린다** — 바인딩이 전부 `none`/
   `structure_plate`(= 마네킹을 실제로 굽지 않는 조합)여도 v14+lane pipe ON+
   체인 OFF 면 스텝이 fail 한다. 브리프 지정 위치를 따랐고, 그 조합 자체가
   설정 오류라는 판단(fail-closed 관례). 더 좁히려면 가드를 `map_marker`
   바인딩 존재 여부로 조건화하면 된다.
3. `_config_hash` 의 lane 게이팅 결정(위 ①)은 브리프 문구와 다르므로
   리뷰에서 명시적으로 확인받는 게 좋다.

## 리뷰 지적 수정

리뷰 4건 전부 반영. 앞 절 "남은 우려 2" 가 지적①과 같은 내용이라 여기서 해소됐다.

### ① producer 가드가 마네킹을 굽지 않는 lane 까지 hard-fail

`backend/app/core/steps/shot_conti_light_step.py` — `bindings` 는
`lane_bindings_by_tag` 가 돌려주는 전체 바인딩이라 `none`(일반 파이프로
continue)·`structure_plate`(정책 entry 만 만들고 continue)까지 포함한다. 둘 다
스케치를 굽지 않으므로 유출 표면이 0인데, 구 가드는 lane pipe ON 프로젝트를
기본 플래그에서 무조건 fail 시켰다.

가드를 **실제로 마네킹 스케치를 굽는 샷**으로 좁혔다 — `lane == "map_marker"`
**이면서** `is_selected(parse_tag(t), selected_keys)`:

```python
_mannequin_shots = [
    t for t, b in bindings.items()
    if b.get("lane") == "map_marker"
    and is_selected(parse_tag(t), selected_keys)
]
if (
    LANE_SKETCH_PACK_VERSION in _mannequin_sketch_packs()
    and _mannequin_shots
    and not self._mannequin_chain_ready()
):
```

`is_selected`(:946 import)·`parse_tag`(:843 import)·`selected_keys`(파라미터)
전부 이 지점에서 이미 in-scope. 위치는 `if not bindings: return` 조기 반환
뒤 그대로다(루프 진입 전 = 이미지 콜 0회 보장).

선택 샷 기준까지 넣은 이유: 루프가 `is_selected` 로 continue 하므로 선택 밖
`map_marker` 도 스케치를 굽지 않는다 — 가드 집합이 루프 집합과 같아야 한다.

### ② 소비자 가드의 조건 중복 → 파생 local 사용

`backend/app/services/still_recipe_service.py` — `settings` 3플래그 AND 를 다시
풀어쓰던 것을 위에서 이미 계산한 local 로 교체:

```python
_require_mannequin_chain(
    lane_conti,
    chain_ready=bool(bgfirst_on and lane_prev_chain_on),
)
```

`bgfirst_on`(:204)·`lane_prev_chain_on`(:212, `= bgfirst_full_on and lane_prev`)
둘 다 호출(:350)보다 앞에서 할당돼 있어 이동 불필요. 의미는 동일하며
(`bgfirst and full and lane_prev`), 이제 per-shot 루프의
`lane_chain = lane_prev_chain_on and lane_used`(:1637)와 같은 local 을 공유하므로
"lane_chain 이 True 가 될 수 있는 상태" 정의가 한 곳에만 존재한다. 호출은
여전히 `lane_chain` 분기 **밖**·앞이고, 소스 순서를 잠그는 기존 테스트
(`test_service_mannequin_check_runs_before_lane_chain_branch`)의 줄머리 앵커도
유지된다.

### ③ bgfirst ON + full ON + lane_prev OFF 런타임 테스트

`test_run_still_recipe_raises_on_mannequin_cp_with_lane_prev_off` 추가. v14 CP 가
가장 그럴듯하게 남는 상태(체인 ON 으로 구운 뒤 플래그 하나만 내림)이고, CP 에
`conti_pack = resolve_prompt_version("5")` 를 넣어 선행 conti_pack 검사를 **통과**
시킨 뒤 마네킹 가드에 도달함을 증명한다 — 에러 코드
`still_recipe.lane_mannequin_chain_off` assert.

### ④ 중복 import

`tests/core/test_shot_conti_light_lane.py` `_run_lane` 안의
`from contextlib import ExitStack` 제거(모듈 최상단 :7 과 중복).

### 신규 테스트

- `test_mannequin_guard_only_for_shots_that_bake_a_sketch` — `none`+
  `structure_plate` 만인 바인딩은 체인 OFF 에서도 raise 하지 않고
  `structure_plate` 정책 entry(`ab_select_pending`)를 정상 산출; 선택된
  `map_marker` 를 하나 섞으면 `step.config.lane_mannequin_chain_off` raise.
- `test_mannequin_guard_ignores_unselected_map_marker` — `selected_keys=set()`
  이면 `map_marker` 도 가드 대상이 아님.
- `test_run_still_recipe_raises_on_mannequin_cp_with_lane_prev_off` (위 ③).

다중 바인딩 픽스처 헬퍼 `_multi_lane_cps({shot_index: lane})` 추가 — parity·
커버리지 재검증이 성립하도록 staging/validator 샷 목록을 함께 채운다. 픽스처는
전부 시나리오 중립(`sample_site`/`L01`/`SAMPLE`).

### 검증

```
.venv/bin/python -m pytest tests/core/test_shot_conti_light_lane.py \
  tests/unit/test_still_recipe_bgfirst.py \
  tests/pipeline/test_outdoor_marker_map.py -q
→ 120 passed
```

뮤테이션 확인 — 가드에서 `and _mannequin_shots` 를 빼면 신규 테스트 2건이
즉시 FAIL(회귀 가드가 실효). 원복 후 28 passed.

광역 스윕 `tests/unit tests/core tests/pipeline`: 4480 passed / 10 failed.
같은 10건이 **수정 전 clean tree 에서도 동일하게 실패**(4477 passed)하므로 전부
선행 실패이며 회귀 0건 — 증가분 3 = 신규 테스트 3건.

### 남은 우려

앞 절 "남은 우려" ①·③ 은 그대로 유효(구 v14 CP 의 팩 스탬프 부재, `_config_hash`
lane opt-in 게이팅). ② 는 이번 수정으로 해소.
