# 프롬프트·파이프라인 전수 분석 (2026-08-26)

## 무엇을 고치려는가

**이미지가 이상하게 나오는 것을 막는다** — 사이드이펙트, 할루시네이션,
**구도를 모르는 것**, **엉뚱한 엔티티가 들어가는 것**.

프롬프트가 길어지면 문장끼리 모순되고 불필요한 것이 섞여 헛것이 늘어나므로
**길이도 그 원인에 포함**된다. 비용은 목적이 아니다.

## 무엇으로 쟀나

**Opik 이 로그의 SOT 다.** 저장소의 프롬프트 파일이 아니라 **나간 것**이
진실이다. `llm_call_log`(DB)는 이미지 계열 전용이라 분석 단계가 안 남는다 —
그것만 보고 「기록이 없다」고 판단하면 틀린다.

- 대상: 「골목 끝 — reve 2.1 판」 (3씬·7샷), trace 222건 · 52스텝
- 판 특정: system sha `28ad3ed8ed2d` (32,055자, 08-25 20:28~20:29 KST)
  — **시각으로 판을 가르면 안 된다**(`edition_hash.py`)
- 도구: `tools/prompt_measure/` 의 `payload_shape`(신설) · `card_static` ·
  `id_axes` · `edition_hash`. 전부 기록 재읽기라 **요금이 안 나간다**

---

## 발견

### ① 프롬프트 규칙이 코드에 박혀 있다 (하드프롬프트)

`backend/app/core/steps/render_prompt_card.py` — **3,756줄**,
그 안에 **영어 프롬프트 문장 리터럴 168개 · 19,254자**.

| 크기 | 자리 | 내용 |
|---:|---|---|
| 556자 | `L1710` | `Area B — visible prop ∩ visual_identity...` |
| 452자 | `L1147` | `per-subject identity reference policy is provided in...` |
| 413자 | `L1160` | `when an ID is required for a subject...` |
| 384자 | `L1132` | `do not use C##O## for a reproduced face...` |
| 293자 | `L561` | `elements described inside the frame must be at vertical...` |

**왜 문제인가**: 팩(`prompts/_base/<스템>/<버전>/`)은 버전 디렉토리로
판이 갈리는데 **이것은 코드라 판이 안 갈린다.** 고쳐도 「어느 판으로 돌았나」를
나중에 알 수 없고, 되돌리기도 코드 되돌리기가 된다.

### ② 매 샷 똑같은 것을 반복해 보낸다

`scene_detail` 이 샷마다 보내는 `RenderPromptCard` 135장을 항목 단위로 분해:

| | 항목 수 | 크기 |
|---|---:|---:|
| **정적 — 모든 샷 동일** | 90 | **14,868자 (70%)** |
| 가변 — 샷마다 다름 | 25 | 6,278자 |

큰 정적 항목: `id_policy.constraints` 1,858자 ·
`continuity_elements_used.constraints` 1,458자 ·
`render_strategy.spatial_consistency.*` 합계 약 3,000자.

줄 단위로 보면 다른 스텝도 같다:

| 스텝 | user 크기 | 매번 같은 줄 |
|---|---:|---:|
| `outdoor_place_canon` | 4,894 | **4,796 (98%)** |
| `entity_t2i` | 2,569 | **2,344 (91%)** |
| `background_prompt` | 3,088 | **2,579 (84%)** |
| `shot_selection` | 958 | 459 (48%) |
| `scene_camera_flow` | 1,089 | 476 (44%) |
| `shot_director` | 985 | 355 (36%) |

### ③ 이 샷에 해당조차 없는 규칙이 실린다

카드의 `spatial_consistency` 는 세 규칙을 통째로 담는데, 그중 이 샷에
**적용될 수 없는 것**이 그대로 간다.

| 샷 | 인물 수 | framing | 해당 없는 분량 |
|---|---:|---|---:|
| S1sh2 | 1 | medium | 2,123자 |
| S2sh1 | 1 | wide | 2,123자 |
| S3sh2 | 1 | close | 1,893자 |
| S1sh4 | 2 | medium | 742자 |
| S3sh1 | 2 | wide | 742자 |
| S2sh3·S2sh6 | 2 | close | 512자 |
| | | **합계** | **8,647자** |

인물이 **한 명**인 샷에 「두 인물이 상호작용할 때」 규칙
(`fg_bg_shared_anchor_rule`)이 들어가고, `framing_scale` 이 `medium` 인
샷에 `close_framing_rules` 가 들어간다. 그 안에는

> `absent — not rendered at all even if listed in visible_entities`

처럼 **적용되면 안 되는 지시**도 있다.

### ④ 같은 규칙을 두 번 말한다

`render_strategy.constraints`(샷당 578자, 7샷 합 4,046자)가 바로 위
`spatial_consistency` 를 요약해 되풀이한다. 같은 것을 다른 말로 두 번
적으면 모델이 둘을 다른 지시로 읽을 수 있다.

### ⑤ 목록 밖 ID 가 나온다

`id_axes.py` 기준 이번 판(`?32055`)에서 **1/8 컷**에 목록 밖 `L01` 사용.
다른 판에서는 최대 6/48(`L01`), 3/17(`L01`,`L02`).

### ⑥ 한 주행 안에서 판이 갈린다

`payload_shape` 기준 `scene_image_pipeline` 은 **판 10개**, `scene_detail`
은 **판 2개**로 돌았다. 한 주행 결과를 한 판의 성적으로 읽으면 안 된다.

### ⑦ 팩에 옛 판이 쌓여 있다

스템 90개 · 최신판 합계 517,956자인데 **옛 판 포함 4,777,654자**.
`scene_detail` 은 **42판**(1,852,373자), `seed_prompt_variants` 21판,
`shot_staging` 17판.

---

---

## ⑧ 이미지 단계 발송량의 **89.6% 가 판정 루프**다 (span 기준)

★앞의 「호출당 36,012 토큰」은 **trace(샷 묶음)** 합계였다. 실제 모델 호출
 단위(span)로 다시 재면 그림이 달라진다 — 재는 단위를 밝히지 않으면 같은
 수치가 다른 것을 뜻한다.

| op | 호출 | 글자 | 비중 | 호출당 |
|---|---:|---:|---:|---:|
| `still_recipe_fix_rejudge_geminipro` | 12 | 246,072 | 28.1% | **20,506** |
| `still_recipe_judge_geminipro` | 14 | 239,187 | 27.3% | 17,084 |
| `still_recipe_critique_observe` | 10 | 155,853 | 17.8% | 15,585 |
| `still_recipe_critique_compose` | 8 | 143,784 | 16.4% | 17,973 |
| **판정 루프 합계** | **44** | **784,896** | **89.6%** | |
| `still_cine_verify` | 28 | 33,558 | 3.8% | 1,198 |

이미지도 호출마다 32~66장씩 붙는다.

### 네 개가 **같은 브리프를 네 번** 보낸다

user 메시지를 줄 단위로 비교하면 **네 op 모두에 똑같이 들어가는 줄이
76줄 · 7,817자**다.

| op | user | 그중 네 개 공통 |
|---|---:|---:|
| `judge` | 9,572 | **7,817 (82%)** |
| `critique_observe` | 10,976 | 7,817 (71%) |
| `fix_rejudge` | 13,117 | 7,817 (60%) |
| `critique_compose` | 12,662 | 7,817 (62%) |

`judge` 는 고유 내용이 **1,755자**뿐이고 나머지는 다른 셋과 같다.
44호출 × 7,817자 ≈ **34만 자가 같은 브리프의 반복**이다.

★이것은 ①②(카드 안 정적·중복 걷기)와 **다른 축**이다. 앞 실험이 기각한
 것은 「한 호출 안에서 설명을 빼기」였고, 이것은 **네 호출이 같은 것을
 각각 다시 싣는 것**이다. 걷는 게 아니라 **한 번만 보내는** 문제다.

★손대기 전에 확인할 것: 네 호출이 정말 독립이어야 하는가. 대화로 묶으면
 브리프는 한 번이면 된다. 그것이 파이프라인 구조 문제다.

---

## 이미 해봤고 기각된 것 (또 하지 말 것)

**2026-08-24 `a675c352`** — 카드에서 `rationale_summary`(1,865자) +
`forbidden_*`(1,421자) 합 3,286자를 걷는 A/B. 코드를 고치기 전에 **실제 나간
payload 에서 그 두 갈래만 빼고** ABBA·6컷×6회로 돌렸다.

| arm | 컷 | 등급 낱말 | 실수치 | ID 누락 | 평균 길이 |
|---|---:|---:|---:|---:|---:|
| A 카드 그대로 | 17 | 1 | 0 | 0 | 974자 |
| B 설명 갈래 뺀 것 | 19 | **3** | 0 | 0 | 1,007자 |

**뺀 쪽이 오히려 나빴다.** 표본이 작아 「확실히 나쁘다」고도 못 하지만
결론은 같다 — 걷어도 된다는 증거를 못 얻었으면 안 걷는다.

★그때 얻은 교훈: **같은 모양의 문장이라도 어느 자리에 있느냐가 다르다.**
 `wide view` 가 system 산문의 금지문 한 줄에만 있었는데 출력에 샌 적이 있어
 「금지 나열이 프라이밍한다」를 카드에도 넘겨짚었는데, 카드 JSON 의
 `forbidden_combinations` 는 오히려 억제하고 있을 수 있다.

★그러므로 이 문서의 **①②(정적·중복 걷기)는 그 실험이 이미 기각한 축과
 겹친다.** 다시 하려면 다른 근거가 있어야 한다.

★반면 **③(이 샷에 적용될 수 없는 규칙)은 다른 축이다.** 앞 실험은 모든
 샷에서 같은 것을 뺐지만, 이것은 **샷 조건(인물 수·framing_scale)에 따라**
 적용 불가능한 것만 뺀다. 특히 `framing_scale = medium` 인 샷에 실리는

> `absent — not rendered at all even if listed in visible_entities`

는 설명이 아니라 **그 샷에 적용되면 안 되는 지시**다. 모순을 만드는 자리다.

---

## 손댈 자리 (근거 순)

1. **카드의 정적 70% 를 샷마다 보내지 않는다** — 14,868자
2. **해당 없는 규칙을 빼고 보낸다** — 8,647자. 인물 수·framing 은 이미
   카드가 아는 값이라 **코드가 고를 수 있다**
3. **중복 요약을 걷는다** — 4,046자
4. **코드에 박힌 프롬프트를 팩으로 옮긴다** — 판이 갈리게
5. 목록 밖 ID 가 나오는 경로를 막는다

★ 1~3 은 **문안을 고치는 것이 아니라 실을지 말지를 고르는 것**이라
  결과가 나빠질 위험이 가장 낮다.
★ 걷을 때 남기는 기준: 카드에 값은 있는데 「그럼 뭐라 쓰나」가 본문에만
  있으면 남긴다. **금지형만 남기면 그릴 재료가 없어진다.**

### ⑨ 이미 있는 스위치가 무엇을 줄였나 — 그리고 무엇이 남았나

**없는 것을 만들기 전에 있는 것을 확인했다.**

`multiroll_select.run_multiroll_select` 에 `critique_selected_prompt_only` ·
`critique_shared_prompt` 스위치가 이미 있고, `still_recipe_service.py` 가
**여섯 자리에서 `critique_selected_prompt_only=True`** 로 넘긴다.

- 줄인 것: 「세 롤 프롬프트를 다 보내기」 → 「고른 것 하나만」. **이미 적용됨.**
- 안 줄인 것: **브리프 자체.** 그것은 `prompt` 인자로 매 호출 통째로 간다.

op 단위로 「매번 같은 줄」을 재면:

| op | user 평균 | 매번 같은 줄 |
|---|---:|---:|
| `fix_rejudge` | 13,695 | **8,330 (61%)** |
| `critique_observe` | 11,369 | 6,195 (54%) |
| `judge` | 10,273 | 5,707 (56%) |

그 정적 부분의 정체는 **샷과 무관한 일반 규칙**이다:

- `BODY & SUPPORT (default only — every explicit direction above wins)` **755자**
- `CHARACTER REFERENCE ROLE (follow exactly)` **695자**
- 자막·워터마크·오버레이 금지, 은행권 액면·문서 숫자 처리 등 70자짜리 여럿

### ⑩ 팩 구조는 잘 되어 있다 — 옮길 자리가 명확하다

위 문구는 **코드 하드코딩이 아니라 팩의 절 파일**이다:

- `prompts/_base/still_recipe/<판>/support_stillness_clause.md`
- `prompts/_base/still_recipe/<판>/identity_ref_role_clause.md`

그리고 절마다 **자기 버전 상수**를 가진다:

```python
return load_prompt(_MODULE, "identity_ref_role_clause",
                   version=resolve_prompt_version(
                       grok_stem_version(version_selector,
                                         IDENTITY_ROLE_PROMPT_VERSION)))
```

**조건부 절도 이미 있다** — `has_char_refs=False` 면 빈 문자열을 돌려준다
(*"참조가 없는 샷에 이 절이 나가면 존재하지 않는 이미지를 가리키는 거짓
문장이 된다"*). 즉 「해당 없으면 뺀다」는 설계가 이미 서 있다.

그러므로 남은 문제는 **어느 절이 정적인가**가 아니라
**정적인 절이 user 에 실려 있는 것**이다.

### 설계 후보 — 정적 절을 system 으로

판정 함수는 `make_gemini_judge_fn(judge_sys=..., prompt_header=...)` 로
system 을 따로 받는다. 정적 절을 그쪽으로 옮기면:

- 내용은 **그대로다** — 걷는 게 아니라 옮기는 것이라 08-24 에 기각된 축과
  다르다
- 매 호출 반복이 사라진다

★그래도 **A/B 로 재야 한다.** 08-24 교훈이 정확히 이 지점이다 —
 「같은 모양의 문장이라도 어느 자리에 있느냐가 다르다. system 산문과
 카드 JSON 은 모델에게 같은 것이 아니다.」 user→system 이동도 효과가
 달라질 수 있다.

★판정 제공자가 여럿이다(`judge`=gemini-pro, `fix_rejudge`=grok4.6).
 **대화로 묶을 수는 없고**, 각 제공자에서 따로 확인해야 한다.

---

## ⑪ 판정은 **두 모델 합의**이고, 그중 한쪽은 기록이 비어 있다

`multiroll_gg46_judge_enabled=True` · `multiroll_dual_select_judge_enabled=True`
— 후보 선정은 Gemini 와 grok-4.6 **둘이 합의**한다.

| op | 호출 | Opik system | Opik user |
|---|---:|---:|---:|
| `still_recipe_judge_geminipro` | 14 | 6,811 | 9,572 |
| `still_recipe_judge_openrouter:xai/grok4.6` | 13 | **0** | **0** |
| `still_recipe_fix_rejudge_geminipro` | 12 | 6,811 | 13,117 |
| `still_recipe_fix_rejudge_openrouter:xai/grok4.6` | 12 | 0 | 0 |

**OpenRouter 경유 판정은 payload 가 Opik 에 한 줄도 안 남는다.**
호출·태그·usage 는 남는데 input 이 비어 있다. 무엇을 보냈는지 모른다.

### ★「재현 불능」은 내가 틀렸다 (Codex BLOCK 2)

처음에 위 표를 보고 「grok 쪽은 payload 가 없어 재현할 수 없다」고 적었다.
**틀렸다.** `multiroll_gemini.py:1186 judge_fn` 은 `parts` 를 **한 번**
만들어 `_judge_gq(models, _one, parts, ...)` 로 넘기고, `_one` 은 그것을
두 모델에 그대로 준다. `judge_sys` 도 공유다.

즉 **Gemini span 에서 복원한 system/user 가 곧 grok 이 받은 입력이다.**
Opik 기록이 비어 있는 것과 재현이 되는 것은 다른 이야기였다.

그래서 A/B 도구는 두 모델을 다 부르고 `_judge_gq(...,
equal_disagree_combined=True)` 로 **프로덕션 합산까지 그대로 태운다.**
Gemini 슬롯은 프로덕션과 같이 `enable_fallback=False` 로 봉인한다.

★내가 빠진 함정: **기록이 없다**를 **재현이 안 된다**로 읽었다. 조립하는
 코드를 안 열고 로그만 봤기 때문이다.

★별건으로 남긴다 — **OpenRouter 판정 경로에 Opik input 이 안 실린다.**
 재현과 별개로 기록 결함이다.

## ⑫ 후보 라벨 순서가 호출마다 다르다

같은 주행에서 `('A','B')` 6회 · `('B','A')` 8회. 좌우를 바꿔 두 번 보는
설계(`judge_flip`)의 결과다.

★그래서 **「A 를 골랐다」가 무엇을 뜻하는지는 호출마다 다르다.** 승자
 라벨을 샷 사이에 견주면 안 된다. A/B 비교는 **같은 payload 안에서만**
 성립한다(이 도구는 Opik 의 같은 payload 를 재사용하므로 그 조건을 지킨다).

---

## ⑬ payload 복원 A/B 를 접는다 — Opik 은 **이미지를 버린다**

유료 실행 첫 판(1샷×4회)이 **전건 실패**했다. 사유는 양쪽 모델 모두
`Base64 decoding failed` / `invalid_image`.

Opik span 의 이미지 조각은 이렇게 남아 있다.

    [2] image_url  url 길이=53
        'data:image/png;base64,[base64_data truncated: 1.96MB]'

**길이가 53자다.** 데이터가 잘린 게 아니라 **안내 문구로 바뀌어 있다.**

★`--dry` 는 "이미지 4장은 그대로"라고 출력했다. 개수는 맞았다. 그 4장이
 쓸 수 없는 것인지는 **안 봤다.** 세는 것과 쓸 수 있는지는 다르다.

★그래도 이번엔 **거짓 결론이 안 나왔다.** Codex BLOCK 3 으로 넣은
 「실패를 버리지 않는다」가 걸려 결론 없이 exit 1 로 섰다. 종전 코드였으면
 실패 샷이 분모에서 사라져 **「차이 없음」**이 나왔을 것이다.

### 파일에서 되살리는 길도 접는다

`image_asset` 에 후보는 있다(`asset_type='scene'`, 샷당 2~3장). 그런데
판정에 나간 **참조 2장**(LOCATION + CHARACTER)을 정확히 못 맞춘다 —
그 샷 후보의 `reference_image_ids` 는 **1장**뿐이다(생성에 쓴 참조이지
판정에 붙인 참조가 아니다).

짜맞추면 **재는 대상이 프로덕션과 달라진다.** 그렇게 나온 수는 프로덕션에
적용할 근거가 못 된다.

### 그래서 어떻게 재나 — 프로덕션 플래그 + 최소 시나리오 실주행

1. 고칠 것을 **코드로 만들고 플래그로 감싼다** (기본 OFF)
2. 최소 시나리오 「골목 끝」(3씬·6샷)을 플래그 ON/OFF 로 **두 번 돌린다**
3. 이미지·참조는 파이프라인이 알아서 붙이므로 **짜맞출 것이 없다**

★이 길은 08-25 에 이미 쓴 방법이다(체크포인트 두 판 대조). 판정 payload
 를 손으로 복원하는 것보다 **재는 대상이 정직하다.**
★플래그로 감싸면 롤백도 브랜치 되돌리기 없이 된다.

★남은 값: 이번에 만든 도구의 **payload 분해·정적 줄 판별**은 그대로
 쓴다(무료). 「무엇을 옮길 수 있나」는 이미 답이 나왔다 — 69줄 5,761자.

---

# 수정 ① — 이 샷에 해당하는 공간 규칙만 싣는다

발견 ③에 대한 첫 수정. 플래그 `CARD_SPATIAL_RULES_SCOPED_ENABLED`
(기본 **OFF**).

## 무엇이 문제였나

카드의 `render_strategy.spatial_consistency` 는 세 규칙을 **언제나 전문**
싣는다. 그런데

- 인물이 **한 명**인 샷에 「두 인물이 상호작용할 때」 규칙
  (`fg_bg_shared_anchor_rule`, 1,381자)
- framing 이 **medium/wide** 인 샷에 `close_framing_rules`(742자)

가 그대로 간다. 그 안에는

> `absent — not rendered at all even if listed in visible_entities`

가 있다. medium 샷에서 이 문장은 **「그리라고 지정된 인물을 안 그려도
된다」**는 뜻이 된다. 길이 문제가 아니라 **지시 자체가 틀린** 것이다.

★각 규칙에 `applies_when` 칸이 **이미 있다.** 즉 조건을 모델에게 말로
 알려주고 스스로 거르게 하고 있었다. 그런데 인물 수와 framing 은
 **카드가 확정값으로 이미 알고 있다.**

## 어떻게 고쳤나 — 두 실패를 나란히 놓고

원래 코드에는 **RO-9 builder-static** 계약이 있고
`test_spatial_consistency_4_subfields_independent_of_input` 이 강제한다.
지우고 갈 수 없다. 그래서 두 실패를 나란히 적었다.

| | 막으려는 실패 |
|---|---|
| RO-9 (원래) | 입력이 **비어서** 규칙이 조용히 사라지는 것 (Trap #14) |
| 이번 수정 | 입력이 **확정적으로 「해당 없음」**인데 전문이 실리는 것 |

**fail-safe 방향이 반대다.** 그래서 방향을 그대로 두고 조건만 뒤집었다 —
**확정됐을 때만** 걷고, 모르면 지금과 똑같이 전문을 싣는다.

- `framing_scale` 이 `close` → `wide_medium_rules` 를 걷는다
- `wide`/`medium` → `close_framing_rules` 를 걷는다
- **`insert` 와 모르는 값은 손대지 않는다**
- 인물이 **정확히 1명**이면 `fg_bg_shared_anchor_rule` 을 걷는다
- 2명 이상이어도 **걷지 않는다** — 상호작용·fg/bg 분리 여부는 코드가 모른다

★**키는 남긴다.** 팩 `scene_detail/*/system.md:271-273` 이 세 규칙 이름을
 직접 부른다. 통째로 빼면 「카드가 준다는데 없다」는 더 나쁜 모순이 된다.
 걷을 때는 `applies_when` + 사유만 남긴다.
★인물 판별은 `_is_character_subject`(C## 축)를 **그대로 쓴다** — 같은
 규칙을 따로 적으면 두 곳이 따로 틀린다.

## 실측 — 이번 판 7샷

| 샷 | framing | 인물 | 전 | 후 | 줄어든 것 |
|---|---|---:|---:|---:|---:|
| S1sh2 | medium | 1 | 6,182 | 4,525 | 1,657 |
| S1sh4 | medium | 2 | 6,182 | 5,539 | 643 |
| S2sh1 | wide | 1 | 6,182 | 4,523 | 1,659 |
| S2sh3 | close | 2 | 6,182 | 5,774 | 408 |
| S2sh6 | close | 2 | 6,182 | 5,774 | 408 |
| S3sh1 | wide | 2 | 6,182 | 5,537 | 645 |
| S3sh2 | close | 1 | 6,182 | 4,760 | 1,422 |
| | | | **43,274** | **36,432** | **6,842 (15.8%)** |

## 확인

- 새 시험 **62개 통과**
- 기존 RO-9 계약 시험 **54개 그대로 통과**(플래그 OFF)
- **양성 대조**: 축약을 도로 빼니 **7개 빨강**, 되돌리니 62개 통과
- 플래그 OFF 대조: `spatial_consistency` 가 **한 글자도 안 다르다**

★아직 **그림으로는 확인 안 했다.** 다음은 최소 시나리오 「골목 끝」을
 플래그 ON/OFF 로 두 판 돌려 실제 이미지를 본다.

## 수정 ① 재작업 — Codex BLOCK 2건 (2026-08-26)

앞 절의 「확인」은 **부족했다.** Codex 가 둘을 잡았고 둘 다 실증됐다.

### BLOCK 1 — 플래그 ON 이면 카드 생산이 통째로 죽었다

카드는 반환 직전 `assert_card_shape()` 를 거치고, 거기서
`close_framing_rules` 4키 · `wide_medium_rules` 4키 ·
`fg_bg_shared_anchor_rule` 6키를 **요구**한다. 걷어낸 자리가 그 검사에 걸려

    AppError: …close_framing_rules missing required sub-keys
              ['forbidden_combinations', 'other_entities_appearance_options', …]

즉 **알려진 framing 세 값 전부에서 카드가 안 만들어진다.**

★내 시험은 `build_render_strategy` 까지만 태우고 이름을 **`test_끝점_…`**
 이라 붙였다. 끝점이 아니었다. 「조립하는 자리를 시험하고 나가는 것을
 쟀다고 말하지 마라」를 **내가 적어 놓고 또 밟았다.**

수리 = validator 에 **not_applicable sentinel 갈래**를 명시적으로 넣었다.
「해당 없음」이 계약의 일부가 된다. 아무 dict 나 통과시키지 않는다 —
사유가 비었거나 예상 밖 키가 있으면 `AppError`.

### BLOCK 2 — 플래그가 checkpoint 지문에 안 접혔다

`detail_steps.py:_config_hash()` 에 새 플래그가 없어, ON/OFF 를 바꿔도
**옛 checkpoint 가 current 로 읽힌다.** 그러면 A/B 두 판이 사실은 같은
것을 보고, 운영 resume 도 새 카드 계약을 안 태운다.

수리 = **켰을 때만** stamp 추가(기존 `visual_continuity_anchor_stamp` 와
같은 관례). OFF 면 payload 가 불변이라 **기존 판의 지문이 그대로 보존**된다.
실측 `0e21df7659c8f20a`(OFF) ≠ `c3999620c2deda4f`(ON).

### 참고 반영 — `character_count <= 1`

`visible_entities=[]` 는 이 코드베이스에서 **명시적 빈 목록**이고 「모름」은
`None` 이다. 0명도 확정적 해당 없음이라 걷는다. 종전 `== 1` 은 인물 없는
샷에 1,381자를 계속 실었다.

### 다시 확인

- 시험 **71개 통과**(62 → 71, 끝점·지문·sentinel 모양 9개 추가)
- 전체 유닛 **2,193개 통과, 실패 0**
- **양성 대조 2건**: sentinel 갈래를 빼면 끝점 2개 빨강 · stamp 를 빼면
  지문 1개 빨강 · 되돌리면 71개 통과
- 끝점 실증: 플래그 ON/OFF **양쪽 다** 카드 생산 성공

## 수정 ①-c — 걷어낸 규칙을 **가리키는 지시**도 같이 뺀다

Codex 재리뷰 APPROVE 뒤에 **내가 스스로 찾은 잔여 결함**이다.

`render_strategy.constraints[3]` 은 이렇게 말한다.

> two characters in fg/bg separation **must** share a named surface/space
> anchor — see …spatial_consistency.fg_bg_shared_anchor_rule

인물이 한 명 이하인 샷에서 **규칙 본문만 걷고 이 줄을 두면**, 모델은
여전히 「두 인물이 …해야 한다」는 **명령형 지시**를 받는다. 없앤 것을
가리키는 지시가 남는 것이 더 나쁘다. 걷다 만 셈이었다.

★**새 문장을 짓지 않는다.** 「해당 없음」 문구를 새로 쓰면 이 파일의
 하드프롬프트가 더 는다(발견 ①). 해당 없는 줄은 그냥 뺀다.
★안전 확인: `render_strategy.constraints` 를 **인덱스로 읽는 코드도**,
 이것만 따로 읽는 **소비자도 없다**(팩이 카드 JSON 을 통째로 읽는다).
 길이가 5 → 4 가 되어도 깨지는 자리가 없다.

### 발견 ④ 의 실체 — `body-part close-up` 은 **다섯 번** 나온다

| 곳 | 횟수 |
|---|---:|
| `constraints[1]` · `constraints[4]` | 2 |
| `spatial_consistency` 안 | 3 |

이번 수정은 그중 **해당 없는 것**만 건드린다. 「같은 말을 여러 번 하는 것」
자체를 줄이는 것은 **다른 축**이라 별도 플래그로 다룬다 — 한 플래그에
두 축을 넣으면 A/B 에서 어느 것이 효과인지 못 가린다.

### 실측 (spatial + constraints 합계)

| 샷 | framing | 인물 | 전 | 후 | 줄어든 것 |
|---|---|---:|---:|---:|---:|
| S1sh2 | medium | 1 | 6,760 | 4,956 | 1,804 |
| S1sh4 | medium | 2 | 6,760 | 6,117 | 643 |
| S2sh1 | wide | 1 | 6,760 | 4,954 | 1,806 |
| S2sh3 | close | 2 | 6,760 | 6,352 | 408 |
| S2sh6 | close | 2 | 6,760 | 6,352 | 408 |
| S3sh1 | wide | 2 | 6,760 | 6,115 | 645 |
| S3sh2 | close | 1 | 6,760 | 5,191 | 1,569 |
| | | | **47,320** | **40,037** | **7,283 (15.4%)** |

### 확인

- 시험 75 → **87 통과** · 전체 유닛 **2,209 통과, 실패 0**
- **양성 대조**: 지시 걷기를 도로 빼니 3개 빨강
- 플래그 OFF 대조: 원본과 **같다**

---

# 발견 ④ 를 **수정 대상에서 내린다**

「같은 규칙을 두 번 말한다」를 고치려고 실측했더니 **걷을 것이 별로 없다.**
도구 = `backend/tools/prompt_measure/card_redundancy.py` (무료).

## 실측 — 기본 카드와 실물 7샷이 같은 값

| | |
|---|---:|
| 규칙 문구 | 66개 · 5,713자 |
| 되풀이 묶음 | 11개 · 3,165자 |
| 한 번씩만 두면 | 1,464자 |
| **겹치는 양** | **1,701자** |

되풀이가 몰린 자리: `spatial_consistency` **2,843자** · `constraints` 322자.

## 그런데 그 「되풀이」는 **각도가 다른 다섯 면**이다

가장 큰 묶음(`fg_bg_shared_anchor_rule`, 7번 777자)을 뜯으면

| 자리 | 무엇을 말하나 |
|---|---|
| `applies_when` | **언제** 적용되나 |
| `recommended_keywords` | **그럼 뭐라 쓰나** ← 재료 |
| `self_check_steps` | **어떻게 확인하나** |
| `rationale_summary` | **왜** |
| `constraints[3]` | 요약 포인터 |

★`recommended_keywords`·`recommended_phrasings` 는 **재료**다. 걷으면
 「금지형만 남아 그릴 것이 없어지는」 자리가 된다.
★`rationale_summary`(왜)를 걷는 것은 **08-24 `a675c352` 가 이미 기각한
 축**이다. 같은 실험을 다시 하지 않는다.
★남는 것은 `constraints` 포인터 322자뿐이고, 그중 해당 없는 한 줄은
 **수정 ①-c 에서 이미 뺐다.**

## 그래서

발견 ④는 「중복 5회」라는 수치가 자극적이었을 뿐, **뜯어보면 대부분이
재료이거나 이미 기각된 축**이다. 걷지 않는다.

★얻은 것: 되풀이를 세는 도구가 남았고, 「몇 번 나온다」를 **자리별로**
 볼 수 있게 됐다. 다음에 같은 의심이 들면 이 표부터 본다.

## 다음 축은 발견 ⑧ 이다

이미지 단계 발송량의 89.6% 가 판정 루프이고, 네 op 가 **같은 브리프를
각각 다시 싣는다**(76줄 7,817자 × 44호출 ≈ 34만 자). 이것은 「걷기」가
아니라 **한 번만 보내기** — 파이프라인 구조 문제다.

★다만 지금 판(공간 규칙 걷기)의 A/B 결과를 **눈으로 본 뒤에** 착수한다.
 한 번에 두 축을 바꾸면 어느 것이 효과인지 못 가린다.

---

# ★수정 ① 의 근거를 **철회한다** — 카드는 현재 이미지에 안 닿는다

「골목 끝」 7샷을 플래그 OFF/ON 두 판 유료 주행(122분)해 사용자가 「B가
낫다」고 판정했고 그것을 기본값 승격의 근거로 썼다. **그 귀속은 틀렸다.**

## 확정 (Codex 검증 + 실측)

**현재 이미지 생성(recipe v1)은 카드를 아예 안 읽는다.**

- `scene_image_service.py:397-400` — v1 분기가 legacy 경로를 우회
- `still_recipe.py:1544-1585` — `build_still_prompt` 입력 계약에
  `render_prompt_card`·`spatial_consistency`·`t2i_prompt` 인자가 **없다**
- `still_recipe_service.py:3419-3475` — 실제 호출도 shot_desc/place/time/
  world/pose/인물/카메라/조명/signage/pack 만 넘긴다
- v1 이 `scene_detail` 에서 읽는 유일한 것은 `outfit_assignments`
  (`still_recipe_service.py:818-840`)

**걷어낸 문구가 A판 이미지 프롬프트에 애초부터 없었다** — `absent — not
rendered at all…` · `fg_bg_shared_anchor_rule` · `close_framing_rules` ·
`spatial_consistency` 전부 문자열 검색 False.

## A/B 그림이 달라진 진짜 이유

| 샷 | 차이 | 출처 |
|---|---|---|
| S1sh2 | `"유리문"` → `"정비소 유리문"` (4자) | `signage_author` 재호출 |
| S2sh3 | SIGNAGE TEXT 절 삽입 (236자) | `signage_author` 재호출 |
| **S3sh1** | **프롬프트 완전 동일** | **순수 재추첨** |

★S3sh1 은 Opik span 입력 SHA 가 후보별로 A/B 완전 동일
(`2f20d1c4…`/`bd861251…`)인데 **PNG SHA 가 달랐다**. seed 고정이 없다
(`gemini_image_client.py:186-195` · `grok_image_client.py:166-170`).

## 카드가 닿는 끝

`scene_detail` LLM 까지다 — Opik 실발송 payload 에 `[RenderPromptCard v1]`·
`spatial_consistency` 가 실제로 있다. 그 산출 `t2i_prompt` 는 **legacy 이미지
경로에서만** 쓰이고 current v1 은 그 경로를 안 탄다.

## 그래서 근거를 이렇게 바꾼다

수정 ①(`0b718710`)의 근거는

- ✅ `scene_detail` payload **15.4% 절감**
- ✅ 해당 없는 **틀린 지시 제거**(medium 샷의 `absent — not rendered…`)
- ❌ ~~최종 이미지 A/B 선호~~ ← **철회**

기본값 True 는 유지한다. 수정 자체는 맞다 — 다만 **이미지 효과는 미증명**
이고, `scene_detail` 산출 자체로 다시 판정해야 한다.

## 이 판에서 배운 것

★**재기 전에 「무엇이 무엇에 닿는지」부터 확인한다.** 바꾼 문구를 지금
 산출물에서 문자열로 찾아보면 **30초·무료**였다. 그것을 안 하고 2시간
 유료 주행을 돌렸다.
★**seed 고정이 없으면 어떤 A/B 도 재추첨과 못 가른다.**
