# Area #11 — `prompt_service._classify_label` Substring Replacement SOT v1 Design

**Date**: 2026-05-18+
**Roadmap section**: §5.11 (Track B Tier 2 #3)
**Status**: brainstorming → spec draft (Codex iter required)
**Pattern donor**: Area #5 Reference phrase / phantom guard replacement v1 (sidecar SOT)

## 1. Background

`backend/app/services/prompt_service.py:75-116` 의 `_classify_label` 함수가 labeled_refs label string 의 substring (`"previous shot"`, `"same room"`, `"continuity"`, `"background chain ref"`, `"wearing"`, `"outfit"`, `"prop"`, `"object"`, `"background"`, `"location"`) 으로 10-branch dispatch.

**Issue (Track B G6 cross-cut + Open-world semantic regex 위반)**:
- Producer label format 진화 시 silent miss risk (e.g., producer 가 "previous shot" → "prior shot" 으로 wording 변경 시 dispatch 무성공 후 `fallback` branch).
- LLM-generated prose 변형 시 substring 매칭 깨짐.
- code regex (substring `in` operator) 가 semantic 판단 — `feedback_llm_based_judgment.md` 4-gate 위반 (Semantic Regex Ban).
- Area #5 phantom guard classifier 와 same class of problem.

## 2. Goal

`_classify_label` substring dispatch 폐기. Producer (scene_reference_service / scene_generation_coordinator / pipeline) 가 label emit 시점에 **canonical role enum + metadata sidecar** 동반 emit. Consumer (`resolve_ref_roles`) 는 enum 만 read, substring branch 0.

Area #5 sidecar SOT pattern reuse — `reference_phrase_kinds` 와 동일 mechanism. labeled_refs 2-tuple shape 보존, parallel `ref_roles: List[str]` + `ref_role_metadata: List[Dict]` sidecar.

## 3. Design (Codex iter 1 권고 채택)

### 3.1 LabeledRefPayload container

```python
# backend/app/services/prompt_service.py (또는 별도 module)
from dataclasses import dataclass
from typing import Any, Dict, List, Tuple

REF_ROLE_VALUES = (
    "outfit_ref_explicit",
    "previous_shot_same_frame_zoomed",
    "previous_shot_same_room",
    "previous_shot_continuity",
    "background_chain_ref",
    "character_ref",
    "character_state_ref",   # Codex iter 1 Important fix — state variant 별 enum
    "outfit_ref_inline",
    "prop_ref",
    "background_general",
    "fallback",
)


class RefRoleError(Exception):
    """LabeledRefPayload invariant 위반 — fail-fast (No Silent Fallback)."""


@dataclass(frozen=True)
class LabeledRefPayload:
    """Area #11 v1 — producer-side structured payload for reference image dispatch.

    Invariants (fail-fast on construction via `make_labeled_ref_payload` factory):
    - len(labeled_refs) == len(ref_roles) == len(ref_role_metadata) == len(attached_meta)
    - 각 ref_roles entry ∈ REF_ROLE_VALUES (else RefRoleError)
    - ref_role_metadata 의 각 entry 는 dict (None / 다른 type 시 RefRoleError)
    """
    labeled_refs: List[Tuple[str, Any]]       # (label_str, bytes) — LLM prompt 표시용
    ref_roles: List[str]                      # canonical enum per ref
    ref_role_metadata: List[Dict[str, Any]]   # per-ref metadata (sid, outfit_id, kept/ignore sentences 등)
    attached_meta: List[Tuple[str, str]]      # (kind, id) D5 §4.3 carry


def make_labeled_ref_payload(
    labeled_refs: List[Tuple[str, Any]],
    ref_roles: List[str],
    ref_role_metadata: List[Dict[str, Any]],
    attached_meta: List[Tuple[str, str]],
) -> LabeledRefPayload:
    """Codex iter 1+2 fix — explicit factory with fail-fast validation.

    `.get(..., [])` silent fallback 절대 금지. caller side 의 length / type / enum
    invariant 위반 시 RefRoleError raise.
    """
    # Codex iter 2 Minor 3 fix: top-level type isinstance gate (non-list 우회 차단)
    if not isinstance(labeled_refs, list):
        raise RefRoleError(
            f"labeled_refs is {type(labeled_refs).__name__}, not list"
        )
    if not isinstance(ref_roles, list):
        raise RefRoleError(
            f"ref_roles is {type(ref_roles).__name__}, not list"
        )
    if not isinstance(ref_role_metadata, list):
        raise RefRoleError(
            f"ref_role_metadata is {type(ref_role_metadata).__name__}, not list"
        )
    if not isinstance(attached_meta, list):
        raise RefRoleError(
            f"attached_meta is {type(attached_meta).__name__}, not list"
        )
    n = len(labeled_refs)
    if len(ref_roles) != n:
        raise RefRoleError(
            f"ref_roles length {len(ref_roles)} != labeled_refs length {n}"
        )
    if len(ref_role_metadata) != n:
        raise RefRoleError(
            f"ref_role_metadata length {len(ref_role_metadata)} != labeled_refs length {n}"
        )
    if len(attached_meta) != n:
        raise RefRoleError(
            f"attached_meta length {len(attached_meta)} != labeled_refs length {n}"
        )
    for i, role in enumerate(ref_roles):
        if role not in REF_ROLE_VALUES:
            raise RefRoleError(
                f"ref_roles[{i}]={role!r} not in REF_ROLE_VALUES "
                f"(allowed: {REF_ROLE_VALUES})"
            )
    for i, m in enumerate(ref_role_metadata):
        if not isinstance(m, dict):
            raise RefRoleError(
                f"ref_role_metadata[{i}] is {type(m).__name__}, not dict"
            )
    return LabeledRefPayload(
        labeled_refs=list(labeled_refs),
        ref_roles=list(ref_roles),
        ref_role_metadata=list(ref_role_metadata),
        attached_meta=list(attached_meta),
    )
```

### 3.2 Producer cascade

#### scene_reference_service (primary producer)
- `resolve_refs_for_prompt`, `resolve_refs_for_prompt_set` 에서 `labeled_refs` build 시 각 append site 별로 `ref_role_value` + `ref_role_metadata` 동반 emit.
- Return type 2-tuple `(labeled_refs, attached_meta)` → `LabeledRefPayload` 또는 4-tuple `(labeled_refs, ref_roles, ref_role_metadata, attached_meta)`.

Producer site mapping (cascade grep Q0-light, Codex iter 1 Important fix 반영):
| append site (line) | ref_role enum value | metadata (예시) |
|--------------------|---------------------|----------------|
| `:377` (state-variant composite, `attached_meta=("character_state", sid:state)`) | `character_state_ref` | `{"sid": char_sid, "state": sv['state']}` |
| `:396, :416` (character identity composite) | `character_ref` | `{"sid": char_sid}` |
| `:407, :456` (character {sid/name} in outfit composite) | `outfit_ref_inline` | `{"sid": sid, "outfit_kind": "composite"}` |
| `:447, :473` (character identity bare) | `character_ref` | `{}` |
| `:509` (prop) | `prop_ref` | `{"sid": prop_sid}` |
| `:1030` (build_custom_labeled_refs — custom-prompt path) | **v1 out-of-scope** (Codex iter 2 Important 1 decision) — 이 path 는 scene_image_service custom-prompt entry 전용이며 `prompt_service.build_final_scene_prompt` 진입 안 함. v1 closure claim 에서 제외. v2 후속 area 에서 동일 patten 적용 가능. | n/a |

#### scene_generation_coordinator (chain_bg + prev_shot insert)
- `:395` chain_bg `labeled_refs.insert(0, ...)` 시 parallel `ref_roles.insert(0, "background_chain_ref")` + `ref_role_metadata.insert(0, {"bg_id": _bc_bg.get("bg_id", "")})` + `attached_meta.insert(0, ("background", bg_id))`.
- `:446` prev_shot insert 시 parallel `ref_roles.insert(0, role)` + `ref_role_metadata.insert(0, metadata)` + `attached_meta.insert(0, ("background_prev_shot", _loc_id))`.

**prev_shot role 결정 (Codex iter 1 Important fix — under-specified 보완)**:
- `scene_reference_service.build_prev_shot_background_ref` (현재 `:892-928`) 가 label/bytes/loc_id 결정 시 사용하는 `ref_usage` (`zoom_in_detail` / `atmosphere_reference` / `exact_background`) field 가 이미 structured. 이 field 를 role enum 으로 1:1 매핑:
  - `ref_usage == "zoom_in_detail"` → `previous_shot_same_frame_zoomed`
  - `ref_usage == "atmosphere_reference"` → `previous_shot_continuity`
  - `ref_usage == "exact_background"` → `previous_shot_same_room`
  - else (fallback) → `previous_shot_continuity` (default conservative)
- helper signature 확장: `build_prev_shot_background_ref(...) -> Optional[Tuple[label, bytes, loc_id, ref_role, ref_role_metadata]]` (3-tuple → 5-tuple). metadata 안 `{"keep_elements": [...], "ignore": "...", "remove_hints": [...]}` 등 prose split 결과 pre-parsed 포함 → consumer 가 prose 다시 parse 안 함.

#### _build_image_index_helper boundary (Codex iter 2 Important 2 decision)

**Deterministic ID extraction (label → `sid_to_img_map`) preserved boundary**:
- 현재 `build_image_index` 는 label string 안 `C##` / `C##O##` / `P##` 같은 short_id pattern 을 deterministic 추출 → `sid_to_img_map: Dict[sid, img_index]` 빌드 (T2I prompt 의 `C01O02` 토큰 → "Image 1" 치환에 사용).
- 이는 **role classification 이 아님** (ID extraction == closed-world ID pattern, role dispatch == open-world semantic). 보존 boundary.
- Area #11 v1 closure claim = "production **role dispatch path** 에서 semantic substring classifier 0" (sid extraction 제외).
- Alternative (v2 consideration): `ref_role_metadata["sid"]` 로 sid 도 sidecar 이동 → label parsing 완전 제거. v1 out-of-scope (별도 area).
- W2 의 새 `build_image_index` 6-tuple 은 label rewrite (Image N 치환) 만 변경, sid extraction logic 보존.

#### _build_image_index_helper signature (Codex iter 1 Important fix — exact shape)
Current signature: `build_image_index(labeled_refs, entity_lookup, *, attached_meta=None) -> (indexed_labeled_refs, sid_to_img_map, sid_info, indexed_attached_meta)` (4-tuple).

Area #11 v1 new signature (6-tuple parallel pass-through):
```python
def build_image_index(
    labeled_refs: List[Tuple[str, Any]],
    entity_lookup: Dict[str, Dict],
    *,
    ref_roles: List[str],
    ref_role_metadata: List[Dict[str, Any]],
    attached_meta: List[Tuple[str, str]],
) -> Tuple[
    List[Tuple[str, Any]],   # indexed_labeled_refs (label rewrite — Image N 치환)
    Dict[str, int],           # sid_to_img_map
    Dict[str, Any],           # sid_info
    List[str],                # indexed_ref_roles (passthrough, length 동일)
    List[Dict[str, Any]],     # indexed_ref_role_metadata (passthrough, length 동일)
    List[Tuple[str, str]],    # indexed_attached_meta (passthrough)
]:
    """labeled_refs label 만 rewrite. 3 parallel list (ref_roles + ref_role_metadata + attached_meta)
    는 length 보존하며 그대로 passthrough. fail-fast: 모든 list length 동일."""
```

#### reference_phase3_service (Phase 3 — face/outfit refs)
- `:156, :158` Phase 3 standalone path — 별도 boundary, prompt_service classifier path 와 무관 (Phase 3 has its own consumer). v1 in-scope or out-of-scope 결정 의무 (Codex iter Q4 ask).

### 3.3 Consumer cascade (`_classify_label` 폐기)

#### resolve_ref_roles signature 변경
```python
def resolve_ref_roles(payload: LabeledRefPayload) -> RefResolution:
    """payload.ref_roles enum dispatch only. _classify_label substring branch 폐기."""
    ref_roles_text: List[str] = []
    ref_instructions: List[str] = []
    for i, (label, _) in enumerate(payload.labeled_refs, 1):
        role = payload.ref_roles[i - 1]
        metadata = payload.ref_role_metadata[i - 1]
        if role == "outfit_ref_explicit":
            ...
        elif role == "previous_shot_same_frame_zoomed":
            # metadata 안 kept/ignore sentences pre-parsed 받음, prose split X
            for sentence in metadata.get("keep_ignore_sentences", []):
                ref_instructions.append(f"- from image {i}: {sentence}")
        elif ...
        # 11 branch enum dispatch (substring 0) — Codex iter 3 Minor fix
    return RefResolution(...)
```

#### build_final_scene_prompt signature cascade
```python
def build_final_scene_prompt(
    t2i_prompt: str,
    payload: LabeledRefPayload,           # was: labeled_refs
    style_context: str,
    tracer: Any = None,
    scene_index: int = 0,
    project_config: Optional[Dict] = None,
    entity_text_map: Optional[Dict[str, str]] = None,
) -> str:
    ref_resolution = resolve_ref_roles(payload)
    cleaned = replace_entity_ids(t2i_prompt, payload.labeled_refs, entity_text_map)
    ...
```

#### caller cascade (scene_generation_coordinator)
- `:459` (single path) + `:1475` (variation path): `_build_final_scene_prompt(var_t2i, labeled_refs, ...)` → `_build_final_scene_prompt(var_t2i, payload, ...)`.

### 3.4 Boundary preservation

- `_is_explicit_character_ref_label` (prompt_service.py:64-72) **closed-token exact match** — production dispatch path 에서 폐기 (W2). Migration 후 retention 은 **test helper / legacy boundary 용도 only** (production code path 에서 호출 0). closure claim = "production dispatch path 에서 substring classifier 0", test helper level retention OK.
- `outfit_ref_explicit` exact match (current branch 1, `label_lower == "outfit appearance"`) — **closed, production dispatch 에서는 enum 으로 대체** (`resolve_ref_roles` 의 enum dispatch only, exact match 호출 0). exact match wording 보존은 test helper / legacy boundary level 만 허용.
- `scene_image_pipeline.py:243` "Previous scene (same location)" append — Gemini image reference path (prompt_service classifier 경로 아님). **v1 out-of-scope** (Codex 권고). Boundary row 명시.

## 4. 4-gate compliance

| Gate | Pre-Area #11 | Post-Area #11 |
|---|---|---|
| Semantic Regex Ban | `_classify_label` 8 substring branch (`"previous shot" in`, `"continuity" in`, `"background" in`, `"wearing" in`, `"prop" in`, `"location" in` 등) | 0 hits (production dispatch path) |
| Prompt Closed-List Ban | n/a (prompt-side wording 영향 없음, code-side classifier 만) | n/a |
| Structured SOT Required | LLM/code prose label substring inferred kind | producer emit canonical `ref_roles: List[str]` enum sidecar |
| No Silent Fallback | `_classify_label` fallback branch (line 116) — silent permissive | payload construction fail-fast on length mismatch / enum invalid / missing |

## 5. Closure criteria

1. **`LabeledRefPayload` container 도입** + **in-scope producer site** (scene_reference_service + scene_generation_coordinator chain/prev_shot insert + _build_image_index_helper passthrough — §5.4 producer site coverage row 와 consistency) 가 ref_roles + ref_role_metadata 동반 emit.
2. **`_classify_label` production dispatch 폐기** — `resolve_ref_roles` 가 payload.ref_roles enum dispatch only. (`_is_explicit_character_ref_label` closed-token retention OK.)
3. **`build_final_scene_prompt` signature cascade** — payload 받음. 2 caller (scene_generation_coordinator:459, :1475) cascade.
4. **Residue gate strict 0**:
   - production residue: `prompt_service.py` 내 `"previous shot" in`, `"continuity" in`, `"wearing" in`, `"prop" in`, `"location" in`, `"background chain ref" in` 등 substring branch 0.
   - active prompt residue: n/a (prompt-side 영향 없음).
   - producer site coverage (Codex iter 3 Important 1 fix — overclaim 정정): **in-scope producer site only** 에 ref_role enum 동반 emit verify. in-scope = `prompt_service.build_final_scene_prompt` path 진입하는 producer (scene_reference_service.py:377/396/407/416/447/456/473/509 + scene_generation_coordinator.py:395/446 + reference_phase3_service.py face/outfit if used). out-of-scope = `build_custom_labeled_refs` (:1030, custom-prompt entry 전용 — `build_final_scene_prompt` 미진입) + `scene_image_pipeline.py:243` (Gemini image ref path). 두 boundary는 closure claim 에서 제외.
5. **Canary matrix deterministic 17 scenario PASS** (Codex iter 4 W1-W4 range review 권고 verbatim — count 정정):
   - **11 enum dispatch** (outfit_ref_explicit / previous_shot_same_frame_zoomed / previous_shot_same_room / previous_shot_continuity / background_chain_ref / character_ref / character_state_ref / outfit_ref_inline / prop_ref / background_general / fallback) 각각 dispatch verify.
   - **4 factory fail-fast** representative coverage: (1) non-list labeled_refs (canary 12) / (2) length mismatch ref_roles vs labeled_refs (canary 13) / (3) invalid enum value (canary 14) / (4) non-dict ref_role_metadata entry (canary 15).
   - **1 full pipeline happy path** (canary 16) — build_final_scene_prompt with payload end-to-end.
   - **1 REF_ROLE_VALUES 11-count sanity** (canary_extras) — drift 차단.
   - **Total 17 canary** = 11 enum + 4 fail-fast + 1 full pipeline + 1 sanity.

## 6. Risk + Mitigation

- **Risk 1**: Producer 미적용 site missed → fallback 분기 silent. **Mitigation**: residue gate test + payload construction fail-fast (No Silent Fallback).
- **Risk 2**: `_build_image_index_helper` 가 labeled_refs label 만 rewrite, parallel 3 list pass-through 안 됨. **Mitigation**: helper signature 확장 + 3 list length 보존 verify test.
- **Risk 3**: scene_image_pipeline.py:243 "Previous scene (same location)" 가 prompt_service path 진입 시 fallback. **Mitigation**: v1 out-of-scope 명시 + 별도 boundary row.
- **Risk 4**: legacy test (`test_classify_label*`) 가 substring 가정 — cascade rewrite 필요. **Mitigation**: Area #5 W2 pattern 적용 (test rename + body inversion).

## 7. Dependency / Cost

- **Dependency**: independent (Area #5 sidecar SOT pattern reuse, 다른 area 미차단).
- **Cost**: medium (producer cascade scope + helper signature + 1 module signature cascade + 2 caller). Area #6 보다 작음.
- **Paired**: Area #5 closure 직후 진입 (sidecar SOT momentum + classifier 폐기 pattern 동일).

## 8. Wave outline (W0-W4 atomic — Codex iter 1 Critical fix)

**Critical fix rationale**: W1 에서 producer return shape (`resolve_refs_for_prompt_set` 2-tuple → payload) 를 단독으로 바꾸면 coordinator caller (`:659` unpack site) 가 동일 commit 안에서 cascade 안 되면 production / test break. 따라서 W1 = compatibility helper introduction (production return shape 보존), W2 = atomic production switch (producer return + consumer + caller cascade 동시).

| Wave | Scope | Verify |
|---|---|---|
| W0 | spec + plan + Codex iter approval | Codex APPROVED_FOR_EXECUTION |
| W1 | `LabeledRefPayload` container + `make_labeled_ref_payload` factory + `REF_ROLE_VALUES` enum + `RefRoleError` 신설. **Production return shape 보존** — producer side 에 role collection helper (`_collect_ref_roles(...)`, internal-only) 만 추가, 기존 `resolve_refs_for_prompt_set` return 2-tuple 유지. **W1 unit test = 9 factory fail-fast paths** (Codex iter 3 Minor 2 split clarification): 3 length mismatch + 4 non-list top-level isinstance + 1 invalid enum + 1 non-dict metadata. consumer / caller 미변경. | W1 unit 9 fail-fast PASS. 기존 production / test green. |
| W2 | **Atomic production switch**: producer return shape 변경 (`resolve_refs_for_prompt_set` → payload return) + coordinator caller (`scene_generation_coordinator.py:659` unpack site + `:395` chain_bg insert + `:446` prev_shot insert + `:457` `_build_final_scene_prompt` call site) cascade + `build_prev_shot_background_ref` 3→5-tuple 확장 + `_build_image_index_helper` 4→6-tuple cascade + `resolve_ref_roles(payload)` rewrite (`_classify_label` 폐기) + `build_final_scene_prompt(payload, ...)` signature cascade. 모두 1 atomic commit. | substring branch 0 (production dispatch path) + coordinator caller cascade 정합 + 2 caller (:459, :1475) payload arg 정합 + green test |
| W3 | test cascade: `test_prompt_service` + `test_prompt_service_label_routing` rewrite (substring 가정 폐기 → enum dispatch verify) + `test_build_scene_attached_refs` cascade (build_scene_attached_refs return 또는 caller cascade) + `test_single_scene_uses_helper` 등 mock cascade. | 모든 affected test PASS, regression 0, pre-existing baseline 영향 없음 |
| W4 | residue gate strict 0 (production substring branch + **in-scope producer site coverage 100%** + _is_explicit_character_ref_label production dispatch 진입점 0) + canary matrix (**11 enum dispatch + 4 fail-fast + 1 full pipeline + 1 sanity = 17 scenario**, Codex iter 4 W1-W4 range review 권고 verbatim count 정정) + roadmap §5.11/§11/§14 update + closure memo + Codex W1-W4 range review → APPROVED_FOR_PUSH → push to origin/main | 4 gate verify + Codex APPROVED_FOR_PUSH |

## 9. Non-claim

- `scene_image_pipeline.py:243` Gemini image reference path → Area #11 v1 out-of-scope (boundary).
- `_is_explicit_character_ref_label` closed-token exact match → 보존 (production dispatch path 에서 classifier 0 claim 은 substring branch 한정).
- LLM prose label quality 변화는 producer cascade 가 1차 방어 (ref_roles enum mismatch fail-fast).

## 10. 다음 area (post-Area #11)

Tier 2 #4 = Area #6 (T2I review mutation redesign).

## 11. 함정 carry (Tier 1+2 + Area #11 신규)

**Status**: plan-time fill required (Codex iter 2 Minor 4 marker). spec→plan handoff 시 implementation plan에서 finalize.

### Tier 1+2 carry (Area #1-#5 36 함정 — session memos 참조)
- [[session_20260516_area_1_id_outlook_reference_policy_sot_v1_closure]]
- [[session_20260517_area_2_state_gaze_separation_closure]]
- [[session_20260518_area_3_visibility_physical_presence_sot_v1_closure]]
- [[session_20260518_area_4_scene_consistency_element_scope_sot_v1_closure]]
- [[session_20260518_area_5_reference_phrase_phantom_guard_sot_v1_closure]]

### Area #11 신규 placeholder (plan 작성 시 finalize)
1. label producer site coverage gap (**in-scope** append/insert site → ref_roles emit verify; out-of-scope = `build_custom_labeled_refs` + `scene_image_pipeline:243`)
2. `build_image_index` deterministic ID extraction = role classification 분리 boundary
3. `_is_explicit_character_ref_label` test/legacy helper retention vs production dispatch 0
4. Compatibility helper (W1 only) vs production switch (W2) commit boundary 명확
5. Factory fail-fast 5 path 모두 unit test 의무 (length 3 + enum + non-dict + non-list 4)
6. `build_custom_labeled_refs` v1 out-of-scope boundary 명시 (v2 carry)
7. (plan 작성 시 추가 fill)
