"""고증 경로의 **모델 호출마다 무슨 판단을 새로 하는가**. ★무료 · 외부 0.

Codex 가 A/B 판단표에 요구한 것 (2026-08-31):

    「각 모델 호출이 무슨 semantic 판단을 **새로** 하는지, downstream 이
     실제 어느 필드를 쓰는지만 CodeGraph/호출 경로로 적으세요.
     grep 읽기 수는 의미 소비 증명이 아닙니다.」

## 이 파일이 하는 일

호출마다 세 칸을 적는다 —

    새 판단      그 호출이 **처음** 내리는 의미 판단
    겹치는 곳    같은 판단을 **또** 내리는 다른 호출
    원문 거리    원문이 모델을 **몇 번** 거쳐 이 호출 입력이 되었나

★「원문 거리」가 A/B 를 가르는 축이다. 거리가 늘수록 판단 근거가 **모델이
다시 쓴 문장**이 되고, 원문에 있던 것이 사라진 채로 판정된다. 실제로 겪었다
— 분류기가 「원문 인용」 대신 「LLM 이 상상해 쓴 description」을 보던 것.

★이 표는 **읽어서 적은 것**이다. 자동 추출이 아니다. 그래서 각 줄에
**근거 file:line 을 함께 적고**, 그 자리가 실제로 그렇게 생겼는지
`--verify` 가 확인한다. 근거가 안 맞으면 **선다**.
"""
from __future__ import annotations

import sys
from pathlib import Path
from typing import Any, Dict, List

ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))

#: 한 줄 = 한 모델 호출. `anchor` 는 그 자리에 **실제로 있어야 하는 문자열**.
CALLS: List[Dict[str, Any]] = [
    {
        "name": "grounding_a0",
        "reads": "원문 전문 + 계획된 샷",
        "hops": 0,
        "new": "이 원고에서 **고증이 필요할 만한 대상**을 건진다 "
               "(다섯 owner · 표면형 · 원문 인용 · anchor)",
        "overlaps": "★`entity_all` 과 **중복이 아니다** — 그쪽은 「2회 이상 "
                    "나오면」, 여기는 「한 번만 나와도 만들기 어려우면」이다. "
                    "**두 독립 축**이고, 합치더라도 union 을 보존해야 한다",
        "why": "샷 구조에 없는 대상은 entity_all 이 볼 기회조차 없다",
        "at": ("app/modules/pipeline/grounding_a0.py",
               "★원문 **전체**를 넣는다. 자르지 않는다."),
    },
    {
        "name": "entity_all_{character,location,prop} ×3",
        "reads": "샷 description 전부 (★원문은 안 읽는다)",
        "hops": 1,
        "new": "샷에 실제로 나오는 **엔티티 목록**과 출현 횟수",
        "overlaps": "★`grounding_a0` 와 **다른 축**이다(위). 여기서 진짜 겹치는 "
                    "것은 **세 갈래가 같은 샷 전부를 각각 한 번씩 받는 것**이다",
        "why": "갈래마다 제외 기준이 달라 분리했다",
        "at": ("app/modules/pipeline/entity_lister.py",
               "def list_entities_from_shots"),
    },
    {
        "name": "entity_extract_{character,location,prop} ×3",
        "reads": "★원문 전문 **또** + 앞 단계 이름 목록",
        "hops": 0,
        "hops_selection": 1,
        "new": "각 엔티티의 **시각적 상세**",
        "overlaps": "없음 — 상세는 여기서만 만든다. "
                    "★다만 원문 전문을 **세 번** 더 보낸다",
        "why": "이름만으로는 상세를 못 쓴다",
        "at": ("app/modules/pipeline/entity_extractor_v4.py",
               "def extract_entities_by_type_with_list"),
    },
    {
        "name": "entity_merge",
        "reads": "세 갈래 이름 + 상세",
        "hops": 2,
        "new": "**같은 대상이 두 갈래에 등록됐나**",
        "overlaps": "없음",
        "why": "갈래를 나눠 뽑았으니 겹칠 수 있다",
        "at": ("app/core/steps/entity_steps.py", "class EntityMergeStep"),
    },
    {
        "name": "grounding_plan(classifier) ×6",
        "reads": "대상 묶음 (표면형 + 원문 인용 **또는** 상세 묘사)",
        "hops": 0,
        "new": "이 대상이 **무엇인가**(`grounding_class` · "
               "`referent_specificity`) + **화면에 보이나**(`visibility_intent`) "
               "+ **만들기 어려운가**(`generation_difficulty`) + "
               "`locale`·`generation`·`visible_discriminators`",
        "overlaps": "★`assess_subjects` 와 **겹치는 것은 칸이 아니라 의미 축의 "
                    "일부**다 (Codex 재정정). assess schema 에는 "
                    "`generation_difficulty` **칸이 없다** — 그쪽은 두 조건"
                    "(만들기 어려움 AND 그곳 사람이 알아챔)을 "
                    "**`subjects[]` 에 넣느냐 마느냐** 하나로 접고 원어 검색어를 "
                    "낸다. 여기는 3값 enum 이다. **같은 칸도, 같은 판정도 아니다**",
        "why": "겹치는 축 하나 때문에 통째로 지우면 고유 칸도 같이 사라진다",
        "at": ("app/modules/pipeline/grounding_classifier.py",
               "def classify_samples"),
    },
    {
        "name": "grounding_research(web search) ×N",
        "reads": "대상 하나 (batch=1)",
        "hops": 0,
        "new": "그 대상의 **출처 붙은 사실** + 시대 차이 유무(`delta`)",
        "overlaps": "없음",
        "why": "★★**지금 이 결과가 참조를 강제한다.** "
               "`episode_reference_policy_step._research_forced_short_ids` 가 "
               "정본 revision 의 `completed + delta=yes` 를 읽어 "
               "`reference_required` 를 올리고, `unresolved`/`retryable` 은 "
               "하류를 **막는다**. 「audit 전용」은 **앞으로의 설계 의도**이지 "
               "지금 코드가 아니다 (Codex 정정)",
        "at": ("app/core/steps/grounding_steps.py",
               "class GroundingResearchStep"),
    },
    {
        "name": "grounding_screen ×N (예정·지금 꺼짐)",
        "reads": "대상 하나 (표면형 + 원문 인용)",
        "hops": 0,
        "new": "그 시대·지역에서 **형태가 갈리는가** + **그곳 사람이 알아채는가** "
               "+ **원어 검색어**(`search_terms_native`·`language_lock_native`)",
        "overlaps": "classifier 와 **의미 축 일부**가 겹친다(만들기 어려움). "
                    "다만 산출 모양이 다르다 — 여기는 **목록에 넣느냐**로 접고 "
                    "classifier 는 3값 enum 이다. "
                    "★**원어 검색어는 여기서만** 나온다 — classifier 에 없다",
        "why": "판별과 질의 저작을 1콜로",
        "at": ("app/modules/pipeline/grounding_screen.py",
               "def screen_subjects"),
    },
]


#: 다섯 owner 를 **지금 누가 담당하나**. `producer` 가 비면 그 갈래는
#: 「후보로는 잡히는데 만들 자리가 없다」는 뜻이다.
OWNER_TODAY = {
    "prop":          {"후보": "grounding_a0", "producer": "entity_all_prop"},
    "character":     {"후보": "grounding_a0", "producer": "entity_all_character"},
    "location":      {"후보": "grounding_a0", "producer": "entity_all_location"},
    "location_part": {"후보": "grounding_a0", "producer": "★없다 (§2-6.5a)"},
    "outlook":       {"후보": "grounding_a0", "producer": "outlook_phase1 (19.0)"},
}

#: ★★★**두 축은 중복이 아니다** (사용자 확정 · Codex 정정).
#:
#:     entity_all   「**2회 이상** 나오면 등록」        ← 반복 출현 축
#:     A0/고증 예외 「**한 번만 나와도** 만들기 어려우면 등록」 ← 예외 축
#:
#: 「같은 대상 고르기를 두 번 한다」로 읽고 한쪽을 지우면 **다른 축이 통째로
#: 사라진다.** 한 호출로 합칠 수는 있어도 **두 결과의 union 을 보존**해야 한다.
TWO_AXES = (
    ("반복 출현", "entity_all → entity_filter(`_appearance_count`)",
     "≥2 이면 남긴다"),
    ("고증 예외", "grounding_a0 → 보호·승격",
     "1회라도 만들기 어려우면 남긴다"),
)


def _screen_calls_per_subject() -> bool:
    """★「assess 하나」라는 문구가 **N콜을 숨긴다** — 코드로 확인한다."""
    import inspect

    from app.modules.pipeline import grounding_screen as gs

    src = inspect.getsource(gs.screen_subjects)
    return "for subj in subjects" in src and "env = call(" in src


#: 세 구조. ★**수는 `call_payload_table` 이 SOT** 다. 여기는 호출 그래프와
#: 「무엇을 잃나」만 적는다.
OPTIONS = {
    "A — A0 를 씨앗 단계로 (★유효하지 않다)": {
        "실제 호출": "A0 1 + entity_all ×3 + entity_extract ×3 + merge 1 "
                 "+ classifier ×6 + screen ×N — **「한 번에」가 아니다**",
        "잃는 것": "★`entity_all` 을 A0 씨앗만 받게 하면 **평범한 반복 엔티티**를 "
                "잃는다. A0 는 「고증이 필요할 만한 것」을 건지지 「두 번 이상 "
                "나오는 것」을 건지지 않는다 — **다른 축**이다",
        "판정": "★기각. 두 축 중 하나가 사라진다",
    },
    "B — 기존 추출이 겸한다 (★유효하지 않다)": {
        "실제 호출": "entity_all ×3 + entity_extract ×3 + merge 1 "
                 "+ classifier ×6 — **「한 번에」가 아니다**",
        "잃는 것": "★A0 를 없애면 **일회성 고증 예외**와 `location_part`·`outlook` "
                "**두 갈래**를 잃는다. `entity_all` 은 샷만 읽고 갈래가 셋뿐이다",
        "판정": "★기각. 예외 축과 두 갈래가 사라진다",
    },
    "C — 구간마다 **한 번만 읽고 union 을 낸다** (Codex 제안)": {
        "실제 호출": "원문을 S 구간으로 나눠 **구간당 1콜**. 한 산출에 "
                 "다섯 owner · 원문 인용 · stable ID · **출현 여부** · "
                 "**고증 예외 여부** · 기본 시각 정보 · 참조 의무 · 원어 질의를 "
                 "**함께** 낸다. 그 뒤 코드가 출현을 **합산**해 "
                 "`≥2 OR 고증 예외` 로 등록한다 — 모델이 다시 안 정한다",
        "두 축": "★**union 으로 보존된다** — 같은 행에 두 flag 가 함께 있다",
        "결속": "★후보와 엔티티가 **처음부터 같은 행**이라 이름·부분문자열 결속이 "
              "**필요 없어진다**(3b 가 만든 ID 운반도 그 자리에서 단순해진다)",
        "잃는 것": "갈래별 제외 기준이 한 지문에 섞인다 — 지금은 갈래마다 다르다",
        "★모르는 위험": "한 응답이 **100~160 엔티티**를 내야 한다. 잘림·오결속 "
                    "위험이 있고, **이 표는 그것을 못 잰다** — 구간 크기 S 를 "
                    "정하려면 실제로 재 봐야 한다",
        "판정": "★세 안 중 유일하게 두 축을 다 지킨다. 다만 S 와 잘림 위험이 미정",
    },
}


def options() -> None:
    print("■ ★★두 축은 **중복이 아니다** — 한쪽을 지우면 다른 축이 사라진다")
    for name, where, rule in TWO_AXES:
        print(f"   {name:8} {where:44} {rule}")
    print()
    print("── 다섯 owner 를 지금 누가 담당하나")
    for o, d in OWNER_TODAY.items():
        print(f"   {o:15} 후보={d['후보']:14} producer={d['producer']}")
    print()
    if _screen_calls_per_subject():
        print("★「assess 하나」는 **거짓**이다 — `screen_subjects` 는 대상마다 "
              "`assess_plan_cached` 를 부른다(N콜).")
        print()
    for name, d in OPTIONS.items():
        print(f"── {name}")
        for k, v in d.items():
            print(f"   {k:12} {v}")
        print()


def verify() -> int:
    """★근거 자리가 실제로 그렇게 생겼는지 본다. 안 맞으면 **선다**.

    표를 손으로 적으면 코드가 움직인 뒤에도 표는 그대로 남는다. 그러면
    그 표로 정한 설계는 실제와 무관해진다.
    """
    bad = 0
    for c in CALLS:
        rel, needle = c["at"]
        p = ROOT / rel
        if not p.exists():
            print(f"★없는 파일: {rel} ({c['name']})")
            bad += 1
            continue
        if needle not in p.read_text(encoding="utf-8"):
            print(f"★근거를 못 찾았다: {rel} 에 {needle!r} ({c['name']})")
            bad += 1
    # ★★표가 **주장**하는 manifest 사실을 코드에서 확인한다. 손으로 적은
    #  표는 코드가 움직여도 그대로 남는다 — 그러면 그 표로 정한 설계는
    #  실제와 무관해진다.
    from app.core.step_manifest import get_manifest_dict

    claims = [
        ("grounding_a0", "applicability", "if_grounding_v2",
         "A 안: A0 는 legacy 에서 안 돈다"),
        ("entity_all_character", "applicability", "always",
         "B 안: entity_all 은 legacy 도 탄다"),
        ("entity_all_location", "applicability", "always", ""),
        ("entity_all_prop", "applicability", "always", ""),
        ("grounding_screen", "applicability", "disabled",
         "screen 은 아직 안 켰다"),
    ]
    for step, key, want, why in claims:
        got = get_manifest_dict(step).get(key)
        if got != want:
            print(f"★표의 주장이 틀렸다: {step}.{key} = {got!r} (표는 {want!r}) {why}")
            bad += 1
    # ★「classifier 의 이 칸들을 planner 가 required 로 본다」는 주장을 확인한다.
    import inspect as _ins

    import app.modules.pipeline.grounding_planner as _pl

    _psrc = _ins.getsource(_pl)
    for f in ("grounding_class", "referent_specificity", "visibility_intent",
              "generation_difficulty"):
        if f not in _psrc:
            print(f"★표의 주장이 틀렸다: planner 가 {f!r} 를 안 본다")
            bad += 1
    # ★「assess schema 에 difficulty 칸이 **없다**」는 주장을 확인한다.
    from app.modules.pipeline.era_research import build_assess_schema

    _ac = set(build_assess_schema()["properties"]["subjects"]["items"]
              ["properties"])
    if any("difficult" in k for k in _ac):
        print(f"★표의 주장이 틀렸다: assess 에 difficulty 칸이 있다 {sorted(_ac)}")
        bad += 1
    if "search_terms_native" not in _ac:
        print("★표의 주장이 틀렸다: assess 가 원어 검색어를 안 낸다")
        bad += 1

    # ★`entity_all` 갈래가 셋뿐이라는 주장 — 목록에서 직접 센다.
    from app.modules.pipeline.entity_lister import _MODULE  # noqa: F401
    from app.modules.pipeline.grounding_overlay import _ENTITY_PREFIX

    kinds = {v for v in _ENTITY_PREFIX.values()}
    if len(kinds) != 3:
        print(f"★표의 주장이 틀렸다: base 갈래가 {len(kinds)}개다 (표는 3개)")
        bad += 1
    print(f"근거 {len(CALLS) - bad}/{len(CALLS)} + manifest 주장 확인")
    return bad


def main() -> int:
    bad = verify()
    if bad:
        print("★근거가 안 맞는다 — 표를 안 낸다.")
        return 1
    print()
    print("■ 호출마다 **무슨 판단을 새로 하는가**")
    print("   원문 거리 두 종류 (Codex NON-BLOCK) —")
    print("     evidence  판단 **근거**가 원문에서 모델을 몇 번 거쳤나")
    print("     selection 무엇을 **볼지 고른 것**이 몇 번 거쳤나")
    print("   ★`entity_extract` 는 근거는 원문 직접(0)인데 **대상 목록은 "
          "`entity_all` 산출**(1)이다 — 그 목록이 틀리면 상세는 "
          "**틀린 것을 정확히** 쓴다.")
    print()
    for c in CALLS:
        sel = c.get("hops_selection", c["hops"])
        print(f"── {c['name']}   (evidence {c['hops']} · selection {sel})")
        print(f"   읽는 것 : {c['reads']}")
        print(f"   새 판단 : {c['new']}")
        print(f"   겹침    : {c['overlaps']}")
        print(f"   왜 있나 : {c['why']}")
        print()
    print("■ 겹치는 판단 — ★**「겹친다」와 「지워도 된다」는 다르다**")
    print("   1. 대상 고르기 : grounding_a0  ↔  entity_all ×3")
    print("      ★★**중복이 아니다.** 두 **독립 축**이다 —")
    print("        entity_all   「**2회 이상** 나오면 등록」")
    print("        A0/고증 예외 「**한 번만 나와도** 만들기 어려우면 등록」")
    print("        한 호출로 합칠 수는 있어도 **두 결과의 union 을 보존**해야 "
          "한다. 한쪽을")
    print("        지우면 그 축이 통째로 사라진다.")
    print("   2. 조사 필요?  : classifier ×6  ↔  screen ×N")
    print("      ★★**같은 칸이 아니다.** assess schema 에는 "
          "`generation_difficulty` 칸이 없다 —")
    print("        그쪽은 두 조건(어려움 AND 알아챔)을 **목록에 넣느냐**로 접고, "
          "classifier 는 3값 enum 이다.")
    print("        겹치는 것은 **의미 축의 일부**다. classifier 의 나머지 칸")
    print("        (`grounding_class`·`referent_specificity`·`visibility_intent`·"
          "`confidence`·`locale`·`generation`)")
    print("        은 `grounding_planner` 가 **required 로 검사**하고 그것이 "
          "`route` 를 만든다.")
    print("        → 「classifier ×6 을 지운다」는 **그 칸들을 옮긴 뒤에만** "
          "성립한다. 안 옮기면")
    print("          `route` 가 안 서고 저빈도 보호가 통째로 죽는다.")
    print()
    print("■ 원문을 다시 보내는 자리")
    print("   grounding_a0 1 + entity_extract 3 = **4번**")
    print("   샷 전부      : entity_all 3 = **3번**")
    print()
    options()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
