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

- **커밋**: `b28f16fe` — `feat(lane): 배경 i2i 장소·world 텍스트 배선 + bg_fill 팩`
- **브랜치**: `feat/w19-w20-bg-planning-cleanup` (푸시 안 함)
- **베이스**: `73a7ec33`

---

## 1. 생성된 팩

**`prompts/_base/still_recipe/12.202607270537/`** — `11.202607220237` 의
`cp -r` 사본(`diff -r` 무차이 확인) + 신규 스템 2종.

| 파일 | 상태 |
|---|---|
| `bg_fill_head.md` | 신규 (브리프 원문 verbatim) |
| `bg_fill_tail.md` | 신규 (브리프 원문 verbatim) |
| `bg_reproject_head.md` / `bg_reproject_tail.md` / `bg_reproject_seed_clause.md` / `drawn_mark_clause.md` / `groupbg_head.md` / `groupbg_tail.md` / `groupbg_detail_head.md` / `groupbg_evidence_head.md` / `naturalism_clause.md` | v11 byte-identical 사본 |

기존 팩 1~11 은 한 바이트도 건드리지 않았다(덮어쓰기 금지 규칙).

팩 추가가 다른 경로에 새는지 확인함 — `still_recipe` 모듈의 `load_prompt`
호출은 **전부** `version=resolved` 로 핀돼 있어(전수 grep) `_list_module_versions`
의 latest 가 12 로 바뀌어도 비 lane 스템 해석은 불변이다.

## 2. selector 상수 (exact)

`backend/app/modules/pipeline/still_recipe.py`

```python
BGFIRST_LANE_PROMPT_VERSION = "12"
BGFIRST_LANE_CONTRACT_VERSION = "1"
```

`PROMPT_VERSION_MAP` 등록: `"12": "12.202607270537"` (Korean 주석 동반).
팩과 selector 상수는 같은 커밋 — 팩 발행 = selector 배선까지가 한 단위.

## 3. 파일별 변경

### `backend/app/modules/pipeline/still_recipe.py`

1. `PROMPT_VERSION_MAP` 에 `"12"` 등록 + v12 주석(bg_fill 2종 추가, 나머지=v11 사본).
2. `BGFIRST_LANE_PROMPT_VERSION` / `BGFIRST_LANE_CONTRACT_VERSION` 신설
   (`BGFIRST_FULL_*` 정의 바로 아래, "한 상수에 세 역할 섞지 않는다" 주석).
3. **신규 `build_place_facts_block(spec) -> str`** — 브리프 코드 그대로.
   `layout_narration_en` 1단락 + items 의 `kind`/`name_en` 만 직렬화.
   `placement_en`(콘티와 이중 권위) / `code`·`evidence`·`inferred`·
   `zone_labels_en`(감사 필드·중복) / `excluded_transient_elements`(프라이밍)
   전부 제외. non-dict → `""`, name 중복/공백 skip.
   - 브리프의 `seen = set()` 은 `seen: Set[str] = set()` 으로 annotate
     (모듈이 `Set` 을 이미 import 하고 주변 코드가 전부 annotate 관례).
4. **`build_bgfirst_bg_prompt`** 시그니처에 `place_facts_block=""`,
   `world_facts_block=""`, `lane_fill=False` 추가(기존 인자 순서 보존,
   `prompt_version` 은 계속 마지막). docstring 에 lane_fill 항 추가.
   본문은 브리프대로 head/tail 스템 분기 + fail-closed 2종
   (`place_facts_block` 빈 값 / `world_facts_block` 빈 값 → `ValueError`).
   사실 블록은 CAMERA/LIGHTING **뒤**에 붙는다 — tail 의 "the list above"
   가 바로 앞 두 절을 가리켜야 배치 무권한 선언이 성립한다(코드 주석에 명시).

### `backend/app/services/still_recipe_service.py`

1. **모듈 상단 import 2종 추가** (순환 없음 — 아래 §5 참조):
   ```python
   from app.core.world_context import build_world_facts_block
   from app.modules.pipeline.still_recipe import build_place_facts_block
   ```
   이 파일이 지연 import 관례라는 점을 주석으로 남겼다(왜 이 둘만 상단인지).
2. **CP 2종 런당 1회 로드 + `_lane_place_facts` 클로저** — `run_still_recipe_generation`
   안, `location_detail_by_name` 블록 직후 / `contis = conti_data.get("contis")`
   직전 (원본 `:331` 부근). 첫 사용 지점은 Step1 프롬프트 조립(`:2509` 부근)
   이므로 정의가 확실히 앞선다. 성공 판정은 브리프대로
   `isinstance(entry, dict) and not entry.get("error")` + `isinstance(spec, dict)`
   + 블록 non-empty (3층 fail-closed). `status == "ok"` 는 쓰지 않는다.
3. **Step1 호출 lane 분기** (`build_bgfirst_bg_prompt`):
   `_lane_fill = _authority_kind == LANE_CONTI_ONLY`, `place_facts_block=`
   `_lane_place_facts(lane_entry.get("group_id") or "")` (lane 일 때만),
   `world_facts_block=_lane_world_block`, `lane_fill=_lane_fill`,
   `prompt_version` 3분기(`lane → v12`, `full → v11`, `else → v7`).
   블록 import 에 `BGFIRST_LANE_PROMPT_VERSION as _BGF_LANE_PACK_SEL` 추가.
4. **지문 스탬프**: `if bgfirst_on:` 블록 안, `if bgfirst_full_on:` 다음에
   `if settings.still_lane_prev_bgfirst_enabled:` 분기 →
   `extra_fingerprint["bgfirst_lane_pack"]` / `["bgfirst_lane_contract"]`.
   (브리프 스니펫의 8-space 들여쓰기를 따랐다. lane ⊆ full ⊆ bgfirst 는
   `:248-262` fail-closed 가 이미 보장하므로 중첩/평면 어느 쪽이든 동작은
   같지만, 중첩이 "체인이 있을 때만 스탬프"라는 의미를 더 정확히 읽힌다.)

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

- 브리프의 테스트 3종 + 보강 6종. 픽스처는 전부 `SAMPLE_FIXTURE_*` 범용 주제
  (waiting area / paved yard / low perimeter wall — 작품 고유명사 0).
- 모듈 계약: `test_place_facts_block_is_restrained`,
  `test_place_facts_block_degrades_on_non_dict`,
  `test_place_facts_block_dedupes_and_skips_nameless`,
  `test_lane_pack_registered`, `test_lane_contract_version_present`,
  `test_bg_fill_prompt_requires_place_and_world_facts`,
  `test_bg_fill_prompt_order_and_stems`.
- **비 lane byte-identical**: `test_non_lane_bg_prompt_is_byte_identical` —
  v7·v11 두 selector 모두에 대해 ①새 인자 default vs 미전달 ②`lane_fill=False`
  면 사실 블록을 넘겨도 조립 불변, 두 가지를 핀한다.
- 서비스 배선(기존 `_run_lane_shot` 하니스 확장):
  `test_lane_step1_prompt_carries_place_and_world_facts` (실제 루프의 Step1
  프롬프트에 narration·`- wall: low perimeter wall`·Region·Era 가 실리고
  `placement_en`·code·`LOCATION PHOTOGRAPH` 는 없음),
  `test_lane_step1_fails_closed_without_place_spec`,
  `test_lane_step1_fails_closed_on_failed_place_spec_group` (error entry),
  `test_lane_step1_fails_closed_without_world_rules`,
  `test_lane_facts_are_not_wired_into_non_lane_paths` (소스 앵커 잠금).
- `_run_lane_shot` 에 `place_spec` / `world_rules` / `place_group` 파라미터와
  `outdoor_place_spec`·`visual_world_rules` CP 저작, lane entry 의 `group_id`,
  Step1 프롬프트 캡처(`seen["step1_prompt"]`)를 추가했다.

## 4. 검증

```
cd /Users/manta/Documents/Projects/TheRoad-I1/backend
.venv/bin/python -m pytest tests/unit/test_still_recipe_bgfirst.py \
    tests/unit/test_still_recipe.py tests/pipeline/test_outdoor_place_spec.py -q
```
→ **`161 passed in 0.38s`**

TDD 순서 실측:
- Step 2 (구현 전): `8 failed, 34 deselected` — `ImportError: cannot import
  name 'build_place_facts_block' / 'BGFIRST_LANE_CONTRACT_VERSION'`.
- 구현 후 해당 8개 전부 PASS.

광역 회귀: `pytest tests/unit tests/pipeline tests/core -q`
→ `10 failed, 4502 passed, 7 skipped, 2 deselected`.
**10건은 전부 사전 결함** — 같은 tracked 변경분을 `git stash` 하고 HEAD 상태로
그 10개를 재실행해 동일 집합이 실패함을 확인했다
(`test_background_prompt_step_v7` 1건 / `test_d6_hash_isolation` 2건 /
`test_d6_master_plan_determinism` 3건 / `test_outdoor_place_spec_step` 1건 /
`test_visible_entities_validator` 3건). 내 변경이 만든 실패 0.

추가 확인: 추가된 모든 라인 ≤ 79자(문자 기준). flake8 은 이 venv 에 미설치.

## 5. 브리프 부정확/보완 사항

1. **`bg_fill_head` 는 단락이 3개라 `"\n\n".split()` 기반 순서 테스트가
   성립하지 않는다.** 기존 `bg_reproject_head` 는 단일 블록이라 관례적으로
   `parts = p.split("\n\n")` 인덱스 검사를 써 왔는데, 새 head 는 내부 빈 줄이
   있어 `parts[1]` 이 head 의 두 번째 단락이 된다. 스템 문구는 브리프대로
   유지하고(verbatim 요구), 내가 추가한 순서 테스트를 등장 위치 정렬
   (`positions == sorted(positions)`) 방식으로 바꿨다. 구현에는 영향 없음.
2. **모듈 상단 import 는 순환을 만들지 않는다 — 그대로 상단에 뒀다.**
   `app.core.world_context` 는 stdlib 만 import 하고,
   `app.modules.pipeline.still_recipe` 는 `app.modules.prompt_loader` 만
   import 하며(그 파일은 `app.core.config` 를 함수 안에서 지연 import),
   `still_recipe` 쪽에서 services 를 역참조하지 않는다.
   `python -c "import app.services.still_recipe_service"` 및 전체 스위트로 확인.
   다만 이 파일은 상단에 app import 가 **하나도 없던** 파일이라, 왜 이 둘만
   예외인지 Korean 주석으로 남겼다.
3. **`_lane_place_facts` 정의 위치** — 브리프는 "`:250` 부근 background_share_plan
   로드 옆"이라 했는데, 실제 그 로드는 `:288` 이고 그 뒤로 `group_of` /
   `group_evidence` / `location_detail_by_name` 조립이 이어진다. 그 블록을
   쪼개지 않도록 `location_detail_by_name` 완료 직후(=`contis` 대입 직전)에
   넣었다. 첫 사용은 Step1 조립부라 정의가 충분히 앞선다.
4. **브리프의 기존 테스트 영향 미기재** — `_run_lane_shot` 하니스가
   `outdoor_place_spec` / `visual_world_rules` CP 를 저작하지 않고 lane entry 에
   `group_id` 도 없어서, 배선 직후
   `test_lane_shot_with_assigned_plate_resolves_lane_conti_only` 가 새 fail-closed
   에 걸려 깨졌다(= 배선이 실제로 살아있다는 증거). 하니스에 CP 2종 +
   `group_id` 를 추가해 복구했고, 결손 케이스는 새 fail-closed 테스트로 분리했다.
5. Step 9 의 실행 명령이 `python -m pytest` 인데 bare `python` 에는 pytest 가
   없다 — `.venv/bin/python -m pytest` 로 실행했다.

## 6. 우려 사항

1. **`group_id` 는 계약이 아니라 실측이다.** `_lane_place_facts` 의 조회 키는
   lane CP 의 `group_id` 와 place spec CP 의 그룹 키가 같은 네임스페이스라는
   전제에 기대고 있다(예: `bg_rooftop_residence`). 두 스텝의 그룹 키가 갈라지면
   전 lane 샷이 "그룹 결손" 으로 fail-closed 되어 **전량 정지**한다 — 조용한
   degrade 보다는 낫지만, E2E 첫 런에서 이 축이 제일 먼저 깨질 지점이다.
   운영 시 첫 lane 샷의 mark_failed 메시지를 확인할 것.
2. **`visual_world_rules` 미실행 프로젝트는 lane 샷을 전량 잃는다.**
   `build_world_facts_block` 은 CP 부재 시 `""` 를 반환하고(비 lane byte-identical
   보장을 위한 설계), lane 은 그 빈 블록에서 죽는다. 이것이 의도된 fail-closed
   지만, 파이프라인 상 `visual_world_rules` 가 lane 스틸의 **경성 선행 의존**이
   된 것은 이 태스크가 새로 만든 결합이다. 스텝 순서상 항상 앞서 실행되므로
   정상 run-all 에서는 문제없으나, 부분 재실행 시나리오는 검증 안 됨.
3. **`region`/`era` 가 빈 프로젝트도 같은 방식으로 정지한다** — rules 만 있고
   region/era 가 비면 `build_world_facts_block` 이 rule guideline 만 담은 블록을
   내므로 통과하지만, 셋 다 비면 `""` 다. 실측 프로젝트에서는 region/era 가
   항상 채워져 있어 지금은 문제되지 않는다.
4. **팩 v12 는 비 lane 스템 9종의 사본을 포함한다.** 현재 코드가 전부
   version-pinned 라 드리프트 위험은 없지만, 앞으로 누군가 `version=` 없이
   `load_prompt(_MODULE, ...)` 를 추가하면 latest=12 로 해석돼 팩 간 결합이
   생긴다. `PROMPT_VERSION_PACK_STRICT` 를 켤 계획이 있다면 12 팩에 `stage_head`
   등 v7 전용 스템이 없다는 점(현재 v7 팩에만 존재)이 걸릴 수 있다 —
   이는 v9~v11 도 동일한 기존 상태다.
5. **육안 검증 미실시.** bg_fill 프롬프트가 실제로 마네킹을 보존한 채 배경만
   채우는지, 절제된 사실 블록이 할루시네이션을 막는지는 결정론 테스트로
   증명되지 않는다 — E2E 이미지 육안이 유일한 판정이다.

---

## 리뷰 지적 수정

리뷰 5건(Important 1 + Minor 4) 대응. 팩 변경 없음(v12 그대로).

### Important — 팩 v12 가 스텝 config hash 에 안 보여 완료 프로젝트가 기능을 통째로 스킵

`image_steps.py` 의 `SceneImagePipelineStep._config_hash_base` 는 lane 체인
분기에서 불리언 `still_lane_prev_bgfirst_enabled = True` 만 찍고 있었다. 두 줄
위 형제 분기(`still_bgfirst_full_enabled`)는 정확히 이 목적으로
`bgfirst_full_pack` / `bgfirst_full_contract` 를 병행 스탬프한다.

문제는 스텝이 **실행 여부를 먼저 접는다**는 것이다 — CP 가 clean 하고 verify 가
통과하면 `step_runner.py:613-620` 이 그대로 skip 하므로, 이 플래그 조합으로 이미
`still_recipe` 를 완주한 프로젝트는 팩을 v11→v12 로 올려도 재실행 자체를 하지
않아 bg_fill 스템도 장소·world 사실 블록도 영영 못 본다. 직전 커밋이 넣은
`extra_fingerprint` 는 스텝이 "실행한다"고 정한 **뒤**에나 작동하므로 이 구멍을
못 막는다.

수정 — 기존 분기 안에 형제와 동형으로 두 줄 추가(로컬 리졸버 `_bgf_pack` 재사용):

```python
payload["bgfirst_lane_pack"] = _bgf_pack(_bgf_lane_sel)
payload["bgfirst_lane_contract"] = _bgf_lane_contract
```

**config hash 가 무엇이 달라지는가**
- 이전: `BGFIRST_LANE_PROMPT_VERSION` 을 "11"→"12" 로 올려도 payload 는
  `{"still_lane_prev_bgfirst_enabled": True}` 로 **동일** → hash 불변 → 완료
  프로젝트 clean-skip 유지 → v12 미적용.
- 이후: payload 에 해석된 팩 디렉토리(`12.202607270537`)와 계약 버전(`1`)이
  들어가 selector 상향·팩 재발행·계약 상향 모두 hash 를 움직인다 → 완료
  프로젝트의 CP 가 stale 로 판정되어 재실행된다.
- OFF 경로(`still_lane_prev_bgfirst_enabled=False`)는 payload 키가 추가되지
  않아 기존 hash byte-identical — 테스트로 함께 잠갔다.

테스트: `tests/unit/test_still_recipe.py::test_scene_pipeline_hash_stamps_lane_bgfirst_pack`
(기존 lane-pipe 팩 테스트 바로 뒤, 같은 style). OFF 불변 → ON 변화 → 팩만
재발행 시 변화 → 계약만 상향 시 변화 를 각각 핀. 수정 전 코드에서 실패 확인.

### Minor 1 — 잘못된 타입 주석이 가드를 죽은 코드로 보이게 함

`still_recipe.py` `build_place_facts_block(spec: Dict[str, Any])` → `spec: Any`.
`Dict` 로 좁히면 정적 검사기가 `isinstance(spec, dict)` 를 항상 참으로 narrow
해 가드가 unreachable 로 읽히지만, 런타임에서는 체크포인트 원본(무타입)이
그대로 들어와 `None`/`[]` 가 실제로 도달한다(기존 테스트가 이미 증명).
`Optional[Dict[...]]` 로는 `None` 만 덮이고 `[]` 를 못 담아 부적합. docstring 에
사유를 남겼다.

### Minor 2 — 제외 필드 6종 중 3종이 미검증

`SAMPLE_FIXTURE_SPEC` 에 `evidence` 키가 아예 없었고 `inferred`/`temporal_scope`
는 부재 단언이 없어, 그 셋을 블록에 다시 넣어도 테스트가 초록이었다.
- fixture B2 항목에 `evidence={"scene_index": 3, "quote_ko": "SAMPLE_FIXTURE_QUOTE"}`
  와 `inferred: True` 추가.
- `test_place_facts_block_is_restrained` 의 부분 문자열 부재 나열을
  `block.splitlines() == [...]` **행 전수 고정**으로 교체(같은 파일의
  `test_place_facts_block_dedupes_and_skips_nameless` 가 이미 쓰던 패턴).
  단언 한 줄이 placement_en/code/evidence/inferred/temporal_scope/zone_labels_en/
  excluded_transient_elements 의 미주입을 동시에 잠근다.

### Minor 3 — 생산자의 세 번째 entry shape 가 무명

`outdoor_place_spec_step.py:197-200` 은 근거 씬 0 인 그룹에
`{"skipped": ..., "outdoor_loc_ids": [...]}` 를 남긴다. spec 키가 없어 기존
비-dict 가드로 fail-closed 되긴 했지만 운영자에게는 "spec 이 dict 아님" 으로
보여 원인(씬 매핑 결손)에 닿지 않았다. `_lane_place_facts` 에 `entry.get("skipped")`
분기와 전용 메시지("저작 스킵 … 근거 씬이 매핑되지 않아 스펙이 없음") 추가,
docstring 에 세 번째 shape 를 명시. 테스트
`test_lane_step1_fails_closed_on_skipped_place_spec_group` (수정 전 코드에서는
"dict 아님" 메시지가 나와 실패 확인).

### Minor 4 — 순서 테스트가 구분자를 안 잠금

`test_bg_fill_prompt_order_and_stems` 의 `positions == sorted(positions)` 는 절이
`"\n\n"` 대신 `"\n"` 으로 이어져도 통과한다. 사실 블록이 앞 절에 붙으면 tail 의
"the list above" 지시가 흐려지므로 빈 줄 경계를 명시 단언:
`"\n\nTHINGS AT THIS PLACE:" in p`, `"\n\nWORLD FACTS (creator-confirmed — always true):" in p`.

### 범위 밖(의도적 미수정)

Step2 가 여전히 v7 `stage_head` 를 쓴다는 지적 — 마네킹이 최종 이미지까지
보존되고 lane 이 더는 첨부하지 않는 LAYOUT SKETCH 를 참조하는 문제. 다음
태스크의 주제로 남긴다.

### 검증

```
cd backend && .venv/bin/python -m pytest \
  tests/unit/test_still_recipe_bgfirst.py tests/unit/test_still_recipe.py tests/core/ -q
→ 10 failed, 1739 passed, 7 skipped, 2 deselected
```

기준선(HEAD `b28f16fe`, 수정 전) = `10 failed, 1737 passed, 7 skipped` — **동일한
10건**이 사전 실패(test_background_prompt_step_v7 1, test_d6_hash_isolation 2,
test_d6_master_plan_determinism 3, test_outdoor_place_spec_step 1,
test_visible_entities_validator 3)이고 이번 수정과 무관하다. 증가분 +2 = 신규
테스트 2건. 신규 2건은 각각 수정 전 코드에서 실패함을 실측 확인(red→green).

추가 확인: 변경 라인 전부 79자 이내(비 lane 경로 무변경, 팩 무변경).

### 우려 사항

1. **완료 프로젝트의 재실행 비용.** 이 수정의 의도된 효과가 곧 비용이다 —
   lane 체인 ON 상태로 `still_recipe` 를 완주한 프로젝트는 이 커밋 이후 CP 가
   stale 이 되어 스텝 전체가 재실행된다(lane 샷뿐 아니라 그 스텝의 전 샷).
   의도이나 E2E 예산에 잡힐 항목이다.
2. **`skipped` 분기는 여전히 fail-closed 다.** 메시지만 정확해졌을 뿐 근거 씬
   0 인 그룹의 lane 샷은 계속 죽는다. 그 그룹에 실제로 lane 샷이 배정되는
   조합이 실측에 존재하는지는 확인하지 않았다 — 존재한다면 이 태스크가 아니라
   씬 매핑 쪽 결함이다.
3. **행 전수 고정 테스트의 취약성.** `splitlines()` 단언은 narration 문구를
   그대로 담고 있어 fixture 를 손대면 함께 고쳐야 한다. 절제 계약이 이 블록의
   본질이라 의도한 트레이드오프다.
