# 변경 이력 — prompt diet

각 단계는 **독립 커밋**이다. 되돌리려면 해당 커밋만 `git revert` 하면 된다.
프롬프트 팩은 덮어쓰지 않고 **새 판**으로 발행하므로 이전 판이 그대로 남는다.

---

## ① camera-only repair — 원문 번들 재첨부 제거

**파일**: `backend/app/modules/pipeline/shot_aware_bg_render_plan_llm_provider.py`

**무엇을 뺐나**: `_build_camera_repair_user_prompt()` 말미의

```
## Original input bundle (for context)

{original_user_prompt}
```

와 그 파라미터, 호출부 인자(`:766` → `original_user_prompt=user_prompt`).

**왜 안전한가**

1. 이 분기는 `_is_camera_only_failure()` 가 `camera_in_candidates_ok` 만
   False 이고 나머지 **11개 검증축 전부 True** 일 때만 진입한다
   (`required_shape_ok` / `graph_completeness_ok` / `node_index_order_ok` /
   `dag_ok` / `max_refs_per_bg_ok` / `two_refs_distinct_spaces_ok` /
   `same_fp_only_ok` / `anchor_exactly_one_ok` /
   `anchor_in_clean_candidate_set_ok` / `rationale_and_mode_ok` /
   `synthetic_readback_production_clear`). 즉 graph·anchor·reference 는
   이미 확정돼 있다.
2. 프롬프트 계약이 "카메라 필드만 고치고 나머지는 전부 보존"이다 —
   `Do NOT change any other field. Do NOT invent any new node. Do NOT
   compute or interpolate a coordinate — use ONLY values from the
   candidate arrays above.`
3. 고쳐야 할 좌표는 프롬프트 안 `camera_cell_candidates_per_unit` /
   `look_at_cell_candidates_per_unit` 배열에서만 고른다. 이전 graph 전문도
   `## Previous graph you emitted` 블록으로 이미 들어 있다.
   → **원문 번들이 기여할 자리가 없다.**

**절감**: 그 분기 콜의 user 대부분. 최대 콜 기준 user 114,089자 중
원문 재첨부분이 빠진다.

**손대지 않은 것**: `_build_graph_anchor_repair_user_prompt()` 의
`original_user_prompt` 는 **유지**했다(`:422,448,932`). 이 경로는 누락 노드·
관계를 다시 만들 수 있어 원문 컨텍스트가 필요하다.

**검증**: `tests/pipeline/test_shot_aware_bg_render_plan_llm_provider.py`
60 passed. (이 영역의 실제 판정은 ④ A/B 육안 — 테스트는 shape 보장일 뿐이다.)

**복구**: 이 커밋만 revert.

---

## ② 최초 shot_aware 호출 — dossier 안 per-BG facts 중복 제거

**파일**: `backend/app/modules/pipeline/shot_aware_bg_render_plan_llm_provider.py`
(`_build_user_prompt`)

**무엇을 뺐나**: `dossier_block` 직렬화 사본에서
`per_bg_render_facts_by_bg_id` 키만 제외했다.

```python
per_bg_facts = (dossier or {}).get("per_bg_render_facts_by_bg_id") or {}
dossier_for_block = {k: v for k, v in dossier.items()
                     if k != "per_bg_render_facts_by_bg_id"}   # 신규
...
dossier_block=json.dumps(dossier_for_block, sort_keys=True),   # 변경
per_bg_facts_block=json.dumps(per_bg_facts, sort_keys=True),   # 그대로
```

**왜 안전한가**

- 같은 JSON 이 한 콜에 **두 번** 실리고 있었다 — `dossier_block` 안에 한 번,
  `per_bg_facts_block` 으로 또 한 번.
- 프롬프트 팩(`3.202607231435/user_template.md`)이 두 섹션을 이미 분리해
  두었고(`## Base location dossier` / `## Per-BG render facts (from
  W19B-1 / W20A)`), dossier 섹션이 facts 포함을 말하지 않는다.
  → 전달 내용의 손실 0. 오히려 섹션 제목대로 정확해진다.
- `dossier` 가 dict 가 아닐 때(None 등)는 사본을 만들지 않고 원래 값을
  그대로 넘겨 기존 직렬화 결과(`null`)를 유지한다.

**손대지 않은 것**: `camera_candidates_block` 은 `geometry_block` 안에도
같은 후보 집합이 들어 있지만 **의도된 중복**이다(W20F8 — LLM 이
exact-copy 를 어기고 좌표를 지어내지 않도록 두 번 노출). docstring 에
"prompt-diet 대상이 아니다"라고 명시해 두었다.

**검증**: `test_shot_aware_bg_render_plan_llm_provider.py` +
`test_shot_aware_bg_render_plan.py` + `test_w20f_narrow_wave.py`
**181 passed**.

**복구**: 이 커밋만 revert.

---

## ③ scene_detail v38 — 카드가 확정하는 사안의 산문 중복 제거

**새 판**: `prompts/_base/scene_detail/38.202608032308/`
(37판을 복사해 만들었고 **37판은 그대로 남아 있다** — 되돌리려면 38판
디렉토리를 지우고 상수를 37 로 되돌리면 끝).

**크기**: 39,812 → **33,673자 (-6,139자, -15.4%)**

### 무엇을 근거로 뺐나

1. `system.md` 자체가 이미 명시한다 — *"본문 prose 는 G4.2~G4.5 lift 완료 후
   단계적으로 제거 예정이므로 reference 일 뿐 contract 가 아니다"*.
   이번 작업이 그 제거다.
2. `detail_steps.py:2966` 주석에 **"R1-I4 card-wins precedence"** — 충돌 시
   카드가 이긴다는 것이 이미 코드 계약이다.
3. `render_prompt_card.py` 의 `_card_metadata.lift_status` 가 무엇이 lift
   완료인지 기록한다. `id_policy_composite_lifted` /
   `id_policy_close_framing_face_lifted` /
   `id_policy_reproduction_surface_lifted` / `rule_h_lifted` **전부 True**.
4. 실측 대조 — 카드 `id_policy.constraints` 5개가 산문의 핵심을 전부 담고
   있고 오히려 더 완전하다(`face_identifiability_rule`, ethnicity·age_band
   실제 토큰 목록, 물리적 사진 prop 은 `P##` 유지라는 예외까지). 산문 스스로
   *"구체 token list 는 모두 `id_policy` sub-field 에서 가져온다"* 고 적고 있다.

### 섹션 ①: `## ID Policy` 3,601자 → 약 350자

카드 `id_policy.constraints` 가 policy enum 3종 처리·생략 시 default·
reproduction surface·demographic format 을 전부 지시하므로, 본문은 카드를
가리키기만 한다. **남긴 것** = `perception_mode` 가
`REPRODUCTION_PERCEPTION_MODES` 에 속할 때의 우선 적용(카드에 없음).

### 섹션 ②: `## Rule X-2` 4,309자 → 약 1,100자

policy enum 3종 반복 설명, "왜 policy-conditional 인가" 장문, ✗/✓ 예시
블록을 제거했다. **남긴 것 = 카드가 주지 않는 5개**:

1. descriptor 는 ID 를 대체하지 못한다(보조로만)
2. multi-character 에서 같은 base ID 반복 금지
3. forward enforcement 면제 조건(`partial_focus` /
   `reproduction_surface_rule.applies`), reverse·`entity_canon.name` 은 항상 검사
4. 미등록 background figure 는 ID 없이 신원 미상 표현
5. `entity_canon.name` 을 적었으면 같은 문장에 ID 동반

여기에 `visible_entities_validator` 가 policy 별로 fail-fast 한다는 사실.

### 함께 갱신한 것

- `detail_steps.py:108` `SCENE_DETAIL_PROMPT_VERSION` → `"38.202608032308"`
  (cp `config_hash` 계산용 — 이 상수를 안 올리면 cp invalidation 이
  어긋난다. 파일 주석에 *"미bump 부채 — constant≠latest 기존실패의 실체"*
  라는 이력이 있다.)
- `tests/test_prompt_versions.py::test_detail_steps_constants_match`
- `SCENE_DETAIL_SCHEMA_VERSION` 은 **13 유지** — 출력 shape 변경 0.

### 손대지 않은 것

- `rule_e_lifted` 는 lift_status 에서 **조건부(변수)** 다 → `## Background
  Binding (Rules A/C/E)` 993자는 그대로 뒀다.
- 복장·실루엣·Continuity·Spatial 등 나머지 28개 섹션 전부 그대로.

**검증**

- 로더가 38판을 고르는지 확인 — `system.md` / `detail_schema.json` 둘 다
  `38.202608032308`, **drift 없음**(스키마도 함께 복사했기 때문).
- `test_prompt_versions.py` 17 passed.
- scene_detail 관련 focused 6파일 **115 passed / 4 skipped / 3 failed**.

★ 실패 3건은 **이 작업과 무관한 기존 부채**다. `git stash` 로 변경 전
상태에서 돌려 **동일하게 3건 실패**함을 확인했다
(`test_validate_fail_when_entity_name_anchored_to_other_id` /
`_outside_window` / `_in_separate_sentence`). 원인은 W20F2 에서 Source 2
(`entity_canon.name` ±60 window)를 **audit warning 으로 강등**했는데 테스트는
여전히 `AppError` 를 기대하는 것 — 코드 변경 시 테스트 미갱신. 범위 밖이라
고치지 않았다.

**실제 판정은 ④ A/B 육안** — 테스트는 shape 보장일 뿐이다.

**복구**: 이 커밋 revert (38판 디렉토리 삭제 + 상수·테스트 원복).

---

## ⑤ structure_form_ref — 언어 판정용 원문을 앞부분 발췌로

**파일**: `backend/app/modules/pipeline/search_grounded_ref.py`
(`build_search_brief_user`)

**무엇을 바꿨나**: `source_text` 전문 대신 앞부분
`_SOURCE_LANGUAGE_SAMPLE_CHARS = 1200` 자만 싣는다. 블록 제목도
`SOURCE TEXT BLOCK` → `SOURCE TEXT SAMPLE (opening excerpt)` 로 바꿔
발췌임을 모델에게 명시했다.

**이것은 절대 규칙의 예외다 — 사용자가 조건부로 열었다**

> *"단순한 정보를 얻기 위해서 전체를 넣는 것보다 부분만 넣어도 되는
> 경우에는 예외로 하자 (단 무조건 전체가 아니어도 되는 경우만!!!)"*
> — 2026-08-03

**조건 충족 근거**

1. **용도가 하나뿐이고 코드가 그렇게 못 박고 있다.** 같은 지시문이
   `THIS BLOCK OWNS \`source_language\` AND NOTHING ELSE ... Read it only
   to decide which language to write in` 이라고 적는다. 함수 주석도
   *"씬 원문은 `source_language` 판정 전용으로 격리한다"*.
2. **언어는 도입부 몇 줄로 확정된다** — 전체가 필요한 정보가 아니다.
3. **전체를 싣는 것이 오히려 위험하다.** 같은 지시문이 인물·사건·임시
   사물·레이아웃·작품 고유명사가 검색 지시문으로 새어 나가는 것을
   금지하는데, 원문이 길수록 누출 표면이 커진다. 이 모듈 docstring 1번에
   *검색 대상이 타지역으로 샌* 실측이 기록돼 있다.
4. 실측 기준 이 블록이 콜당 **65,758자**로 전체 68,364자의 **96%** 였다.

**절감**: 콜당 약 64,500자. 이번 작업 전체에서 단일 항목 최대.

**적용 범위 한정**: 이 예외는 **이 블록에만** 적용했다. 분석·추출처럼
원문 전체가 판단 근거인 경로는 그대로 전문을 넘긴다. 짧은 원문은 잘리지
않고 그대로 실린다(실측 확인).

**검증**

- 140,000자 원문 → 프롬프트 1,621자, `SOURCE TEXT SAMPLE` 문구 확인.
- 짧은 원문(`"짧은 원문"`)은 그대로 포함됨.
- `source_text_sha` 는 원문 자체에서 계산하므로 영향 없음(결속 유지).
- `test_form_reference_round_wiring.py` + `test_typology_prior_narrowing.py`
  **74 passed**.

**복구**: 이 커밋만 revert. 또는 `_SOURCE_LANGUAGE_SAMPLE_CHARS` 를 크게
잡으면 사실상 전문 전달로 돌아간다.

---

## ③-fix scene_detail v39 — 재현 표면 조항 복원 (육안 반려 대응)

**새 판**: `prompts/_base/scene_detail/39.202608040209/` (v37 39,812 → v38
33,673 → **v39 33,897자**. v37 대비 **-5,915자 (-14.9%)** 절감 유지, 224자만 복원)

### 무엇이 반려됐나 (사용자 육안, 2026-08-04)

> *"s28sh3 만 덜어내기 전이 좋아, 특히 사진의 크기가 덜어낸 후에 너무 비대하게 커."*
> *"아 s29sh5 살짝 덜어낸후가 살짝 비대하게 커 사진이."*

두 shot 모두 `reproduction_surface_rule.applies == true` 이고 `P02`(사진)를 가진다.

| | v37 | v38 |
|---|---|---|
| 사진 속 내용 | 한국식 기와집 + **두 소녀** | 인물 없음 |
| 사진 속 장소 | 1970년대 한국 시골 식당 | **"RURAL CAFE" 영문 간판 + 미국식 건물** |
| 사진 크기 | 손바닥 크기, 프레임 1/5 | **프레임 1/3** |

★사용자가 지적한 것은 크기였지만, 확인해 보니 **사진 속 인물이 통째로
사라지고 지역 설정까지 어긋난 것**이 더 심각했다(1·2회 모두 재현).

### 원인 — 값은 카드에 있었지만 "그 값을 쓰는 법"이 없었다

카드에는 지시가 전부 있었다 — `applies: true`,
`id_use: "forbidden — generic descriptor with demographic only"`,
`common_noun_required_when: ["a reproduced face inside any declared
reproduction surface"]`, constraints 의 "use a generic descriptor instead".

**그런데 카드의 표현이 전부 금지형이다** — "C##O## 를 쓰지 마라, 대신
generic descriptor 를 써라". 내가 v38 에서 뺀 산문은 이렇게 적혀 있었다:

> `applies==true` 인 shot 에서는 그 표면 안 인물 ID 사용 금지 **(보통명사 +
> demographic descriptor 사용)**. 이유: 얼굴 ref 가 원본 해상도로 inject 되어
> surface 위에 실물 크기 합성.

이 조항이 "표면 안에 인물이 있으면 이렇게 묘사하라"를 능동적으로 상기시켰다.
빠지자 금지만 남았고 모델은 **인물을 아예 그리지 않는 쪽**을 골랐다.

★★★ **어제 교훈의 정확한 재현 — 방향만 반대다.** 어제는 금지를 쌓아 재료가
장면뿐이 되어 `밤길쉼터` 가 나왔다. 오늘은 내가 **재료(어떻게 묘사할지)를 빼고
금지만 남겼다.** 덜어낼 때도 모델에게 재료가 남는지 봐야 한다.
**값과 그 값을 쓰는 법은 다른 것이다** — "카드에 값이 있으니 산문은 중복"이라는
내 판단이 여기서 틀렸다.

### 복원한 조항 (224자, `## ID Policy` 안)

> `id_policy.reproduction_surface_rule.applies` 가 true 면 그 표면(사진·화면·
> 반사면) 안에 무엇이 담겨 있는지 **함께 묘사한다** — 인물은 ID 없이 보통명사
> + demographic descriptor 로, 장소·사물은 `entity_canon` 의 canonical content
> 그대로. 표면을 이루는 물리적 prop 자체는 `P##` 를 쓴다.

금지를 늘리지 않고 **서술 방식**으로 적었다. ID Policy / Rule X-2 에서 뺀
나머지(policy enum 반복 설명, ✗/✓ 예시)는 그대로 둔다 — 그 부분에서는 퇴행이
관측되지 않았다.

### 검증 (2 shot × 2회 재생성, v38 이미지는 삭제)

| 항목 | 결과 |
|---|---|
| 사진 속 두 소녀 | **복귀** — 1·2회 모두 |
| 한국식 건물·한글 간판 | **복귀** — 원형 지붕 + `금월` 간판, 1·2회 모두 |
| 사진 크기 | 1회는 여전히 큼, **2회는 적절** — 프롬프트의 "roughly a third of the frame" 은 v39 에도 남아 있어 변동 범위 안 |
| 1회차 `[L17B02]` 텍스트 화면 렌더 | 2회차에는 없음 — 확률적 |

`test_prompt_versions.py` 17 passed. 로더가 39판 선택, drift 0.

**복구**: 39판 디렉토리 삭제 + 상수를 38 로 되돌리면 v38 상태. 전체를 되돌리려면
`28561203` revert.

---

## ⑥ shot_staging v18 — 스키마의 중복 description 축소

**새 판**: `prompts/_base/shot_staging/18.202608040220/`
(17판 복사. 되돌리려면 18판 디렉토리만 지우면 된다 — 이 모듈은 **버전 상수가
없고** `config_hash` 를 프롬프트·스키마 내용 해시로 직접 계산하므로
`shot_staging_step.py` 를 손댈 필요가 없다.)

**크기**: compact 전송 기준 8,461 → **7,236자 (-1,225자, -14.5%)**
한 콜 = system 16,482 + schema 7,236 = **23,718자** (이전 24,944자)

### 왜 스키마부터인가

크기 자체보다 **400 반려 위험**이다. 2026-08-02 실측에서 판정 스키마를
1,605자로 키웠다가 Gemini 가 **125콜 전건을 400 INVALID_ARGUMENT 로 반려**했고,
공식 문서도 복잡도가 *긴 속성명 · 값이 많은 enum · 선택 속성이 많은 객체*
에서 온다고 적는다([[feedback-brevity-beats-exhaustive-spec]]).

실측 결과 이 스키마는 **description 26개가 4,627자로 compact 의 55%** 였고,
긴 것들은 전부 enum 값의 뜻을 푸는 사전이었다.

### 전수 대조 후 "확실한 중복"만 줄였다

`system.md` 에 **전용 섹션이 있는 필드**만 골랐다 — 재료가 이미 프롬프트에
있으므로 잃는 것이 없다(v38 에서 재료를 뺏은 실수를 반복하지 않는다).

| 필드 | system.md 전용 섹션 | description |
|---|---|---:|
| perception_mode | `## perception_mode (8-token enum, 필수)` 592자 | 498 → 71 |
| framing_scale | `## framing_scale (필수)` 551자 | 334 → 55 |
| body_pose | `## 자세(body_pose) 다양화` | 301 → 51 |
| angle | `## 인물-카메라 각도` | 155 → 58 |
| subject_reference_policy | `## subject_reference_policy[]` | 132 → 68 |
| character_angles | `## 인물-카메라 각도` | 118 → 58 |
| camera_direction | `## 카메라 관점과 지각 주체` 외 1 | 107 → 59 |

남긴 문구는 포인터 한 줄이다 —
`See the system prompt section defining \`<필드>\`.`

`perception_mode` 를 예로 들면 system.md 가 8개 enum 값을 한국어로 상세히
설명하고 스키마 description 이 같은 8개를 영어로 요약하고 있었다. **같은
내용이 두 곳에 있었다.**

### 손대지 않은 것

전용 섹션이 **없는** 18개(`directionality_class` 639자 · `gaze_target_id`
489 · `orientation` 415 등 합계 2,952자)는 그대로 뒀다. system.md 로 옮기면
스키마 복잡도는 낮아지지만 **총량이 그대로**라 이번 목적(중복 제거)에
맞지 않는다.

### 검증

- 로더가 18판 선택, `system.md` / `schema.json` 둘 다 drift 0
- `frame_spatial_contract` 필드 유지, `shots[].items` 필드 12개 그대로
- `test_shot_staging_v13_schema.py` + `test_errors_shot_staging_orientation.py`
  + `test_prompt_versions.py` **37 passed**
- `test_step_manifest_v3.py` 5 failed 는 **기존 부채** — `git stash` 로 변경 전
  상태에서 동일하게 5건 실패함을 확인했다(`test_order_is_unique` 의
  `assert 84 == 83` 등 step 개수 단언). 범위 밖이라 고치지 않았다.

**실제 판정은 A/B 육안** — 테스트는 shape 보장일 뿐이다.

**복구**: 18판 디렉토리 삭제.

---

## ⑦ shot_dependency_t2i v9 — 스키마 description 을 system.md 포인터로

**새 판**: `prompts/_base/shot_dependency_t2i/9.202608040240/`
(8판 복사. 되돌리려면 9판 디렉토리를 지우고 `version_registry` 를 v8 로.)

**크기**: compact 3,371 → **1,636자 (-1,735, -51.5%)**

### 어떻게 골랐나 — v18 에서 검증된 기준 그대로

`system.md` 에 그 필드 **전용 섹션이 있는 것만** 줄였다. 재료가 이미
프롬프트에 있으므로 잃는 것이 없다.

| 필드 | system.md 전용 섹션 | description |
|---|---|---:|
| `ref_usage` | `## 참조 유형 판단 (ref_usage)` + 하위 3섹션(약 1,800자) | 293 → 55 |
| `ignore_elements` | `## 무시할 요소 지시 (ignore_elements)`(약 1,700자) | 329 → 57 |
| `keep_elements` | `## keep_elements`(약 3,300자) | 540 → 56 |
| `keep_elements[].subject_kind` | `### subject_kind 결정 (필수 emit)` | 450 → 57 |
| `keep_elements[].kind` | `### kind enum 정의 (2종 only)` | 237 → 60 |
| `keep_elements[].label` | `### 출력 shape` + `### 작성 규칙` | 213 → 56 |

**검증**: 로더가 9판 선택(system.md·schema.json 둘 다, drift 0) ·
`description` 을 제외하면 8판과 **완전 동일**(필드·enum·required 불변).

### 함께 회복한 정합 — 어제 누락분

`version_registry` 가 **scene_detail 을 v37 로 가리키고 있었다**. 어제
v38·v39 를 발행하면서 registry 갱신이 통째로 빠졌다(4-point sync 위반).
`MODULE_VERSIONS["scene_detail_composer"]` 1.37.0 → **1.39.0**,
`prompt_dependency` v37 → **v39**. provenance 기록이 실제 나간 판과
어긋나 있던 것을 고친 것이다.

---

## ⑧ form_ref 심판 머리말에서 배치 서술 제거 — 이번 라운드 최대 절감

**파일**: `outdoor_structure_seed.py`(`derive_seed_inputs`) ·
`outdoor_structure_form_reference_step.py` · `search_grounded_ref.py`

### 무엇을 발견했나

총 송신량은 크기만이 아니라 **콜 수**가 정한다. Opik 실측에서
`structure_form_ref_pick` 은 한 콜 5,150자지만 **311콜**이라 총량 상위였다.

그 콜의 지시문(`pick_system`)이 스스로 두 번 못박는다:

> "NO PHOTOGRAPH HAS TO SHOW THEM ALL, OR SHOW THEM ARRANGED THAT WAY …
> a different arrangement of them, is **NEVER** a reason to reject a
> candidate. Where things sit is decided later by other contracts."
> "**JUDGE THE STRUCTURE, NOT ITS SURROUNDINGS**, AND NOT THE
> COMPLETENESS OF THE SCENE."

그런데 user 머리말에는 seed 저작용 서술(layout narration + 항목별 위치
설명)이 콜마다 실려 있었다. **지시문이 쓰지 말라고 한 정보를 매 콜 보내고
있었다.**

### 왜 안전한가

재료를 뺏는 것이 아니라 **금지를 지키기 쉽게** 만드는 방향이다
([[feedback-value-vs-how-to-use-it]] 의 반대 사례 — 여기서는 산문이
"무엇을 그릴지"를 말하지 않고 "판단하지 말 것"을 말하고 있었다).
심판이 골라야 할 **주된 구조물 종류**의 근거인 이름은 남긴다.

### 어떻게 구현했나

- `derive_seed_inputs` 가 `structure_names_en`(항목 `name_en` 만)을 함께
  낸다. **문자열 파싱이 아니라** spec items 원 데이터에서 직접 뽑는다.
- **pick 경로만** 그것을 쓴다. 검색어 저작(brief)과 씨드 저작은 그대로
  전체 서술을 받는다 — 거기서는 배치가 판단 근거다.
- 결손이면 기존 서술로 폴백.
- `world_facts_block` 은 **손대지 않았다**. region·era 는 심판의 판정
  기준("another country or another era")이고 `architecture` 규칙도
  판정에 쓰일 수 있다 — 무관하다고 단정할 근거가 없다.

### 실측 (프로젝트 3개 · 그룹 17)

| | 합계 | 평균 |
|---|---:|---:|
| `structure_desc` (before) | 18,893자 | 1,111 |
| `structure_names_en` (after) | 2,236자 | 131 |

**그룹당 979자**, Opik 실측 311콜 기준 **약 30만 자**.

**검증**: form_reference 계열 181 passed. `_run_group` 대역 2곳이
시그니처를 고정하고 있어 `**_ignored` 로 열었다(그 대역은 라운드 CP
로직만 시험한다).

---

## ⑨ 판 단언 하드코딩 제거 — 같은 누락이 반복되지 않도록

테스트 5개 파일이 판 숫자를 박아 두고 있었다(`"37."` / `"1.37.0"` /
`"scene_detail/v37"`). 판을 올릴 때마다 깨지므로, 어제 v38·v39 에서는
**registry 쪽 갱신이 통째로 누락된 채** 남았다.

지켜야 할 계약은 "지금이 몇 판인가"가 아니라 **"네 곳이 서로 일치하는가"**
이므로, 기대값을 `_list_module_versions("scene_detail")[0]` 에서 파생하게
바꿨다. major 숫자 자체를 단언하던 줄은 지웠다(판마다 깨지는 스냅샷).

`tests/prompts` 실패 **18 → 11**(새로 깨진 것 0). 남은 11 은
entity_extractor 판 관련 기존 부채로 범위 밖 — `git stash` 로 변경 전에도
동일하게 실패함을 확인했다.

---

## ⑩ 판 목록에서 dot 디렉토리 제외 + 팩 캐시 제거

`_list_module_versions` 가 모듈 디렉토리 아래 **모든** 디렉토리를 판으로
셌다. 실측으로 `multiroll_judge/.pytest_cache` ·
`shot_continuity/.pytest_cache` 가 판 목록에 실려 있었다. 최신 선택은
무사했지만(`_version_sort_key` 가 `(0, name)` 으로 떨어뜨린다)
`get_effective_source` 의 `module_pack.versions` 에 판인 척 노출된다.

### 판 drift 50건은 정리하지 않는다 — 근거

- 로더가 stem 별 독립 탐색을 **backward-compat 으로 명시**한다
  (`prompt_loader.py` docstring). strict 모드가 이미 opt-in 으로 있다.
- 옛 판 파일을 최신 판에 복사하면 **내용 변화 0 · 절감 0** 이고 팩 총량만
  29,232자 늘어난다. 이번 목적(덜어내기)에 역행한다.
- 실제 위생 문제였던 것은 위 dot 디렉토리 하나였다.

### 팩 밖 코드 내장 프롬프트도 이번 범위에서 뺀다 — 근거

조사에서 "약 15만자"로 컸지만 그것은 **227건이 서로 다른 콜에 흩어진 합**
이다. 한 콜 기준 최대는 `space_set_bg.ANALYZE_FA_SYS` **5,967자**로,
지금 도는 경로의 지시 최대(`scene_detail` 35,555 · `seed_variant_author`
17,363)보다 훨씬 작다. 즉 **크기 문제가 아니라 판·이력이 없다는 거버넌스
문제**이고, 팩 이관은 내용을 바꾸지 않아 이번 A/B 대상도 아니다.

`space_set_bg` 의 user 앞부분 536자가 system 요약을 반복하지만, 그것은
"이번 콜에서 무엇을 하라"는 작업 지시로 system(역할·규칙)과 역할이 다르고
절감도 작아 손대지 않았다.

## ⑪ scene_detail v40 — 육안 판정 결함 3건에 재료를 준다

사용자 육안 판정(2026-08-04)은 **"전반적으로 after 가 좋아"** 였고, 결함
4건이 붙었다. 이 판은 그 중 셋을 다룬다. 넷째는 재현되지 않아 손대지 않았다.

**새 판**: `prompts/_base/scene_detail/40.202608041049/`
(v39 48,810 → v40 49,209자, **+399자**. 이번 것은 덜어내기가 아니라 되돌려
주는 작업이다.)

### 먼저 확인한 것 — 어느 층에서 난 결함인가

| 결함 | 사용자 원문 | 확인 결과 |
|---|---|---|
| ④ 크기 | "둘다 사진 크기를 너무 과하게" | **판 무관**. 아래 참조 |
| ② 자세 | "after 는 사진이 세워져 있는 경우도" | 조항 부재 (판 차이 아님) |
| ③ 공간 | "매우 큰 공간으로 묘사되는데 말이 안돼, 작은 배인데" | 카드 `bg_id` 노출 |
| ① 두 장 | "after 는 사진이 두장인 경우도" | **재현 실패** — 수정 보류 |

### ④ 판 무관이 데이터로 확증됐다

산출물의 `occupies (roughly) a third of the frame` 은 팩 어디에도 없는
문구다 — LLM 이 스스로 썼다. 그리고 그것을 지배하는 두 섹션은

```
diff <(awk '/^## 크기·치수/,/^## Continuity/' …/37…/system.md) \
     <(awk '/^## 크기·치수/,/^## Continuity/' …/39…/system.md)   → 차이 없음
diff <(awk '/^## 카메라 앵글/,/^## 씬 유형/'  …/37…/system.md) \
     <(awk '/^## 카메라 앵글/,/^## 씬 유형/'  …/39…/system.md)   → 차이 없음
```

**v37↔v39 바이트 동일**이다. 카드 쪽도 idx=12 는
`frame_spatial_contract: null` 이라 크기를 지시하지 않는다.

원인은 두 조항의 **관할이 안 나뉜 것**이었다. `## 카메라 앵글/샷 타입 지시
금지` 는 "close-up 을 쓰지 말고 프레임 배치로 묘사하라"며 예시 셋을 전부
점유율로 준다(`occupies the left half` / `dominates the upper frame`).
`## 크기·치수` 는 제목이 "실 수치 금지"라 **숫자를 쓸 때만** 적용되는 것으로
읽힌다. 그래서 손 크기 물체에도 점유율이 붙었다. 재료(`smaller than her
palm` 류 관계 묘사)는 이미 그 섹션에 있었다 — 연결이 없었을 뿐이다.

### ② 조항이 자세를 강제하지 않았다

같은 카드(idx=6·9, 둘 다 `bg_id=L18B02`·`helm console` 이 owned)에서 네
조합이 **모두** 나왔다.

| | 평평함 | 놓인 표면 |
|---|---|---|
| idx6 before | ✓ `lies flat` | ✓ `on the … helm console` |
| idx6 after | ✗ (`rests`) | ✗ |
| idx9 before | ✗ (`is positioned`) | ✗ |
| idx9 after | ✓ `lies flat` | ✗ |

판으로 갈리지 않는다. `frame_spatial_contract` 가 주는 것은 `screen_zone`
(화면 좌표)과 `depth_plane` 뿐이고, 프롬프트가 요구하는 것도 그 반영까지다.
**무엇 위에 어떤 자세로 놓였는지를 적게 하는 재료가 없었다.**

### ③ 카드의 `bg_id` 가 산문으로 샜다

`background_binding.bg_id` 는 참조 이미지를 가리키는 식별자인데, 그것을
산문에 쓰지 말라는 말이 어디에도 없었다. `[L##: ...]` block 사용은 명시돼
있지만 그것은 별개 사안이다. 결과가 `Dim dusk ambient lighting fills the
L18B01 space` — 이미지 모델에게 `L18B01` 은 의미 없는 토큰이라 임의의 큰
공간이 된다.

### ① 동격 명사구 가설은 지지되지 않았다 — 그래서 고치지 않는다

가설은 `gripping P02, a hand-sized crumpled and bloodstained old
photograph, …` 의 **ID 뒤 동격 명사구**가 두 물체로 읽힌다는 것이었다.
`artifact/20260804_appos_repro/` 에서 **동격 하나만** 뺀 대조를 6회씩 그렸다
(나머지 문장은 after 원문 그대로 — before/after 전문 비교는 문장이 여럿
달라 원인을 격리하지 못한다).

| | 사진 두 장 |
|---|---|
| 동격 있음 (after 원문) | **0/6** |
| 동격 없음 | **0/6** |

원 A/B 의 1/3 을 합쳐도 동격 조건은 **1/9** 이고, 동격 없는 6회와 구분되지
않는다. 다만 관측 하나는 적어 둔다 — 두 장이 나온 그 한 장은 **손이 옆으로
늘어뜨려지지 않고 올라간** 유일한 컷이었다(12장 전부 자세를 지켰다). 자세
이탈과 함께 일어났다는 뜻이지만 표본 1건이라 단정하지 않는다.

### 고친 것 — 셋 다 금지가 아니라 대안을 함께 준다

1. `## 크기·치수` 끝에 — 점유율은 인체 규모 이상 대상의 배치에 쓰고, 손에
   들리거나 표면에 놓이는 물체는 바로 위 관계 묘사로. **이유를 붙였다**
   ("점유율로 적으면 그 물체가 인체 규모로 커진다").
2. 재현 표면 조항에 — 표면이 놓여 있으면 **무엇 위에 어떤 자세로**
   (평평하게 놓임 / 기대 세움 / 손에 들림). 적을 항목과 보기를 함께 준다.
3. `## Background Binding` 머리에 — `bg_id` 는 참조 식별자이므로 공간·조명
   산문에는 일반 명사로(`the wheelhouse interior`, `the room`).
   `[L##: ...]` block label 사용은 그대로 둔다.

★[[feedback-value-vs-how-to-use-it]] 와 [[feedback-brevity-beats-exhaustive-spec]]
사이를 지키는 방식이다 — 금지를 쌓지 않고(어제의 `밤길쉼터`), 재료를 빼지도
않고(v38 의 빈 사진), **재료를 주되 한 조항씩만** 준다.

## ⑫ scene_detail v41 — E2E 가 드러낸 ID 지시 결손 (+ 손실 방지 3건)

금월도 E2E 재실행(프로젝트 `e716bafb`)에서 `scene_detail` 이 **255샷 중
1샷** 때문에 두 번 죽었다. 그 1샷이 다이어트의 결손을 정확히 짚었다.

### 무엇이 없어졌나

```
S41_Shot7 (variation 0): subject 'C28' policy='id_and_outlook_required'
requires base C## in t2i_prompt, but missing. visible_entities=['C28']
```

`C28` = **검은 염소**. 아웃룩이 0개인 동물이 **혼자 나오는 샷**이다.

같은 시나리오의 이전 실행(v37)은 통과했고 그때 산출은 이랬다 —
`C25O00, a large black goat, showing black fur, ... curved horns`.
즉 v37 은 동물에도 ID 를 붙였다.

상류는 양쪽이 같았다. 이전 실행도 shot 노드 237개 중 **221개(93%)** 가
`subject_reference_policy` 빈 배열이었다 — exceptions-first 계약대로의 정상.
갈린 것은 프롬프트다. v38 이 `## ID Policy` 3,601자를 카드 포인터로 줄이며
**두 가지 상기**가 사라졌다.

1. 목록에 없는 visible subject 는 기본값 `id_and_outlook_required`
2. 아웃룩이 없는 subject 는 outlook 자리에 `O00`

카드에 ①은 있다(`constraints[2]` 끝의 `omitted subjects default to
id_and_outlook_required`). 그러나 긴 문자열 끝에 묻혀 있고 배열 자체가 비어
있어 "적용할 정책 없음" 으로 읽힌다. ②는 카드에 아예 없다 — 코드에만 있다
(`detail_steps.py:352` 의 ID 정규식이 `\bO00\b` 를 인식).

★[[feedback-value-vs-how-to-use-it]] 의 세 번째 재현이다. **값이 있어도
"그 값을 쓰는 법" 을 말해 주던 산문을 빼면 모델은 그냥 안 쓴다.**

### 고친 것 — v41 (`41.202608041645`, +11줄)

`## ID Policy` 에 두 문단 추가. 셋 다 금지가 아니라 **무엇을 쓸지**를 준다.
①배열이 비는 것이 정상이고 목록에 없으면 기본값이라는 것 ②base `C##` 와
composite `C##O##` 를 함께 ③아웃룩 짝이 없으면 `C##O00`, 외양은 ID 뒤에
보통명사로. ★예시에 작품 소재를 쓰지 않는다(초안에 `black goat` 을 썼다가
[[feedback-no-scenario-specific-coding]] 위반으로 즉시 교체).

**결과: 255/255 통과, 위반 0.**

### 함께 고친 손실 3건 (Codex 리뷰 3회)

| | 문제 | 수정 |
|---|---|---|
| A | 1샷 실패가 성공 254샷을 **저장 전에** 폐기 (`scene_still` 0행) | `_run_shots_with_retry` 의 raise → 반환. step_runner 가 partial 로 저장 |
| B | resume 이 성공분을 재사용하지 않아 전량 재호출 | 직전 CP 가 **partial** 이고 `config_hash` 가 같을 때만 재사용 |
| C | 배치 `run-all` 경로에 **post-sync 부재** → `scene_still` 영구 0행 | flagged step 실행 **후에도** `orchestrate_full_sync` |

★A 의 무결성은 `step_manifest` 의 `allow_partial_downstream: False` 가
지킨다(partial 이면 하류 차단). ★B 의 게이트에 `status == "partial"` 이
반드시 필요하다 — `mode` 만으로는 부족한데, step_runner 가 stale/failed 를
force 로 격상해도 `_execute_rerun_self()` 는 `_execute(mode="resume")` 를
부른다(`step_runner.py:1129,1153`). `config_hash` 는 계약 해시일 뿐 씬·shot
selection 같은 입력 identity 를 담지 않는다. ★C 는 단일 step 경로엔 이미
있었다(`step_execution_service.py:111`) — 배치 경로에만 없던 구멍이다.

Codex 가 3라운드에 걸쳐 잡은 것 — ①`failed_shots` 가 미해결이 아니라 **시도
이력**이라 retry 로 회복된 shot 까지 실패로 남아 다음 resume 이 그것을 다시
부름 ②partial 게이트 부재 ③테스트가 ①을 잡지 못함. 셋 다 재검증 후 수용했다.

검증: 관련 **521 passed** + dispatch **43 passed**, 4-point sync 4/4.
