# GROUNDING-V2 §4-primitive — 출처 붙은 claims

> 2026-08-30 · Claude 초안 · Codex 검토 전
> ★**§4 를 둘로 쪼갠다** — ①claims primitive ②production 배선.
> ★★2026-08-30: §C(capability registry)가 **기각**되면서 이 primitive 의 몫이
> 커졌다. 이제 route 를 가르는 **A(시대 차이가 실제로 있나)를 이것이 확정**한다.
> 분류기는 검색을 안 하므로 그 답에 권위가 없다. §2-3c 도 이것 뒤로 옮겼다.

## 1. 절반은 이미 있다 — 새로 만들지 않는다

무료 조사(codegraph)로 확인한 것:

| 이미 있는 것 | 어디 | 무엇을 하나 |
|---|---|---|
| `build_web_search_tool()` | `search_grounded_ref.py:319` | 사진+글 결과를 받는 검색 툴 스펙. ★`search_content_types` 를 안 켜면 **글만** 온다 |
| `search_typology_answers()` | `typology_prior.py:416` | 원어 문항으로 검색해 `{index, answer_en, **found**, **sources[]**}` 를 돌려준다 — **출처 붙은 답이 이미 나온다** |
| `_extract_json_object()` | `typology_prior.py:391` | 코드펜스 섞인 응답에서 JSON 하나 꺼내기 |
| provider 호출 기록 | 두 모듈 모두 | ★`responses.create` 는 **litellm 밖**이라 Opik 자동 추적이 안 닿는다. `record_provider_call` 로 직접 남긴다 |
| 상한 | `era_research.py:69` `MAX_SUBJECTS_PER_CALL = 2` · typology 그룹당 문항 상한 | 제약이 생성 시간이라 **상한을 낮게 두는 것이 계약의 일부**다 |

★그리고 **검색에 나가는 프롬프트는 무조건 원본어**다(2026-08-03 사용자 재지시).
언어 잠금은 지시문 **맨 앞**에 놓는다 — 뒤에 적으면 길어질수록 묻힌다(실측).

## 2. 그래서 무엇이 없나

| 없는 것 | 왜 필요한가 |
|---|---|
| `claim_id` | 참조 생성·검증이 **어느 claim 을 소비했는지** 기록에 남긴다(계약 §6). id 가 없으면 「무엇을 근거로 그렸나」를 못 되짚는다 |
| `required` 여부 | 계약 §6. 부수 claim 까지 required 로 세면 아무것도 통과 못 한다 |
| `found=false` 의 **행선지** | 계약 §3: 「검색 실패는 no 가 아니라 **grounding unresolved**」. ★**A=no 는 「차이가 없다」는 긍정적 출처가 있을 때만**이다. 미발견·충돌·출처 없음·시간 초과는 전부 `unresolved` — `claims[]` 가 아니라 **`gaps[]`** 에 질의·구별점·사유를 남긴다 |
| **금지 사실** | 계약: 조사 산출물은 ①출처 붙은 사실 **과 ②금지 사실**을 가른다. 「이건 이 시대에 없었다」가 그림을 더 많이 고친다 |
| durable record | 계약: canon 평문 칸·`review_notes` 에 쓰지 않는다 — 이번에도 검증 결과가 그 칸을 덮어 감사가 사라졌다. **별도 record** 로 둔다 |

## 3. 낼 모양

```
claim = {
  claim_id,            # 안정된 id — 참조 생성·검증이 무엇을 소비했는지 남긴다
  research_subject_id, # 무엇에 대한 사실인가
  kind,                # "fact" | "prohibition"   ★금지 사실을 갈라 둔다
  required,            # 이 그림에서 반드시 맞아야 하는가
  statement_native,    # 원어 문장
  delta_effect,        # supports_difference | supports_no_difference | neutral
  discriminator_family,# 계약 §2 의 관찰 가능한 시각 속성 부류 (catalog ID)
  sources: [url, ...], # ★비면 이 claim 은 성립 안 한다
}
```

★**`found` 칸은 없다.** 못 찾은 것은 claim 이 아니라 **`gaps[]`** 로 간다 —
`found=false` 인 claim 을 목록에 두면 「출처 없는 claim 이 0」이라는 통과 조건을
그 행이 조용히 어긴다. `subject_key` 도 `research_subject_id` 로 통일했다
(발급 규칙이 `grounding_subject.mint_subject_id` 하나뿐이라야 한다).

★**`delta_effect` 가 왜 필요한가** — `required` 만 보면 시대 차이와 **무관한**
사실 하나가 그대로 `yes` 가 된다. 그리고 차이 있음 claim 과 차이 없음 claim 이
같이 오면 **`unresolved`** 여야 하는데, bool 로는 그걸 표현할 수 없다.

★**`sources` 가 빈 required claim 은 claim 이 아니다.** 그걸 통과시키면
「조사했다」가 거짓이 된다 — 계약 §4 통과 조건이 「`claims[]` 에 source 없는
required claim 이 **0**」이다.

## 4. 통과 조건 (계획 §2-4 에서 가져옴)

- `claims[]` 에 **source 없는 required claim 이 0**
- 못 찾으면 **`unresolved` 로 서고 상상으로 안 내려감**
- `research_required` → `reference_required` 강제됨
- ★§8.5 **시간 통제 gate** 여섯 줄 (이미 PR #72 로 닫혀 있다)

## 5. route 와의 접점 — ★이것이 A 를 확정한다

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

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

★`grounding_class=generic` 으로 **먼저 skip 하던 것도 걷어낸다** — 「시대·지역이
겉모습을 안 구속한다」는 것 자체가 **세상 사실**이라, 검색 없이 답하면 같은
false negative 통로가 남는다.

★상한은 **팬아웃 전 admission** 에 둔다(`research_subject_id` 안정 정렬 ·
캐시/중복 제거 뒤 admission · 초과는 `unresolved` + `time_capped_count`).
★**잘린 주행은 acceptance 기준선이 아니다.**

저장 60화 후보 분포(무료 실측): 중앙 **53** · p90 77 · p95 **123** · 최대 163 ·
합계 3,388. 상한 위치는 Codex 와 확정 중.

## 6. ★상한과 progressive admission

### 왜 상한만으로는 안 되나

상한을 낮게 두고 초과를 `unresolved` 로 두면 **안전하긴 하다.** 하지만 다음 주행이
같은 상한에 다시 걸리면 **영구 partial** 이 된다 — 매번 앞의 것만 사고 뒤는
영원히 안 산다. 그러니 상한을 쓰려면 **이어가는 계약**이 먼저다 (Codex).

```
① `research_subject_id` 로 **안정 정렬**한다 (같은 입력이면 같은 순서)
② 이미 끝난 row 를 읽어 **재구매하지 않는다**
③ 남은 것(pending) 앞에서부터 상한만큼 admission
④ 초과는 `unresolved` + `time_capped_count`
⑤ 다음 resume 은 **③ 의 pending 부터 결정적으로 이어간다**
```

★**잘린 주행은 acceptance 기준선이 아니다.** 「이번엔 상한에 걸렸다」와
「이 대상은 조사해도 못 찾았다」는 다른 말이고, 섞으면 통과가 거짓이 된다.

### 상한을 어디에 두나 — ★팬아웃 **전**

admission 은 **호출을 내보내기 전**에 결정한다. 내보낸 뒤에 세면 재시도·
fallback 이 상한 밖으로 새어 나간다(§8.5 에서 같은 결함을 이미 고쳤다).

### 상한 값은 **아직 안 정한다**

실측(무료, 저장 60화):

| | |
|---|---|
| subject 합계 | 3,388 (location 40.6% · character 31.3% · prop 28.2%) |
| 에피소드당 | 중앙 **53** · p90 77 · p95 **123** · 최대 163 |
| 에피소드 안 exact 중복 | **0건** — dedup 으로 줄어드는 것이 없다 |
| project 안 **표면형 exact** 재사용 | **2건** (0.06%) |
| 판정 근거 | `entity_description` **3,388 (100%)** · `manuscript` **0** |

★**이 3,388 은 screening 모집단이 아니다.** routing 전이고, 저장 CP 가 A0
이전이라 원문 인용이 하나도 없다. **pre-routing 상한 참고값**으로만 쓴다.

★그래서 **표면형 exact cache 는 0.06%** 밖에 못 줄인다. 표면형이 매우
구체적이라 겹치지 않는다. ★**「캐시로 아무것도 못 아낀다」로 넓혀 쓰지 마라** —
canon link 상한은 아직 안 셌다(아래).

캐시를 더 값있게 하려면 **부류 단위**로 묶어야 하는데 그건 §C 에서 막힌
문제로 돌아가고, 무엇보다 서로 다른 subject 의 결과를 넓은 이름으로 합치는
**정본 오결속**이다. 초판 캐시는 「같은 조사 owner 임이 이미 증명된 것」
(`persistent lineage/canon_id + owner_facet + era + region + claims-pack hash`)
에만 쓴다.

★**batch 결과 row 의 캐시 키에 batch id 를 넣지 않는다.** 각 subject 의
**research revision / content hash** 를 넣어야, batch 크기를 바꿔도 완료된
subject 를 다시 사지 않는다 (Codex).

### ★lineage/canon 재사용 — 아직 「cache hit」라 부를 수 없다

현재 main 에는 `research_subject_id → canon_id` **persistent bind 가 없다.**
`EntityCanon` 에 research subject/lineage 칸이 없고, `research_subject_id` 는
grounding 체크포인트에만 있다. 그러니 `entity_canon` 행을 세어 「lineage cache
hit」라고 부르면 **거짓**이다 (Codex).

지금 무료로 셀 수 있는 것은 **재사용 기회 상한**뿐이고, 그것도 현 canon sync 의
name/short_id heuristic 산출이라 **「현재 canon 재사용 진단」**으로만 적는다.
실제 hit/miss 는 §6 의 append-only subject→canon bind 가 구현된 뒤 canary 에서
잰다. ★**진단이지 gate 가 아니다.**

DB 진단 (2026-08-30, 읽기만 · 무료):

```
entity_canon              3,825
entity_episode_link       3,667
2화 이상에 걸린 canon         **4**       ← 재사용 기회 상한
그 canon 들이 걸린 link         8  (0.2%)
```

★그러니 **canon 단위 캐시의 천장도 0.2%** 다. 표면형 exact(0.06%)보다는 크지만
여전히 지렛대가 아니다. 남는 것은 **batching** 하나다.
★단, 이 수는 현 canon sync 의 name/short_id heuristic 산출이다 —
「현재 canon 재사용 진단」이지 앞으로의 hit 율이 아니다.

### 그러니 남는 지렛대는 **batching** 하나다

★`era_research.MAX_SUBJECTS_PER_CALL = 2` 는 **분류 응답에서 대상을 2개로
자르는 옛 상한**이지 검색 batch 근거가 **아니다**(Codex 정정 — 내가 이걸
베껴서 「에피소드당 62회」라는 틀린 수를 냈다). `search_typology_answers` 는
원래 **여러 문항을 한 호출**에 넣고 index 별 답+출처를 돌려준다.

```
batch 8 → 중앙 53 ÷ 8 ≈ **7회/화** · p95 123 ÷ 8 ≈ **16회/화**
batch 2 → 27회 / 62회      ← 틀린 수
```

★**이 16 은 text screening 의 「논리 호출」 수뿐이다.** sourced `A=yes` 가 N개면
그 뒤 image search 가 **N회 더** 붙는다. `A=yes` 비율을 아직 모르므로
**§2-4 전체 시간·비용을 「소액」이라고 결론내면 안 된다** (Codex).
`A=yes` 실제 분포와 image search 까지 잰 뒤에야 말할 수 있다.

★**batch 안에서도 subject 마다 판정·claim·출처는 독립**이다. 한 subject 가
미발견이어도 다른 subject 의 답을 **공유하지 않는다.**

### batch 크기를 정하는 실험 (다음 순서)

미리 잠근 **9 subject** 로 개별 baseline + 2/4/8 을 비교한다.

★★**9다, 12가 아니다** (2026-08-30 확정). A0 승격 뒤 이 에피소드의 원고 기반
subject 가 9개이고, 모자란 몫은 facet producer(§2-6.5)를 기다리는 `deferred` 다 —
**표본 수 때문에 그 유료 단계를 앞당기지 않는다.**
★그리고 **`batch=12` 를 후보에서 뺐다.** 9개를 한 묶음으로 보내는 것은
12-subject 결속·leakage 검증이 아니다. 이 판으로 고를 수 있는 **최대 기본값은 8**
이고, 12는 원고 기반 고유 subject 가 12개 생긴 뒤 **별도 재검증**이다.
정본은 `grounding_claims_search.EXPERIMENT_SAMPLE_SIZE / EXPERIMENT_BATCH_SIZES`.

```
논리 호출 = 9(개별) + 5 + 3 + 2 = **19회**
   ★표본 수와 호출 수를 섞지 않는다 — 9 subject 로 batch 1/2/4/8 이면 19회다
```

★**무엇을 정확 parity 로 요구하는지 가른다** (Codex). 검색은 비결정적이라
한 번의 개별 결과와 batch 결과가 route 까지 같기를 요구하면, 그건 batch 효과가
아니라 **표본 흔들림**을 재는 것이다.

| 어디서 | 무엇을 | 정확 일치를 요구하나 |
|---|---|---|
| unit / mock | subject 누락·중복 **0** · claim↔source/id 결속 · 다른 subject 답 섞임 **0** | ★**요구한다** |
| 실제 provider | completeness · citation support · cross-subject leakage · latency | 요구 **안 한다** — route 정확 일치는 독립 gold 나 반복 없이는 못 잰다 |

### §8.5 산식도 바뀐다

★**논리 호출과 물리 전송을 갈라 적는다.** 이 판에서 이미 세 번 고친 혼동이다.

```
logical text calls   = ceil(eligible subjects / batch_size)
logical image calls  = sourced A=yes subject 수
physical transmissions = 각각 **재시도·키 슬롯 allowance 까지 센 별도 상한**
```

「text 전송 = ceil(...)」이라고 쓰면 그 혼동이 되돌아온다.

옛 「subject 마다 text 1 + image 1」을 그대로 두면 예산이 **과대예약**되어
admission 이 불필요하게 줄어든다.

## 7. 아직 안 정한 것

- **batch 크기** — 위 실험으로 정한다. 정하기 전에는 계약값이 없다
- **admission cap 값** — routing·dedup·batching 뒤의 **실제 eligible 분포**로
  정한다. 지금 3,388 은 그 분포가 아니다
- ★`search_typology_answers` 는 지금 **image+text 를 함께** 요청한다
  (`build_web_search_tool()`). text-first 로 그대로 못 쓴다 — helper 를
  parameterize 하거나 **text 전용 spec** 이 필요하다 (Codex)
- ~~`entity_canon` 의 persistent lineage 재사용 가능 수~~ — **셌다**(무료 DB 읽기).
  표면형 exact cache 는 **0.06%**, canon 상한은 **4 canon / 0.2%**. 이 규모로는
  캐시가 시간을 줄이는 축이 아니다 — batch 크기와 admission cap 이 축이다

## 10. §4b — 실제 검색 배선 (다음 PR)

★**순서를 지킨다**: 계약 → 정본 테이블 → **여기**. 유료 호출이 durable 기록보다
먼저 생기면 안 된다.

### 새 모듈 `grounding_claims_search`

`typology_prior` 에서는 **저수준 기구만** 재사용한다 — 검색 전송·audit 기록·
JSON 꺼내기. **프롬프트 의미는 안 섞는다**(Codex). 팩은 새 버전 디렉토리로.

- 검색 툴은 `build_web_search_tool(want_images=False)` — 글만
- 지시문은 **원본어**, 언어 잠금은 **맨 앞**(뒤에 적으면 길어질수록 묻힌다)
- `responses.create` 는 **litellm 밖**이라 `record_provider_call` 로 직접 남긴다

### batch 실험은 **이 PR 안에서**

```
① search_claims(..., batch_size=<명시값>) 구현
② 1/2/4/8 mock parity + durable-write 시험 (★12는 후보가 아니다)
③ ★아직 production default 도 파이프라인 배선도 안 넣는다
④ 잠근 9 subject 로 19회 실험 (9 + 5+3+2)
⑤ 결과를 근거로 **기본값을 정하는 마지막 커밋**
⑥ 그 뒤 병합
```

★임시 기본값을 먼저 계약에 넣거나, 배선 후 별도 PR 에서 실험하는 것 **둘 다
안 된다**(Codex).

**무엇을 정확 parity 로 요구하나**:

| 어디서 | 요구한다 |
|---|---|
| unit / mock | subject 누락·중복 **0** · claim↔source 결속 · 답 섞임 **0** |
| 실제 provider | completeness · citation support · leakage · latency만. ★route 정확 일치는 **안 요구**(검색은 비결정적이라 표본 흔들림을 재게 된다) |

### provenance 를 채우는 자리

기존 `record_provider_call` 이 이미 `step`·`model`·`prompt`·`status`·
`duration_ms` 를 남긴다. **같은 자리에서 뽑아** revision 행에 넣는다 —
따로 모으면 두 기록이 갈리고, 갈리면 어느 쪽이 정본인지 모른다.

★거절된 **원본**은 `audit={"raw": ...}` 로 **여기서 넘긴다**. 정본
(`claims_json`)에는 검증 통과한 정규화 행만 들어간다.

### §8.5 산식

```
logical text calls   = ceil(eligible / batch_size)
logical image calls  = sourced A=yes subject 수
physical transmissions = 각각 재시도·키 슬롯 allowance 까지 센 별도 상한
```

★논리 호출과 물리 전송을 갈라 적는다 — 이 판에서 세 번 고친 혼동이다.
