# GROUNDING-V2 이행 플랜

> 계약 = `2026-08-29-grounding-v2-contract.md`. 이 문서는 **현 코드 inventory ·
> 이행 순서 · 검증 · 되돌리기**만 담는다. 구현 전 문서다.
>
> 브랜치: 문서는 `docs/grounding-v2-plan`, 구현은 **`feat/grounding-v2` 하나**.
> worktree `/Users/manta/Documents/Projects/TheRoad-I1-grounding-v2`.
> `shadow_plan → v2` 는 **같은 브랜치 안에서 mode 승격**이다 — 브랜치를 또 나누면
> 승격 때 이름이 낡는다.

---

## 1. 현 코드 inventory — 실물로 읽은 것

### 1.1 살아 있는 것 · 죽은 것

| | 경로 | 상태 |
|---|---|---|
| 씬 장소 조사 | `era_research.assess_and_research_cached` ← `scene_image_pipeline` | **살아서 돈다.** 2026-08-29 주행의 참조 2장이 이것 |
| 엔티티 조사 | `reference_image_generator.py:239` | ★**죽은 코드** — `generate_for_entity_with_retry` 호출 `app/`·`tests/` 통틀어 **0건**, `ReferenceImageGenerator` 인스턴스화 0건 |
| 현행 참조 경로 | `ref_image_pipeline.generate_and_validate_reference` | phase1·2·3·composite·entity 서비스가 **전부** 이걸 부름. `era` 라는 낱말 **0건** |

→ 저장소 전체 **엔티티용 eraref 0장**. 플래그를 켰어도 안 돌았다.

### 1.2 재사용할 것 — `search_grounded_ref.py`

머리글에 그대로 적혀 있다:

> 「이 모듈은 **순수 함수 + 호출만** 담는다. DB 쓰기·스텝 오케스트레이션 없음」

| 함수 | 하는 일 | V2 |
|---|---|---|
| `search_reference_images` | OpenAI `web_search`(`search_content_types:["image","text"]`) → `{queries, images, said}` | **재사용** |
| `download_candidate` | 안전 다운로드 — SSRF 가드(공인 IP만·redirect 후 재검사) · 12MB · 40M px · content-type | **재사용** |
| `build_pick_schema` / pick | 후보 중 **1장** VLM 선택 | **재사용** |
| `SAFE_DOWNLOAD_POLICY_VERSION` | 정책 버전을 `config_hash` 에 편입 | **재사용** |

★**새 service·provider·download·picker 를 만들 필요가 없다.**

### 1.3 그대로 재사용하면 안 되는 것 — `era_research.research_reference`

legacy 계약 셋이 박혀 있다:

1. **조사 실패가 None 이어도 생성이 계속된다** (「조사 실패는 생성 비차단이 계약」)
   → V2 는 `research_required` 면 **unresolved fail-closed**
2. **상상으로 만든 `terms` 를 입력으로 쓴다** — `assess_subjects` 가 외부 근거 없이 검색어를 짓는다.
   죽은 엔티티 갈래는 `name + 이미 상상한 description + traits` 를 넣었다(자기 환각 재검색)
3. **`said` 와 text source 를 버린다**

→ V2 owner 는 **낮은 층(검색·다운로드·선택·안전 정책)만** 재사용한다.

### 1.4 ★`said` 로는 「텍스트 먼저」가 성립하지 않는다

내가 처음에 「`said` 를 되살리면 된다」고 봤는데 **틀렸다** (Codex 정정):

- 지금은 **한 번의 `responses.create`** 에 image+text 를 같이 요청한다
- 이미지 결과와 `said` 가 **같은 응답**에서 나와 함께 파싱된다
  → **`said` 가 이미지 질의를 만든 인과 순서가 없다**
- 코드가 non-image text result 와 citation/annotation 을 **보존하지 않고**
  message 평문만 이어 붙인다 → **durable grounded evidence 가 아니다**

**최소 추가**는 새 검색 서비스가 아니라 같은 `search_grounded_ref.py` 안의
**작은 text-first phase** 다:

```
① text-only web search  →  claim 단위 durable shape
② 그 image_query_hints 를 기존 search_reference_images 의 terms 로 전달
③ 기존 download / pick 그대로
```

★**평행 배열로 저장하면 안 된다** (Codex BLOCK) — `sourced facts` 와 `source URLs` 를
따로 두면 **어느 출처가 어느 주장을 지지하는지 잃는다.** URL 이 붙어 있어도 근거 없는
문장이 섞여 v2 detail 의 SOT 가 될 수 있다. **claim 단위로 고정한다**:

```
claims[{ id, claim, polarity, source_urls[] }]        polarity = 사실 | 금지 사실
image_query_hints[{ query, derived_from_claim_ids[] }]
```
★**source 가 없는 required claim 은 `unresolved`** 다.

`said` 는 **감사용 보조 원문**으로만 저장한다 — research SOT 나 query 근거로 승격하지 않는다.

★이러면 research 대상마다 **text 1 + image 1 = 두 논리 호출**이 된다.
**planner/cache 가 시간을 통제해야 한다** (제약은 돈이 아니라 생성 시간).

### 1.5 추출 규칙이 고증 대상을 먼저 거른다

`prompts/_base/entity_extract_v4/10.202607021935/prop.md` 「제외 기준」:

```
- 배경 구조물에 고정 설치되어 독립적으로 들고 다닐 수 없는 장비류
- 사람이 몸에 입거나 착용하는 형태의 모든 것
- 일반 탈것/일반 물체 — 고유한 외형 특징이 없으면 제외
- 중요!!! 여러 번 생성했을 때 차이를 사람이 구분하기 어려우면 제외   ← ★
```

마지막 줄이 **정확히 고증 대상을 거른다.** 저빈도 필터(3씬 이하)도 같은 방향.

### 1.6 C(반복 등장)는 **이미 있다** — 내 앞선 주장 정정

```
entity_steps.py:353   「entity_all에서 shot_count 복원 (extract/merge에서 유실됨)」
                       ← EntityFilterStep, order 13.7
```

즉 **조사 경계에 `shot_count` 가 있다.** 내가 「등장 횟수가 뒤에 세어진다」고 한 것은
**DB 투영(`sync_t2i_appearance_counts`, `entity_t2i` 뒤)** 얘기였고 다른 값이다.

**없는 것은 selected-shot count 뿐이다** (`shot_selection` order 15.5).

★**결정 (닫음)**: **C = planned `shot_count`** 를 보존 기준으로 쓴다.
selected count 는 **planner 우선순위로만** 쓴다.
★`entity_filter.py:34` 의 `or` 사슬은 **별도 작은 선행 PR** 로 먼저 고친다 (Codex):
```
지금   count = shot_count or scene_count or len(appearances)   ← 0 이 falsy 라 넘어감
고침   shot_count 키가 **있으면 0도 authoritative**.
       missing/None 일 때만 scene_count, 그 다음 appearances
```
먼저 **실데이터에서 값 불일치 수를 무료로 세고**, 함수 끝점 시험으로
**0 · None · missing 을 각각 잠근다.**
★큰 grounding PR 안에 숨기지 않는다 — 되돌리기에도 그쪽이 낫다.

#### ★세어 봤다 — **오늘 데이터에서는 이 `or` 사슬이 아무것도 안 바꾼다** (2026-08-30, 무료)

체크포인트 전수 (`projects/*/checkpoints/episodes/*/`):

| 모집단 | 총 | 결과 |
|---|---|---|
| `entity_all_*` (shot_count 생산지) · 181 CP | 3404 | **전부 양수.** `0`·`None`·키 없음 **0건** |
| `entity_merge` (필터 입력) · 60 CP | 3388 | **3387 이 `shot_count` 키 자체가 없다.** 그 3387 은 `scene_count=None` · `scene_appearances` 빈칸 → **fallback 도 전부 0** |

즉 `count = shot_count or scene_count or len(appearances)` 에서 **두 fallback 이
살아나는 경우가 실데이터에 없다.** 원래 걱정했던 「0 이 falsy 라 넘어간다」는
**오늘은 일어나지 않는다.**

**그 자리의 진짜 결함은 `or` 가 아니라 이름 join 이다**:
```
entity_steps.py:357   count_map = {e["name"]: ...}
              :360   e["shot_count"] = count_map.get(e["name"], 0)
```
merge 프롬프트는 「총1, 총2 → 총」처럼 **이름을 바꾼다.** 실측 결과
**3387 중 1건**이 이름이 안 맞아 `count=0` → 저빈도로 강등 → LLM 제거 후보가 됐다.
그 1건(`드들강 및 강 넓은`)은 **merge 산출의 `short_id` 가 빈 문자열**이라
`short_id` 로 join 을 바꿔도 안 살아난다 — **merge 가 short_id 없는 엔티티를 낸 별개 결함**이다.

★**그래도 `or` 는 고친다. 이유는 오늘이 아니라 V2 다** —
A0 후보는 **정의상 샷 구조에 없어서 `shot_count` 가 0** 이다. V2 에서 `0` 은
흔해지고 **authoritative 해야** 한다. 「오늘 영향 0건」을 PR 본문에 함께 적는다.

### 1.7 단계 순서 (실측)

```
 7.2  shot_extract          7.25 shot_validator
12.0  entity_all_prop      13.0  entity_extract_prop
13.5  entity_merge         13.7  entity_filter      ← shot_count 복원
14.0  entity_detail        15.0  entity_t2i
15.5  shot_selection       16.0  scene_director
23.0  ref_image_gen        25.0  scene_image_pipeline
```

### ★★경계가 **하나가 아니라 둘**이다 — 내 앞선 안 정정 (Codex)

「13.7 뒤 하나」로 두면 **보호하기 전에 저빈도 필터가 후보를 지운다.**
그리고 그 자리엔 **canon_id 가 아직 없다**(`EntitySyncService` 는 `entity_t2i`(15.0)
체크포인트를 읽는다). 그래서 **분류와 검색을 갈라야 한다**:

```
13.5  entity_merge                    short_id 중복 제거 (DB canon 아님)
  ↓
★A. grounding 후보 분류 / 계획          **검색 0** · short_id 기준
      Sol classifier · research_required 판정 · query plan
  ↓
      기존 visual_similarity 보호집합과 **union**
  ↓
13.7  entity_filter                   보호 목록에 있으면 안 지운다
  ↓
      durable identity 확정            (canon 을 언제 만들지는 §5-5 gate)
  ↓
★B. 실제 검색                          text-first → image → reference
  ↓
14.0  entity_detail · 15.0 entity_t2i  조사 결과를 소비만
```

★**새 필터 기구는 만들지 않는다** — 기존 `protected_short_ids` 통로에
research 확정 후보를 union 한다.

### ★최소 완화선 — 전역 문구 완화는 **반대**

```
research_required 또는 uncertain   → 기존 type/one-shot/lowfreq 제외보다 **우선해 보존**
                                     + owner 별 reference 강제
고증 불요이고 planned C >= 2       → **일관성 사유로 보존**
둘 다 아니면                        → 기존 legacy 제외 **유지**
```

★**착용물·고정 설비를 prop 으로 억지 통과시키지 않는다** —
**outlook / location-part / data entity** owner 로 라우팅한다.

★**배경도 객체 권위를 풀지 않는다.** 그리고 이 권위 표는 배경만이 아니라
**조사 뒤의 모든 저작 자리**(outlook · background · scene_detail · VCA)에 적용된다 —
정본은 **계약 §13**, 여기는 요약만:

| | 권위 |
|---|---|
| WHAT · COUNT | **A0 / 원문** |
| WHERE · STATE | staging / continuity anchor |
| LOOK · FORM · MATERIAL | **research evidence** |
| 일반 set dressing | 기존 재량 |

★`visual_continuity_anchor_plan.py:638` 의 `printed_content` 는 회수권 인쇄면
그 자체다 — v2 에서 이 칸은 **조사 claim 을 읽어 채우고**, 없으면 저작하지 않는다.

이 자리에 있는 것: `shot_count` ✓ · 전체 planned shot ✓.
**없는 것: selected shot · canon_id**.

---

## 1.8 설계도 — 현 구조 → 바꿀 구조

### 지금

```
추출 ──▶ 합치기 ──▶ 저빈도 필터 ──▶ 상세 ──▶ T2I ──▶ 샷 선택 ──▶ … ──▶ 참조 이미지
 12·13    13.5        13.7           14.0     15.0      15.5              23.0
                        │                                                   │
                        │ 「구분 어려우면 제외」가                            │ ★엔티티 조사
                        │  고증 대상을 거른다                                 │   = 죽은 코드
                        ▼                                                   ▼
                    고증 대상이 여기서 사라질 수 있다              한 번도 안 돌았다
                                                                            
                                              씬 이미지 ──▶ 장소 era 조사  25.0
                                                              └ 살아서 돎 · 뒤쪽 · 장소만
```

**묘사가 조사보다 먼저 상상으로 쓰이고 정본이 된다. 조사가 묘사를 고칠 길이 없다.**

### ★★A 경계가 **합치기 뒤로는 너무 늦다** — 실물 확인 (Codex BLOCK)

```
prompts/_base/entity_all/7.202607101540/prop.md:28
  「번호표, 명함, 영수증 등 텍스트만 다른 종이류 — 이미지로 구분 불가」   ← ★회수권이 이 부류
prompts/_base/entity_all/7/prop.md:20
  「여러 번 생성했을 때 차이를 구분하기 어려우면 제외」
prompts/_base/entity_extract_v4/10/prop.md
  고정 설비 · 착용물 · 일반 물체 · 「구분 어려우면 제외」를 **또** 거른다
```

**제외가 `entity_all`(12.0)에서 이미 일어난다.** 두 schema 에 grounding candidate /
provenance 칸이 **없다**. 그래서 **merge 뒤 A classifier 만으로는 회수권·고정 요금통
같은 후보가 이미 사라져 복구 불가**다. `protected_short_ids` union 은 **살아온 후보만**
지킨다.

### ★A0 는 **전용 sidecar checkpoint** 이되, **관찰용이 아니라 후보 승격 통로**다

`entity_all` schema 를 고치는 안은 기각 (Codex 실물):
- `entity_all` schema 가 **`additionalProperties=false`** (`name` + `shot_count` 뿐)
- `entity_extractor_v4.py:69` 가 **`e["name"]` 만 텍스트로 내려** provenance 를 버린다
- `EntityFilterStep` 의 count 복원도 **entity_all 생존자만** 대상

```
자리    shot_validator(7.25) 뒤 · entity_all_character(8.0) 앞
입력    fulltext + validated planned shots
        (★shot prompt 가 복장을 금지하고 중요 물체만 남기므로 **fulltext 필수**)
보존    원문 위치 · planned occurrence count · provisional owner  ← A0 가 자체 보존
shadow  **sidecar 지문만** 바뀌고 production dependency/hash 는 **불변**
```

### ★★그런데 sidecar 로만 두면 **결속할 엔티티가 아예 없다** (Codex BLOCK · 실물 확인)

```
entity_lister.py:170   list_entities_from_shots — **shot description 만** 이어붙인다.
                       fulltext 를 안 본다
shot_extract schema    required = ["shot_index","description","based_on_beat"]
                       ← **characters 가 필수가 아니다**
entity_character_list  「이미지 일관성을 위함이므로 **여러 번 출현하는 경우만** 추출」
```

즉 **샷 구조에 안 들어온 대상은 entity_all 이 볼 기회조차 없다.** 한 번만 크게 나오는
실존 인물·고증 소품은 A0 가 찾아내도 **merge 뒤에 결속할 short_id 가 존재하지 않는다.**
「결속 실패는 unresolved」로 두면 **영원히 unresolved 로 서 있게 된다.**

### ★A0 → v2 전용 **effective candidate overlay**

```
원문 근거
  → A0 후보 (sidecar CP)
  → ★v2 전용 effective candidate overlay        ← legacy 산출은 **손대지 않는다**
  → entity_all / entity_extract 의 **입력에 합류**
  → merge 뒤 정본 owner 에 결속
```

★legacy `entity_all` 산출 파일은 그대로 두고 **overlay 를 별도 CP** 로 만든다 —
`mode=legacy` 는 overlay 를 안 읽으므로 되돌리기가 성립한다.

### ★후보를 버리는 자리가 **한 군데가 아니다** — 네 자리를 다 손대야 한다

```
entity_all/7*/prop.md:19-28        착용물 · 고정 설비 · 일반 물체 ·
                                   「번호표·명함·영수증 등 텍스트만 다른 종이류」  ← 회수권
entity_extract_v4/10*/prop.md      같은 부류를 **또** 거른다
entity_filter (13.7)               저빈도 강등
entity_extractor_v2 (14.0)         상세 단계에서 **또** 제거
```

`protected_short_ids` 는 **이미 살아남아 short_id 를 받은 것만** 지킨다 →
프롬프트 한 줄을 느슨하게 하는 것으로는 안 된다. **네 자리가 전부 필요하다**:

| # | 무엇 | 성격 |
|---|---|---|
| 1 | A0 후보를 **extract 입력에 실제로 합친다** | overlay |
| 2 | research 후보는 **legacy 제외 규칙의 예외**로 반환하게 한다 | 프롬프트 (v2 namespace) |
| 3 | ★**extract / detail 출력에 후보 완전성 gate** | 코드 · 결정적 |
| 4 | 그 뒤 `protected_short_ids` 에 union | 기존 통로 재사용 |

★3 이 없으면 1·2 가 조용히 실패해도 모른다. gate 는 **결함의 어구를 이름 지어야 한다** —
「A0 가 `research_required`/`uncertain` 로 표식한 후보 중 산출에 없는 것이 있으면
**미확정으로 서고 통과로 세지 않는다**」.

### 바꾸면

```
 7.2·12.0  shot/fulltext ──▶ ★A0. 후보 sidecar     ← ★제외보다 **먼저**
                              (검색 0 · 원문 근거 함께)
                                    │
추출 ──▶ 합치기 ──▶ ★A. 분류·계획 ──▶ 보호집합 union ──▶ 저빈도 필터
 12·13    13.5       검색 0건            기존 통로 재사용      13.7
                     short_id 기준       (새 기구 없음)          │
                     Sol classifier                              │
                     research_required                           ▼
                     query plan                          identity 확정
                                                          (canon 자리 = gate)
                                                                 │
                                                                 ▼
                                                        ★B. 실제 조사
                                                          텍스트 먼저
                                                            → claim + source
                                                          → image_query_hints
                                                          → 이미지 검색·선택
                                                          → **downloaded image evidence**
                                                            (★정본 참조가 아니다)
                                                                 │
                        ┌────────────────────────────────────────┘
                        ▼
                    상세 ──▶ T2I ──▶ … ──▶ ★generated canonical reference
                    14.0     15.0            23.0
                    조사 결과를 **소비만** 한다
                    ★정본 참조는 **grounded detail/T2I 뒤**에 온다 —
                     검색 직후의 것은 image evidence 일 뿐이다
```

### 쓰는 자리 — 되돌릴 수 있게

```
                 ┌──────────────────────────────┐
   소비처 전부 ──▶│   중앙 mode-aware resolver    │
                 └──────────────────────────────┘
                      │                    │
              mode=legacy            mode=v2
                      ▼                    ▼
        EntityCanon.description    v2 revision (append-only)
        t2i_prompt  (그대로 보존)   research record + reference
        옛 reference               evidence 셋 완성 뒤 **원자적** 활성화
```

★소비처마다 `if mode` 를 흩뿌리지 않는다. **resolver 하나만 안다.**
★`legacy` 는 v2 pointer 를 **무시**한다 → 되돌리면 baseline 이 그대로 살아난다.

### 소유권

| 자원 | owner |
|---|---|
| 이름 있는 소품·유물·장치·브랜드·명소·제복 | **canonical entity** |
| 엔티티가 아닌 잔여 환경 재질·set dressing | **location / world scope** (v2 에서도 유지) |
| 얼굴·골격 | 기존 identity |
| 시대 복장·제복·머리·분장 | CharacterOutlook / visual variant |

캐시 키 = **canon/variant + era + region + facet + pack hash**.
층별로 **classifier / text / image 세 key 를 분리**한다.

---

## 2. 이행 순서

> ★이 표를 그대로 `/goal` 로 쓴다. 각 줄의 **「끝났다고 말할 수 있는 조건」**이
> 통과 조건 칸이다. 그 조건을 못 보이면 **끝났다고 쓰지 않는다.**

| 단계 | 무엇 | 유료 | ★끝났다고 말할 수 있는 조건 |
|---|---|---|---|
| **0** | 이 문서 + 계약 PR 병합 | 무료 | Codex APPROVE · main 에 두 문서가 tracked 로 있음 |
| **1** | 전수 감사표 (아래 §3) — ★**아홉 갈래를 한 번에 끝까지 읽고 한 표로**. 건건이 안 올린다. 계약·삽입 순서를 무너뜨리거나 파괴·중복결제 위험을 발견할 때만 즉시 중간 보고 | 무료 | 표 완성 |
| **1.5** | ★**선행 PR** — `entity_filter.py:34` `or` 사슬 (grounding 밖) | 무료 | 실데이터 불일치 수 + 끝점 시험 3갈래 |
| **2** | `feat/grounding-v2` worktree · schema + Sol classifier + planner. **검색 호출 없음** | 무료 | ★**canon 자리 gate 가 닫혀 있어야 시작**(§5-5). unit 통과 · `netprobe` 바깥 호출 **0** · classifier 가 `referent_specificity`·`visibility_intent`·`confidence`·`locale/세대`를 다 채움 (★2026-08-30: 통과 조건에서 **`difficulty` 를 뺐다** — 폐기한 축을 통과 조건에 두면 계약과 조건이 서로 반대로 말한다) |
| **3a** | `shadow_plan` **offline 재생기** — 저장 CP 만 읽어 분류·계획 | 검색 무료 · ★**Sol 호출은 유료** | 저장 에피소드 **N개**에서 **검색 호출 0** · production 산출 **전 파일 불변**(manifest 만이 아니라) · 읽기 실패 **0** · shadow CP 에만 기록하고 **앞 판을 안 덮음** |
| **3b** | ★**production mode 배선** — 실제 스텝이 `resolve_grounding_mode` 를 읽는다 | 무료 | `legacy` 주행 산출이 **바이트 단위 불변** · `shadow_plan` 을 켠 것만으로 하류가 **stale 되지 않음** |
| **3c** | ★**지정 대조군 판정** — 아래 **§10 전체**. ★★**2026-08-30: §4a 뒤로 미뤘다** (아래 「§C 기각」) | Sol 유료 | ★**§10 의 아홉 줄이 전부** 맞아야 한다: 저장 양성 **4** 가 모두 `research` · 저장 음성 **3** 과 합성 음성 **2** 가 모두 **non-research**. **하나라도 어긋나면 미확정**이고, 일부만 맞은 것을 통과로 세지 않는다 |
| **3.5** | ★**후보 승격 overlay + 완전성 gate** (§1.8) + ★**분류기 배선** | ★**Sol 유료 · 검색 0** — overlay·gate·unit 은 무료지만 배선을 실제로 태우면 에피소드마다 N표본 Sol 호출이다 | A0 가 표식한 `research_required`/`uncertain` 후보가 **entity_extract·detail 산출에 전부 남아 있음** · 하나라도 없으면 **미확정으로 서고 통과로 안 셈** · `mode=legacy` 산출은 **바이트 단위 불변** · ★**`classify_samples` 를 실제 스텝에 잇고**, `payload_hash`·`prompt_version`·judge alias·physical model 이 **비었거나 표본마다 다르면 fail-closed** (지금 그 gate 는 측정 도구에만 있다) |
| **4a** | ★**sourced-claims primitive** — 출처 붙은 사실/금지 사실 + **sourced-delta 결정 API**. §2-3c 가 이것 뒤로 옮겨졌다 | 소액 | `claims[]` 에 **source 없는 required claim 0** · 못 찾으면 `unresolved` (미발견·충돌·출처 없음·시간 초과 전부) · ★**A=no 는 「차이가 없다」는 긍정적 출처가 있을 때만** · admission cap 이 팬아웃 **전에** 걸리고 초과는 `unresolved`+`time_capped_count` · ★**잘린 주행은 acceptance 가 아니다** |
| **4b** | text-first phase production 배선 + 검색 primitive 이관 | 소액 | ★**시간 통제 gate**(§5-6 → **§8.5 에서 닫음**)의 여섯 줄을 보여야 한다. `claims[]` 에 **source 없는 required claim 이 0** · 못 찾으면 **`unresolved` 로 서고 상상으로 안 내려감** · `research_required` → `reference_required` 강제됨 |
| **4.7** | ★**아웃룩 phase2 자체 모순 수리** — 「해당 인물에게만 배정」 vs 「없으면 **가장 유사한 다른 인물의 아웃룩을 배정**」. grounding **밖의 독립 결함이지만 canary 선행조건**이다: 고증한 제복이 **다른 인물에게 배정될 수 있다** (Codex) | 무료 | 카탈로그에 없을 때 **남의 옷을 빌리지 않고 unresolved 로 선다**는 것을 끝점 시험으로 잠금 |
| **5** | `v2` 소수 canary — ★이름을 **`prop/character 한정 canary`** 로 못박는다. location_part·outlook facet 은 아직 없다 | 유료 | ★★**판정 주체를 가른다** (2026-08-30 사용자 확정). **자동이 판정하는 것 = 실행 무결성뿐**: 같은 대상 **중복 구매 0** · resume 이 같은 research 를 **다시 안 삼** · 세 층 캐시 키가 각각 맞음 · **rollback acceptance 통과**(baseline → shadow 무변화 → v2 → legacy 복귀 후 prompt·ref·지문·source id 가 baseline 과 **같음**). ★**굽힌 canary 이미지가 고증상 맞고 나아졌는가는 사람 acceptance 다.** VLM 을 평가자·판정자·랭커로 쓰지 않는다 |
| **5.5** | ★**claims 를 소비하는 reference 생성·검증** (계약 §6) | 유료 | 생성 기록에 **소비한 claim id · 금지 claim · 근거 이미지 · 실제 provider prompt · research revision hash** 가 남음 · `research_required` 인데 claim 0 이면 **fail-closed** |
| **6** | old vs v2 눈가림 A/B **여러 번** | 유료 | ★★**눈가림 판정은 사람만 한다** (2026-08-30 사용자 확정). 자동화가 맡는 것은 **순서 은닉 · arm 신원 · 기록 · 누락 방지**뿐이고, 「어느 쪽이 더 낫고 통과인가」는 **사람**이 정한다. **VLM 점수·critique·다수결을 PASS 근거로 쓰지 않는다.** 순서를 가린 **사람** 판정에서 **요금통·회수권이 old 보다 낫다**가 반복으로 확인됨. ★여기 통과해야 **옛 호출부 제거 후보**. ★**참조 층 A/B 를 먼저 하고**(참조 프롬프트엔 40% 제한이 없다) 최종 스틸은 **3-arm**(legacy / grounded-only / grounded+framing 완화)으로 가른다 — 계약 §2. 여기서 못 이기면 **되돌린다** |
| **6.5** | ★**`location_part` · `outlook facet` producer + gate** — 5.5 뒤 · 통합 E2E 전. 「나중에」로 두면 `/goal` 이 **둘 없이 7단계에서 끝난다** (Codex) | 유료 | `location_part`·`outlook facet` 이 **참조 대상 목록에 들어오고** 실제로 구워짐 · base location 을 prop 으로 우회 등록한 행 **0건** · 두 owner 도 claims 를 소비 · ★**§10 의 「차장 제복」이 research 로 찍힘** — 사용자 지정 축이라 뺄 수 없다 |
| **7** | 통합 E2E — TASK-59/55/31/71 **표 넷으로 분리** | 유료 | 각 acceptance 따로 |

### §2 통과 조건 — **잰 값** (2026-08-31) · ★**Codex 확인 완료 → 통과**

★상태 칸은 **Codex 확인 뒤에** 바꾼다. 여기는 **무엇을 어떻게 쟀는지**만
적는다 — 「됐다」를 먼저 쓰고 근거를 나중에 대지 않는다.

| 조건 | 잰 값 | 어떻게 |
|---|---|---|
| canon 자리 gate 닫힘 (§5-5) | ✅ | 이 문서 §6 이 「닫음」이고 `grounding_subject` 가 append-only 로 구현 (`canon_id: None`, bind 는 15.0 뒤) · 시험 109건 |
| unit 통과 | ✅ **1698** | `pytest tests/grounding` · 기준선(core/modules/api 17건) 밖 새 실패 0 |
| `netprobe` 바깥 호출 **0** | ✅ **0건** | 마지막 한 건이 litellm 요금표 GET 이었다 — `LITELLM_LOCAL_MODEL_COST_MAP` 로 껐다 (`eb184b50`) |
| classifier 가 네 칸을 다 채움 | ✅ | `referent_specificity`·`visibility_intent`·`confidence`·`locale`·`generation` **다섯 칸**이 schema 에 있고 `grounding_planner` 가 **required 로 검사**한다 |

★`difficulty` 는 2026-08-30 에 통과 조건에서 뺐다 — 폐기한 축이다.

★★**§2 = 통과** (Codex 확정, 2026-08-31 — **범위 한정**).

네 조건은 위 표대로 채워졌다. 조건 칸에 **`difficulty` 의 「부재」는 없다** —
그래서 코드에 살아 있는 `generation_difficulty` 는 §2 의 문이 아니다. 대신
그것이 **무엇인지**를 계약 문서 §3 「어긋난 것」 절에 정확히 적었다 —
**§3.5 원자적 cutover 전까지의 한시적 다리이고 정본이 아니다.** 판정 정본은
`era_research.assess_subjects` 다.

★**통과한 것은 §2 뿐이다.** 「V2 됐다」가 아니다 — 이 문서 머리말대로
full V2 는 6.5 뒤다. §3.5 는 **별도 보류**이고, 그 앞에 생산 구조(구간마다
1콜 / 병렬 chunk + merge)를 정해야 한다. 지금 screen 은 **대상당 호출 N개**라
그대로 켜는 것은 아직 승인 안 됐다.

★**여기 통과 조건은 안 고쳤다** — 조건을 결과에 맞추지 않는다.

### ★§3.5 앞에서 멈춰 있는 것 (2026-08-31)

§3.5(판별 배선)를 하다가 **반복 분석 구조**가 드러났다. 원고 셋에서 같은
모양이다 — **원문 전문이 4번**(A0 1 + `entity_extract` 3), **샷 전부가 3번**
(`entity_all` 3). 그래서 켜기 전에 구조를 다시 잡는 중이고, **그 판단이
끝나기 전에는 판별 스텝을 안 켠다.**

    지금        66천자 원고 · 알려진 14 호출 + 검색 R · 1.84~2.66 MB
    새 구조(C)  구간마다 1콜 × S · 입력 총량 **오늘의 15~29%**
                ★호출 수는 14 → 27 로 **늘어난다**

★**벽시계 시간은 안 쟀다.** 제약이 시간이므로 그것이 결정적인데, 그건
유료로만 잰다. 실험용 합성 원고를 그래서 지었다
(`tests/grounding/fixtures/synthetic_episode.py` · 6,823자 · 씬 4 · 샷 8).

★**full V2 완료 = §2-6.5 통과 뒤다.** 그 전 성과는 전부 **prop/character 한정**이라고
쓴다. 「V2 됐다」로 쓰지 않는다.

### ★★§C(capability registry) 기각 · 3c 를 §4a 뒤로 미룸 (2026-08-30)

**무엇이 막았나.** §2-3c 아홉 축 중 셋이 계속 안 맞았고, 셋 다 `difficulty`
자리였다. 저장 기록으로 축별 갈림을 세니 그 축만 **5/6**, **한 모델 안에서만
봐도 4/6** 이었다. 두 판정자의 눈금이 한 칸씩 어긋나 있었고(같은 대상에
Sol `easy`×3 · Gemini `medium`×3, **나머지 네 축은 여섯 표가 동일**),
route 를 자르는 자리가 정확히 그 사이였다.

**왜 못 고치나.** `difficulty` 는 계약 §2 세로축 `reliably_known` 의 구현이고,
그건 「**다른 모델**이 외부 근거 없이 맞힐 수 있나」다. 판정 모델은 그것을 본 적이
없다. 계약 §3 이 이미 「같은 LLM 자기평가로 bypass 금지」라고 못박은 자리다.

**기각한 안.** target 모델에게 참조 없이 굽게 하고 VLM 이 채점해 발급하려 했다.
사용자 판정 — 「고증이 필요한 부분을 어찌 VLM이 알 수 있어?」
「나도 모르는데 vlm 이 어찌알아?」 맞는 지적이다. VLM 도 그 시대 물건을 본 적이
없고, **정답 사진을 얻으면 이미 조사를 산 것**이라 아낀 것이 없다.
계약에 REJECTED 절을 tracked 로 남겼다.

**정한 것 (Codex 승인).** 세로축을 **없앤다**. 그리고 A 도 **분류기 답으로
쓰지 않는다** — 그 모델은 문안 첫 줄부터 「검색을 하지 않는다」인데 세상 사실을
답하고 있었다. `grounding_class=generic` early skip 도 같은 이유로 걷어낸다.

```
검색 전에 정하는 것 = fictional 여부 · referent_specificity · B   ← 원고에서 아는 것만

fictional                        → design
B=no                             → skip
현실 대상 + B=yes/uncertain      → §4a 텍스트 조사
  출처로 시각 차이 확인          → research + reference
  출처로 「차이 없음」 확인       → skip
  미발견·충돌·출처없음·시간초과  → unresolved
```

**§10 기대값은 안 바뀐다** (2026-08-30 사용자 정정).

★내가 **엔티티 보존과 조사를 섞어서** 「§10 기대값을 어떻게 할까」라는 갈래를
세웠는데 **잘못된 물음**이었다. 사용자: 「한복이나 이런게 엔티티 아웃룩에
들어가는것과 조사를 하는것은 별개야」

계약 §7 이 이미 「**세 축은 별개**」라고 못박아 뒀다:

| | 엔티티 보존 | 조사 |
|---|---|---|
| 한복(아웃룩) · 무표시 자동차 · 자전거 정비소 | ✅ 반복 등장 일관성 | ❌ |
| 1983 회수권 · 차장 제복 | ✅ | ✅ |

§10 의 `non-research` 는 **조사 축만** 말한다 — 엔티티에서 빼라는 뜻이 아니다.
그러니 세로축을 없애도 기대값은 **한 줄도 안 바뀐다.**

★**무료 재채점으로 3c 를 통과시키지 않는다.** 옛 기록의 A 자체가 검색 없는
짐작이라 새 계약의 acceptance 가 못 된다.

### ★3 을 셋으로 쪼갠 이유 (2026-08-30, 실측)

| | |
|---|---|
| **3a/3b 를 가른 이유** | offline 재생기는 저장 CP 만 읽으므로 **파이프라인에 배선하지 않아도 돈다.** 한 PR 로 묶으면 「배선 없이 도는 것」을 「배선됐다」로 보고하게 된다 — 실제로 그렇게 썼다가 물렸다 |
| **3c 를 좁힌 이유** | ★**차장 제복은 3 단계에서 잴 수 없다.** 원고에 **outlook 엔티티가 없고**, outlook facet producer 는 **§2-6.5** 다. 잴 수 없는 것을 통과 조건에 두면 갈음하거나 미확정을 통과로 바꾸게 된다 |
| **★그렇다고 지우지 않는다** | 사용자가 지정한 대조군이다. **뒤 단계로 옮겼다** — 아래 §10 |
| **★남는 한계** | **A0 가 없으면 「앞에서 지워지는 결함」은 여전히 안 보인다.** 3c 는 *살아남은* 엔티티만 본다 — 그건 §2-3.5 뒤에 다시 잰다 |

★**「무료」 칸도 고쳤다.** `shadow_plan` 이 안 사는 것은 **검색**이고, 분류기 Sol 호출은
**돈이 든다.** 같은 이유로 `legacy` 도 무료가 아니다 —
`ERA_RESEARCH_ENABLED=true` 면 장소 시대 조사를 산다(`still_recipe_service.py:2159,4152`).

★**TASK-107**(테스트 기준선 59건) 은 **§2-6 뒤 · main 병합 직전** 관문이다 —
「예상 밖 실패 0」 기준선. 무료 감사(§1)를 막지 않는다.

★**어느 단계든 통과 조건을 못 보이면 「미확정」이지 「통과」가 아니다.**
0%·100% 나 「안 났다」는 결함보다 **재는 도구의 오류**를 먼저 의심한다.

---

## 3. 전수 감사표 — 훑을 **아홉** 갈래

★결과는 **별도 tracked evidence 문서**(`2026-08-29-grounding-v2-audit.md`)에 남기고
이 문서는 링크한다. ★**그러나 감사 완료는 설계 승인의 전제다** —
사용자 최신 지시(「코드도 프롬프트도 **전수조사**해서 재조정」)에 따라
**감사 → 최소 완화·부작용 반영 → 그 뒤 최종 설계 승인** 순서다.

각 행에 **최소 열 칸** (Codex):

```
1  active pack/step + live/dead
2  현재 SOT · 입출력
3  ★정확한 충돌 문구 (파일:줄 그대로)
4  ★최소 완화 방식 (예외 통로 / 라우팅 / 그대로)
5  owner · facet
6  A0 + C evidence
7  reference producer + completion gate
8  ★shadow 증가량 — 후보 수 · 조사 수 · ref 수 · **시간**
9  rollback prompt source + content hash
10 unresolved 처리 + 끝점 acceptance
```

★**「제외는 prop 에 몰려 있다」는 내 읽기는 방향만 맞고 결론은 틀렸다** (Codex).
고증 경계는 prop 만의 문제가 아니다 — 실물:

| 자리 | 무엇이 고증 대상을 거르나 |
|---|---|
| `entity_character_list:12` | ★**여러 번 출현만 추출** — 한 번 크게 나오는 **실존·인지 인물이 A0 전에 빠진다** |
| `entity_all/prop:20-28` · `entity_extract_v4/prop:12,23-29` | 회수권·고정 설비·착용물·일반 탈것 |
| outlook `phase1:14` | **근거 없어도 의상을 구체화**한다 · `:21` 임시 작업구를 이미지 단계로 민다 |
| outlook `phase2:23` | **카탈로그가 없으면 다른 인물 의상을 배정**한다 |
| background `v14:16,21-24` | `visual_world_rules` 만으로 **시대 설비·소품·차량을 스스로 발명**한다 |
| `entity_filter` · `episode_reference_policy` | one-shot/저빈도를 **다시 강등** |
| 현행 reference phase1 / gate | location · outlook 을 **제외** |

```
① prop/location/character 추출 프롬프트와 parser
①-b ★`entity_character_list` (prop 보다 앞이고 더 세다)
② outlook 생성·소유 경로 (phase1 · phase2)
②-b ★background 프롬프트가 시대 설비를 발명하는 자리
②-c ★`episode_reference_policy` 의 재강등
③ background / location part 와 고정 설비 귀속
④ merge/canon sync 전후 필터
⑤ 등장 횟수 · 저빈도 · generic 제외
⑥ detail/description/t2i_prompt 가 쓰이는 시점
⑦ reference 생성 대상 · skip · 완료 판정
⑧ 현재 scene era research 의 owner/cache/key
⑨ fingerprint / resume 영향
```

시작점(Codex 지정): `episode_projection_service.py:63` ·
`outlook_sync_service.py:18` · `reference_pipeline_orchestrator.py:66` ·
`pipeline_gate.py:179`

**감사의 목적은 구현이 아니라** — 살아 있는 검색 능력을 **어느 service 에서
재사용하고 어느 호출부를 제거할지 SOT 를 확정**하는 것.

---

## 4. 되돌리기

★**`mode=legacy` 만으로는 되돌리기가 성립하지 않는다** (Codex BLOCK).
현재 `EntityCanon.description` / `t2i_prompt` 는 **mutable 이고 revision 계층이 없다** —
v2 가 덮으면 **legacy 가 읽을 옛 값이 사라진다.**

따라서 되돌리기의 본체는 모드가 아니라 **쓰는 자리**다:

1. v2 는 **append-only research + visual/detail revision** 에만 쓴다.
   기존 canon 칸을 **덮지 않는다**
2. **중앙 resolver 하나**가 mode 에 따라 **legacy canon 필드** 또는
   **완결된 v2 revision** 을 고른다 — 소비처마다 고르지 않는다
3. 활성화는 **text evidence + image evidence + reference 가 모두 완성된 뒤 원자적으로**
4. `legacy` mode 는 **v2 pointer 를 무시한다**
5. 옛 호출부는 **final cutover 전까지 삭제하지 않는다**
6. ★**프롬프트 module namespace 를 갈라야 한다** — `prompt_loader` 는 module 의
   **latest 를 자동 선택**하되, 실측하니 **module 이 아니라 stem 단위**다
   (`prompt_loader.py` 머리글 = `problems.md #6`: 같은 module 안에서 `system.md` 는 v17,
   `schema.json` 은 v10 이 실제로 섞여 있다 — `shot_extract` 가 그 상태). **기존 module 에
   새 버전만 더하면 legacy 도 새 prompt 를 읽는다.** ★**v2 전용 module namespace 로
   고정한다.** 대안(legacy pin)은 남기지 않는다.
   ★**「그 namespace 에만 strict 를 켠다」는 안은 취소** (Codex BLOCK · 실물 확인) —
   `_is_version_pack_strict()`(`prompt_loader.py:273-288`)는 **인자가 없는
   process-global** 이다(`PROMPT_VERSION_PACK_STRICT` ENV → `settings`).
   켜면 **legacy 의 섞인 팩 전체가 깨지고**, DB hit 경로는 우회한다.
   대신 **v2 호출부가 version 을 명시로 pin 하고 pack completeness + content hash 를
   스스로 검증**한다. module 단위 strict API 가 필요하면 **별도 설계**로 뺀다.
   ★**DB override 는 현재 활성 0건 · strict off** — 즉 지금 실경로는 파일 프롬프트다(실측).
   초기 v2 step 에는 **prompt · schema · classifier · model 의 step-local content hash** 가 필요하다.
   rollback acceptance 에 **prompt source path + content hash** 도 넣는다
7. worktree 분리라 main 은 항상 깨끗
8. ★`shadow_plan` 은 **production fingerprint/산출을 절대 stale 시키지 않는다** —
   shadow CP 에만 지문화
9. ★`legacy` / `v2` 의 **cache · checkpoint · asset namespace 와 호출 owner 를 분리**
10. ★DB 는 **additive-only**. 되돌리기는 **schema downgrade 가 아니라 mode 전환만**
11. ★`v2` **resume 이 같은 research 를 중복 구매하지 않는 idempotency**
12. ★하류 지문에 **mode 문자열을 넣지 않는다** — 실제 소비한
    **research revision / content hash** 를 넣는다
13. ★**파일 밖 인라인 프롬프트도 버전·해시에 넣는다** — `entity_merge` 와
    `visual_continuity_anchor` 의 프롬프트는 **코드 안에 문자열로** 있다.
    이것들이 지문 밖에 있으면 **바꿔도 하류가 안 갱신된다**
14. ★참조 기록은 **additive-only** — `prompt_used` 의 뜻을 바꾸지 말고
    **새 칸에** 실제 provider prompt 를 넣는다 (rollback baseline 보존)

### ★rollback acceptance — 명시한다
같은 에피소드에서
`baseline → shadow(무변화) → v2 → legacy 복귀` 를 돌린 뒤,
**prompt · ref · fingerprint · source id 가 baseline 과 같아야 한다.**
- 기존 `records.json`(장소 free-text 키) → 새 키
  `(canon_id, facet, world/era scope, target image model)` 로의 migration/lazy reuse 여부는
  **감사에서 정한다**
- `feat/grounding-v2` 는 별도 worktree — main 은 언제나 깨끗

---

## 5. 미결 — **막연히 「구현 전」으로 두지 않고 gate 에 배치한다**

Codex BLOCK: 넷을 다 「구현 전」으로 두면 2단계 schema/classifier 를 시작한 뒤
핵심 의미가 바뀔 수 있다. **최소 결정은 이 문서에서 닫는다.**

| # | 결정 | 닫는 자리 |
|---|---|---|
| 1 | ★**C = planned `shot_count` 를 보존 기준으로 쓴다.** selected count 는 **planner 우선순위로만** | **닫음** |
| 2 | ★**A 는 대상 공개 locale/세대를 schema 에 넣는다** + `uncertain → research` | **닫음** |
| 3 | ★**cache 는 classifier / text / image 세 층 key 를 분리**한다 | **닫음** |
| 4 | ★기존 `records.json` 은 text-first claim provenance 가 없으므로 **`legacy_unverified` 로만** 둔다 | **닫음** |
| 5 | **canon_id 가 조사 경계에 없다** → ★**닫음. 아래 §6** | **닫음** |
| 6 | text-first 의 **시간 통제** — 대상마다 두 논리 호출 | ★**닫음. 아래 §8.5** |
| 7 | legacy records **이관 실행 여부** | **v2 cutover 전 gate** |
| 8 | ★**`location_part` · `outlook facet` reference producer** — 소유 모델은 계약에서 **닫음**(base location 을 prop 으로 우회 등록 금지). 구현은 **§2-6.5 명시 단계**로 배치했다(Codex: 「나중에」로 두면 `/goal` 이 둘 없이 끝난다). ★프롬프트는 이미 있다 — `character_outlook_ref.md`. 못 굽는 이유는 **producer/gate 가 타입으로 뺐기 때문** | **닫음 — §2-6.5** |
| 9 | ★**프레임 점유 완화 범위** — 대상은 `research_required` + `exact_variant`/`unique_identity` + 인쇄·표장·규격 discriminator. ★판정은 **최종 A/B 뒤가 아니라** 참조 층 A/B(①) 와 3-arm 스틸 A/B(②) 로 **갈라서**(순환 회피, 계약 §2) | **닫음 — ②에서 판정** |
| 10 | ★**VCA 가 `printed_content` 를 조사에서 받는 경로** — gate 대상은 `research_required` **만이 아니라** `grounding_controlled`(= research_required ∪ uncertain ∪ unresolved). generic 은 자유 생성 유지 (Codex BLOCK) | **닫음 — 계약 §13** |
| 11 | ★**사람 override 재배선** → ★**닫음. 아래 §7** | **닫음** |
| 12 | ★**v2 프롬프트 pin 방식** → ★**닫음. 아래 §8** | **닫음** |

---

## 6. §5-5 닫음 — 조사 대상의 **임시 owner** (provisional research subject)

### ★내가 낸 안은 틀렸다 — **cache scope 를 identity 에 섞었다**

「research 를 `(project_id, entity_type, 정규화 name, facet, era, region, pack hash)`
로 키 잡자」고 했다. **두 군데가 틀렸다** (Codex BLOCK · 실물 확인):

| 무엇 | 왜 틀렸나 |
|---|---|
| **`facet·era·region·pack hash` 를 key 에 넣은 것** | 이건 **cache scope** 지 identity 가 아니다. 팩이나 시대가 바뀌면 **같은 owner 의 새 revision** 이 나와야지 **새 identity 가 생기면 안 된다** |
| **정규화 name 을 identity 로 본 것** | 유일성 불변식이 **없다**. 실측: `entity_canon` 의 유일 색인은 `uq_entity_canon_short_id (project_id, short_id) WHERE short_id IS NOT NULL` **하나뿐**(`database.py:178`)이고 **name 제약은 없다**. CLP 중복 0 은 **오늘 표본의 관찰**일 뿐이다 |

그리고 **PATCH 만 막아도 안 된다** — 추출기가 다음 에피소드에서 같은 실물을
**다른 이름으로** 부르거나, 서로 다른 동명 인물·소품·장소를 만드는 길이 남는다.
`EntityAlias` 는 **canon_id 가 생긴 뒤에만** 있으므로 pre-canon 판별에 못 쓴다.
outlook 이름 중복 31그룹 67행은 **「name 은 identity 로 안전하다」의 반증**이라
별개 결함으로 밀어도 **설계 근거에서 빼면 안 된다**.

### 닫는 안

```
A0 / A 가        research_subject_id 를 발급한다   ← append-only, provisional
                 (조사 record 의 owner)
     ↓
조사·revision 은 subject_id 에 달린다
     ↓
15.0 sync 뒤     subject_id → canon_id 를 **append-only 로 bind**
                 ★destructive re-key 가 아니다. 임시 owner 는 남아 provenance 가 된다
```

**두 key 를 갈라 둔다**:

| | 무엇 |
|---|---|
| **research row key / owner** | `research_subject_id` |
| **cache key** | `facet · era · region · pack hash · target image model` |

### ★그런데 「발급 + 사후 bind」만으로는 gate 가 안 닫힌다 (Codex BLOCK)

`EntitySyncService:137-138` 이 **`existing_canons.get(name)` 을 먼저** 한다.
그대로 두면 **기존 동명 canon 이 있을 때 서로 다른 subject 둘이 같은 canon 에 붙고**,
표기 drift 는 **새 canon 을 만든다.** 시험 ①②가 성립하지 않는다.
**셋을 더 적어야 닫힌다.**

#### ㉮ 발급 규칙 — **resume 에 안정적**이어야 한다

★**A0 pack hash 를 id 에 넣으면 안 된다** — 내가 같은 절 안에서 자기모순을 냈다 (Codex BLOCK).
바로 위에 「pack 이 바뀌면 **같은 subject 의 새 revision**」이라 써 놓고 pack hash 를 id 에
넣었다. 그러면 **A0 프롬프트 팩만 올려도 같은 원문 대상이 새 identity** 가 되고,
이미 canon 에 bind 된 뒤라면 새 subject 가 **충돌 규칙(㉰④)을 밟는다.**

```
research_subject_id = 결정적 함수(
    project_id · episode_id ·
    원문 근거 anchor(위치) ·
    candidate discriminator(정규화 표면형 — 원문에 실제로 쓰인 말) ·
    provisional owner type
)
provenance 로 **따로** 기록:  A0 pack hash · A0 model/version
```
★discriminator 를 **순번이 아니라 정규화 표면형**으로 잡는다 — 순번은 A0 산출 순서가
바뀌면 흔들린다. 표면형이면 **팩을 올려도 같은 말은 같은 id** 다.

같은 입력이면 **A0 를 다시 돌려도 같은 id** 가 나온다 → resume 이 조사를 다시 사지 않는다(§4-11).
★**cache scope(`facet·era·region·target model`)를 이 함수에 넣지 않는다** — 그것들이 바뀌면
**같은 subject 의 새 revision** 이어야 한다.
★episode 범위로 발급해도 된다 — **에피소드 사이 재사용은 bind 뒤 canon 층에서** 일어난다.

#### ㉯ 운반 칸 — A0 부터 `entity_t2i` 까지 살아 있어야 한다

sync 가 볼 수 있으려면 `research_subject_id` 가 **15.0 체크포인트까지** 실려 와야 한다.
legacy `entity_all` schema 는 **`additionalProperties=false`(name+shot_count)** 라 못 싣는다
→ **v2 overlay CP 와 v2 전용 detail/t2i schema 가 운반한다.**
legacy CP 는 **손대지 않는다**.

#### ㉰ bind 알고리즘 — ★**name lookup 보다 먼저** 돈다

### ★★「재배정 뒤 확정 short_id」도 identity 증거가 아니다 — **순환이었다** (Codex BLOCK)

내가 두 번 고쳤는데 두 번 다 틀렸다. 실물:

```
entity_sync_service.py:138   existing = existing_canons.get(name)      ← 이름으로 먼저 고르고
                     :168-169  if cp_short != existing.short_id:
                                   existing.short_id = cp_short         ← ★그 canon 에 short_id 를 **써 넣는다**
```

미bind 행은 ①을 못 타고 **이름으로 canon 이 정해진 뒤 그 canon 이 CP short_id 를 받는다.**
그러니 「재배정 뒤」에는 **당연히 그 short_id 를 가진 canon 이 하나**고, 내 ②가 그걸 bind 한다.
**내가 금지한 name bind 가 short_id 를 거쳐 그대로 통과하고 ③은 아예 실행되지 않는다.**

### bind 알고리즘 — 다시

```
① subject 에 **이 sync 이전부터 있는 명시적 lineage(bind 기록)** 가 있으면 → 그 canon
② lineage 가 없으면 → ★**독립 identity resolution 을 돌린다**
       name · short_id 는 **후보 신호일 뿐** 결정 근거가 아니다
       근거는 원문 anchor · owner type · discriminator · 조사 claim
       ├ 확정      → bind 한다 (★이때 lineage 가 생긴다)
       ├ 모호      → unresolved
       └ 새 대상   → **새 canon**
③ **같은 에피소드 · 같은 A0 후보집합 안에서** 서로 다른 subject 둘이
   한 canon 에 옴                                          → ★둘 다 unresolved (임의 병합 금지)
```

★**이 sync 가 방금 배정한 short_id 는 ②의 근거가 될 수 없다.** 허용되는 것은
**sync 이전부터 존재하는 persistent lineage** 뿐이다.
★그래서 v2 경로에서는 subject_id 를 든 행에 대해 **`existing_canons.get(name)` 갈래를 아예 안 탄다** —
그 갈래가 먼저 돌면 resolution 이 시작도 못 한다.
★**에피소드 사이 재사용은 ②가 담당한다.** ★그리고 **①로 넘어가지 않는다** (Codex BLOCK) —
`research_subject_id` 에 `episode_id` 가 들어 있어 **2화 subject 와 3화 subject 는 다른 subject** 다.
2화가 만든 lineage 는 **그 subject 의 것**이라 3화가 물려받지 못한다.

```
1화 subject  →  ②  →  canon X   (이 subject 의 lineage 생김)
2화 subject  →  ②  →  canon X   ★독립 resolution. 1화 lineage 를 타는 게 아니다
3화 subject  →  ②  →  canon X   ★마찬가지
같은 subject 의 resume·rerun  →  ①    ← ①은 **여기서만** 쓴다
```
즉 **①은 「같은 subject 를 다시 돌릴 때」 전용**이고, 새 에피소드의 첫 subject 는
**매번 ②로 독립 판정**해 같은 canon 에 닿는다.
★판정은 **구조화 판정**이지 글자 일치가 아니다. 못 정하면 **fail-closed = unresolved**.

★**④를 「한 canon 에 서로 다른 subject 둘」로 두면 정상 재사용을 막는다** (Codex BLOCK):
subject 는 **episode 범위**라 2화·3화가 같은 실물을 가리키면 subject 가 당연히 다르다.
**에피소드가 다르면 many-to-one bind 를 허용한다** — 그것이 재사용 경로다.
금지하는 것은 **한 에피소드 안에서 두 후보가 같은 canon 을 먹는 것**뿐이다.

★**「②를 short_id 재배정 뒤에 둔다」는 문장은 폐기한다** — 순환 규칙의 잔재다 (Codex BLOCK).
v2 subject 행은 **name upsert 갈래를 아예 타지 않고**, **sync write 전에 ① 또는 ②가
canon 을 정한다.** 재배정 시점과 무관하다. legacy 경로 순서는 그대로다.

★**모호하면 임의 name bind 가 아니라 `unresolved`** 다.
★`identity-only sync` 기각은 유지한다 (legacy canon 생성 시점을 바꾼다).
★`EntityCanon` 에 `identity_key` 칼럼을 새로 다는 것은 **필요 없다**.

**끝점 시험 넷**
```
① 같은 type + 같은 name 인 서로 다른 후보 둘이 **연구를 공유하지 않는다**
② 같은 후보의 표기 drift 가 **임의의 새 canon 이나 오결속을 만들지 않는다**
③ pack·era 를 바꾸면 **같은 subject 의 새 revision** 이지 새 subject 가 아니다
④ resume 이 같은 research 를 **다시 사지 않는다**
⑤ ★**A0 팩을 올려도 같은 원문 대상은 같은 subject** 다 (pack hash 는 provenance 에만)
⑥ ★**기존 canon A 와 동명인 다른 subject B 가 short_id 재배정을 거쳐도
   B 가 A 에 bind 되지 않고 A 를 덮지도 않는다** (이름·short_id 우회로 둘 다 막혔는지)
⑦ ★**두 에피소드가 한 canon 으로 재사용된다** — many-to-one bind 가 막히지 않는다
```

---

## 7. §5-11 닫음 — 사람 rename 은 `display_name` **override field** 다

### ★`entity_alias` 로 rename 을 구현하려던 안은 기각

실물 확인:

```
project.py:62-69      EntityAlias = canon_id + alias 뿐.
                      actor·time·supersedes·clear 도, preferred/display 표식도 **없다**
entities.py:84-96     _entity_to_response 가 name 을 **canon.name 에서** 낸다.
              :220    alias 는 상세 응답의 **문자열 목록**일 뿐이고 목록 정렬도 canon.name
scene_context_loader  alias 의 실제 뜻은 **prop term-match 용 동의어**다
                      (:533-540). live producer 는 import 경로 하나뿐
```

→ 「name 을 안 바꾸고 alias 한 행 추가」하면 **UI·API 표시명이 안 바뀌고**,
**어느 alias 가 사람이 고른 현재 이름인지 resolver 도 모른다.**

### 닫는 안

```
name 도 append-only override 의 한 field 로 둔다  →  display_name
alias 는 지금 뜻 그대로 — **검색 / 과거 이름 vocabulary**
중앙 resolver 가 display_name 을 고르고, list · get · sync 뒤 소비처가 그것을 쓴다
```

★`EntityAlias` 를 preferred/provenance/clear 까지 넓히는 대안은 **현재 뜻과 소비처를
깨므로 더 크다.** 사람 rename 이 **identity key 를 바꾸지 않는다**는 원칙은 그대로다.

**끝점 시험 — 앞의 넷에 하나 더**
```
⑤ PATCH 로 이름을 바꾸면 list · get 이 **사람 표시명**을 내고
   `canon.name` 은 **불변**이다
```

---

## 8. §5-12 닫음 — pin 만으로는 **실효 프롬프트를 증명하지 못한다**

### ★내가 낸 안의 구멍

```
prompt_loader.py:190-201   version 을 줘도 **같은 version 의 활성 DB row 가 있으면
              227-241       DB content/schema 가 파일을 이긴다**
pack_dir_content_hash       :121-147 는 **파일 디렉토리 bytes 만** 해시한다
```

→ 오늘 「DB override 0건」은 **현재 상태**일 뿐이다. 내일 같은 version 의 DB override 가
생기면 **실제 prompt 는 바뀌는데 completeness 도 파일 hash 도 그대로**라
**지문이 거짓말을 한다.**

### 닫는 안 — **B** 를 고른다

| | 무엇 | 판단 |
|---|---|---|
| A | v2 는 **`db=None` file-only bundle** 로 로드하고 exact stem/schema manifest + directory/content hash 를 검증 | 최소안으로는 정직하다. **B 가 막히면 여기로** |
| **B** | loader 가 **stem 마다 effective source(db/file) · version · raw content hash** 를 돌려주고 그 **bundle manifest 를 지문화**한다 | ★**채택.** §4 되돌리기 계약이 이미 **source path + content hash** 를 요구하므로 A 는 그 칸을 언젠가 거짓으로 만든다 |

### ★「`get_effective_source` 를 쓰면 된다」는 내 말은 틀렸다 (Codex BLOCK · 실측)

| 내가 쓴 것 | 실물 |
|---|---|
| 「loader 공통 함수」 | **admin 진단 뷰**다(`:379-414`). `load_prompt`/`load_schema` 는 **각자 따로** source 를 고른다 |
| 「이미 다 돌려준다」 | **`.md` 전용**이다 — File 후보를 `.md` 로만 본다 |
| docstring 이 가리키는 `get_effective_schema_source` | ★**정의가 없다.** 저장소 전체에서 그 docstring 한 줄뿐이다 |

### 닫는 안 — **prompt/schema 공용 effective-content resolver**

```
resolve_effective(module, stem, *, kind, version, db) → {
    kind:      "prompt" | "schema"      ★ .md / .json 과 content / schema_json 중
                                          무엇을 고르는지 이 칸이 정한다
    source:    "db" | "file"
    locator:   {"db_row_id": ...} | {"file_path": ...}   ★어느 행·어느 파일인지
    version:   str
    raw_content_hash: str               ★로드한 **원본 bytes** 의 해시
    content:   prompt=text · schema=parsed
}
```
★**`load_prompt` · `load_schema` · 지문이 이 반환값 하나를 같이 소비**한다.
따로 고르면 「지문이 본 것」과 「모델에 나간 것」이 갈라진다.

★**hash 는 두 개다. 섞으면 안 된다** (Codex BLOCK):

| | 무엇 |
|---|---|
| `raw_content_hash` | 로드한 **원본 source bytes** — 어느 source 를 썼는지 증명 |
| `payload_hash` | **실제로 provider 에 나간 payload** — prompt 는 `.format(**kwargs)` 뒤, schema 는 **재직렬화 뒤**라 원본과 **원리상 다르다** |

지문에는 **둘 다** 넣는다. 「실제로 나간 bytes」를 `raw_content_hash` 로 재면
**format 인자가 바뀌어도 지문이 안 움직인다.**

★**「호출부가 `load_prompt` 를 각각 부른 뒤 디렉토리 hash」는 불가**다.

**끝점 시험 — 「거부되거나」가 아니다. B 를 골랐으므로 셋 다 요구한다**
```
exact-version DB row 를 주입하면
  ① 그 DB row 가 **winner 로 잡히고** (locator 가 그 row id 를 가리킨다)
  ② **지문이 움직이고**
  ③ `raw_content_hash` 가 **그 source 의 원본 bytes** 해시와 같고
  ④ `payload_hash` 가 **실제로 나간 payload** 해시와 같다
  ★③과 ④를 한 칸으로 합치지 않는다
```

---

## 8.5 §5-6 닫음 — text-first 의 **시간 통제** (§2-4 진입 gate)

★**제약은 돈이 아니라 생성 시간이다.** §2-4 는 대상마다 **두 논리 호출**을
쓴다 — ①텍스트만 검색해 인용 붙은 claim 을 얻고 ②그 claim 으로 이미지를
찾는다. 지금처럼 한 번의 `responses.create` 에 둘을 같이 요청하면 **`said` 가
이미지 질의를 만든 인과 순서가 없다**(§1.4). 그래서 갈라야 하는데, 가르면
호출 수가 **대상당 2배**가 된다.

### 새로 만들지 않는다 — 저장소에 **이미 다 있다**

`core/image_call_budget.py` 는 이름과 달리 **이미지 전용이 아니다** — 스레드
지역 counter + stop check + 팬아웃 전파를 한 벌로 갖춘 primitive 다.
`reserve_current_call` 은 예산이 안 깔린 경로에서는 no-op 이라 기존 호출부에
영향이 없다.

| 있는 것 | 무엇을 막나 | 어디 |
|---|---|---|
| `call_with_deadline` | **한 호출**의 벽시계. litellm 의 `timeout` 은 per-read 라 slow-stream 을 못 잡는다 — 그 모듈에 「`timeout=180` 이 21분 매달렸다」고 적혀 있다 | `pipeline/llm_deadline.py` |
| `ImageCallBudget` · `reserve_current_call` | **주행 전체** 호출 수. 네트워크에 닿기 **전에** 거절 | `core/image_call_budget.py` |
| ★`ResearchCallBudget` | 위와 **같은 꼴의 새 owner**. 이미지 예산과 한 counter 를 쓰면 서로의 상한을 갉아먹는다 | ★**새로 만든다** — 다만 검증된 counter 꼴을 그대로 쓴다 |
| `install_stop_check` · `get_current_stop_check` | **아무 이유로든 멈춘다.** `reserve_current_call` 이 예산보다 **먼저** 부른다 — 예산이 안 깔린 경로에서도 정지는 들린다 | 같은 파일 |
| `bind_current_budget` | 팬아웃 전파. 예산 **과 stop check 를 같이** 실어 나른다 | 같은 파일 |

★셋은 서로 다른 것을 막는다 — 하나가 다른 하나를 대신 못 한다.
호출 하나가 안 매달려도 대상이 많으면 주행이 길고, 호출 수가 적어도
한 호출이 매달리면 사슬이 멈춘다.

### 닫는 안 — 세 겹

| 겹 | 무엇으로 | 넘으면 |
|---|---|---|
| ① **호출마다** | `call_with_deadline` 로 감싼다 | 그 대상만 `unresolved` · 주행은 계속 |
| ② **주행 전체 호출 수** | `ResearchCallBudget` — `ImageCallBudget` 과 **같은 꼴의 새 owner** | 남은 대상 전부 `unresolved` |
| ③ **주행 전체 벽시계** | ★`install_stop_check` 에 **마감 시각을 보는 check** 를 깐다. `reserve_current_call` 이 예산보다 먼저 부르므로 **네트워크 전에** 멈춘다 | 남은 대상 전부 `unresolved` |

★③ 을 「새 대상을 안 시작한다」는 loop 조건으로만 두면 **팬아웃 안에서 이미
제출된 것들**이 계속 나간다. stop check 로 깔면 예약 자리에서 막힌다.

★**`skip` 이 아니라 `unresolved` 다.** 시간이 모자라 못 산 것을 「조사할 필요가
없었다」로 적으면 계약 §3(「모른다를 아니다로 닫지 않는다」)을 어긴다.
`unresolved` 는 보호 대상이라 저빈도 필터가 못 지우고, 하류 owner 도
LOOK/FORM/MATERIAL 을 다시 저작하지 못한다(계약 §13).

★**몇 개가 시간에 잘렸는지 체크포인트에 남긴다** — `time_capped_count`.
안 남기면 시간 제한 주행이 「아무것도 조사할 게 없었다」로 읽힌다.

### ★`call_with_deadline` 은 **취소가 아니라 포기**다 — 이것이 폭을 새게 한다

그 함수는 daemon 스레드에 일을 맡기고 마감이 지나면 **그 스레드를 버린다.**
CPython 이 C 레벨 소켓 읽기에 갇힌 스레드를 못 깨우기 때문이다(그 모듈이
그렇게 적어 뒀다). 버려도 **소켓은 살아 있다** — 언젠가 오류가 나거나 프로세스가
끝날 때까지.

그래서 「마감 지났으니 다음 대상을 시작한다」로만 두면 **명목 동시 실행 폭보다
실제로 물려 있는 provider 호출이 많아진다.**

#### ★두 상한은 **다른 것**을 센다 — 한 뜻씩만 쓴다

| 상한 | 단위 | 무엇을 묶나 |
|---|---|---|
| **호출 수(②)** | **누계** 물리 전송 | 주행 전체가 **몇 번 나갔나** = 비용. 예약이 네트워크 **전에** 일어나고, 시간 초과로 버려진 것도 **예약을 안 돌려준다** |
| **물리 슬롯 W(③)** | **순간** 열린 호출 | 지금 **몇 개가 물려 있나**. 마감으로 버린 것도 그 스레드가 실제로 끝날 때까지 슬롯을 붙잡는다 |

★앞 판은 「W 는 열린 연결을 못 묶는다」고 써 놓고 acceptance 에서는 「실제
동시 호출 ≤ W」를 요구했다 — **자기모순**이었다(Codex). 못 묶었던 것은
**논리 폭**(무엇을 시작할지)이고, **물리 슬롯**은 묶는다.

    논리 폭    무엇을 시작할지 — 마감 뒤에도 스레드가 살아 있어 이것만으로는 샌다
    물리 슬롯  실제로 열린 호출 — 버린 뒤에도 안 놓으므로 이것이 순간값을 묶는다

★비용을 말할 때는 **누계**를, 동시성을 말할 때는 **순간값**을 쓴다.
둘을 한 낱말로 쓰면 위와 같은 모순이 다시 난다.

★그리고 **`bind_current_budget` 으로도 이 구멍은 안 닫힌다.** 그 helper 는
워커 스레드에 예산·정지를 실어 나르지만, `call_with_deadline` 이 그 **안에서
또 다른 daemon 스레드**를 만든다 — 실제 provider 호출은 거기서 난다. 그
스레드에는 아무것도 안 실린다.

#### 그래서 슬롯을 **버린 뒤에도 붙잡는다**

    물리 슬롯(semaphore)은 `HardDeadlineExceeded` 에서 **안 놓는다.**
    버려진 worker 가 실제로 끝날 때(오류·응답) **그 스레드가** 놓는다.
    슬롯이 다 차 있으면 **새 대상을 admission 하지 않는다.**

이러면 「마감 났으니 다음을 시작한다」가 저절로 막힌다 — 슬롯이 없기 때문이다.
★논리 폭(무엇을 시작할지)과 **물리 슬롯**(실제로 열려 있는 연결)을 갈라
둔다. 앞의 것만 두면 위의 구멍이 그대로다.

#### ★그런데 버려진 스레드가 **영영 안 끝날 수 있다**

C 레벨 소켓 읽기에 갇힌 스레드는 상대가 응답하거나 오류를 낼 때까지 안
깨어난다 — **영영 안 깨어날 수도 있다.** 그러면 그 슬롯이 영구히 잠기고,
슬롯을 기다리는 쪽이 **끝나지 않는다.** 「주행이 끝난다」와 정면으로 부딪힌다
(Codex).

    슬롯 얻기는 **막히지 않게** 한다 — 안 되면 바로 실패하거나,
      짧게 끊어 기다리며 **매번 stop check 를 본다**
    ★평범한 `acquire()` 로 막으면 그 자리에서 stop 도 마감도 **안 들린다**
    못 얻으면 **전송 없이** 그 대상을 `unresolved` 로 끝낸다
    슬롯이 영구히 잠겨 있어도 **주행은 끝난다**

★토큰을 **놓는 것은 실제 provider 스레드**다 — 마감을 세는 쪽이 아니다.
마감 쪽이 놓으면 아직 소켓을 물고 있는 호출이 슬롯 밖에 있게 된다.

★즉 물리 슬롯은 **동시성 상한**이지 **진행 보장**이 아니다. 진행은 마감이
보장한다. 둘을 같은 것으로 쓰면 위의 교착이 난다.

★그리고 영구히 잠긴 슬롯은 **수를 남긴다**(`leaked_slot_count`). 안 남기면
다음 주행이 왜 느린지 아무도 모른다 — 프로세스가 살아 있는 동안 그 슬롯은
계속 없는 것이다.

★버려진 호출은 **`unresolved` 로 서고 결과가 늦게 와도 안 받는다.** 받으면
같은 대상에 두 판정이 생긴다.

#### ★그런데 「늦은 것은 아무 데도 안 남긴다」는 **틀린 말이다**

버려진 호출은 **실제로 나갔고 돈이 들었다.** 그 기록까지 지우면
감사가 사라지고 비용이 안 보인다. 갈라야 한다 —

| 늦게 온 것이 | 어떻게 |
|---|---|
| **판정·체크포인트·DB 등 파이프라인 상태** | ★**안 바꾼다.** 주행이 끝난 뒤 산출이 바뀌면 안 된다 |
| **비용·trace·감사 로그**(Opik 포함) | ★**반드시 남긴다.** 「버려짐」 표시를 붙여서 |

★모든 provider 호출은 Opik 에 남아야 한다는 것이 이 저장소의 규칙이다.
마감으로 버렸다고 그 규칙이 면제되지 않는다 — 오히려 **버린 호출이야말로
비용이 조용히 새는 자리**라 반드시 세어야 한다.

### ② 누가 quota 를 받는지가 **결정적이어야** 한다

여럿이 동시에 예약하면 **누가 먼저 닿느냐**로 갈린다 — 같은 입력에 같은
대상이 잘린다는 보장이 없다. 그러면 「무엇이 잘렸나」가 그날 스레드 순서에
달린다.

    admission 은 **팬아웃 전에** 한다 — 안정된 순서로 정렬해 토큰을 미리 나눈다
    정렬 키는 `research_subject_id` (결정적 발급이다 · §6 ㉮)
    토큰을 받은 것만 팬아웃에 들어간다 · 못 받은 것은 그 자리에서 `unresolved`

★**대상을 정렬해 넣는 것만으로는 부족하다**(Codex). 재시도·fallback 은
provider 경계에서 **각각** 예약하는데, 그 예약은 팬아웃 안에서 **동시에**
경합한다. 그러면 admission 목록이 같아도 **어느 대상이 완주하느냐가 달라진다** —
남의 재시도가 내 몫을 먹는다.

그래서 **대상마다 시도 몫을 미리 떼어 준다**:

    subject 하나당 시도 allowance = 2 × (1 + 우리 재시도 수) × 슬롯 수

    admission 때 그 몫을 **통째로** 떼어 준다 — 나중에 경합하지 않는다
    승인한 대상들의 allowance **합**이 주행 상한을 넘지 않게 승인한다
    자기 몫을 다 쓰면 그 대상만 `unresolved` · 남의 몫을 안 건드린다

#### ★몫을 세기 전에 **호출 그래프를 못박는다**

이것을 두 번 틀렸다. 처음엔 tier 를 통째로 빠뜨렸고, 고치면서 **둘 다
`call_structured` 를 탄다고 가정**했다. 세 번째로 틀리지 않으려면 **각 화살표가
어느 함수·어느 API 를 타는지 먼저 정해야 한다**(Codex).

§1.4 가 말하는 text-first 는 이렇다:

    ①텍스트만 검색 → 인용 붙은 claim  ②그 claim 으로 이미지 검색

★**둘 다 `client.responses.create` 직접이다.** ①은 새로 만들고 ②는
`search_reference_images` 를 그대로 쓴다. `responses.create` 는 구조화 출력을
낼 수 있으므로 **claim 정규화를 위한 별도 `call_structured` 를 두지 않는다.**

    호출 그래프  = Responses ×2 · Router 호출 **0**

★그래서 **tier 산식이 아예 안 들어온다.** 앞 판이 적은 `tier 3 × (1+num_retries)`
는 이 경로에 없는 것이다. 있지도 않은 것을 상한에 넣으면 몫이 부풀어 승인
대상이 까닭 없이 줄어든다.

#### ★SDK 재시도는 **예약 밑에서** 돈다 — 그래서 0 으로 내린다

`OpenAI(max_retries=…)` 의 재시도는 우리 wrapper **아래**에서 일어난다.
그러면 `reserve_current_call` 도 감사 기록도 **물리 전송마다 안 돈다** —
숫자를 읽어 와 봐야 「물리 전송을 예약했다」가 거짓이 된다(Codex).

    이 경로는 `max_retries=0` 으로 만든다
    재시도·키 슬롯 전환은 **우리가** 돈다 — 한 번마다 예약하고 기록한다

★**`max_retries=0` 만으로는 모자란다**(Codex). 그것은 SDK 안쪽 재시도만 끈다.
지금 `openai_client()` 가 주는 것은 `FailoverOpenAIClient` 이고, 그
`_invoke()` 가 **프록시 안에서** 키 슬롯을 돌린다(`openai_keys.py:379-390`).
그런데 `search_reference_images` 의 감사 기록은 **프록시 호출 전체를 한 번만**
감싼다(`search_grounded_ref.py:369-400`). 그래서

    슬롯 2개로 실제 2번 전송  →  예약 1 · 감사 1

이 되어 아래 등식이 성립하지 않는다. 둘 중 하나로 닫는다:

| 어떻게 | 무엇을 하나 |
|---|---|
| ㉠ **슬롯을 우리가 돈다** | 구체 슬롯·키로 `OpenAI(max_retries=0)` 를 직접 만들어 **한 슬롯 = 한 전송**으로 쓰고, 전환 loop 를 우리 코드에 둔다. 예약·감사가 그 loop 안에 들어간다 |
| ㉡ **프록시 안으로 넣는다** | `_invoke` 의 **시도마다** 예약·감사가 돌게 한다 |

★**㉠ 을 고른다.** ㉡ 은 이미지·다른 경로까지 다 바꾸는 일이라 이 gate 의
범위를 넘고, 그 경로들은 지금 예약 계약이 다르다.

이러면 **모든 물리 전송이 예약과 감사를 지난다.** 그때 비로소

    allowance(대상) = 2 × (1 + 우리 재시도 수) × 슬롯 수

라고 **물리 전송 단위로** 말할 수 있다. ★이 값도 상수로 안 적는다 — 설정에서
읽어 오고, **admission 정책 버전**과 함께 지문에 접는다.

★적게 잡으면 정상 실패만으로도 남의 몫을 먹어 완주 대상이 갈린다. 많이
잡으면 승인 대상 수가 줄 뿐이라 안전하다 — 못 받은 것은 `unresolved` 다.

★**봉인하는 길도 있다** — 이 경로만 `enable_fallback=False`·`num_retries=0`
으로 내려보내면 몫이 정확히 논리 호출 수가 된다(그 파일이 「모델마다 한 번씩」이
계약인 자리는 그렇게 한다고 적어 뒀다). ★**안 고른다** — 조사 호출은 검색을
끼고 있어 일시적 실패가 잦고, 봉인하면 429 한 번에 그 대상이 `unresolved` 로
떨어진다. 대신 **최악을 통째로 예약**해 결정성을 산다. 그 대가로 한 주행에
승인되는 대상 수가 줄지만, 못 받은 것은 `unresolved` 라 안전하다.

★주행 전체 호출 수 상한은 **안전망**으로 남긴다 — 몫 계산이 틀렸을 때
비용이 새는 것을 막는 마지막 벽이다. 두 상한이 **다른 일**을 한다:

    subject 몫      누가 얼마를 쓰는지 — 결정성
    주행 안전망     통틀어 얼마나 나가는지 — 비용

★상한을 **논리 호출 수**로 셀지 **물리 전송 수**로 셀지도 못박아야 한다.
Router 재시도·fallback 때문에 둘은 다르다(§2-3a 에서 같은 이유로 이름을
`classifier_logical_calls` 로 못박았다).

    이 gate 의 상한은 **물리 전송 수**다.
    그래서 예약은 **provider 경계에서** 한다 — 재시도·fallback 이 각각 예약한다.
    논리 호출 수로 세면 재시도가 상한 밖에서 나가 「상한을 걸었는데 더 나갔다」가 된다.

### 시간에 잘린 주행은 **기준선이 아니다**

같은 입력에 같은 산출이 나오지 않는다 — 무엇이 잘리는지가 그날 벽시계에
달려 있다. 그래서

    time_capped_count > 0 인 주행은 acceptance 기준선으로 쓰지 않는다
    지문에는 **상한값 전부**를 접는다 — 잘린 결과가 아니라

접을 것 다섯:

| 무엇 | 왜 |
|---|---|
| 호출마다의 마감 | 늘리면 잘리던 것이 안 잘린다 |
| 주행 호출 수 상한 | 늘리면 더 많은 대상이 조사된다 |
| 주행 벽시계 상한 | 같은 이유 |
| 동시 실행 폭 W | 순서·재시도가 달라진다 |
| ★**admission 정책 버전** | 누구에게 토큰을 주는 규칙이 바뀌면 **다른 대상이 잘린다** |

★`time_capped_count` 의 단위는 **서로 다른 subject 수**다. 호출 수로 세면
한 대상이 두 논리 호출을 쓰므로 두 배로 읽힌다.

★상한을 지문에 안 접으면 상한을 바꿔도 옛 체크포인트를 그대로 건너뛴다.
★잘린 결과를 지문에 접으면 같은 설정인데 지문이 매번 달라진다. 가르는 것은
**설정은 접고 결과는 안 접는다** 다.

### 벽시계를 실제로 줄이는 것은 **동시 실행**이다

대상끼리는 서로 의존하지 않는다. 상한 있는 동시 실행이면 벽시계가
`2N × 한 호출` 이 아니라 대략 `ceil(2N/W) × 한 호출` 이 된다.

★**폭은 지문에 접는다.** 폭이 바뀌면 재시도·순서가 달라져 같은 입력이라도
산출이 달라질 수 있다. ★다만 위에 적었듯 폭은 **새로 시작하는 수**이지
물려 있는 연결 수가 아니다.

★팬아웃은 **`bind_current_budget(fn)`** 로 감싼다. 예산과 stop check 가 둘 다
스레드 지역이라 그냥 `submit` 하면 워커에서 **둘 다 안 보인다** — 이미
`ThreadPoolExecutor` 용으로 그 helper 가 있고, 예산만 나르고 stop check 를
빠뜨리면 「팬아웃 안에서 정지가 통째로 안 들린다」고 그 함수가 적어 뒀다.

★`call_with_deadline` 은 **thread-local 을 안 물려준다**(그 모듈 머리에 적혀
있다). 그래서 순서가 정해져 있다 — **예약(`reserve_current_call`)을 먼저,
그 다음에 `call_with_deadline` 로 감싼다.** 반대로 하면 예약이 감싸기 안쪽에
들어가 예산도 정지도 안 보인다.

### 끝났다고 말할 수 있는 조건

```
호출 하나를 일부러 매달아도 그 대상만 unresolved 이고 주행이 끝난다
★**마감 뒤를 포함해** 실제로 **순간** 물려 있는 provider 호출이 W 를 안 넘는다
누계 물리 전송 수가 주행 상한을 안 넘는다 (버려진 것도 세어서)
버려진 호출이 늦게 답해도 그 대상의 **판정·체크포인트·DB 가 안 바뀐다**
 (파이프라인 상태에 늦은 쓰기 0건)
★그런데 그 호출의 **비용·trace 는 남아 있다** — 「버려짐」 표시와 함께.
 안 남기면 돈이 조용히 새고 감사가 사라진다
시간 초과로 버려진 호출도 **호출 수 상한을 갉아먹는다**(예약을 안 돌려준다)
★같은 입력을 두 번 돌리면 **같은 대상이** admission 되고 **같은 대상이 완주**한다
 (id 목록만이 아니라 완주 목록도 같다 — 재시도 경합으로 안 갈린다)
한 대상이 자기 몫을 다 써도 **다른 대상의 몫이 안 줄어든다**
★A 가 **재시도를 다 쓰고 키 슬롯을 다 갈아 써도** B 의 완주 여부가 안 바뀐다
★**물리 전송 하나마다** 예약과 감사 기록이 하나씩 있다
 (전송 수 = 예약 수 = 감사 기록 수. SDK 안쪽 재시도가 있으면 이 셋이 안 맞는다)
allowance 를 **설정에서 읽는다** — 재시도 수·슬롯 수를 바꾸면 몫도 따라 바뀐다
이 경로에 Router(`call_structured`) 호출이 **0건**이다
승인한 대상들의 allowance 합이 주행 상한을 안 넘는다
호출 수 상한에 걸리면 남은 대상이 unresolved 이고 provider 에 안 닿는다
벽시계 상한에 걸리면 **팬아웃 안에서도** 멈추고 남은 것이 unresolved 다
★슬롯이 **전부 영구히 잠겨 있어도** 주행이 끝난다 (기다리기가 막히지 않는다)
슬롯을 못 얻은 대상은 **전송 0** 으로 `unresolved` 다 (예약도 감사도 안 생긴다)
키 슬롯을 갈아 쓴 만큼 **예약과 감사 기록도 그만큼** 생긴다 (전환이 감사 안쪽이 아니다)
영구히 잠긴 슬롯 수가 산출에 남는다
동시 실행 안에서 예산·정지가 둘 다 들린다 (하나만 나르면 안 걸린다)
셋 중 어느 것으로 잘렸든 `time_capped_count` 가 그 수와 같다
`skip` 으로 적힌 것이 0건이다
상한값 다섯(마감·호출 수·벽시계·폭·admission 정책 버전)이 지문에 접혀 있고,
 **잘린 결과는 안 접혀 있다**
`time_capped_count` 가 **서로 다른 subject 수**와 같다 (호출 수가 아니다)
```

---

## 9. DB 스키마를 **바꿀 때만** — 되돌리기 방법 (사용자 지시, 2026-08-30)

> 「db 는 **스키마 수정시에만** rollback 방법 필요함」

★**§2-2 는 스키마 변경이 0건이라 지금 할 일이 없다.**
`backend/app/models/` · `backend/alembic/` · `app/core/database.py` 안 건드렸다.
표를 만들게 되는 것은 §2-3.5 이후다 (research record · override · subject→canon bind).

그때 지킬 것 넷:

| # | |
|---|---|
| 1 | **additive-only** — 새 표 + nullable 새 칸만. 기존 칸을 지우거나 뜻을 바꾸지 않는다 |
| 2 | **실제 `downgrade()`** — 저장소 관례대로 `sa.inspect` 로 있는지 보고 지운다. 번호는 **011** 부터 |
| 3 | ★**`up → down → up` 을 실제로 돌려 본 것만 통과**. 「작성했다」로 안 센다 |
| 4 | ★**되돌리기 1순위는 downgrade 가 아니라 `mode=legacy` 전환**이다 (계약 §12). 1 덕분에 downgrade 없이도 되돌아간다 — downgrade 는 **v2 표를 걷어낼 때만** 쓴다 |

---

## 10. 지정 대조군 — **고정 좌표** (사용자 지정, 옮겨 적음)

> 「회수권·요금통·차장 제복이 research_required 로 찍힘 · 한복·무브랜드 차가 안 찍힘」

★**사후에 고를 수 있는 것은 대조군이 아니다.** 「일반 소품이 skip 됐다」는 판정 뒤에
아무 skip 이나 골라도 참이 된다. 그래서 **에피소드 + short_id + 이름**으로 못박는다.
프로젝트 `da049582`(막차 검증 원고) 기준이다.

### 양성 — **research 로 찍혀야 한다**

| 좌표 | 이름 | 언제 잰다 |
|---|---|---|
| `97375a4b` / `P01` | 쇠사슬로 묶인 기계식 요금통 | **§2-3c** |
| `97375a4b` / `P03` | 고무줄로 묶인 낡은 종이 회수권 뭉치 | **§2-3c** |
| `0aea12c2` / `P01` | 쇠사슬에 묶인 투명 요금통 | **§2-3c** |
| `0aea12c2` / `P03` | 고무줄로 묶인 낡은 종이 회수권 뭉치 | **§2-3c** |

### 음성 — **안 찍혀야 한다**

| 좌표 | 이름 | 언제 잰다 |
|---|---|---|
| `fb7a883f` / `P01` | 낡은 자전거 체인 | **§2-3c** |
| `fb7a883f` / `P03` | 휴대용 손전등 | **§2-3c** |
| `fb7a883f` / `L01` | 좁은 자전거 정비소 내부 | **§2-3c** |

### ★지금 잴 수 없어 **뒤로 옮긴 것** — 지운 것이 아니다

| 대상 | 왜 지금 못 재나 | 어디서 잰다 |
|---|---|---|
| **차장 제복** | 이 원고에 **outlook 엔티티가 없다.** 정임(`C01`)은 인물로만 잡힌다. outlook facet producer 가 없다 | ★**§2-6.5** (producer+gate) 의 acceptance 에 **필수 항목**으로 넣는다. §2-7 통합 E2E 에서 재확인 |
| **한복** · **무브랜드 차** | 이 원고에 **없다** | ★**고정 fixture 합성 후보**로 **§2-3c 에서 함께 잰다**(아래 「합성 음성」). 원고에 실제로 나오는 판이 생기면 그 좌표로 승격한다 |

### 합성 음성 — **돌리기 전에 못박는다**

원고에 없는 두 축(한복 · 무브랜드 차)은 합성 후보로 잰다. ★**입력을 사후에 바꿀 수
없어야 대조군이다** — 판정이 원하는 대로 안 나왔을 때 문안을 고쳐 다시 돌리면
그건 측정이 아니다. 그래서 **돌리기 전에** 아래를 전부 고정하고 **content hash 를
기록**한다.

| 고정할 것 | 왜 |
|---|---|
| `era` · `region` | 실측에서 이것만으로 판정이 뒤집혔다 |
| `surface_form` · `source_quote` | 표면형이 subject id 를 정한다 |
| `owner_type` | route 의 전제 |
| target image `provider`·`model`·`version` | capability 는 여기 귀속한다 |
| **위 전부의 content hash** | 사후 수정을 못 하게 |

| 합성 좌표 | 대상 | 기대 |
|---|---|---|
| `fixture:hanbok` | 한복 (특정 양식·계급 지정 없음) | **non-research** |
| `fixture:unbranded_car` | 무브랜드 90년대 승용차 | **non-research** |

★**두 축은 계약 §2 의 「알아보는데 근거 있음」 칸을 대표한다** — 시대를 줘도
모델이 맞게 만들 수 있으므로 조사를 안 산다. 이 칸이 안 지켜지면
**시대만 주면 전부 research 로 가는** 반대쪽 결함이 생긴다.

★**§2-6.5 는 이 표의 「차장 제복」이 통과하지 않으면 끝난 것이 아니다.**
사용자가 지정한 축이라 임의로 뺄 수 없다.
