# Single-vs-Batch Reference Contract Fix — Design Spec

**Date**: 2026-05-08
**Status**: DRAFT_PATCHED_R1 (self-audit 1차 — IMPORTANT 2/MINOR 2 반영)
**Author**: Claude (Opus 4.7) + 사용자 directed review (Codex auxiliary)
**Trigger**: C01 zero-gate 후속 검증 시 단건 image 재생성 25건 시각 NG 다수 — 단건 경로의 reference attach 결함 + lineage divergence + face close-up exemption 과다 노출

---

## §1. Background

### 1.1 노출 사고 (2026-05-08)

PID `80f62523` C01 (수리영) forbidden trait zero-gate cleanup 후 **단건 endpoint** `POST /api/v1/projects/{pid}/stills/{still_id}/generate-image` 25 still 재생성. cycle 1~5 zero-gate (active gate=0) PASS. 사용자 시각 검증에서 8건 NG — 공간 인지 (출입문/자전거), 캐릭터 누락 (S13_Shot6 "No reference images"), narrative-critical prop 누락 (S21_Shot6 살인사진/좌표메모), shot-intent 정합성 (S14/S16) 결함 다수.

### 1.2 사고가 드러낸 system defects (data + 코드 확정)

| # | Defect | Evidence |
|---|---|---|
| **D1** | 단건 경로(`_build_single_scene_prompt_and_refs`)와 배치 경로(`generate_images`)의 ref builder 가 다름 | `coordinator.py:677-739` (단건) ⊊ `coordinator.py:178-326` (배치). 단건은 chain_bg / prev_shot_ref / state_variant 모두 누락 (≈65 LOC 차이) |
| **D2** | `image_asset.reference_image_ids` lineage 가 **실제 LLM attached refs 가 아님** (운영 디버깅 오도) | data 검증: S13 NOW lineage 2 / actual `[]` (llm_call_log) / S21 NOW lineage 3 / actual 1. lineage 는 `build_lineage_fields(ref_entity_ids=visible_entities[].id)` (coordinator.py:763-768) — chain_bg/prev_shot/state_variant 영원히 누락 (설계상) |
| **D3** | `_forward_enforcement_exempt()` 의 `body_part_focus_rule.trigger_phrases` 가 face/eyes close-up 까지 면제 | `visible_entities_validator.py:131-199`. S13 prompt "Focus on the tear-filled, dark eyes" → "focus on" trigger 매칭 → exempt → C01/C02 ID 누락 통과 → labeled_refs `[]` |
| **D4** | required_refs 와 actual labeled_refs 의 일치성 보장 부재 | `RenderPromptCard.asset_requirements.required_refs` 가 character_outlook/background 명시해도 generate 직전 비교/차단 없음. "from the reference" phantom 문구 잔존 + 실제 ref 부재 silent 통과 |

### 1.3 사용자 보고 NG 와 defect 매핑

| Still | 사용자 보고 | Root cause defect |
|---|---|---|
| S8_Shot4 | 출입문이 아닌 사람 가리킴 | D1 (bg chain ref 누락) + D4 ("from the reference" phantom 잔존) |
| **S13_Shot6** | 캐릭터 들어가 있는지 + 어깨 손 | **D3 (face close-up exemption) → labeled_refs `[]`** + D1 |
| S15_Shot5 | 90도 빗나감 | D1 (bg chain ref 누락 → 공간 anchor 약화) |
| S19_Shot1 | 바퀴/핸들 정면 이상 | D1 (bg chain ref 누락) + D4 |
| **S21_Shot6** | 책상 위 사진 없음 | D1 (bg + prop ref 누락) + **prop contract 부재** (P0-5 후속) |
| S14_Shot9 | 마주봄 의도 | (D1/D4 무관) — scene_detail manifest 의 source intent 정합성 결함 (P0-8 후속, 별도 P1) |
| S16_Shot3 | 폰+손만 나옴 | (현재 manifest = "phone extreme close-up" source-faithful) — 사용자 의도 다르면 P0-8 후속 |

### 1.4 lineage vs actual divergence (data로 확정 — llm_call_log 기준)

| Still | lineage `reference_image_ids` | actual labeled_refs (llm_call_log) | divergence |
|---|---|---|---|
| S8_Shot4 NOW | 2 | 1 (`character C01O02 in outfit`) | bg lineage 1 가짜 |
| **S13_Shot6 NOW** | 2 | **0 (`[]`)** | **모두 가짜 — "No reference images"** |
| S14_Shot9 NOW | 3 | 2 (C02O03+C01O02) | location 1 (정책상 정상) |
| S15_Shot5 NOW | 2 | 2 | 일치 (visible 2 character) |
| S19_Shot1 NOW | 2 | 1 (C01O02) | bg lineage 1 가짜 |
| **S21_Shot6 NOW** | 3 | **1 (C01O02만)** | **2 가짜 (P09 사진+P07 메모)** |

→ **운영자가 `image_asset.reference_image_ids` 를 actual ref source 로 사용하면 잘못된 결론 (S13 "ref 2개 들어갔는데 왜 안 나오지" 잘못된 디버깅)**

---

## §2. Goals

1. **단건 경로가 배치 경로와 동일한 reference contract** 사용 (chain_bg / prev_shot_ref / state_variant / close framing skip 동등).
2. **`required_refs` vs actual `labeled_refs` runtime fail-fast** — character_outlook + background required 가 실제 attach 안 되면 generate 차단. "from the reference" phantom 문구 ↔ 실제 ref 부재 일치성 검사.
3. **face/eyes close-up exemption 축소** — `_forward_enforcement_exempt()` 의 `body_part_focus_rule.trigger_phrases` 가 face/eyes/expression 패턴 매칭 시 면제 거부. **fail-fast 만** (fallback inject 금지 — silent fallback 재발 방지).
4. **actual refs observability lite** — `image_asset.reference_image_ids` 컬럼/UI 라벨 = "lineage". actual refs 는 `llm_call_log.reference_image_ids` 로 join 표시. 운영자 혼동 차단.

---

## §3. Non-goals (명시적 제외)

다음 영역은 **본 spec 의 범위 외**:

- **prop 신규 schema 필드** (scene_detail v22 의 `narrative_critical_props` 등) — P0-5 후속. 본 spec 은 deterministic guard 1차 (visible prop 이 representative_moment / shot_description / source_facts 등장 시 fail-fast) 만.
- **별도 `image_asset_ref_attachment` 테이블** — audit 정식화 단계. 본 spec 은 llm_call_log 활용 + 컬럼 라벨 정리만.
- **scene_detail manifest 재생성** (cleaned source 기반 LLM 재호출) — P0-7 후속. 본 spec 은 manifest 변경 0.
- **S14_Shot9 / S16_Shot3 source intent** (scene_detail step 의 shot intent literal preservation) — P0-8 후속, 별도 P1.
- **샷별 prompt 수동 보정** — 본 spec **명시적 금지**. system 결함 fix 후 자동 regen 으로만 검증.
- **LVM cost/quality / scene_detail prompt v22** — out of scope.
- **`image_asset.reference_image_ids` 컬럼명 변경** (alembic rename) — out of scope. R1 patch 는 schema response + docstring 만 변경 (LOC 최소).

### 3.1 Plan handoff enforcement

본 spec 의 plan drafting 시 plan header **반드시** 아래 non-goals checklist 포함 (literal 복사):

```markdown
> **Non-goals enforcement** (이 plan 의 모든 task 에서 절대 추가 금지):
> - [ ] prop 신규 schema 필드 (scene_detail v22 narrative_critical_props 등) 추가 X
> - [ ] image_asset_ref_attachment 별도 테이블 X
> - [ ] scene_detail manifest 재생성 / cleaned source LLM derive X
> - [ ] 샷별 prompt 수동 보정 X (어떤 task 든)
> - [ ] LVM cost/quality / scene_detail prompt v22 변경 X
> - [ ] image_asset.reference_image_ids 컬럼명 alembic rename X
>
> 매 task 시작 시 reviewer 가 위 6개 위반 안 했는지 confirm. 위반 발견 시 task 중단 + spec 변경 협의.
```

Plan drafting 시 위 block 누락은 plan 자체의 BLOCKING. self-review 단계에서 검사.

---

## §4. Architecture

### 4.1 공통 helper extract (D1 fix)

```
coordinator.py:263-326 의 ref 빌드 블록을 신규 helper 로 lift:

build_scene_attached_refs(
    *, still, episode_id, still_data,
    visible_entities, ref_image_map,
    background_chain_bg_map, best_prev_bytes,
    current_location_ids, dep_scene_id, stills,
    location_scene_history, dep_detail_map,
    staging, state_variant_sids, entity_lookup,
    db, project_id, project_config,
) -> tuple[str, list[tuple[str, bytes]]]:
    """단건/배치 양쪽 호출. var_t2i + labeled_refs 반환.
    
    내부 로직 (배치 `coordinator.py:178-326` 와 동일 순서 보존):
      1. load_shot_t2i_variations → var_t2i
      2. build_scene_ref_image_map → scene_ref_image_map (location 자동 제외)
      3. resolve_refs_for_prompt → labeled_refs (entity-only: character composite + prop name 매칭)
      4. _is_close_framing 검사 (camera_direction = staging.camera_direction)
      5. background fallback chain (R1 정확화):
           5a. _bc_bg 가능 + NOT close → labeled_refs.insert(0, _bc_bg) — background_chain ref injected
           5b. _bc_bg 가능 + close → chain_bg ref skip (Phase 9.1 정책: wide bg ↔ close 인물 scale mismatch 방지)
                + prev_shot_ref try (close framing 이라도 prev_shot 자체는 차단 X)
           5c. _bc_bg 부재 (chain 안 만들어진 location) → prev_shot_ref try
           5d. prev_shot_ref try 실패 (raise 시 warning + 빈 chain slot) → entity-only fallback (Codex review C3 보존)
      6. _build_image_index_helper → labeled_refs 번호 부여 (Image 1, Image 2, ...)
         + var_t2i 의 C##O## → "the character from Image N" rewrite
      7. _build_final_scene_prompt → _full_prompt (resolve_ref_roles + replace_entity_ids + translate_if_korean + build_scene_text)
    
    Returns: (_full_prompt, labeled_refs)
    """
```

- 단건 경로 `_build_single_scene_prompt_and_refs` (`coordinator.py:677-739`) → helper 호출 단순화.
- 단건 caller input prep (배치는 이미 generate_images 안에서 build):
    - `background_chain_bg_map` = `load_background_chain_bg_map(projects_dir, project_id, episode_id)` — episode 단위 cached
    - `best_prev_bytes` = 단건 시점에 None (prev_shot_ref builder 가 fallback path 안에서 같은 location 의 가장 최근 still 이미지 자동 lookup) OR 단건 caller 가 explicit lookup
    - `dep_scene_id`, `dep_detail_map`, `location_scene_history`, `staging`, `state_variant_sids`, `entity_lookup` = 배치와 동일 helper 로 build (재사용)
- 배치 경로 `generate_images` → 같은 helper 호출 (loop 내).
- 단건 caller 가 input 일부 None 으로 호출 시 helper 내부 default 처리 — chain_bg skip 시 prev_shot_ref 가능 (5d), 모두 부재면 entity-only fallback (silent skip 아니라 명시적 path).

### 4.2 runtime fail-fast (D4 fix)

```
generate_and_validate_scene 직전에 신규 validator:

validate_attached_refs(
    rpc: dict,                    # RenderPromptCard
    labeled_refs: list,           # actual attached
    prompt: str,                  # var_t2i (translated 후)
    is_close_framing: bool,       # camera_direction 기반 — caller 에서 forward
) -> None:
    """contract 위반 시 RefContractError raise.
    
    R1 정밀화: 단순 phrase count 사용 X. phrase 주변 100자 안의
    classifier (character/object/background) 매칭으로 가리키는 ref type 분류.
    
    검사 항목:
      1. required_refs.character_outlook 명시 + visible 에 character 존재 →
         labeled_refs 에 'character C##O##' (또는 'character C## identity') 라벨 존재.
         missing 시 fail.
      2. required_refs.background 명시 + NOT is_close_framing →
         labeled_refs 에 'BACKGROUND' (chain_bg) OR 'previous shot' (prev_shot_ref) 라벨 존재.
         close framing 시 background skip 정책 일관 (Phase 9.1) — 면제.
      3. prompt 안 'from the reference' phrase 등장 시 phrase position-based classifier:
           a. phrase 주변 ±100자 윈도우 토큰 매칭:
                - 'character' / 'figure' / 'face' / 'eyes' / 'her hand' / 'his hand' → character ref classifier
                - 'door' / 'glass' / 'wall' / 'floor' / 'desk' / 'storefront' / 'aisle' / 'TV' → background ref classifier
                - 'photograph' / 'memo' / 'paper' / 'phone' / 'cup' / object name 매칭 → object ref classifier
                - classifier 매칭 0 (모호) → MINOR warning (fail-fast 아님), 운영자 점검
           b. classifier per type 별 검사:
                - character classifier → labeled_refs 에 character 라벨 존재 의무 (close framing 무관)
                - background classifier → close framing 시 면제, NOT close 시 의무
                - object classifier → labeled_refs 에 object 라벨 존재 의무
           missing 시 fail.
      4. (count 검사 폐기) 단순 'from the reference' phrase count 불일치 검사 사용 X —
         phrase position-based classifier 로 대체.
    
    위반 시 RefContractError(detail) raise — generate 전 차단.
    """
```

- `RefContractError` 신규 exception class. `generate_and_validate_scene` 의 caller 에서 catch + retry 1 회 (var_t2i rebuild) + 재실패 시 propagate.
- false positive 방지:
    - `is_close_framing` 시 `required_refs.background` 면제 (close framing skip 정책 일치)
    - classifier 토큰 list 는 conservative — false positive 발생 시 list 확장이 아니라 classifier 매칭 0 케이스 = warning (fail-fast 아님). 운영자 점검 후 list 보강.
    - 'from the reference image as inspiration' 같은 generic phrase: classifier 매칭 0 → warning. 운영자가 prompt 의 generic phrase 보강.

### 4.3 face/eyes exemption tightening (D3 fix)

```
visible_entities_validator.py:131 _forward_enforcement_exempt() 변경:

신규 helper:
def _is_face_close_up(prompt: str) -> bool:
    """face/eyes/expression close-up 패턴 매칭."""
    patterns = [
        r"focus on .{0,40}\b(face|eye|eyes|expression|gaze|stare)\b",
        r"close on .{0,40}\b(face|eye|eyes|expression)\b",
        r"tight on .{0,40}\b(face|eye|eyes|expression)\b",
        r"\b(eye|eyes|face) close[\-\s]?up\b",
    ]
    return any(re.search(p, prompt, re.IGNORECASE) for p in patterns)


기존 _forward_enforcement_exempt() 안:
    body_focus = id_policy.get("body_part_focus_rule")
    if isinstance(body_focus, dict):
        triggers = body_focus.get("trigger_phrases")
        if isinstance(triggers, list):
            for t in triggers:
                if isinstance(t, str) and t and t.lower() in prompt_lower:
                    # NEW: face/eyes/expression close-up 시 면제 거부
                    if _is_face_close_up(prompt):
                        # face close-up 은 character ID 의무 — exemption skip
                        continue  # 다음 trigger 또는 fall-through
                    return True, (...)
```

- fallback inject **금지** (Codex/사용자 binding) — silent fallback 재발 방지. exemption 거부 시 caller 의 forward_enforcement 가 fail (visible base ⊂ prompt ID set 위반 → fail-fast).
- false positive 방지: body close-up (손/발/입술/물건 insert) 는 `_is_face_close_up` 미매칭이라 정상 면제 유지.

### 4.4 observability 강화 (D2 fix — R1 patched)

R1 검증 결과 (frontend grep):
- frontend `src/types/episode.ts` 의 `reference_image_id` (singular) 는 다른 의미 (entity 의 ImageAsset.id) — lineage list 무관
- frontend `reference_image_ids` 직접 사용 = **0건**
- 즉 frontend 변경 0. risk 는 backend API consumer (외부 script / 디버그 / future UI) 에 한정.

변경 사항:
- `image_asset.reference_image_ids` 컬럼 docstring + `models/project.py:187` 주석에 명시:
    `"lineage refs (visible_entities character/prop UUID list, NOT actual LLM-attached refs). For actual attached refs see llm_call_log.reference_image_ids."`
- `schemas/image.py:36` `ImageResponse.reference_image_ids` field description 추가:
    `description="(lineage) visible_entities character/prop UUID list. NOT actual LLM-attached refs. See actual_attached_refs."`
- **`ImageResponse.actual_attached_refs` 신규 field 추가** (Optional[list[str]]):
    - source: `llm_call_log.reference_image_ids` join (operation_type IN ('single_scene_image_gen', 'scene_image_gen') AND metadata_json.still_id 매칭). 가장 최근 entry.
    - 값: actual labeled ref labels (`["character C01O02 in outfit", "BACKGROUND chain reference", ...]`).
    - llm_call_log retention 외 또는 매칭 없음 시 None (caller 가 lineage fallback 표시).
- `image_to_dict` (`image_service_helpers.py:182`) 가 actual_attached_refs 도 join 하도록 보강.
- `models/project.py:288` (llm_call_log) 의 `reference_image_ids` 컬럼 docstring 도 명시: `"actual ref labels attached to LLM call (e.g., 'character C01O02 in outfit', 'BACKGROUND chain reference')"`.
- 새 DB 컬럼/테이블 도입 X (LOC 최소). schema response 변경만 + 새 join. alembic migration 0.

---

## §5. Success Criteria

### 5.1 Acceptance criteria

- **AC-1**: 단건 호출 (`POST /stills/{id}/generate-image`) 결과의 actual labeled_refs (llm_call_log 기준) 가 배치 호출 결과와 **동등** (label set + count + order — close framing 분기 동일 적용).
- **AC-2**: S13_Shot6 같은 face close-up + visible C01/C02 → **`RefContractError` raise** (현재 silent labeled_refs `[]`).
- **AC-3**: `required_refs.background` 명시 + close framing 아님 + bg ref 미첨부 → fail-fast.
- **AC-4**: prompt 에 "from the reference" phrase + classifier 매칭 character/object → 해당 ref 미첨부 시 fail-fast. classifier 매칭 0 = MINOR warning (fail-fast 아님).
- **AC-5**: `ImageResponse` 가 `reference_image_ids` (lineage 명시) + `actual_attached_refs` (llm_call_log join) 두 필드 분리 노출. API consumer 가 lineage 와 actual 명확히 구분.
- **AC-6**: 컬럼/schema docstring 에 "lineage" vs "actual" 명시 — 외부 운영자 디버깅 시 혼동 차단.

### 5.2 Regression test fixture

`tests/fixtures/c01_zero_gate_regression.json` 신규 (또는 동등 형식):
- S8_Shot4, S13_Shot6, S15_Shot5, S19_Shot1, S21_Shot6 의 still_id + visible_entities + 의도된 actual labeled_refs count/labels
- 배치/단건 모두 통과해야 하는 contract test

### 5.3 Quantitative gate

- 6 NG stills (S8/S13/S14/S15/S19/S21) 단건 재호출 후 lineage = actual ref UUID set 일치 (location 제외) **OR** RefContractError raise.
- broader regression: 기존 통과 stills (cycle 1-5 OK 30건) 재호출 시 false positive 0건.

---

## §6. Risk / Mitigation

| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| 공통 helper extract → 배치 경로 회귀 (signature 불일치) | medium | high | helper unit test (단건/배치 양쪽 호출 시 동등 결과 검증). 기존 batch integration test (1 episode E2E) 통과 필수 |
| fail-fast → false positive (legitimate body-part close-up) | medium | medium | `_is_face_close_up` 패턴 정밀화 + body close-up (손/발/입술) negative case fixture |
| exemption tightening → 기존 통과 stills 새로 fail | medium | medium | scene_detail manifest 의 face close-up 통과 stills (cycle 1-5 OK 30건) sample regression. 1+ fail 시 패턴 정밀화 |
| llm_call_log retention 정책 → actual refs 부재 (오래된 asset) | low | low | UI 에서 "actual refs (last 90 days)" 명시. retention 외는 lineage fallback 표시 |
| `RefContractError` 발생 시 사용자 UX | medium | low | 명확한 error message ("required ref X not attached — check scene_detail manifest 또는 ref pipeline"). HTTP 422 status |
| classifier 토큰 list false negative (적용 안 되는 케이스) | medium | low | classifier 매칭 0 = MINOR warning (fail-fast 아님). 운영자가 list 보강 가능. 보수적 default — false-fail 보다 silent passthrough warning 선호 |
| `ImageResponse.actual_attached_refs` join 비용 | low | low | image_to_dict 호출당 1 query 추가 (still_id 인덱스 hit). 캐싱 X (lineage 와 다르게 read-only join). 만약 batch list 응답 (e.g. /episodes/{id}/images) 에서 N+1 → 별 쿼리로 batch lookup |

---

## §7. Out-of-scope follow-ups (별도 spec 필요)

- **P0-5 deterministic prop guard**: visible prop 이 representative_moment / shot_description / source_facts 등장 + actual refs 미첨부 시 fail-fast. 본 spec 의 P0-3 framework 확장 가능 (별도 spec 권장).
- **P0-6 regression canary**: S8/S13/S15/S19/S21 + cycle 1-5 OK 30건을 daily canary 로. 본 spec 의 fixture 가 input.
- **P0-7 manifest cleanup 임시성 격리**: 본 세션 29 phrase swap 의 임시 patch 표시 + 다음 episode regen 에서 cleaned source 자연 derive. 본 spec 변경 0.
- **P0-8 scene_detail "shot intent literal preservation"** (S14/S16): scene_detail prompt v22 신규 rule + source 검증 validator. 별도 P1.

---

## §8. binding refs

- 본 분석 종합: `~/.claude/projects/.../memory/session_20260508_block_c_closure_full.md` (Block C closure)
- C01 zero-gate carry: `next_session_c01_zero_gate_done_carry.md`
- 사용자 binding: manual prompt patch **금지**, single-vs-batch reference contract fix 시스템 패치만, fail-fast (fallback inject 금지)
- Codex auxiliary review: actual refs 집계 기준 (llm_call_log.reference_image_ids), `_forward_enforcement_exempt()` 정확한 정책 위치, 좁혀진 즉시 P0 scope (P0-1+P0-3+P0-4+P0-2-lite)
