# C(c) production 배선 — 설계 초안과 호출 그래프

> **아직 아무것도 안 켰다.** 이 문서는 Codex 가 배선 판정을 내리며 「설계 초안과
> 현재/새 호출 그래프를 먼저 올리라」고 한 것에 대한 답이다. 유료 호출 0.

## 0. 무엇이 정해졌나 (2026-08-31, Codex)

| | |
|---|---|
| 등장 단위 | ①**샷 정보를 같은 chunk 호출에 싣는다.** 모델은 숫자를 안 쓰고 **샷 ID 를 고른다** |
| ②scene_count fallback 안 | **기각** — production 이 `shot_count` 를 우선하므로 반복축이 바뀐다 |
| 지금 B-only 결과 | **scene-level 가능성 진단으로만** 남긴다. 새 shot-aware 요청의 근거가 아니다 |
| screen | provider 가 아니라 **C(c) 두 축을 투영하는 결정적 단계**로 바꾼다 (`assess_calls=0`) |
| 「원자적」의 뜻 | 커밋 하나가 아니라 **활성화 순간에 두 벌/0벌이 없어야 한다** |

---

## 1. 지금 호출 그래프 (`if_grounding_v2` 를 켠 상태)

★`applicability` 와 `order` 는 `app/core/step_manifest.py` 에서 그대로 읽었다.

```
 4.0  visual_world_rules      always            gpt
 6.0  scene_save              always            (호출 없음)
 6.5  entity_character_list   always            gpt          ← 유료
 7.1  beat_extract            always            gemini-pro
 7.2  shot_extract            always            gemini-pro
 7.25 shot_validator          always            gemini-pro   ← ★샷 ID 가 여기 생긴다
 7.5  grounding_a0            if_grounding_v2   gpt          ← 유료 · 원문 전문
 8.0  entity_all_character    always            gpt          ← 유료 · 샷 전부
 9.0  entity_extract_character always           gpt          ← 유료 · 원문 전문
10.0  entity_all_location     always            gpt          ← 유료 · 샷 전부
11.0  entity_extract_location always            gemini-pro   ← 유료 · 원문 전문
12.0  entity_all_prop         always            gpt          ← 유료 · 샷 전부
13.0  entity_extract_prop     always            gemini-pro   ← 유료 · 원문 전문
13.5  entity_merge            always            gpt          ← 유료
13.6  entity_relation         always            gpt
13.65 grounding_plan          if_grounding_v2   gpt          ← 유료 (표본×판정자)
13.66 grounding_screen        disabled          gemini-flash ← ★켜면 **대상마다 N회**
13.67 grounding_research      if_grounding_v2   gpt          ← 유료 · 검색
13.7  entity_filter           always            gpt-mini     ← 유료 (저빈도 의미 재질문)
14.0  entity_detail           always            gpt
```

★**원문 전문이 4번**(a0 + extract 3), **샷 전부가 3번**(all 3) 나간다. 이것이
C(c) 를 시작한 까닭이다.

### `entity_merge` 를 읽는 곳 — **12개 모듈**

```
shot_staging · shot_validator · entity_steps · outdoor_place_spec ·
location_consistency · grounding_steps · background_chain_planning ·
background_planner · shot_director · scene_camera_flow · steps/__init__ ·
shot_dependency_t2i
```

★그래서 **모양을 바꾸면 안 된다.** v2 에서 `entity_merge` 는 새 producer 를
기존 `characters/locations/props` 모양으로 내는 **read-only adapter** 가 된다.

---

## 2. 새 호출 그래프 (v2 만. legacy 는 위 그대로)

```
 7.25 shot_validator                       ← 샷 catalog 재료
 7.4  ★grounding_entity_chunk (새 producer)
        chunk 호출 × S (병렬) + merge 1     ← 유료 · **원문이 한 번**
 8.0~13.0  entity_all_* · entity_extract_*  → v2 에서 **no-call**
 7.5  grounding_a0                          → v2 에서 **no-call**
13.5  entity_merge         → **read-only adapter** (호출 0)
13.6  entity_relation      그대로
13.65 grounding_plan       → v2 소비 0 (남긴다면 **audit-only** 라고 명시)
13.66 grounding_screen     → **결정적 투영** (assess_calls=0)
13.7  entity_filter        → **결정적 투영** (저빈도 의미 재질문 0)
13.8  reference_acquisition → C(c) 의 obligation ID + final entity ID +
                              search terms **만** 소비
```

유료 호출 수: `S + 1` (+ 참조 획득이 실제로 여는 것). 지금은 알려진 것만 **14**.

---

## 3. 샷 결속 — 이름으로 추정하지 않는다

실제 체크포인트(`shot_validator`)가 이미 갖고 있는 것:

```
scenes[].scene_index          scenes[].shots[].shot_index
                              scenes[].shots[].description
                              scenes[].shots[].characters
```

그래서 **안정 ID = `scene_index` + `shot_index`** 다. 새로 지어낼 것이 없다.

### ★상태를 셋으로 가른다 — 빈 배열 하나로 뭉치면 두 뜻이 합쳐진다

`shot_appearance_ids: []` 만 두면 **「원문엔 있는데 이 샷들엔 안 보인다」**와
**「모델이 못 붙였다」**가 **둘 다 0회**가 된다 (Codex 판정 1).

| 상태 | `shot_appearance_ids` | 반복 축 |
|---|---|---|
| `bound_complete` | 1개 이상 · catalog ID 만 · unique | 고유 샷 ID 수를 **센다** |
| `not_in_catalog_shots` | 정확히 **빈 배열** | **0회** (명시 판정) |
| `unresolved` | 감사용으로 남길 수 있으나 | **미확정** — false 가 아니다 |

★나체 `[]` 는 **금지**다. 상태 칸이 반드시 함께 온다.

등록은 **두 축의 union** 이고, 미확정이 섞이면 결과도 미확정이다 —

```
반복 축   bound_complete 의 고유 샷 ID ≥ 2
예외 축   같은 행에서 hard AND notice
결과      반복 축이 unresolved 이고 예외 축도 안 서면 → **등록도 unresolved**
          (「미등록」이 아니다)
```

★merge 뒤에도 상태를 **보존**한다. 모르는 ID·중복·occurrence 씬 **밖** ID 는
fail-closed/격리. 이름·substring 추정은 **0**.

### ★catalog 크기 — 실제 DB/CP 로 쟀다 (2026-08-31)

앞서 나는 한 에피소드에서 본 「구간당 6~10개」를 적었다. **틀렸다.**
`projects/` 의 실제 체크포인트 **61 에피소드 · 330 구간**으로 다시 쟀다
(`BUNDLE_TARGET=3000` 생산 관례 그대로) —

| | p50 | p95 | max |
|---|---|---|---|
| 구간당 샷 수 | **44** | **71** | **228** |
| catalog bytes | **8,336** | **12,943** | **29,988** |

★`description` 은 **자르지 않는다** — 검증된 전문을 싣는다. 임의 절단은
구별점을 없애 **잘못된 샷 결속**을 만들고, 그 결함은 validator 가 못 잡는다.
상한을 넘으면 **description 을 자르지 말고 chunk/catalog 를 더 나누거나
preflight 에서 선다.**

★catalog 에 들어가는 것은 **runtime 동적 ID + 검증된 전문 + 기존 구조화
characters** 뿐이다. 고정 예시·고정 명사·하드 프롬프트는 **0**.

---

## 4. 다섯 갈래 — 지금은 **추출까지만** 확인됐다

C(c) 가 다섯 갈래를 **낸 것**은 유료 주행으로 확인했다(location 7 · prop 30 ·
location_part 55 · character 6 · outlook 8). 그러나 **production SOT 까지
결속·소비된 것은 아니다.**

### 실제 SOT 를 확인했다 (DB, 2026-08-31)

```
entity_canon.entity_type   location 1333 · character 932 · outlook 872 · prop 688
                           ★**location_part 갈래가 없다**
character_outlook          (character_id, outlook_id)  ← outlook 결속 SOT **있음**
relation_fact / relation_participant
                           relation_family · relation_type · directionality
                           + 참가자 role  ← 일반 관계 SOT (지금은 visual_variant 146건)
```

| 갈래 | 붙일 자리 | 지금 |
|---|---|---|
| prop · character · location | `entity_canon` | base adapter 로 materialize |
| outlook → character | **`character_outlook`** | ★SOT 가 **이미 있다** |
| location_part → location | `entity_canon` 에 갈래가 **없다** | ★**durable debt** |

★`location_part` 를 `location` 으로 등록하면 그것이 바로 **base 갈래 우회
등록**이다 — 안 한다. 갈래를 새로 만들거나 facet 저장소가 생기기 전까지
후보·obligation 을 **durable debt** 로 남긴다.

★relation 이름은 기존 SOT(`relation_fact.relation_type`)를 따르고, 이름·표면형·
구체 대상 예시로 결속하지 않는다.

---

## 4.5 `grounding_plan` 과 `grounding_research` — v2 에서 **provider no-call**

C(c) 가 같은 뜻의 두 축과 obligation 을 이미 냈다. 다시 판정하면 **SOT 가 둘**
이고 **비용도 중복**이다 (Codex 판정 3).

| | v2 |
|---|---|
| `grounding_plan` | **provider 0** — 다만 즉시 지우지 않고 C(c) 장부를 기존 모양으로 내는 **결정적 compatibility adapter** 로 남긴다(하류 dependency·resume 무효화 사슬 때문). ★`provider_calls=0` · 같은 입력이면 **byte 동일** |
| `grounding_research` | **eligibility·sourced-delta 를 다시 사는 두 번째 판정자가 되면 안 된다.** 새 중앙 reference acquisition 이 「의무 판정 뒤 검색→이미지검색」의 **유일 producer** |
| 순서 | D 에서 실제 소비자(`episode_reference_policy` 등)를 **새 obligation/reference 결과로 먼저 재배선**한 뒤 old research 를 no-call/read-only 로 내린다. ★거꾸로 하면 하류가 끊긴다 |
| legacy | 현행 유지 · 과거 CP/감사 기록 **보존** |

## 5. 구현 순서 — 「원자적」은 **활성화 순간**을 말한다

| | 무엇 | 유료 |
|---|---|---|
| **A** ✅ | inert — 샷 catalog · runtime enum · **세 상태** · 샷 장부 · facet binding 계약/adapter. **provider 활성화 0** | 0 |
| **B** ✅ | 무료 끝점 — 실제 `shot_validator` CP 로: **상태 truth table** · runtime enum · **씬 불일치** · merge union · **미확정 전파** · **예외축 독립성** · **전문 payload 상한** · 다섯 갈래 debt/결속 · **provider 0** | 0 |
| **C** ✅ 기계만 | 실제 에피소드 1개 canary — 승인·실행·대조 끝. **사람 평가는 대기** | **5회 씀** |
| **C-사람** ⏸ | v3 검토 화면에서 **샷 결속 의미 · 두 축 · 엔티티/facet 품질** 판정 | 0 |
| **D-inert** ✅ | `entity_merge` 호환 adapter — provenance 한 벌 · 소비 전 계약 검사. **배선 0** | 0 |
| **D** ⬜ | **원자적 활성화** — 새 producer ON · 옛 v2 provider 경로 OFF · screen/filter 결정적 전환 · reference consumer 전환 · `generation_difficulty` 제거 · schema/config stamp bump 를 **같이** | 0 |
| **E** ⬜ | production E2E — Opik + provider/failover 로그 + DB/체크포인트 + code/config hash + 실제 prompt/payload 를 **동시에** 대조. 뒤쪽 late `era_research` 생산 호출 **0** 확인 뒤 제거/read-only adapter 화 | 승인 필요 |

### 뒤쪽 중복 유료 호출 — **다시 셌다** (2026-08-31)

★앞에 적은 여덟 모듈은 **틀렸다.** `era_research` 라는 낱말이 나오는 파일을 센
것이라, `resolve_model_physical`(무료)만 쓰거나 주석에 이름만 적힌 것까지 들어
있었다.

**실제로 사는 진입점**을 AST 로 되짚어 다시 셌다 — `assess_subjects` ·
`research_reference` · `assess_plan_cached` · `acquire_from_plan_cached` ·
`assess_and_research_cached` (전부 `call_structured` 로 나가고, research 계열은
`search_reference_images`·`download_candidate`·`combine_coarse_verdicts` 까지
연다).

| 파일 | 줄 | 무엇을 사나 |
|---|---|---|
| `app/services/still_recipe_service.py` | **2189 · 4170 · 4504** | `assess_and_research_cached` (판별 + 검색 + 이미지 후보) |
| `app/modules/reference_image_generator.py` | **255 · 264** | `assess_subjects` · `research_reference` |
| `app/modules/pipeline/grounding_screen.py` | **234 · 304** | `assess_plan_cached` |

**모듈 3곳 · 호출 자리 7곳.** 나머지 일곱 모듈은 **한 푼도 안 산다.**

★★내 도구가 두 번 틀렸다 —

    ①provider 경계 함수 이름을 **짐작**했다(`call_llm`…) → 유료 진입점 **0건**
    ②`from … import X as _alias` 와 `fn or _era.X`(참조로 넘기기)를 놓쳤다
      → 구매 자리 **1곳**

둘 다 「없다」가 아니라 **못 찾은 것**이었다. 이름은 조립부(`llm_client`)에서
그대로 가져오고, 별칭과 참조 전달까지 되짚어야 수가 맞는다.

★E 에서 **중앙 결과 조회 전용 또는 명시 adapter** 로 바꾼다. 지금은 안 건드린다.

---

## 6. 이 문서가 말하지 **않는** 것

- C(c) 가 지금보다 낫다 — **아직 모른다**
- 씬 단위 진단이 shot-count 를 대신한다 — **아니다**. 개발 기록으로만 남긴다
- 다섯 갈래가 끝났다 — **추출까지만** 확인됐다
- 활성화 시점 — **승인 전이다**


---

## 7. C 유료 canary 가 실제로 낸 것 (2026-08-31)

★**이것은 「기계가 도는가」까지다.** production PASS·D 활성화·「C(c)가 현행보다
낫다」는 **아니다**. 의미 품질은 사람이 v3 화면에서 본다.

| | |
|---|---|
| 비용 | 논리 **5**(산 것 5 · 재사용 0) · dispatch 5/5 · 물리 ≥6(키 슬롯 전환 1) ≤ 상한 10 |
| 대조 | 장부 5 ↔ Opik 5 · **양방향 차집합 0** · 무표식 0 · 중복 0 |
| 토큰 | 입력 60,631 · 출력 56,930 |
| 모델이 낸 행 | **196** = 살아남은 158 + **격리 38 (19.4%)** |
| 격리 사유 | 46건 — 샷 결속 18 · 인용 20 · 근거 8 |
| 격리 **뒤** 잔존 | **0** ← ★「모델이 계약을 다 지켰다」가 **아니라** 검증기가 막아낸 것 |
| merge 뒤 | **141**(합쳐진 것 17 · `part_of` 48 · **삭제 거부 2**) |
| 갈래 | prop 44 · character 30 · location 29 · **location_part 29** · outlook 9 |
| 샷 결속 | `bound_complete` 123 · `not_in_catalog_shots` 15 · `unresolved` 3 — **세 상태가 다 쓰였다** |
| 등록 | **92** = 반복 축 **68** + 고증 예외 **24** · 미등록 19 · **미확정 30** |
| 두 축 | 둘 다 참 **52/141**, 질의가 채워진 행도 **정확히 52** |
| facet | 결속 2(outlook) · 빚 36 — `location_part` **29개 전부** 우회 등록 0 |

산출 = `artifact/20260831_cc_preflight/c_live_{j.json,j_run.json,out.txt,reconcile.txt}`
검토 화면 = `c_live_veto_review_v3.html` (v1·v2 도 보존)

### merge 가 삭제를 **거부한** 2짝 — 사람이 볼 자리

```
c2#42 outlook 「백팩」      ↔ keep c1#38 prop      「수리영 방안에 놓여진 백팩」
c2#54 prop    「배 한척」   ↔ keep c3#8  location  「바다위에 떠있는 소형어선」
```

모델은 각 짝을 **같은 실물**이라고 이었지만 갈래가 달라 안 합쳤다. fail-closed
는 맞고, **어느 owner·표현이 맞는지는 사람이 본다.**

---

## 8. D-inert adapter 가 정한 것

| | |
|---|---|
| 신원 | `short_id` = **장부의 `final_id`**. 새로 발급하지 않는다. 겹치면 **선다** |
| 겉모습 | `description=""` · `visual_traits=[]`. `visual_brief` 를 **안 옮긴다** — `description` 에는 단일 시각 형태 계약이 붙어 있고 그 글이 **T2I 로 그대로** 간다 |
| 근거 | `grounding_provenance` **한 벌** — 인용+**자리(span)** · 겉모습 근거 · 샷 결속 · 두 축 · 질의 · **장부 기록 통째로** |
| facet | base 갈래로 **우회 등록 0**. `location_part` 는 자리가 없어 **빚** |
| 미확정 | 「미등록」으로 **안 접는다** |
| 입력 계약 | 소비 **전에** fail-closed — 행/장부 1:1 · 등록이면 `final_id` 필수 · 접두가 중앙 `OWNER_PREFIX` 와 일치 · 신원 전역 중복 0 |

실측 투영: **141행 = 옮긴 69 + facet 23 + 미확정 30 + 미등록 19**, 조용히
사라진 행 0.

★`description` 을 비워도 되는 근거 — `entity_detail` 의 입력 queue 는
`(name, entity_type, short_id)` 뿐이라 merge 의 description 을 **안 받고**,
`background_chain_planning` 은 「entity_detail 에서 보강 (있으면 우선)」이다.
merge 의 그 칸은 **detail 이 없을 때의 대비책**이지 정본이 아니다.


---

## 9. 「반만 켠 판」을 두 번 만들 뻔했다 (2026-08-31)

### ①모드를 **받는 집합에 미리** 넣었다

`v2_chunk` 를 `GROUNDING_MODES` 에 미리 넣었더니 —

```
resolve_grounding_mode({"grounding_mode": "v2_chunk"})  → 통과
buys_v2_research("v2_chunk")                            → True
  → if_grounding_v2 통과
    → grounding_a0 · grounding_plan · grounding_research ·
      reference_acquisition  **넷이 즉시 applicable**
새 producer 는 manifest 에 **없다**
```

즉 **새 것은 안 돌고 옛 유료 사슬만 켜진다.** 「아무도 지금 안 고른다」는
**운영 스냅샷**이지 구조가 아니었다.

고침: 이름은 `PLANNED_MODES` 에 두고 **받는 집합에서 뺀다.** `project_config`
와 ENV 어느 쪽으로 넣어도 D 전에는 `AppError` 로 선다. 받는 집합으로 옮기는
것은 **새 producer 를 manifest 에 잇는 것과 같은 커밋**이어야 한다.

★따로 boolean 을 더 만들지 않았다 — 그러면 경계가 또 늘어난다.

### ②취소를 구간 하나의 실패로 접었다

병렬 loop 의 `except Exception` 이 `step.cancelled` 를 `failed += 1` 로
삼켰다. 그러면 ⓐ나머지 worker 가 계속 돌아 돈이 나가고 ⓑ`rows` 가 남아
**merge 유료 호출까지** 이어지며 ⓒ감사에는 provider 장애로 남는다.

고침: `run_control.ABORT_CODES` **한 곳**(`step.cancelled`·`step.owner_lost`
·`step.gate_unreadable`)을 만들고 만나면 **즉시 위로 올린다**. merge 는 유료라
payload 를 만들기 **전에** 한 번 더 본다. (`detail_steps` 가 같은 목록을 두 번
인라인으로 적어 뒀다 — 그것도 이 상수를 쓰게 정리할 자리다.)

## 10. D 활성화 전에 반드시 닫을 것 (Codex NON-BLOCK)

지금 producer 뼈대에 **없는** 것들이다. inert 단계에서는 없어도 되지만
manifest 에 잇기 전에는 있어야 한다.

| | 왜 |
|---|---|
| 호출마다 durable journal · 재개 | crash 뒤 **끝난 구간을 다시 산다** |
| 논리·물리 예산 | 상한이 코드에 없으면 승인한 수와 실제가 갈린다 |
| 마감·정지 순서 | 상한/마감은 **취소와 별개**로 다룬다 |
| 구간 신원 ↔ Opik 결속 | 무엇을 샀는지 못 되짚는다 |
| acquisition/config hash | 팩·모델을 바꿔도 **옛 CP 가 재사용**된다 |
| partial 계약 | 일부만 된 산출을 `completed` 처럼 내리지 않는다 |

### 여섯 중 다섯을 닫았다 (2026-08-31, inert)

| | 어떻게 |
|---|---|
| durable journal · 재개 | `grounding_chunk_journal.ChunkJournal` — **신원**(`acquisition_identity`)으로 잇는다. 순번으로 이으면 구간 나누기가 바뀔 때 **다른 원문의 답**을 붙인다. `tmp`+`os.replace` 로 원자적 |
| 논리 예산 | `_cap(plan) = 구간 + merge 1`. **보내기 전에** 보고, 넘으면 **선다** — 조용히 줄이면 반쪽 산출이 완료로 보인다 |
| 마감·정지 순서 | `buy_or_reuse` 가 ①정지 ②예산 ③구매 순서로. 정지가 제일 먼저라 **돈이 안 나간다** |
| 신원 ↔ Opik | `opik_metadata["grounding_chunk_identity"]`. 보내는 자리가 **한 곳**인 것도 시험이 본다 |
| config hash | `_config_hash` 가 팩 **바이트** + 후처리·adapter·chunk-plan 계약 + `BUNDLE_TARGET` 을 접는다. 버전 문자열만 접으면 같은 버전 디렉토리 내용을 고쳤을 때 못 잡는다 |
| **partial** | ★한 구간이라도 못 읽으면 **선다**(`incomplete_read`). 등록 축이 **구간을 걸쳐** 세므로 남은 것만으로 내면 「모자란」 것이 아니라 **틀린** 수가 된다 |

★답을 못 받은 호출은 `uncertain` 으로 적고 **자동 재구매 안 한다** — 샀을
수도 있다. 사람이 Opik 을 보고 정한다.

### 남은 빚 — 별도 안전 리팩터링에서 (Codex 2026-08-31)

| | |
|---|---|
| `ABORT_CODES` 소비자 | `detail_steps`×2 · `scene_image_service` · `scene_generation_coordinator` · `cine_transform` 이 같은 코드 목록을 각자 적어 뒀다. **전수 세어** 뜻이 같은 경계만 `run_control.is_abort` 로 옮기고, `__all__` 에 공개하고, 경계마다 재전파와 일반 오류 비회귀를 끝점으로 본다 |
| 부분 저장 helper | `grounding_screen_step._save_partial` 과 `ChunkJournal._flush` 가 같은 관례(`tmp`+`os.replace`)를 각자 적었다 |

---

## 11. `location_part` — 「자리가 없다」가 아니었다 (2026-08-31, 실측)

앞에서 「`entity_canon` 에 `location_part` 갈래가 **없다**」고 적었다. **부정확
했다.** 실제로 DB·코드를 같이 보니 —

| 잰 것 | 실제 |
|---|---|
| `entity_canon.entity_type` | **제약 없는 `text` 칸** — DB 는 안 막는다 |
| CHECK 제약 | **없다**(pkey + project FK 뿐) |
| 지금 든 값 | location 1333 · character 932 · **outlook 872** · prop 688 |
| 쓰는 문 | `entity_sync_service._TYPE_DEFS` = 3갈래. 주석에 「future entity_type 추가 시 **명시적 매핑 의무**」라고 적혀 있다 |
| `outlook` 은 어떻게 들어갔나 | **전용 sync**(`OutlookSyncService`)가 `entity_type="outlook"` 행을 쓴다 |
| 읽는 쪽 | `== ` 로 가르는 자리 47곳 · `in_` 5곳 — 모르는 갈래는 **조용히 빠진다**(안 터진다) |

즉 빚의 정체는 「자리가 없다」가 아니라 **「쓰는 문의 명시 매핑에 없고, 읽는
47곳이 조용히 무시한다」**다. 등록은 **할 수 있고**, 소비가 안 될 뿐이다.

### ★결속 표를 새로 만들 필요도 없다

`outlook` 은 `character_outlook(character_id, outlook_id)` 전용 표를 썼다.
그러나 **일반 관계 저장소가 이미 있다** —

```
relation_fact(project_id, relation_family, relation_type, directionality, …)
relation_participant(relation_id, canon_id → entity_canon.id,
                     participant_role, participant_order)
```

`relation_family`·`relation_type` 은 **제약 없는 text** 이고 참가자는
`entity_canon` 을 가리킨다. 그래서 `location_part ⊂ location` 은 **표를 더
만들지 않고** `part_of` 관계 두 참가자(part/whole)로 적을 수 있다. 지금
`grounding_facet_binding` 이 내는 것이 정확히 그 모양이다.

### 그러면 남는 일

| | |
|---|---|
| ①쓰는 문 | `location_part` 행을 쓰는 경로. `_TYPE_DEFS` 에 더하거나 전용 sync (outlook 선례) |
| ②결속 | `relation_fact/participant` 에 `part_of` — **새 표 없음** |
| ③읽는 쪽 | 47곳 중 **어디가 이 갈래를 봐야 하는가**. 전부가 아니다 — 봐야 할 곳을 골라야 하고, 그것이 사람이 정할 것이다 |
| ④참조 획득 | 두 축이 여는 대상에 facet 이 들어가는지 |

★①②는 마이그레이션이 **필요 없다**(둘 다 제약 없는 text 칸). ③이 실제
결정이고, **그것은 「어느 하류가 부분 공간을 알아야 하는가」라는 제품 질문**이다.


---

## 12. 재리뷰가 잡은 것 (2026-08-31, 2차)

### ★후처리만 바꿔도 **유료 raw 를 다시 샀다**

`_config_hash` 가 후처리·adapter 계약까지 접고, `_journal` 이 계약 drift 면
`entries.clear()` 를 했다. 그러면 **같은 `acquisition_identity` 의 저장 응답도
지워져** provider 가 다시 나간다. 이중 구매다.

    획득(acquisition)  팩 바이트 · 모델(alias+physical) · **실제 요청 계약**
                       → 바뀌면 **다시 산다**
    해석(processing)   후처리 · adapter · 구간 나누기 · 상한
                       → 바뀌면 **무료 재해석**

지문이 둘을 **갈라 담고**, 장부 계약에는 획득만 넣는다. drift 를 봐도
**안 지운다** — 신원이 다르면 어차피 못 찾고, 같으면 그 raw 는 유효하다.

### ★실제 요청 계약을 안 넘겼다

`call_structured` 기본은 `enable_fallback=True` · `num_retries=None`(라우터
기본 재시도)이다. 안 적으면 **몇 번 나가는지 모른 채** 산다.
`REQUEST_CONTRACT = {enable_fallback: False, num_retries: 0, temperature: 0.2}`
로 못박고 **신원에도 접는다** — 재시도를 켠 판과 끈 판은 같은 것이 아니다.

### ★장부가 병렬 쓰기에서 깨졌다

10 worker 가 같은 `entries` 와 같은 `.tmp` 를 썼다. `os.replace` 가 반쪽을
확정하거나 나중 쓰기가 앞 쓰기를 통째로 덮어 **산 것이 사라진다**.
`threading.Lock` + **호출마다 다른 tmp 이름**. 끝점은 40개 병렬 구매를
파일에서 되읽어 확인한다.

### ★깨진 장부를 **빈 것**으로 읽었다

「안 샀다」가 아니라 **「무엇을 샀는지 모른다」**다. 비었다고 치면 이미 산
구간을 통째로 다시 산다. `JournalUnreadable` 로 **선다** — 사람이 Opik 을 보고
정한다. (파일이 **없는** 것은 다르다 — 그건 새 시작이다.)

### 마감·정지

`research_run_scope(cap, deadline_seconds)` 로 열고 worker 에
`bind_current_research_budget` 으로 실어 보낸다. 정지 표는 **스레드 지역**이라
안 실으면 팬아웃에서 통째로 no-op 이다. `_completion` 이 키 전환 루프 **앞**에서
그 표를 보므로 네트워크 직전에 막힌다.

### 물리 예산 — **막는** 대신 **정한다**

글 호출 경로(`_completion`)에는 **`reserve` 자리가 없다.** 이미지 쪽에는
`reserve_current_call` 이 있는데 글 쪽에는 정지 표만 있다. 그래서 그 자리에서
물리 수를 **거절할 수는 없다**.

★대신 **구조적으로 위를 정한다.** 재시도와 fallback 을 끄면 한 논리 호출이
여는 물리 최대는 **키 슬롯 loop** 뿐이다 —

```
physical_per_logical = slots × (fallback ? 3 : 1) × (1 + num_retries)
  못박은 계약(num_retries=0 · enable_fallback=False)에서는 = slots
물리 상한 = 논리 상한(구간 + merge 1) × physical_per_logical
```

그 수가 **손으로 적은 `APPROVED_PHYSICAL_MAX`** 를 넘으면 `research_run_scope`
를 열기 **전에** 선다 — 아무것도 안 산다. 계약을 바꾸면 식이 같이 움직인다
(fallback+재시도 2 · 슬롯 3 이면 27).

★이것은 **실측이 아니라 상한**이다. 실제로 몇 번 나갔는지는 **Opik + provider
로그**로 본다(C canary 에서 그렇게 했다 — dispatch 5 + 키 슬롯 전환 1 = 최소 6).
산출 칸 이름도 `physical_upper_bound` 이지 `physical_calls` 가 아니다.

★`_completion` 에 reserve 를 거는 것은 **모든 글 호출에 닿는 active 변경**
(`scene_detail` 팬아웃 포함)이라 inert 범위 밖이다. 그것은 D 에서 Codex 와
같이 본다 — 위 상한은 그때까지의 **구조적 보장**이다.

### Opik 결속 — 「관측했다」로 쓰지 않는다

신원은 **부모 trace metadata**(`cc_call_identity`, 감사 도구와 같은 이름)로
간다. `opik_metadata` 의 다른 키는 litellm 이 안 읽고 태그는 축 whitelist 가
거른다. ★다만 `open_trace` 가 None 이어도 provider 는 나가므로, **결속이
됐다는 근거는 실제 trace 를 본 뒤에만** 쓴다.
