"""GROUNDING-V2 §2-3c — 대조군 판정 도구의 계약.

★이 시험들이 없어서 「갈린 판정을 통과로 접기」와 「논리 호출을 물리 호출로
보고하기」가 둘 다 초록으로 지나갔다.
"""
import inspect
import importlib.util
from pathlib import Path
from unittest.mock import patch

import pytest

_TOOL = (Path(__file__).resolve().parents[2] / "tools" / "prompt_measure"
         / "grounding_controls_acceptance.py")
#: §10 대조군 정본. ★판정자 문안이 이 이름들을 알면 안 된다.
_FIXTURE = (Path(__file__).resolve().parents[1] / "fixtures" / "grounding"
            / "controls.json")
#: ★리그레션 대조군. 한때 튜닝 독립성 증명용이었으나 이 축들의 **갈림 통계**를
#:  보고 문안을 고친 뒤로는 아니다(Codex).
_HELDOUT = (Path(__file__).resolve().parents[1] / "fixtures" / "grounding"
            / "heldout_controls.json")


def _tool():
    spec = importlib.util.spec_from_file_location("_controls_tool", _TOOL)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


class TestFoldDoesNotHideDisagreement:
    """★`research/skip/skip` 을 research 로 접으면 **미확정을 합격으로** 바꾼다.

    ★이 시험들이 예전에 도구의 **죽은 `_fold`** 를 불렀다 — 도구는
    `_fold_records` → `decide_route_from_samples` 를 쓰는데 시험은 아무도 안 쓰는
    함수를 재고 있었다. 죽은 helper 를 지우니 드러났다. 산 경로를 잰다.
    """

    def _rec(self, **over):
        base = dict(
            grounding_class="generic", discriminability="no",
            referent_specificity="generic_class", difficulty="easy",
            # ★`generic` 이 아니라 **B=no** 가 skip 을 내는 자리다
            #  (계약 §2, 2026-08-30). 기본형을 그리로 옮긴다.
            confidence=0.9, visibility_intent="no", locale="KR",
            generation="1980s", visible_discriminators=["x"],
            likely_failure_modes=["y"], generation_difficulty="not_hard")
        base.update(over)
        return base

    def _live(self, records):
        """★도구가 **실제로 쓰는** 함수를 태운다."""
        return _tool()._fold_records(records)

    def test_unanimous_is_stable(self):
        route, votes, unstable = self._live([self._rec()] * 3)
        assert route == "skip" and unstable is False and votes == "skip×3"

    def test_a_route_split_is_reported_but_the_axes_still_decide(self):
        """★route 투표가 갈려도 **축 다수가 서면** 접은 판정이 정본이다.

        판정을 축 접기로 바꾼 뒤로 route 투표는 **진단**이다. 그래도 갈렸다는
        사실은 숨기지 않는다 — 투표 칸에 `[축은 접힘]` 으로 적는다.
        """
        route, votes, undecided = self._live(
            [self._rec(), self._rec(),
             self._rec(visibility_intent="yes")])
        assert undecided is False, "축 다수가 섰는데 못 정했다고 했다"
        assert "skip×2" in votes and "research×1" in votes
        assert "[축은 접힘]" in votes, "갈렸다는 사실을 숨겼다"

    def test_an_axis_with_no_majority_is_undecided(self):
        """★「못 정했다」는 **축 다수 실패**다."""
        route, votes, undecided = self._live(
            [self._rec(), self._rec(visibility_intent="yes")])
        assert undecided is True
        assert "축 미결: visibility_intent" in votes

    @pytest.mark.parametrize("want,got,mark", [
        ("research", "research", "맞음"),
        ("research", "skip", "★어긋남"),
        ("non-research", "skip", "맞음"),
        ("non-research", "research", "★어긋남"),
        # ★갈린 것은 통과도 실패도 아니다 — 프로덕션은 1콜이라 어느 쪽이든 나온다
        ("research", "흔들림:research", "미확정"),
        ("non-research", "흔들림:skip", "미확정"),
        ("research", "unresolved", "미확정"),
        ("research", "후보에 없음", "미확정"),
        ("research", "표본 없음", "미확정"),
    ])
    def test_the_scorer_counts_a_split_as_undecided(self, want, got, mark):
        """★표시만 바꾸고 채점에서 통과로 세면 소용없다.

        ★소스 문자열이 아니라 **채점 함수**로 잰다 — 채점이 두 곳(출력 loop 와
        exit 판정)에 있으면 갈린다. 실제로 갈릴 뻔했다.
        """
        assert _tool().score_row(want, got) == mark

    def test_no_dead_fold_helper_remains(self):
        """★죽은 helper 를 두면 시험이 그것을 재고 **산 경로를 안 잰다.**"""
        src = _TOOL.read_text(encoding="utf-8")
        assert "def _fold(" not in src and "def _votes(" not in src
        assert "def _fold_records(" in src


class TestSinglePhysicalAttempt:
    """★fallback·재시도를 안 봉인하면 「논리 호출 N회」가 실제 전송보다 적고,
    반복 판마다 다른 모델·다른 시도 수를 탄다."""

    def test_classify_seals_fallback_and_retries_when_asked(self):
        from app.modules.pipeline import grounding_classifier as gc
        from app.modules.pipeline.grounding_subject import build_subject
        seen = {}

        def _call(**kwargs):
            seen.update(kwargs)
            return {"classifications": []}

        s = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                           surface_form="가", owner_type="prop")]
        with patch.object(gc, "_call_structured", _call):
            gc.classify(s, era="1983년", region="KR",
                        strict_single_attempt=True)
        assert seen.get("enable_fallback") is False
        assert seen.get("num_retries") == 0

    def test_default_does_not_change_production_behaviour(self):
        """★프로덕션 경로는 그대로다 — 봉인은 **측정용**이다."""
        from app.modules.pipeline import grounding_classifier as gc
        from app.modules.pipeline.grounding_subject import build_subject
        seen = {}

        def _call(**kwargs):
            seen.update(kwargs)
            return {"classifications": []}

        s = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                           surface_form="가", owner_type="prop")]
        with patch.object(gc, "_call_structured", _call):
            gc.classify(s, era="1983년", region="KR")
        assert "enable_fallback" not in seen and "num_retries" not in seen

    def test_the_tool_asks_for_the_seal(self):
        src = _TOOL.read_text(encoding="utf-8")
        assert src.count("strict_single_attempt=True") >= 2, \
            "저장 대조군과 합성 fixture 양쪽에 봉인이 걸려야 한다"


class TestProvenanceIsGatedNotJustPrinted:
    """★기록만 하면 「같은 것을 N회 쟀다」가 성립하는지 아무도 안 본다."""

    def test_the_tool_fails_on_mixed_or_empty_provenance(self):
        """★소스 문자열이 아니라 **판정 함수**로 잰다 — 그 줄이 죽어도
        통과하던 검사였다."""
        # ★문자열이 아니라 **gate 를 실제로 태워서** 본다. 그 줄이 죽어도
        #  소스에 남아 있으면 문자열 검사는 통과한다.
        from app.core.steps.grounding_steps import check_provenance as _cp

        fp = {"payload_hash": "h", "prompt_version": "v",
              "requested_judge_alias": "gpt", "judge_model_alias": "gpt",
              "judge_physical_model": "openai/x"}
        assert _cp([fp, fp]) == []
        assert _cp([fp, {**fp, "judge_physical_model": "openai/z"}]) != []
        assert _cp([{**fp, "payload_hash": None}]) != []
        t = _tool()
        rows = [("stored", f"축{i}", "research", "research", "", "manuscript")
                for i in range(9)]
        code, reasons = t.decide_exit(
            rows, ["ep: judge_physical_model 이 판마다 다르다"])
        assert code == 1, "불일치인데 통과로 돌아간다"
        assert any("judge_physical_model" in r for r in reasons)

    def test_fixture_hash_is_rechecked(self):
        t = _tool()
        with pytest.raises(SystemExit):
            with patch.object(Path, "read_text",
                              lambda self, **k: '{"contract_version":1,'
                                                '"content_hash":"deadbeef"}'):
                t.load_fixture()


class TestHeaderMatchesTheImplementation:
    """★머리말이 구현과 반대면 다음 사람이 머리말을 믿는다."""

    def test_no_stale_fold_rule_in_the_docstring(self):
        src = _TOOL.read_text(encoding="utf-8")
        assert "한 번이라도" not in src, "옛 접기 규칙 설명이 남아 있다"

    def test_completion_wording_is_narrow(self):
        src = _TOOL.read_text(encoding="utf-8")
        assert "갈림 0건 관찰" in src, \
            "「안정성이 증명됐다」로 일반화하지 않는다는 문구가 없다"


class TestProductionFoldsSamples:
    """★한 표본으로 route 를 정할 수 없다 — 판정기를 결정적으로 못 만든다.

    실측: 같은 입력·같은 팩·같은 판정자가 판마다 `discriminability` 를
    yes/uncertain 으로, `confidence` 를 0.82/0.62 로 답하고 route 가 뒤집혔다.
    `gpt`(Sol) 는 `_NO_TEMPERATURE_ALIASES` 라 temperature 를 못 내린다.
    """

    def _rec(self, difficulty="easy", confidence=0.9, **over):
        base = dict(
            grounding_class="generic", discriminability="no",
            referent_specificity="generic_class", difficulty=difficulty,
            confidence=confidence, visibility_intent="no", locale="KR",
            generation="1980s", visible_discriminators=["x"],
            likely_failure_modes=["y"], generation_difficulty="not_hard")
        base.update(over)
        return base

    def test_unanimous_keeps_the_route_and_is_stable(self):
        from app.modules.pipeline.grounding_planner import decide_route_from_samples
        d = decide_route_from_samples([self._rec()] * 3)
        assert d["route"] == "skip" and d["unstable"] is False
        assert d["votes"] == {"skip": 3} and d["sample_count"] == 3

    def test_majority_wins_and_records_the_wobble(self):
        """★한 번 샜다고 뒤집지 않는다 — 그러면 음성 축이 구조적으로 통과 불가다."""
        from app.modules.pipeline.grounding_planner import decide_route_from_samples
        d = decide_route_from_samples(
            [self._rec(), self._rec(), self._rec(visibility_intent="yes")])
        assert d["route"] == "skip", "3회 중 1회 샌 것으로 route 가 뒤집혔다"
        assert d["unstable"] is True, "갈렸다는 사실을 숨겼다"
        assert d["votes"] == {"skip": 2, "research": 1}

    def test_a_tie_closes_toward_research(self):
        """★동점 때만 비대칭을 쓴다 — 조사 안 한 것은 되돌릴 수 없다."""
        from app.modules.pipeline.grounding_planner import decide_route_from_samples
        d = decide_route_from_samples([self._rec(), self._rec(visibility_intent="yes")])
        assert d["route"] == "research" and d["unstable"] is True

    def test_no_samples_is_unresolved(self):
        from app.modules.pipeline.grounding_planner import decide_route_from_samples
        assert decide_route_from_samples([])["route"] == "unresolved"

    def test_classify_samples_calls_n_times(self):
        from app.modules.pipeline import grounding_classifier as gc
        from app.modules.pipeline.grounding_subject import build_subject
        s = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                           surface_form="가", owner_type="prop")]
        sid = s[0]["research_subject_id"]
        calls = {"n": 0}

        def _call(**kwargs):
            calls["n"] += 1
            return {"classifications": [{"research_subject_id": sid}]}

        with patch.object(gc, "_call_structured", _call):
            out = gc.classify_samples(s, samples=3, judges=["gpt"],
                                      era="1983년", region="KR")
        assert calls["n"] == 3
        assert len(out["by_subject"][sid]) == 3
        assert out["logical_calls"] == 3 and out["search_calls"] == 0
        assert len(out["runs"]) == 3

    def test_a_panel_asks_every_judge(self):
        """★판정을 **한 모델에 안 맡긴다** — 같은 모델을 여러 번 물으면
        같은 짐작이 반복될 뿐이다.

        held-out 실측: 한 모델 3표본이 6축 중 3축을 틀렸고, 틀린 셋 중 둘이
        `difficulty` 를 낮게 봤다 — 그 축은 「**다른 모델**이 만들 수 있나」를
        묻는데 근거 없이 짐작으로 답한다.
        """
        from app.modules.pipeline import grounding_classifier as gc
        from app.modules.pipeline.grounding_subject import build_subject

        s = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                           surface_form="가", owner_type="prop")]
        sid = s[0]["research_subject_id"]
        seen: list = []

        def _call(**kwargs):
            seen.append((kwargs.get("project_config") or {})
                        .get("grounding_classify", {}).get("model"))
            return {"classifications": [{"research_subject_id": sid}]}

        with patch.object(gc, "_call_structured", _call):
            out = gc.classify_samples(s, samples=2, era="1983년", region="KR")
        assert out["judges"] == list(gc.DEFAULT_JUDGES)
        assert len(gc.DEFAULT_JUDGES) >= 2, "판정자가 하나면 패널이 아니다"
        # ★판정자마다 samples 회씩 — 그리고 **명시로** 지정한다
        assert sorted(seen) == sorted(list(gc.DEFAULT_JUDGES) * 2)
        assert out["logical_calls"] == 2 * len(gc.DEFAULT_JUDGES)
        assert len(out["by_subject"][sid]) == 2 * len(gc.DEFAULT_JUDGES)

    def test_each_record_says_who_judged_it(self):
        """★어느 모델이 답했는지 **기록마다** 남는다 — 안 남기면 못 되짚는다."""
        from app.modules.pipeline import grounding_classifier as gc
        from app.modules.pipeline.grounding_subject import build_subject

        s = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                           surface_form="가", owner_type="prop")]
        sid = s[0]["research_subject_id"]

        with patch.object(gc, "_call_structured",
                          lambda **k: {"classifications":
                                       [{"research_subject_id": sid}]}):
            out = gc.classify_samples(s, samples=1, era="1983년", region="KR")
        assert {r["judge_alias"] for r in out["by_subject"][sid]} == \
            set(gc.DEFAULT_JUDGES)

    @pytest.mark.parametrize("bad", [0, -1])
    def test_samples_below_one_is_rejected(self, bad):
        from app.modules.pipeline import grounding_classifier as gc
        with pytest.raises(ValueError):
            gc.classify_samples([], samples=bad)

    def test_the_tool_uses_the_same_fold_as_production(self):
        """★도구만 반복하고 프로덕션이 1콜이면 **둘이 다른 것을 잰다.**"""
        src = _TOOL.read_text(encoding="utf-8")
        assert "decide_route_from_samples" in src
        assert "classify_samples" in src


class TestUnresolvedSamplesDoNotVote:
    """★`unresolved` 는 「skip 을 골랐다」가 아니라 **「답을 안 했다」**다.

    표로 세면 미확정이 결정으로 접힌다 — 실측(Codex 재현)에서
    `('skip','unresolved')` 가 `skip` 으로 접혔다.
    """

    def _ok(self, **over):
        base = dict(
            grounding_class="generic", discriminability="no",
            referent_specificity="generic_class", difficulty="easy",
            # ★skip 을 내는 자리는 이제 **B=no** 다 (계약 §2, 2026-08-30)
            confidence=0.9, visibility_intent="no", locale="KR",
            generation="1980s", visible_discriminators=["x"],
            likely_failure_modes=["y"], generation_difficulty="not_hard")
        base.update(over)
        return base

    def _unanswered(self):
        return {"research_subject_id": "x", "classifier_missing": True}

    def test_a_single_answer_against_a_non_answer_is_undecided(self):
        from app.modules.pipeline.grounding_planner import decide_route_from_samples
        d = decide_route_from_samples([self._ok(), self._unanswered()])
        assert d["route"] == "unresolved", "미응답이 skip 으로 접혔다"
        assert d["unanswered"] == 1

    def test_a_majority_of_answers_still_decides(self):
        from app.modules.pipeline.grounding_planner import decide_route_from_samples
        d = decide_route_from_samples(
            [self._ok(), self._ok(), self._unanswered()])
        assert d["route"] == "skip" and d["unanswered"] == 1

    def test_all_unanswered_is_undecided(self):
        from app.modules.pipeline.grounding_planner import decide_route_from_samples
        d = decide_route_from_samples([self._unanswered()] * 3)
        assert d["route"] == "unresolved" and d["unanswered"] == 3

    def test_a_tie_without_research_is_not_broken_arbitrarily(self):
        """★사전순으로 고르면 ('design','skip') 이 design 이 되는데 근거가 없다."""
        from app.modules.pipeline.grounding_planner import decide_route_from_samples
        d = decide_route_from_samples(
            [self._ok(grounding_class="fictional"), self._ok()])
        assert d["route"] == "unresolved", "근거 없이 한쪽을 골랐다"

    def test_a_tie_with_research_still_closes_toward_research(self):
        from app.modules.pipeline.grounding_planner import decide_route_from_samples
        d = decide_route_from_samples([self._ok(), self._ok(visibility_intent="yes")])
        assert d["route"] == "research"

    def test_unanswered_count_is_always_reported(self):
        """★미응답 수를 안 보이면 「답한 표본 2개로 정했다」를 아무도 모른다."""
        from app.modules.pipeline.grounding_planner import decide_route_from_samples
        for recs in ([self._ok()] * 3, [self._ok(), self._unanswered()], []):
            assert "unanswered" in decide_route_from_samples(recs)


class TestLegacyBasisIsNotReadAsPass:
    """★상상 묘사로 재고 「통과」를 찍으면 **미확정을 합격으로 바꾸는 것**이다.

    이 저장소에서 이미 한 번 낸 결함이라 **동작으로** 못박는다 —
    소스 문자열 검사는 그 줄이 죽어도 통과한다(실제로 그랬다).
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    @staticmethod
    def _nine(basis="manuscript"):
        """아홉 축이 **전부 맞은** 행. 근거만 바꿔 가며 잰다."""
        return [("stored", f"축{i}", "research", "research", "", basis)
                for i in range(9)]

    def test_it_passes_when_every_axis_used_the_manuscript(self):
        t = self._tool()
        code, reasons = t.decide_exit(self._nine(), [])
        assert code == 0, reasons

    def test_a_synthetic_axis_basis_is_fine(self):
        """★합성 축은 원문이 없다 — 문안이 정본이다(계획 §10)."""
        t = self._tool()
        rows = self._nine()
        rows[8] = ("synthetic", "fixture:hanbok", "non-research", "skip", "",
                   "fixture")
        assert t.decide_exit(rows, [])[0] == 0

    def test_not_running_a0_is_not_a_pass(self):
        """★A0 를 아예 안 돌린 판은 production 과 다른 입력이다."""
        t = self._tool()
        code, reasons = t.decide_exit(self._nine(), [], a0_ran=False)
        assert code == 1
        assert any("A0 를 안 돌렸다" in r for r in reasons)

    def test_a_mixed_basis_is_fine_when_a0_ran(self):
        """★축마다 `manuscript` 를 요구하지 **않는다.**

        production 도 A0 후보가 없는 대상은 `entity_description` 으로 간다.
        요구하면 프로덕션보다 엄격해지고, A0 도 LLM 이라 판마다 건지는 것이
        달라 **gate 자체가 흔들린다** — 실측에서 원문 근거 축이 판마다
        2~5개로 오갔다.
        """
        t = self._tool()
        rows = self._nine()
        rows[4] = ("stored", "어떤 축", "research", "research", "",
                   "entity_description")
        assert t.decide_exit(rows, [], a0_ran=True)[0] == 0

    def test_the_basis_is_still_printed(self):
        """★판정에서 뺐다고 **안 보이면** 안 된다 — 무엇으로 쟀는지는 남는다."""
        import inspect

        src = inspect.getsource(self._tool().main)
        assert "판정 근거:" in src

    def test_a_mismatched_axis_still_stops(self):
        t = self._tool()
        rows = self._nine()
        rows[3] = ("stored", "축3", "research", "skip", "", "manuscript")
        code, reasons = t.decide_exit(rows, [])
        assert code == 1
        assert any("어긋나거나 미확정" in r for r in reasons)

    def test_an_unstable_axis_is_not_a_pass(self):
        """★「흔들림:research」는 research 가 **아니다.**"""
        t = self._tool()
        rows = self._nine()
        rows[0] = ("stored", "축0", "research", "흔들림:research", "", "manuscript")
        assert t.decide_exit(rows, [])[0] == 1

    def test_wrong_axis_count_stops(self):
        t = self._tool()
        assert t.decide_exit(self._nine()[:8], [])[0] == 1

    def test_broken_provenance_stops(self):
        t = self._tool()
        code, reasons = t.decide_exit(
            self._nine(), ["ep: payload_hash 가 비었다"])
        assert code == 1
        assert any("payload_hash" in r for r in reasons)

    def test_the_flag_that_buys_the_manuscript_basis_exists(self):
        import inspect

        src = inspect.getsource(self._tool().main)
        assert '"--with-a0"' in src
        # ★돈이 든다는 것을 문안이 말한다
        assert "Sol" in src[src.index('"--with-a0"'):][:400]


class TestConfidenceIsDiagnosticNotADecider:
    """★confidence 는 **route 를 안 가른다** — 진단·provenance 로만 남는다.

    ## 왜 (§2-3c 실측)

    표본 146건이 0.78~0.86 에 빽빽하고 옛 문턱 0.8 이 **그 한가운데**에
    앉았다. 자르면 축 답이 뚜렷한 표본까지 버려 **범주적 다수가 뒤집힌다** —
    회수권(**양성** 축)이 `medium` 표본 둘을 잃고 `unresolved` 로 갔다.

    ★숫자를 다른 숫자로 낮추는 것도 답이 아니다. 그건 대조군에 맞추는
    것이고, 어느 값이든 같은 분포 위에서 임의로 자른다 (Codex 판정).

    ★「모른다」의 통로는 둘이다 — **스키마의 명시적 `uncertain`** 과
    **표본 사이 갈림**. 계약 §3 은 그 둘로 지킨다.
    """

    @staticmethod
    def _r(**over):
        base = dict(
            grounding_class="externally_grounded", discriminability="yes",
            referent_specificity="generic_class", difficulty="easy",
            # ★skip 을 내는 자리는 이제 **B=no** 다 (계약 §2, 2026-08-30)
            # ★이 절은 「confidence 가 route 를 안 가른다」를 재므로
                     #  기본형이 **조사로 가는** 쪽이어야 뜻이 산다
            confidence=0.9, visibility_intent="yes", locale="KR",
            generation="1980s", visible_discriminators=["x"],
            likely_failure_modes=["y"], generation_difficulty="not_hard")
        base.update(over)
        return base

    def test_a_low_number_alone_changes_nothing(self):
        from app.modules.pipeline.grounding_planner import decide_route

        # ★기본형이 조사로 가는 쪽이라 「숫자가 route 를 안 가른다」는
        #  **research 가 유지되는가**로 잰다.
        for conf in (0.01, 0.52, 0.78, 0.79, 0.8, 0.95):
            assert decide_route(self._r(confidence=conf))["route"] == "research"

    def test_the_categorical_majority_survives(self):
        """★실측 재현 — `medium` 둘 + `easy` 하나면 **research** 다.

        옛 문턱이 `medium` 표본 둘(0.78)을 버려 `unresolved` 로 갔다.
        """
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        d = decide_route_from_samples([
            self._r(difficulty="medium", confidence=0.78),
            self._r(difficulty="easy", confidence=0.82),
            self._r(difficulty="medium", confidence=0.78)])
        assert d["route"] == "research", d
        assert d["unanswered"] == 0, "표본을 버렸다"

    def test_the_split_is_recorded_per_axis(self):
        """★confidence 를 뺀 대신 **축별 갈림**이 진단이다.

        ★2026-08-30: `difficulty` 로 재던 것을 **살아 있는 축**으로 옮겼다.
        폐기한 축으로 재면 「기구가 산다」가 아니라 **아무것도 안 재는** 것이
        된다 — 접기가 그 칸을 아예 안 본다.
        """
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        d = decide_route_from_samples([
            self._r(referent_specificity="exact_variant"),
            self._r(referent_specificity="generic_class"),
            self._r(referent_specificity="exact_variant")])
        assert d["axis_split"] == {
            "referent_specificity": ["exact_variant", "generic_class"]}
        # ★갈림은 **그대로 기록한다**. 다만 `difficulty` 는 이제 route 를 안
        #  가르므로 답이 안 흔들린다 — 「갈렸다」와 「답이 바뀐다」는 다르다.
        assert d["unstable"] is False

    def test_no_split_means_no_axis_split_entry(self):
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        d = decide_route_from_samples([self._r()] * 3)
        assert d["axis_split"] == {} and d["unstable"] is False

    def test_confidence_is_still_kept_for_diagnosis(self):
        """★없애지는 않는다 — 나중에 「어느 구간에서 갈렸나」를 본다."""
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        d = decide_route_from_samples([
            self._r(confidence=0.52), self._r(confidence=0.9),
            self._r(confidence=0.78)])
        assert d["confidence_range"] == [0.52, 0.9]


class TestExplicitUncertainIsTheUncertaintyChannel:
    """★프롬프트가 「축을 uncertain 으로 **그리고** confidence 를 낮게」라고 시킨다.

    그래서 둘이 **같이** 온다. confidence 를 먼저 보면 축이 `uncertain` 인 판정까지
    통째로 `unresolved` 로 떨어져 「uncertain 은 조사」라는 계약 §3 이 조용히
    사라진다 (Codex 실측: contract 2 에서 uncertain+0.3 이 unresolved 였다).
    """

    @staticmethod
    def _r(**over):
        base = dict(
            grounding_class="externally_grounded", discriminability="yes",
            referent_specificity="generic_class", difficulty="easy",
            # ★skip 을 내는 자리는 이제 **B=no** 다 (계약 §2, 2026-08-30)
            confidence=0.9, visibility_intent="yes", locale="KR",
            generation="1980s", visible_discriminators=["x"],
            likely_failure_modes=["y"], generation_difficulty="not_hard")
        base.update(over)
        return base

    @pytest.mark.parametrize("conf", [0.3, 0.58, 0.79, 0.9])
    def test_an_uncertain_b_is_research_at_any_confidence(self, conf):
        """★핵심 결합 — 축을 모른다고 **말한** 것은 confidence 와 무관하게 조사다.

        ★이제 route 를 가르는 축은 **B** 뿐이라, 사유에 이름이 적히는 것도 B 다.
        """
        from app.modules.pipeline.grounding_planner import decide_route

        d = decide_route(self._r(visibility_intent="uncertain", confidence=conf))
        assert d["route"] == "research", f"conf={conf}"
        assert d["research_required"] is True
        assert "visibility_intent" in d["reason"]

    @pytest.mark.parametrize("axis", ["discriminability", "difficulty"])
    @pytest.mark.parametrize("conf", [0.3, 0.9])
    def test_a_retired_axis_being_uncertain_still_lands_in_research(
            self, axis, conf):
        """★폐기된 축이 `uncertain` 이어도 **조사로 간다** — 다만 그 축 때문이
        아니라 「현실 대상 + B」 때문이다. 사유에 그 축 이름은 안 적힌다."""
        from app.modules.pipeline.grounding_planner import decide_route

        d = decide_route(self._r(**{axis: "uncertain"}, confidence=conf))
        assert d["route"] == "research"
        assert axis not in d["reason"], "폐기된 축이 아직 사유를 만든다"

    @pytest.mark.parametrize("conf", [0.3, 0.79])
    def test_low_confidence_alone_does_not_change_the_route(self, conf):
        """★축은 다 답했는데 숫자만 낮은 것 — **route 를 안 바꾼다.**

        옛 계약은 이것을 `unresolved` 로 뒀는데, 그 문턱이 분포 한가운데에
        앉아 범주적 다수를 버렸다(Codex 판정). 지금은 진단으로만 남는다.
        """
        from app.modules.pipeline.grounding_planner import decide_route

        assert decide_route(self._r(confidence=conf))["route"] == \
            decide_route(self._r(confidence=0.95))["route"]

    def test_the_two_are_told_apart_in_the_prompt(self):
        """★코드만 갈라 놓고 프롬프트가 안 가르면 모델이 둘을 안 가른다."""
        from app.modules.pipeline.grounding_classifier import load_pack

        flat = " ".join(load_pack(db=None)["stems"]["system"]["content"].split())
        assert "축을 `uncertain` 이라고 답하는 것과 숫자만 낮게" in flat
        # ★코드가 confidence 를 안 쓰므로 문안도 그렇게 말해야 한다
        assert "`confidence` 로는 아무것도 안 정합니다" in flat
        assert "**아무것도 안 달라집니다**" in flat
        assert "판정에서 빠집니다" not in flat, "옛 문턱 설명이 남았다"


class TestChangedMeaningMovesTheFingerprint:
    """★뜻이 바뀌었는데 지문이 그대로면 resume 이 **옛 체크포인트를 건너뛴다.**

    고친 것이 아무 데도 안 닿는다 — 이 저장소에서 두 번 낸 결함이다.
    """

    @staticmethod
    def _step():
        from app.core.steps.grounding_steps import GroundingPlanStep

        s = GroundingPlanStep.__new__(GroundingPlanStep)
        s.project_config = {"grounding_mode": "v2"}
        s.project_id, s.episode_id, s.step_id = "p", "e", "grounding_plan"
        return s

    @pytest.mark.parametrize("mod,attr", [
        ("app.modules.pipeline.grounding_planner", "PLANNER_CONTRACT_VERSION"),
        ("app.modules.pipeline.grounding_carry", "CARRY_CONTRACT_VERSION"),
    ])
    def test_contract_versions_are_folded_in(self, mod, attr, monkeypatch):
        import importlib

        m = importlib.import_module(mod)
        before = self._step()._config_hash()
        monkeypatch.setattr(m, attr, getattr(m, attr) + 1)
        assert self._step()._config_hash() != before, f"{attr} 가 지문에 없다"

    def test_the_versions_moved_for_this_change(self):
        """★이번 판에서 실제로 올렸는지 — 안 올리면 위 시험이 통과해도 소용없다."""
        from app.modules.pipeline.grounding_carry import CARRY_CONTRACT_VERSION
        from app.modules.pipeline.grounding_classifier import PROMPT_PACK_VERSION
        from app.modules.pipeline.grounding_planner import PLANNER_CONTRACT_VERSION

        assert PLANNER_CONTRACT_VERSION >= 4, "route 뜻이 바뀌었는데 안 올렸다"
        assert CARRY_CONTRACT_VERSION >= 2, "결속 뜻이 바뀌었는데 안 올렸다"
        # ★문자열 비교는 "10" < "6" 이다 — 수로 본다
        assert int(PROMPT_PACK_VERSION.split(".")[0]) >= 10, \
            "팩 문안이 바뀌었는데 안 올렸다"

    def test_the_pack_bytes_move_it_too(self, monkeypatch):
        import app.core.steps.grounding_steps as g

        before = self._step()._config_hash()
        real = g._pack_fingerprint
        monkeypatch.setattr(g, "_pack_fingerprint",
                            lambda m, v: {**real(m, v), "system": "달라짐"})
        assert self._step()._config_hash() != before


class TestThePromptDoesNotLeakTheControls:
    """★대조군의 **답**을 문안에 적으면 그건 측정이 아니다.

    실제로 그랬다(Codex). 내가 가로축 문안에 「오래 안 변한 것(**전통 복식**·
    기본 공구·**평범한 작업복**처럼)은 `no`」라고 적었는데 — 「전통 복식」은
    §10 의 `fixture:hanbok` 이고 「평범한 작업복」도 저장 원고의 대상이다.
    **대조군이 기대하는 답을 프롬프트가 미리 알려 준 셈**이다.

    ★계획 §10 이 그래서 fixture 를 content_hash 로 잠갔다 —
    「사후에 문안을 고쳐 다시 돌리면 그건 측정이 아니다」. 그 규칙은 fixture
    파일만이 아니라 **판정자 문안에도** 적용된다.
    """

    @staticmethod
    def _control_names():
        import json

        fx = json.load(open(_FIXTURE, encoding="utf-8"))
        names = [r["name"] for g in ("positive", "negative")
                 for r in fx["stored"][g]]
        names += [r["surface_form"] for r in fx["synthetic_negative"]]
        # ★held-out 낱말도 **똑같이** 새면 안 된다. 아홉 축만 보고 있었다 —
        #  잠가 둔 여섯 축의 답을 문안에 적으면 그것도 측정이 아니다.
        ho = json.load(open(_HELDOUT, encoding="utf-8"))
        names += [c["surface_form"] for c in ho["cases"]]
        return names

    def test_no_control_name_appears_in_the_classifier_prompt(self):
        import re

        from app.modules.pipeline.grounding_classifier import load_pack

        # ★A0 문안도 본다 — 후보를 **뽑는** 자리에서 새도 측정이 아니다.
        from app.modules.pipeline import grounding_a0

        text = load_pack(db=None)["stems"]["system"]["content"]
        text += grounding_a0.load_pack(db=None)["stems"]["system"]["content"]
        leaked = []
        for name in self._control_names():
            if name in text:
                leaked.append((name, name))
                continue
            # ★낱말 **하나**로는 대조군이 드러나지 않는다 — 긴 서술형 이름에는
            #  「종이」 같은 흔한 낱말이 섞여 있고, 문안이 **안 쓸 옛 규칙을
            #  인용**하다가 그 낱말을 쓸 수도 있다(실제로 A0 문안이 그랬다).
            #  둘 이상이 같이 나오면 그때는 그 대조군을 가리킨 것이다.
            hit = [w for w in re.split(r"[\s·,]+", name)
                   if len(w) >= 2 and w in text]
            if len(hit) >= 2:
                leaked.append((name, hit))
        assert leaked == [], f"대조군 낱말이 문안에 있다: {leaked}"

    def test_the_prompt_says_not_to_memorize_examples(self):
        """★기준을 적되 **보기 목록으로 읽히지 않게** 못박는다."""
        from app.modules.pipeline.grounding_classifier import load_pack

        text = load_pack(db=None)["stems"]["system"]["content"]
        assert "보기를 외우지 마세요" in text
        assert "종류 목록이 아니라" in text


class TestRegressionControlsAreLockedButNotIndependent:
    """★§10 아홉 축은 문안을 고칠 때 내가 보고 있던 것이다. 거기서 맞았다고
    「규칙이 일반화됐다」가 되지 않는다 — 그건 순환이다. 그래서 §10 과
    **겹치지 않는** 대상을 따로 잠가 뒀다.

    ★그런데 **이 축들도 더 이상 독립이 아니다.** 이 여섯 축의 축별 갈림
    통계(`difficulty` 5/6 · 한 모델 안에서도 4/6)를 보고 판정 문안 v13 을
    고쳤기 때문이다. **낱말이 안 새도 통계를 봤으면 그건 튜닝**이다(Codex) —
    낱말 누출 검사는 lexical leakage 만 막지 tuning leakage 는 못 막는다.

    ★그래서 지금 이것은 **리그레션 대조군**이다. §2-3c 통과 조건도 아니고
    일반화 증명도 아니다. 일반화는 C(capability) 계약을 확정한 뒤
    **한 번도 안 본 새 평가축**을 잠가 그 **첫 실행**으로만 낸다.
    """

    @staticmethod
    def _held():
        import json

        return json.load(open(_HELDOUT, encoding="utf-8"))

    def test_it_is_locked_by_a_content_hash(self):
        """★돌리기 전에 잠근다 — 결과를 보고 문안을 고치면 측정이 아니다."""
        import hashlib
        import json

        spec = self._held()
        want = spec.pop("content_hash")
        body = json.dumps(spec, ensure_ascii=False, sort_keys=True,
                          separators=(",", ":"))
        assert hashlib.sha256(body.encode()).hexdigest()[:16] == want, \
            "held-out 을 고쳐 놓고 hash 를 안 맞췄다"

    def test_it_does_not_overlap_the_nine_axes(self):
        """★§10 과 겹치면 held-out 이 아니다."""
        import json
        import re

        fx = json.load(open(_FIXTURE, encoding="utf-8"))
        names = [r["name"] for g in ("positive", "negative")
                 for r in fx["stored"][g]]
        names += [r["surface_form"] for r in fx["synthetic_negative"]]
        for c in self._held()["cases"]:
            for n in names:
                for w in re.split(r"[\s·]+", n):
                    assert not (len(w) >= 2 and w in c["surface_form"]), \
                        f"{c['id']} 가 §10 의 「{w}」 와 겹친다"

    def test_no_held_out_word_is_in_either_prompt(self):
        """★held-out 은 **두 팩 어디에도** 새면 안 된다.

        판정자(`grounding_classify`)만이 아니라 **후보 수집기**(`grounding_a0`)
        도 본다 — A0 가 그 낱말을 알면 그 대상만 유난히 잘 건져 held-out 이
        아니게 된다. 실제로 내 held-out 하나가 A0 의 owner 표와 겹쳐 있었다.
        """
        import re

        from app.modules.pipeline import grounding_a0 as a0
        from app.modules.pipeline import grounding_classifier as gc

        packs = {"grounding_classify": gc.load_pack(db=None),
                 "grounding_a0": a0.load_pack(db=None)}
        leaked = [(name, c["id"], w)
                  for name, pk in packs.items()
                  for c in self._held()["cases"]
                  for w in re.split(r"[\s·]+", c["surface_form"])
                  if len(w) >= 2 and w in pk["stems"]["system"]["content"]]
        assert leaked == [], f"held-out 낱말이 문안에 있다: {leaked}"

    def test_both_directions_are_represented(self):
        """★한쪽만 있으면 「전부 skip」 하는 문안도 통과한다."""
        cases = self._held()["cases"]
        pos = [c for c in cases if c["expect"] == "research"]
        neg = [c for c in cases if c["expect"] != "research"]
        assert len(pos) >= 3 and len(neg) >= 3, (len(pos), len(neg))


class TestAxesAreFoldedBeforeRouting:
    """★route 로 투표하면 **축이 다 달라도 route 가 같으면 안정**으로 센다.

    Codex 반례 — 셋 다 `skip` 이지만 **서로 다른 이유로** 골랐다:

        (generic,  yes, yes, easy)    → skip  (갈래가 generic 이라)
        (external, no,  yes, medium)  → skip  (가로축 실패라)
        (external, yes, no,  medium)  → skip  (가로축 실패라)

    축별 다수는 `external + yes + yes + medium` 이고 그것은 **research** 다.
    공통된 근거 없이 고증이 조용히 우회된다.
    """

    @staticmethod
    def _r(gclass="externally_grounded", discrim="yes", vis="yes",
           spec="generic_class", diff="easy"):
        return dict(
            grounding_class=gclass, discriminability=discrim,
            visibility_intent=vis, referent_specificity=spec, difficulty=diff,
            confidence=0.9, locale="KR", generation="1980s",
            visible_discriminators=["x"], likely_failure_modes=["y"], generation_difficulty="not_hard")

    def test_the_counterexample_does_not_slip_through_as_skip(self):
        from app.modules.pipeline.grounding_planner import (
            decide_route, decide_route_from_samples)

        # ★옛 4축 반례는 그대로는 못 쓴다. 표본이 **셋**이면 route 를 가르는
        #  축이 줄어 표본 다수와 축 접기가 같은 답을 낸다.
        #  ★★그렇다고 **반례가 없어진 것이 아니다** — 표본이 넷이면 여전히
        #   만들어진다(아래 `test_the_conflict_is_still_constructible_at_four`).
        #   「만들 수가 없다」로 적으면 접기 기구를 지울 근거가 되어 버린다.
        recs = [self._r(vis="no"), self._r(vis="no"), self._r(vis="yes")]
        assert [decide_route(r)["route"] for r in recs] == [
            "skip", "skip", "research"]
        d = decide_route_from_samples(recs)
        # 축 다수(B=no)와 표본 다수(skip)가 같다 — 갈릴 것이 없다
        assert d["route"] == "skip", d["reason"]
        assert d.get("fold_conflict") is None, d
        # ★갈렸을 때 조사 쪽으로 닫는 규칙 자체는 코드에 남아 있다 —
        #  축이 다시 늘면 그 자리가 살아난다. 지금은 갈릴 것이 없어 칸이
        #  아예 안 생긴다(그게 맞다 — 없는 갈등을 적지 않는다).

    def test_the_conflict_is_still_constructible_at_four(self):
        """★★★**축이 셋으로 줄어도 접기 갈등은 남는다.**

        표본 넷:

            (externally, B=no,  generic) → skip
            (externally, B=no,  generic) → skip
            (fictional,  B=yes, generic) → design
            (externally, B=yes, generic) → research

        route 투표는 `skip 2 / design 1 / research 1` 이라 **skip** 이다.
        그런데 축별 다수는 `externally`(3/4) 이고 B 는 2-2 동점이라
        `uncertain` 으로 닫혀 **research** 다. 셋이 서로 다른 이유로 skip 을
        골랐을 뿐 공통 근거가 없다 — 그대로 두면 고증이 조용히 우회된다.
        """
        from app.modules.pipeline.grounding_planner import (
            decide_route, decide_route_from_samples, fold_axes)

        recs = [self._r(vis="no"), self._r(vis="no"),
                self._r(gclass="fictional", vis="yes"), self._r(vis="yes")]
        assert [decide_route(r)["route"] for r in recs] == [
            "skip", "skip", "design", "research"]
        folded = fold_axes(recs)
        assert folded["folded"]["visibility_intent"] == "uncertain", folded
        assert decide_route(folded["folded"])["route"] == "research"
        d = decide_route_from_samples(recs)
        assert d["route"] == "research", \
            f"★축 접기가 research 인데 표본 투표(skip)를 따라갔다 — {d}"

    def test_a_split_axis_is_recorded(self):
        """★살아 있는 축에서 잰다 — 폐기 축은 접기가 아예 안 본다."""
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        d = decide_route_from_samples([self._r(), self._r(spec="exact_variant"),
                                       self._r(spec="exact_variant")])
        assert d["axis_split"] == {
            "referent_specificity": ["exact_variant", "generic_class"]}
        assert d["axis_undecided"] == []
        assert d["route"] == "research", "축별 다수(exact_variant)를 안 따랐다"

    def test_a_tied_axis_closes_toward_research(self):
        """★1대1 로 갈린 축은 **그 축의 `uncertain`** 으로 접는다.

        ★`visibility_intent` 로 잰다 — `uncertain` 이 있는 **살아 있는** 축이다.
        """
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        d = decide_route_from_samples([self._r(vis="yes"), self._r(vis="no")])
        assert d["axis_undecided"] == ["visibility_intent"]
        assert d["route"] == "research"

    def test_a_tied_axis_without_uncertain_uses_the_research_ward_value(self):
        """★`referent_specificity` 에는 `uncertain` 이 없다 — 조사 쪽을 고른다."""
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        d = decide_route_from_samples(
            [self._r(), self._r(spec="exact_variant")])
        assert d["axis_undecided"] == ["referent_specificity"]
        assert d["route"] == "research"

    def test_a_tied_class_with_no_research_ward_value_is_unresolved(self):
        """★고를 근거가 없으면 **임의로 안 고른다.**"""
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        d = decide_route_from_samples(
            [self._r(gclass="generic"), self._r(gclass="fictional")])
        assert d["route"] == "unresolved"
        assert "grounding_class" in d["axis_undecided"]

    def test_agreeing_samples_still_fold_normally(self):
        """positive control — 다 같으면 그대로 간다."""
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        # ★가르는 축이 **B** 다 — `difficulty` 로는 이제 안 뒤집힌다
        assert decide_route_from_samples(
            [self._r(vis="no")] * 3)["route"] == "skip"
        assert decide_route_from_samples(
            [self._r(vis="yes")] * 3)["route"] == "research"


class TestRegressionControlsHaveARealRunner:
    """★잠가 두기만 하고 **돌릴 길이 없으면** 아무것도 못 잰다 (Codex).

    일회성 유료 스크립트로 돌리는 것도 안 된다 — 다시 못 재고 기록도 안 남는다.
    기존 acceptance 안에 모드로 둔다.
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    def test_the_mode_exists_and_costs_money(self):
        import inspect

        src = inspect.getsource(self._tool().main)
        assert '"--held-out"' in src
        # ★고정 폭으로 자르면 인자를 하나 더해도 시험이 깨진다 — **블록**을 본다.
        blk = src[src.index("if args.held_out:"):]
        blk = blk[:blk.index("return run_heldout(") if "return run_heldout(" in blk
                  else blk.index("run_heldout(")]
        assert "i_know_this_costs_money" in blk, blk

    def test_the_lock_is_rechecked_at_run_time(self):
        """★기록된 hash 를 그대로 믿지 않고 **다시 계산**한다."""
        import json
        from unittest.mock import patch

        t = self._tool()
        broken = json.dumps({"contract_version": 1, "cases": [],
                             "content_hash": "deadbeef"}, ensure_ascii=False)
        with patch.object(type(t._HELDOUT), "read_text",
                          lambda self, **k: broken):
            with pytest.raises(SystemExit):
                t.load_heldout()

    def test_the_real_file_passes_its_own_lock(self):
        spec = self._tool().load_heldout()
        assert len(spec["cases"]) >= 6

    def test_the_reason_never_reaches_the_model(self):
        """★`why` 는 사람이 읽는 사유다 — 넣으면 정답을 알려 주는 것이다."""
        import inspect

        src = inspect.getsource(self._tool().run_heldout)
        i = src.index("cached_classify(")
        # ★분류기에 넘기는 인자 어디에도 `why` 가 없어야 한다
        assert '"why"' not in src[:i] or "subj[" not in src[:i]
        assert 'c["why"]' not in src, "사유를 모델 입력에 넣었다"
        assert "why" not in src[i:i + 400]

    def test_it_folds_with_the_production_function(self):
        """★도구가 자기 접기를 다시 만들면 프로덕션과 다른 것을 잰다."""
        import inspect

        src = inspect.getsource(self._tool().run_heldout)
        assert "_fold_records(" in src
        assert "score_row(" in src and "decide_exit(" in src


class TestFoldingDoesNotDependOnSampleOrder:
    """★같은 표본을 **순서만 바꿔** 넣으면 답이 달라지면 안 된다.

    Codex 실측: `[good,bad,bad]` 는 skip, `[bad,good,bad]` 는 unresolved 였다.
    원인 둘 —

        ① 과반 검사를 **접은 뒤에** 했다
        ② 접을 때 `records[0]` 을 비-routing 칸의 바탕으로 썼다
           (그 자리에 `unresolved` 표본이 오면 답이 달라진다)

    고친 것: 과반을 **먼저** 보고, **답한 표본만** 접는다.
    """

    @staticmethod
    def _good():
        return dict(
            # ★skip 을 내는 자리는 **B=no** 다 (계약 §2, 2026-08-30)
            grounding_class="generic", discriminability="yes",
            visibility_intent="no", referent_specificity="generic_class",
            difficulty="easy", confidence=0.9, locale="KR", generation="1980s",
            visible_discriminators=["x"], likely_failure_modes=["y"], generation_difficulty="not_hard")

    @classmethod
    def _bad(cls):
        """축은 같지만 필수 칸이 비어 **개별로는 `unresolved`** 인 표본."""
        r = cls._good()
        r["visible_discriminators"] = []
        return r

    def test_every_permutation_gives_the_same_answer(self):
        import itertools

        from app.modules.pipeline.grounding_planner import (
            decide_route, decide_route_from_samples)

        # ★전제 확인 — bad 는 정말 개별로 unresolved 인가
        assert decide_route(self._bad())["route"] == "unresolved"
        assert decide_route(self._good())["route"] == "skip"

        seen = set()
        for perm in itertools.permutations([self._good(), self._bad(),
                                            self._bad()]):
            seen.add(decide_route_from_samples(list(perm))["route"])
        assert len(seen) == 1, f"순서에 따라 답이 달라진다: {seen}"

    def test_a_minority_of_answers_does_not_decide(self):
        """★답한 것이 1/3 이면 접지 않는다 — 접고 나서 보면 늦다."""
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        d = decide_route_from_samples([self._good(), self._bad(), self._bad()])
        assert d["route"] == "unresolved"
        assert "과반 미만" in d["reason"]

    def test_unanswered_samples_do_not_enter_the_axis_fold(self):
        """★투표에서 뺀 표본은 **축 다수에도** 안 들어간다."""
        from app.modules.pipeline.grounding_planner import (
            decide_route_from_samples)

        bad = self._bad()
        bad["difficulty"] = "hard"          # 접히면 route 가 research 로 바뀐다
        d = decide_route_from_samples([self._good(), self._good(), bad])
        assert d["route"] == "skip", "미응답 표본의 축이 접기에 들어갔다"


class TestTheSplitCountIsReportedSeparately:
    """★「미확정 0」과 「갈림 0」은 **다른 말**이다.

    축 다수로 접은 route split 이 있으면 그것을 안 적고 「갈림 0건」이라 쓰면
    안 된다 — 실제로 통과한 두 판에 각각 2건이 있었는데 내가 「갈림 0건」이라고
    보고했다(Codex 지적).
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    def test_both_summaries_print_the_split_count(self):
        import inspect

        t = self._tool()
        for fn in (t.main, t.run_heldout):
            src = inspect.getsource(fn)
            assert "축 다수 실패" in src and "다수로 접은 것" in src, fn.__name__

    def test_it_warns_against_writing_zero_splits(self):
        import inspect

        src = inspect.getsource(self._tool().main)
        assert "「갈림 0건」이라고 쓰면 안 된다" in src


class TestTheToolUsesTheProductionProvenanceGate:
    """★규칙을 도구에 다시 적었더니 본판만 고치고 held-out 을 안 고쳐
    여섯 축이 통째로 막혔다. 이 판에서만 네 번째다.
    """

    @staticmethod
    def _src(name):
        import inspect
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return inspect.getsource(getattr(t, name))

    def test_neither_runner_rewrites_the_rule(self):
        for fn in ("main", "run_heldout"):
            src = self._src(fn)
            i = src.index("provenance_bad")
            seg = src[i:i + 1400]
            assert "check_provenance(" in seg, f"{fn}: 프로덕션 gate 를 안 부른다"
            assert "expected_judges=" in seg, f"{fn}: 부탁한 집합을 안 넘긴다"
            assert "expected_samples=" in seg, f"{fn}: 표본 수를 안 넘긴다"

    def test_the_fingerprint_is_kept_whole(self):
        """★칸을 골라 복사하면 gate 가 읽는 칸이 조용히 빠진다."""
        src = self._src("_record_run_fp")
        assert "dict(fp or {})" in src, src


class TestWeDoNotClaimIndependenceAnywhere:
    """★독립성을 주장하는 문구를 쓰면, 이 여섯 축의 **갈림 통계를 보고**
    문안을 고친 사실이 지워진다 (Codex).

    낱말 누출 검사는 lexical leakage 만 막는다. **tuning leakage 는 못 막는다.**
    """

    _BANNED = ("튜닝 독립성", "안 보던", "안 보고 잠근",
               "과적합되지 않았다", "일반화됐음")  # _BANNED

    def test_no_file_claims_independence(self):
        for f in (_TOOL, _HELDOUT,
                  Path(__file__),
                  Path(__file__).parent / "test_grounding_v2_stage2.py"):
            text = f.read_text(encoding="utf-8")
            for w in self._BANNED:
                # 이 시험 자신의 금지 목록은 뺀다
                # ★부정문은 뺀다 — 「튜닝 독립성 증명이 **아니다**」는 맞는 말이다.
                hits = [ln for ln in text.splitlines()
                        if w in ln and "_BANNED" not in ln
                        and "아니" not in ln and "한때" not in ln]
                assert not hits, f"{f.name}: 「{w}」 — {hits[:1]}"


class TestTheAcceptanceToolsCostCapMatchesItsCodePath:
    """★★★**출력 문구만 상한이면 승인 문이 아니다** (Codex).

    두 가지를 잠근다:

    1. 세는 수가 **코드 경로**와 같은가 — A0 를 「그룹 수」로 세어 5 라고
       적었는데 A0 는 **저장 에피소드에서만** 돈다(합성 둘은
       `build_subject` + 고정 인용이라 A0 를 안 부른다). 실제는 3 이다
    2. 그 수가 **실제로 막는가** — `ResearchCallBudget` 이 상한에서 거절한다
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    class _Args:
        repeats = 3
        with_a0 = True

    def test_the_cap_is_counted_from_the_fixture_not_written_by_hand(self):
        t = self._tool()
        cap = t._cost_cap(t.load_fixture(), self._Args())
        assert cap["episodes"] == 3 and cap["synthetic"] == 2
        assert cap["groups"] == 5
        assert cap["classify"] == 5 * len(t.DEFAULT_JUDGES) * 3 == 30
        assert cap["a0"] == 3, "★A0 는 저장 에피소드에서만 돈다 — 합성은 안 부른다"
        assert cap["search"] == 9, "지정 아홉 축 · 축당 1회"

    def test_a0_is_zero_when_it_is_not_asked_for(self):
        t = self._tool()

        class _NoA0(self._Args):
            with_a0 = False

        assert t._cost_cap(t.load_fixture(), _NoA0())["a0"] == 0

    def test_the_printed_lines_say_it_is_a_slice_not_a_full_e2e(self):
        """★「full-episode 조사 E2E」로 읽히면 안 된다 (Codex)."""
        t = self._tool()
        text = "\n".join(t._cap_lines(
            t._cost_cap(t.load_fixture(), self._Args()), self._Args()))
        assert "42" in text
        assert "아홉 축만" in text and "E2E 가 아니다" in text

    def test_that_number_actually_blocks(self):
        """★문구가 아니라 **문**인지 — 상한에서 거절해야 한다."""
        from app.core.research_call_budget import (ResearchCallBudget,
                                                   ResearchCallBudgetExceeded,
                                                   install_budget,
                                                   research_calls_armed,
                                                   reserve_current_research_call,
                                                   uninstall_budget)

        t = self._tool()
        cap = t._cost_cap(t.load_fixture(), self._Args())
        b = ResearchCallBudget(cap=cap["search"])
        install_budget(b)
        try:
            bought = 0
            with pytest.raises(ResearchCallBudgetExceeded):
                for _ in range(cap["search"] + 5):
                    with research_calls_armed():
                        reserve_current_research_call(source="search")
                    bought += 1
            assert bought == cap["search"]
        finally:
            uninstall_budget()

    def test_an_unarmed_call_does_not_eat_the_search_budget(self):
        """★같은 공용 경계를 지나는 **다른** 호출은 안 세어야 한다."""
        from app.core.research_call_budget import (ResearchCallBudget,
                                                   install_budget,
                                                   reserve_current_research_call,
                                                   uninstall_budget)

        b = ResearchCallBudget(cap=9)
        install_budget(b)
        try:
            for _ in range(5):
                reserve_current_research_call(source="image-step")
            assert b.snapshot()["used"] == 0
        finally:
            uninstall_budget()


class TestTheToolAndProductionDecideTheSameWay:
    """★도구가 검증·판정을 **다시 만들면** 재는 것과 도는 것이 갈린다 (Codex)."""

    def test_the_tool_imports_the_production_callable(self):
        import inspect
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        src = inspect.getsource(t.sourced_verdict)
        assert "sanitize_and_decide" in src
        assert "validate_claim" not in src, \
            "★도구가 다시 검증하고 있다 — 오염된 줄을 조용히 버리게 된다"


class TestTheFreePreflightShowsTheNineBeforeAnyMoneyMoves:
    """★★유료 30회를 태운 **뒤에** 「후보에 없음」을 보면 늦다 (Codex).

    무료 preflight 가 잠긴 아홉 축의 실제 `research_subject_id` 까지 내고,
    하나라도 안 붙으면 **거기서 선다**.
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    class _Args:
        repeats = 3
        with_a0 = True

        def __init__(self, root):
            self.projects_root = root

    def test_it_names_every_axis_that_cannot_bind(self, tmp_path, capsys):
        t = self._tool()
        rc = t.preflight(self._Args(tmp_path), t.load_fixture())
        out = capsys.readouterr().out
        assert rc == 2, "안 붙는 축이 있는데 통과로 섰다"
        assert "합계 9축" in out
        # ★저장 7축은 못 찾고, 합성 2축은 파일이 없어도 id 가 나온다.
        #  표에 한 번, 「이것부터」 목록에 한 번 — 그래서 14 다.
        assert out.count("에피소드를 못 찾음") == 14
        assert out.count("못 붙은 축 7") == 1
        assert "rs_" in out

    def test_the_synthetic_ids_are_stable(self, tmp_path, capsys):
        """★판마다 id 가 달라지면 「같은 아홉 축」이 성립하지 않는다."""
        t = self._tool()
        t.preflight(self._Args(tmp_path), t.load_fixture())
        a = capsys.readouterr().out
        t.preflight(self._Args(tmp_path), t.load_fixture())
        b = capsys.readouterr().out
        assert a == b

    def test_it_prints_both_the_logical_and_the_physical_cap(self, tmp_path,
                                                             capsys):
        t = self._tool()
        t.preflight(self._Args(tmp_path), t.load_fixture())
        out = capsys.readouterr().out
        assert "논리 최대 **42**" in out
        assert "물리 상한" in out and "슬롯" in out


class TestReplayCannotSmuggleAnOldRunIntoANewContract:
    """★★재생이 **key 만** 보면, 계약을 바꾼 뒤에도 옛 응답으로 통과한다 (Codex).

    검색만 신원을 걸고 분류는 안 걸어 뒀었다 — 그래서 팩·판정자·표본 수를
    바꿔도 저장 파일이 그대로 먹혔다.
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    def test_a_replay_of_a_different_pack_is_refused(self, monkeypatch):
        t = self._tool()
        ident = t._identity_of("classify", pack="1.old", judges="gpt")
        monkeypatch.setattr(t, "_REPLAY",
                            {"k": {"__identity__": ident, "__value__": {"x": 1}}})
        with pytest.raises(SystemExit) as exc:
            t.cached_call("k", lambda: None,
                          identity=t._identity_of("classify", pack="14.new",
                                                  judges="gpt"))
        assert "신원이 다르다" in str(exc.value)

    def test_the_same_identity_replays_fine(self, monkeypatch):
        t = self._tool()
        ident = t._identity_of("classify", pack="14.new", judges="gpt")
        monkeypatch.setattr(t, "_REPLAY",
                            {"k": {"__identity__": ident, "__value__": {"x": 1}}})
        assert t.cached_call("k", lambda: None, identity=dict(ident)) == {"x": 1}

    def test_the_classifier_path_carries_an_identity_at_all(self):
        import inspect

        t = self._tool()
        src = inspect.getsource(t.cached_classify)
        assert "identity=" in src, "★분류만 신원 없이 재생된다"
        assert "PROMPT_PACK_VERSION" in src and "CLASSIFIER_CONTRACT_VERSION" in src


class TestTheBudgetComesDownEvenWhenTheRunThrows:
    """★`install_budget` 만 하고 정상 종료에서만 내리면, 중간에 예외가 나는
    순간 그 스레드에 예산이 **남는다**."""

    def test_the_tool_uses_the_production_scope_not_a_bare_install(self):
        import inspect
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        src = inspect.getsource(t.main)
        assert "research_run_scope" in src
        assert "install_budget(" not in src, \
            "★맨손 install 은 예외로 나갈 때 안 내려간다"
        assert "_RUN_STACK.close()" in inspect.getsource(t.cli)

    def test_the_scope_restores_what_was_there_before(self):
        from app.core.research_call_budget import (ResearchCallBudget,
                                                   get_current_budget,
                                                   install_budget,
                                                   research_run_scope,
                                                   uninstall_budget)

        outer = ResearchCallBudget(cap=3)
        install_budget(outer)
        try:
            try:
                with research_run_scope(cap=9):
                    assert get_current_budget() is not outer
                    raise RuntimeError("중간에 터진다")
            except RuntimeError:
                pass
            assert get_current_budget() is outer, "★앞 예산이 안 돌아왔다"
        finally:
            uninstall_budget()


class TestEveryPaidCallIsSavedBeforeTheNextOne:
    """★★★**산 것을 판정보다 먼저 저장한다** — 유료 산출 23개를 그렇게 날린 적이 있다.

    한 판(판정자 2 × 표본 3 = **6 논리 호출**)이 다 끝난 뒤에만 저장하면,
    네 번째에서 끊길 때 **앞서 산 셋까지 다시 산다** (Codex).
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    def test_the_production_call_hands_back_each_sample(self):
        """★`search_claims(on_batch=)` 와 같은 자리가 분류에도 있어야 한다."""
        from unittest.mock import patch

        import app.modules.pipeline.grounding_classifier as gc

        seen = []
        fake = {"records": [], "fingerprint": {"payload_hash": "h"},
                "contract_version": gc.CLASSIFIER_CONTRACT_VERSION}
        with patch.object(gc, "classify", lambda *a, **k: fake):
            gc.classify_samples([], samples=3, judges=["gpt", "grok"],
                                on_sample=seen.append)
        assert len(seen) == 6, "판정자 2 × 표본 3 = 6 인데 그만큼 안 넘어왔다"
        assert [s["sample_index"] for s in seen] == [0, 1, 2, 0, 1, 2]

    def test_a_crash_midway_still_leaves_what_was_already_bought(self, tmp_path,
                                                                 monkeypatch):
        from unittest.mock import patch

        import app.modules.pipeline.grounding_classifier as gc

        t = self._tool()
        out = tmp_path / "rec.json"
        monkeypatch.setattr(t, "_FLUSH_TO", out)
        monkeypatch.setattr(t, "_CACHE", {})
        monkeypatch.setattr(t, "_REPLAY", None)

        n = {"i": 0}

        def _boom(*a, **k):
            n["i"] += 1
            if n["i"] == 4:
                raise RuntimeError("네 번째에서 끊긴다")
            return {"records": [], "fingerprint": {"payload_hash": "h"},
                    "contract_version": gc.CLASSIFIER_CONTRACT_VERSION}

        with patch.object(gc, "classify", _boom):
            with pytest.raises(RuntimeError):
                t.cached_classify("stored:ep", subjects=[], samples=3)

        import json
        saved = json.loads(out.read_text(encoding="utf-8"))
        assert len(saved) == 3, \
            f"★끊기기 전에 산 셋이 안 남았다 — 다시 산다 ({sorted(saved)})"
        # ★칸 이름은 (판정자, 표본 번호) 다 — 도착 순서가 아니다
        assert sorted(saved) == ["stored:ep#gpt#0", "stored:ep#gpt#1",
                                 "stored:ep#gpt#2"], sorted(saved)

    def test_the_a0_call_carries_an_identity_too(self):
        import inspect

        t = self._tool()
        src = inspect.getsource(t.main)
        assert "_a0_ident" in src and "A0_CONTRACT_VERSION" in src, \
            "★A0 만 key 만 보고 재생된다"


class TestHeldOutAlsoSavesWhatItBought:
    """★★held-out 유료 경로는 `main()` 이 **분기에서 바로 return** 한다.

    `_FLUSH_TO` 를 그 분기 **아래**에서 잡아 뒀더니, 새 프로세스의 held-out
    주행은 `_flush` 가 통째로 no-op 이었다 — 중단이든 완주든 산 것을 잃는다
    (Codex). 끝점(`main`)에서 잰다: 중간에 터져도 앞서 산 것이 파일에 남는가.
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    def test_a_crash_in_the_middle_of_held_out_still_leaves_the_earlier_calls(
            self, tmp_path, monkeypatch, capsys):
        import json
        from unittest.mock import patch

        import app.modules.pipeline.grounding_classifier as gc

        t = self._tool()
        out = tmp_path / "heldout.json"
        monkeypatch.setattr(t, "_CACHE", {})
        monkeypatch.setattr(t, "_REPLAY", None)
        monkeypatch.setattr(t, "_FLUSH_TO", None)   # ★main 이 잡아야 한다
        monkeypatch.setattr(
            sys_argv := __import__("sys"), "argv",
            ["tool", "--held-out", "--i-know-this-costs-money",
             "--save-records", str(out), "--repeats", "1"])

        n = {"i": 0}

        def _boom(*a, **k):
            n["i"] += 1
            if n["i"] == 3:
                raise RuntimeError("세 번째에서 끊긴다")
            return {"records": [], "fingerprint": {"payload_hash": "h"},
                    "contract_version": gc.CLASSIFIER_CONTRACT_VERSION}

        with patch.object(gc, "classify", _boom):
            with pytest.raises(RuntimeError):
                t.cli()
        capsys.readouterr()

        assert out.exists(), "★held-out 이 산 것을 한 글자도 안 남겼다"
        saved = json.loads(out.read_text(encoding="utf-8"))
        assert saved, f"파일은 생겼는데 비었다 — {saved}"

    def test_the_flush_target_is_set_before_any_branch(self):
        """★`--preflight`·`--held-out` 둘 다 분기에서 바로 나간다."""
        import inspect

        t = self._tool()
        src = inspect.getsource(t.main)
        i_flush = src.index("_FLUSH_TO = args.save_records")
        assert i_flush < src.index("if args.preflight:")
        assert i_flush < src.index("if args.held_out:")


class TestTheReplayIdentityIsTheRealPayload:
    """★★신원을 **subject id 목록**으로 잡으면 지시문이 바뀌어도 안 움직인다.

    그러면 팩 13.x 로 산 응답이 14.x 판정에 그대로 먹힌다. 신원의 뿌리는
    **실제로 나갈 payload** 여야 하고, 그것은 프로덕션과 **같은 함수**로 낸다
    (Codex).
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    @staticmethod
    def _subj():
        from app.modules.pipeline.grounding_subject import build_subject
        return [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                              surface_form="가", owner_type="prop")]

    def test_the_free_planner_matches_what_the_call_actually_sends(self):
        """★preflight 가 세는 신원과 실제로 나가는 것이 갈리면 안 된다."""
        from unittest.mock import patch

        import app.modules.pipeline.grounding_classifier as gc

        subj = self._subj()
        want = gc.planned_payload(subj, era="1983년", region="대한민국")
        seen = {}

        def _fake(**kw):
            # ★**실제로 나간 것**으로 다시 낸다 — 지문 칸을 믿지 않는다
            seen["payload"] = gc.payload_identity(
                kw["system_prompt"], kw["user_prompt"], kw["response_schema"])
            return {"classifications": []}

        with patch.object(gc, "_call_structured", _fake):
            out = gc.classify(subj, era="1983년", region="대한민국")
        assert seen["payload"] == want, \
            "★무료 preflight 가 센 신원과 실제로 나간 payload 가 다르다"
        assert out["fingerprint"]["payload_hash"] == want

    def test_changing_the_system_prompt_moves_the_identity(self, monkeypatch):
        """★지시문 한 줄만 바뀌어도 옛 응답이 새 계약을 통과하면 안 된다."""
        import app.modules.pipeline.grounding_classifier as gc

        subj = self._subj()
        before = gc.planned_payload(subj, era="1983년")
        real = gc.load_pack()

        def _tweaked(**kw):
            out = dict(real)
            stems = {k: dict(v) for k, v in real["stems"].items()}
            stems[gc.SYSTEM_STEM]["content"] += "\n한 줄 더."
            out["stems"] = stems
            return out

        monkeypatch.setattr(gc, "load_pack", _tweaked)
        assert gc.planned_payload(subj, era="1983년") != before

    def test_the_tool_uses_that_function_not_a_subject_id_list(self):
        import inspect

        t = self._tool()
        src = inspect.getsource(t.cached_classify)
        assert "planned_payload" in src
        assert "research_subject_id" not in src, \
            "★subject id 목록으로 신원을 잡으면 지시문 변화를 못 잡는다"


class TestResumeBuysOnlyWhatIsMissing:
    """★★★Codex 가 못박은 완료 조건 그대로 잰다:

        4번째에서 실패 → **같은 기록으로 재개** → provider 는 **남은 3회만**
        호출 → 최종 산출이 **무중단 6회와 같다**

    저장만 하고 재개를 못 하면 「산 것을 남겼다」가 **돈을 안 아낀다**.
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    @staticmethod
    def _subj():
        from app.modules.pipeline.grounding_subject import build_subject
        return [build_subject(project_id="p", episode_id="e",
                              source_anchor="S1", surface_form="가",
                              owner_type="prop")]

    @classmethod
    def _rec(cls, alias, n):
        import app.modules.pipeline.grounding_classifier as gc
        sid = cls._subj()[0]["research_subject_id"]
        return {"records": [{"research_subject_id": sid,
                             "grounding_class": "externally_grounded",
                             "판정자": alias}],
                "fingerprint": {"payload_hash": "h", "판정자": alias},
                "contract_version": gc.CLASSIFIER_CONTRACT_VERSION}

    def _run(self, t, monkeypatch, tmp_path, *, boom_at=None, resume=None):
        import json
        from unittest.mock import patch

        import app.modules.pipeline.grounding_classifier as gc

        out = tmp_path / f"rec{boom_at}{bool(resume)}.json"
        monkeypatch.setattr(t, "_FLUSH_TO", out)
        monkeypatch.setattr(t, "_CACHE", {})
        monkeypatch.setattr(t, "_REPLAY", None)
        monkeypatch.setattr(t, "_RESUME",
                            json.loads(resume.read_text(encoding="utf-8"))
                            if resume else None)
        calls = {"n": 0, "who": []}

        def _fake(subjects, *, project_config=None, **kw):
            calls["n"] += 1
            alias = (project_config or {})[gc.STEP_NAME]["model"]
            calls["who"].append(alias)
            if boom_at and calls["n"] == boom_at:
                raise RuntimeError(f"{boom_at}번째에서 끊긴다")
            return self._rec(alias, calls["n"])

        with patch.object(gc, "classify", _fake):
            try:
                got = t.cached_classify("stored:ep", subjects=self._subj(),
                                        samples=3)
            except RuntimeError:
                got = None
        return got, calls["n"], out

    def test_a_resumed_run_buys_only_the_missing_three(self, tmp_path,
                                                       monkeypatch):
        t = self._tool()
        _got, n1, part = self._run(t, monkeypatch, tmp_path, boom_at=4)
        assert n1 == 4, "네 번째에서 안 끊겼다"

        got2, n2, _ = self._run(t, monkeypatch, tmp_path, resume=part)
        assert n2 == 3, f"★재개인데 provider 를 {n2}회 불렀다 — 다시 사고 있다"
        assert got2 is not None
        assert got2["reused_calls"] == 3 and got2["bought_calls"] == 3

    def test_the_resumed_result_matches_an_uninterrupted_run(self, tmp_path,
                                                            monkeypatch):
        t = self._tool()
        _g, _n, part = self._run(t, monkeypatch, tmp_path, boom_at=4)
        resumed, _, _ = self._run(t, monkeypatch, tmp_path, resume=part)
        whole, n, _ = self._run(t, monkeypatch, tmp_path)
        assert n == 6
        assert resumed["by_subject"] == whole["by_subject"]
        assert resumed["logical_calls"] == whole["logical_calls"] == 6
        # ★같은 산출인데 **산 것은 절반**이어야 한다
        assert (resumed["bought_calls"], resumed["reused_calls"]) == (3, 3)
        assert (whole["bought_calls"], whole["reused_calls"]) == (6, 0)

    def test_the_resumed_run_restores_provenance_the_same_way(self, tmp_path,
                                                              monkeypatch):
        """★★재사용 표본과 새 표본이 **같은 좌표로 합쳐져야** 한다.

        `by_subject` 만 같고 `runs`(지문)가 어긋나면, 그 판은
        `check_provenance` 에서 미확정으로 서거나 — 더 나쁘게 — 다른 판정자의
        표본이 한 칸에 섞인다.
        """
        t = self._tool()
        _g, _n, part = self._run(t, monkeypatch, tmp_path, boom_at=4)
        resumed, _, _ = self._run(t, monkeypatch, tmp_path, resume=part)
        whole, _, _ = self._run(t, monkeypatch, tmp_path)

        assert resumed["runs"] == whole["runs"], "★지문이 어긋난다"
        assert resumed["judges"] == whole["judges"]
        # 판정자별 표본 수가 계약대로인가 — 한쪽에 몰리면 안 된다
        for out in (resumed, whole):
            per = {}
            for recs in out["by_subject"].values():
                for r in recs:
                    per[r["judge_alias"]] = per.get(r["judge_alias"], 0) + 1
            assert per == {"gpt": 3, "grok": 3}, per

    def test_a_second_crash_does_not_lose_the_first_runs_calls(self, tmp_path,
                                                               monkeypatch):
        """★★재개판이 또 끊겨도 **앞판에서 산 것**이 새 기록에 남아야 한다.

        이어 받은 칸을 새 파일에 안 남기면, 재개하려고 만든 것이 재개를
        못 하게 만든다 — 두 번째 끊김에서 앞판 셋이 사라진다.
        """
        import json

        t = self._tool()
        _g, _n, first = self._run(t, monkeypatch, tmp_path, boom_at=4)
        assert len(json.loads(first.read_text(encoding="utf-8"))) == 3

        # 재개했는데 (새로 사는) 첫 호출에서 또 끊긴다
        _g2, n2, second = self._run(t, monkeypatch, tmp_path,
                                    boom_at=1, resume=first)
        assert n2 == 1, "★재개인데 앞판 셋을 다시 샀다"
        saved = json.loads(second.read_text(encoding="utf-8"))
        assert len(saved) == 3, \
            f"★앞판에서 산 셋이 새 기록에서 사라졌다 — 다음 재개가 다시 산다 ({sorted(saved)})"

    def test_a_record_from_a_different_contract_is_not_resumed(self, tmp_path,
                                                               monkeypatch):
        """★신원이 다르면 **없는 것으로 친다** — 이어 붙이면 오염이다."""
        import json

        t = self._tool()
        _g, _n, part = self._run(t, monkeypatch, tmp_path, boom_at=4)
        bad = json.loads(part.read_text(encoding="utf-8"))
        for row in bad.values():
            row["__identity__"] = {**row["__identity__"], "pack": "1.아주옛것"}
        other = tmp_path / "bad.json"
        other.write_text(json.dumps(bad, ensure_ascii=False), encoding="utf-8")

        _got, n, _ = self._run(t, monkeypatch, tmp_path, resume=other)
        assert n == 6, "★옛 팩 기록을 그대로 이어 붙였다"


class TestTheCostLineSeparatesLogicalFromPhysical:
    """★「물리 18」은 **검색만**이었다 (Codex).

    분류 30 · A0 3 도 실제 전송이다. 그 둘은 `strict_single_attempt=True` 라
    논리 1 = 물리 1 이지만, **전체 물리**에 안 세면 승인 요청이 실제보다 작다.
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    class _Args:
        repeats = 3
        with_a0 = True

    def test_it_prints_the_whole_physical_cap_not_just_search(self):
        from app.core.openai_keys import slot_count

        t = self._tool()
        cap = t._cost_cap(t.load_fixture(), self._Args())
        text = "\n".join(t._cap_lines(cap, self._Args()))
        # ★세 갈래 **전부** 슬롯 수를 곱한다 — `strict_single_attempt` 는
        #  키 슬롯 전환을 안 막는다(실측: 429 로 primary → secondary).
        logical = cap["classify"] + cap["a0"] + cap["search"]
        whole = logical * max(1, slot_count())
        assert f"전체 **≤{whole}**" in text, text
        assert "키 슬롯 전환은 별개 loop" in text
        assert "논리 최대 **42**" in text

    def test_it_says_which_part_the_budget_actually_gates(self):
        """★막는 것과 안 막는 것을 갈라 적는다 — 안 그러면 승인이 거짓이다."""
        t = self._tool()
        text = "\n".join(t._cap_lines(
            t._cost_cap(t.load_fixture(), self._Args()), self._Args()))
        assert "실제로 막는 것은 검색" in text
        assert "예산 문이 아니다" in text


class TestA0IdentityIsTheRealPayloadToo:
    """★A0 만 팩 **버전**으로 잠겨 있었다 — 같은 버전 안에서 지문 bytes 가
    바뀌면 옛 후보를 새 판에 재생한다 (Codex)."""

    def test_a0_has_the_same_free_planner_as_the_classifier(self):
        from app.modules.pipeline import grounding_a0 as a0

        a = a0.planned_payload("원문 하나", era="1983년", region="대한민국")
        b = a0.planned_payload("원문 하나", era="2020년", region="대한민국")
        assert a != b, "★era 를 바꿨는데 신원이 안 움직인다"
        assert a == a0.planned_payload("원문 하나", era="1983년", region="대한민국")

    def test_it_matches_what_collect_candidates_actually_sends(self):
        from unittest.mock import patch

        from app.modules.pipeline import grounding_a0 as a0

        want = a0.planned_payload("원문 하나", era="1983년", region="대한민국")
        seen = {}

        def _fake(**kw):
            seen["p"] = a0.payload_identity(kw["system_prompt"],
                                            kw["user_prompt"],
                                            kw["response_schema"])
            return {"candidates": []}

        with patch.object(a0, "_call_structured", _fake):
            a0.collect_candidates("원문 하나", project_id="p", episode_id="e",
                                  era="1983년", region="대한민국")
        assert seen["p"] == want, "★무료 preflight 와 실제로 나간 payload 가 다르다"

    def test_the_requested_alias_moves_the_identity(self, monkeypatch):
        """★★A0 는 판정자를 명시로 안 받고 **manifest 기본값**을 탄다.

        그 기본값이 바뀌면 **다른 모델이 건진 후보**인데, 신원에 안 접으면
        옛 후보를 새 판에 재생한다 (Codex). 검색이 `model` 을, 분류가 `judges`
        를 접는 것과 같은 이유다.
        """
        import sys

        from app.modules.pipeline import grounding_a0 as a0

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t

        base = a0.requested_model()
        assert base, "부탁할 모델을 못 읽는다"
        a = t._identity_of("a0", model=base)
        b = t._identity_of("a0", model="아주다른모델")
        assert a != b

    def test_the_manifest_default_is_what_it_reads(self):
        from app.modules.pipeline import grounding_a0 as a0

        assert a0.requested_model({"grounding_a0": {"model": "명시한것"}}) \
            == "명시한것"

    def test_the_fingerprint_keeps_requested_and_actual_apart(self):
        """★부탁한 것과 응답이 말한 것이 갈리면 그것이 tier fallback 이다."""
        from unittest.mock import patch

        from app.modules.pipeline import grounding_a0 as a0

        with patch.object(a0, "_call_structured",
                          lambda **kw: {"candidates": []}):
            out = a0.collect_candidates("원문 하나", project_id="p",
                                        episode_id="e", era="1983년",
                                        region="대한민국")
        fp = out["fingerprint"]
        assert fp["requested_model_alias"] == a0.requested_model()
        assert "judge_physical_model" in fp

    def test_the_tool_folds_the_payload_and_the_scope(self):
        import inspect
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        src = inspect.getsource(t.main)
        assert "_a0.planned_payload(" in src
        assert "scope=f\"{_pid}/{_eid}\"" in src, \
            "★범위가 다르면 subject id 가 달라진다 — 같은 후보가 아니다"


class TestThePreflightIdsMatchWhatTheRunActuallyBuys:
    """★★★preflight 가 **A0 없이** 지은 subject 로 id 를 찍고
    「이것이 이번 주행이 살 아홉 축」이라고 보고했다 — **거짓이었다.**

    실측(2026-08-30 §3c): 아홉 중 **둘**이 달랐다.

        preflight  fb7a883f/P01 → rs_2eab8600c87e7b7f51241628
        실제 주행                → rs_bbe9476b56b012e10724ed6a

    A0 가 건진 원문이 subject 를 바꾸기 때문이다. 기록이 있으면 그 A0 산출로
    **주행과 같은 subject** 를 짓는다.
    """

    @staticmethod
    def _tool():
        import sys

        sys.path.insert(0, str(_TOOL.parent))
        import grounding_controls_acceptance as t
        return t

    def test_it_reads_a0_out_of_a_resume_file(self, monkeypatch):
        t = self._tool()
        monkeypatch.setattr(t, "_REPLAY", None)
        monkeypatch.setattr(t, "_RESUME", {
            "a0:ep1": {"__identity__": {}, "__value__": {"candidates": [{"x": 1}]}}})
        assert t._a0_from_records("ep1") == [{"x": 1}]

    def test_it_reads_a0_out_of_a_replay_file_too(self, monkeypatch):
        t = self._tool()
        monkeypatch.setattr(t, "_RESUME", None)
        monkeypatch.setattr(t, "_REPLAY", {
            "a0:ep1": {"__identity__": {}, "__value__": {"candidates": [{"y": 2}]}}})
        assert t._a0_from_records("ep1") == [{"y": 2}]

    def test_no_records_means_none_not_an_empty_list(self, monkeypatch):
        """★빈 목록을 돌려주면 「A0 가 아무것도 안 건졌다」와 못 가른다."""
        t = self._tool()
        monkeypatch.setattr(t, "_RESUME", None)
        monkeypatch.setattr(t, "_REPLAY", None)
        assert t._a0_from_records("ep1") is None

    def test_the_preflight_says_so_when_it_is_guessing(self, tmp_path, capsys,
                                                       monkeypatch):
        """★A0 전 id 를 찍을 때는 **그렇다고 적어야** 한다."""
        t = self._tool()
        monkeypatch.setattr(t, "_RESUME", None)
        monkeypatch.setattr(t, "_REPLAY", None)

        class _Args:
            repeats = 3
            with_a0 = True
            projects_root = tmp_path

        t.preflight(_Args(), t.load_fixture())
        out = capsys.readouterr().out
        assert "A0 **전** 상태" in out
        assert "주행이 실제로 살 id 와 다를 수 있다" in out
