"""C(c) payload 조립과 좌표 매김. ★유료 0 — provider 를 안 부른다.

★**끝점에서 잰다.** 여기서 만든 행이 `reduce_episode` 에 **그대로** 들어가야
의미가 있다. 조립부만 부르면 모양이 안 맞는 것을 못 본다 — 그 실수를 이 판에
아홉 번 했다.
"""
from __future__ import annotations

import pytest

from app.modules.pipeline import grounding_chunk as gc
from app.modules.pipeline import grounding_chunk_merge as cm
from tests.grounding.fixtures import synthetic_episode as ep

WORLD = "가상의 근대 이후 어느 시기"


def _ids(bundle):
    return [f"scene-{i}" for i in bundle]


def _row(quote, index=1, owner="prop", surface="x", hard=True, notice=True,
         evidence=()):
    """모델이 내는 모양. ★언급과 근거가 **다른 칸**이다."""
    return {"owner_type": owner, "surface_form": surface,
            "mentions": [{"mention_quote": quote, "occurrence_index": index}],
            "evidence_quotes": list(evidence),
            "hard_to_generate": hard, "viewers_would_notice": notice,
            "visual_brief": "", "search_terms_native": [],
            "language_lock_native": ""}


class TestTheQuoteBecomesACanonicalSpan:
    def test_the_span_lands_on_the_quote(self):
        segs = ep.segment_texts()
        ids = _ids(ep.bundles()[0])
        rows = gc.resolve_rows([_row("가방")], chunk_id="c0",
                               segment_ids=ids, segments=segs)["rows"]
        sp = rows[0]["occurrences"][0]["source_span"]
        assert segs[sp["segment_id"]][sp["start"]:sp["end"]] == "가방"

    def test_a_fabricated_quote_quarantines_the_row(self):
        """★★인용을 지어내면 **그 행이 통째로** 자동 경로에서 빠진다.

        ★유료 주행이 이 결을 정했다 (2026-08-31). 185분의 2 때문에 구간
        전체를 죽이면 판이 끝나고, 틀린 것만 떼면 「서로 다른 대상을 한 행에
        합친 오염」이 깨끗한 척 통과한다 (Codex 제3안).
        """
        segs = ep.segment_texts()
        got = gc.resolve_rows([_row("이런 말은 원고에 없습니다")],
                              chunk_id="c0",
                              segment_ids=_ids(ep.bundles()[0]), segments=segs)
        assert got["rows"] == [], "★일부라도 자동 경로에 살렸다"
        assert len(got["quarantined"]) == 1
        q = got["quarantined"][0]
        assert q["local_id"] == "c0#0" and q["chunk_id"] == "c0"
        assert q["problems"][0]["kind"] == gc.Q_MENTION
        assert q["raw_mentions"] and q["processing_contract"]

    def test_a_bad_occurrence_index_quarantines_the_row(self):
        segs = ep.segment_texts()
        got = gc.resolve_rows([_row("가방", index=99)], chunk_id="c0",
                              segment_ids=_ids(ep.bundles()[0]), segments=segs)
        assert got["rows"] == [] and len(got["quarantined"]) == 1

    def test_the_second_occurrence_is_a_different_span(self):
        """★한 구간 안 2회 — 두 자리가 **달라야** 한다."""
        segs = ep.segment_texts()
        ids = _ids(next(b for b in ep.bundles() if 2 in b))   # 「정류장 표지」 2회
        rows = gc.resolve_rows([{
            "owner_type": "location_part", "surface_form": "정류장 표지",
            "mentions": [
                {"mention_quote": "정류장 표지", "occurrence_index": 1},
                {"mention_quote": "정류장 표지", "occurrence_index": 2}],
            "evidence_quotes": [],
            "hard_to_generate": True, "viewers_would_notice": True,
            "visual_brief": "", "search_terms_native": [],
            "language_lock_native": ""}],
            chunk_id="c1", segment_ids=ids, segments=segs)["rows"]
        a, b = [o["source_span"] for o in rows[0]["occurrences"]]
        assert a != b, "★같은 자리를 두 번 셌다 — 출현 수가 거짓이 된다"
        assert a["start"] < b["start"]

    def test_the_index_counts_across_the_whole_chunk(self):
        """★구간이 여러 씬이면 **이어서** 센다 — 모델이 본 것도 이어진 본문이다.

        ★한 판 앞서 나는 여기를 `A or len(...)==2` 로 적었다. 그건 **거의
        무엇이든 통과하는** 조건이라 아무것도 안 재고 초록이었다. 원고에서
        실제 수를 세어 자리를 못박는다 — 1장에 2번, 4장에 1번이므로
        3번째가 **4장으로 넘어가야** 한다.
        """
        segs = ep.segment_texts()
        ids = ["scene-1", "scene-4"]
        assert (segs["scene-1"].count("가방"), segs["scene-4"].count("가방")) \
            == (2, 1), "★원고가 바뀌었다 — 이 시험의 전제부터 다시 세라"
        rows = gc.resolve_rows([{
            "owner_type": "prop", "surface_form": "가방",
            "mentions": [{"mention_quote": "가방", "occurrence_index": n}
                         for n in (1, 2, 3)],
            "evidence_quotes": [],
            "hard_to_generate": True, "viewers_would_notice": True,
            "visual_brief": "", "search_terms_native": [],
            "language_lock_native": ""}],
            chunk_id="c0", segment_ids=ids, segments=segs)["rows"]
        got = [o["source_span"]["segment_id"]
               for o in rows[0]["occurrences"]]
        assert got == ["scene-1", "scene-1", "scene-1"][:2] + ["scene-4"], (
            f"★씬을 넘어 이어 세지 않았다 — {got}")

    def test_counting_stops_at_the_end_of_the_chunk(self):
        """★positive control — 구간 밖까지 세면 안 된다."""
        segs = ep.segment_texts()
        got = gc.resolve_rows([_row("가방", index=9)], chunk_id="c0",
                              segment_ids=["scene-1"], segments=segs)
        assert got["rows"] == [] and len(got["quarantined"]) == 1

    def test_an_owner_outside_the_five_stops(self):
        with pytest.raises(ValueError, match="모르는 owner"):
            gc.resolve_rows([_row("가방", owner="vehicle")], chunk_id="c0",
                            segment_ids=_ids(ep.bundles()[0]),
                            segments=ep.segment_texts())["rows"]


class TestWhatGoesOutTheWire:
    def test_the_chunk_payload_carries_the_segment_text_whole(self):
        """★**자르지 않는다.** 구간 본문이 통째로 실려야 한다."""
        segs = ep.segment_texts()
        ids = _ids(ep.bundles()[0])
        p = gc.build_chunk_payload(ids, segs, WORLD)
        text = p["parts"][0]["text"]
        for sid in ids:
            assert segs[sid] in text, f"★{sid} 본문이 잘렸다"

    def test_the_merge_payload_does_not_carry_the_manuscript(self):
        """★★★**C(c) 의 이득이 여기다.** merge 가 원문을 다시 받으면
        「원문이 한 번」이 깨지고 C 를 할 이유가 없어진다."""
        segs = ep.segment_texts()
        ids = _ids(ep.bundles()[0])
        rows = gc.resolve_rows([_row("가방")], chunk_id="c0",
                               segment_ids=ids, segments=segs)["rows"]
        text = gc.build_merge_payload(rows)["parts"][0]["text"]
        for sid, body in segs.items():
            assert body not in text, f"★merge 에 {sid} 원문이 실렸다"

    def test_the_merge_payload_is_much_smaller_than_the_chunk(self):
        segs = ep.segment_texts()
        ids = _ids(ep.bundles()[0])
        rows = gc.resolve_rows([_row("가방")], chunk_id="c0",
                               segment_ids=ids, segments=segs)["rows"]
        chunk_b = len(gc.build_chunk_payload(ids, segs, WORLD)
                      ["parts"][0]["text"].encode("utf-8"))
        merge_b = len(gc.build_merge_payload(rows)
                      ["parts"][0]["text"].encode("utf-8"))
        assert merge_b < chunk_b

    def test_an_unknown_segment_stops(self):
        with pytest.raises(KeyError):
            gc.build_chunk_payload(["scene-99"], ep.segment_texts(), WORLD)


class TestThePackIsRealAndFailsClosed:
    def test_a_missing_pack_version_stops(self):
        with pytest.raises(FileNotFoundError):
            gc.pack_dir("0.000000000000")

    def test_every_file_the_builders_need_is_there(self):
        for name in ("system.md", "merge_system.md"):
            assert gc.load_text(name).strip()
        for name in ("chunk_schema.json", "merge_schema.json"):
            assert gc.load_schema(name)["type"] == "object"

    def test_the_chunk_schema_asks_for_exactly_the_five_owners(self):
        """★갈래 목록을 팩에 손으로 적었다 — 계약과 어긋나면 여기서 선다."""
        s = gc.load_schema("chunk_schema.json")
        enum = (s["properties"]["rows"]["items"]["properties"]
                ["owner_type"]["enum"])
        assert sorted(enum) == sorted(cm.OWNERS)

    def test_the_merge_schema_asks_for_exactly_the_known_relations(self):
        s = gc.load_schema("merge_schema.json")
        enum = (s["properties"]["decisions"]["items"]["properties"]
                ["relation"]["enum"])
        assert sorted(enum) == sorted(cm.RELATIONS)

    def test_the_chunk_schema_does_not_ask_for_char_offsets(self):
        """★모델에게 좌표를 세라고 하면 셈 실패와 관측 실패가 한 통에 담긴다."""
        s = gc.load_schema("chunk_schema.json")
        occ = (s["properties"]["rows"]["items"]["properties"]
               ["mentions"]["items"]["properties"])
        assert "start" not in occ and "end" not in occ
        assert "source_span" not in occ

    def test_the_chunk_schema_does_not_ask_the_model_for_an_id(self):
        s = gc.load_schema("chunk_schema.json")
        props = s["properties"]["rows"]["items"]["properties"]
        for banned in ("local_id", "stable_id", "id", "existing_id_or_new"):
            assert banned not in props, f"★모델에게 {banned} 를 물었다"


class TestItReachesTheMergeEndpoint:
    """★★★**끝점.** 여기서 만든 행이 `reduce_episode` 에 그대로 들어가나."""

    def test_rows_from_two_chunks_reduce_without_touching_them(self):
        segs = ep.segment_texts()
        r0 = gc.resolve_rows([_row("가방", surface="가방")], chunk_id="c0",
                             segment_ids=["scene-1"], segments=segs)["rows"]
        r3 = gc.resolve_rows([_row("가방", surface="가방")], chunk_id="c3",
                             segment_ids=["scene-4"], segments=segs)["rows"]
        got = cm.reduce_episode(r0 + r3, [{
            "remove_local_id": r3[0]["local_id"],
            "keep_local_id": r0[0]["local_id"],
            "relation": cm.REL_SAME}], segments=segs)
        assert got["counts"]["out"] == 1
        # ★두 구간에서 한 번씩 = 2회 → 반복 축으로 등록된다
        kept = got["rows"][0]["local_id"]
        assert got["registered"][kept]["disposition"] == cm.DISP_REGISTERED

    def test_a_single_occurrence_needs_both_flags_on_the_same_row(self):
        segs = ep.segment_texts()
        hard_only = gc.resolve_rows(
            [_row("옛 요금표", surface="옛 요금표", hard=True, notice=False)],
            chunk_id="c2", segment_ids=["scene-3"], segments=segs)["rows"]
        got = cm.reduce_episode(hard_only, [], segments=segs)
        lid = hard_only[0]["local_id"]
        # ★한쪽만 켜진 것은 「아님」이 아니라 **미확정**이다 (기존 계약).
        assert got["registered"][lid]["disposition"] == cm.DISP_UNRESOLVED

        both = gc.resolve_rows(
            [_row("옛 요금표", surface="옛 요금표", hard=True, notice=True)],
            chunk_id="c2", segment_ids=["scene-3"], segments=segs)["rows"]
        got = cm.reduce_episode(both, [], segments=segs)
        lid = both[0]["local_id"]
        assert got["registered"][lid]["disposition"] == cm.DISP_REGISTERED


class TestThePreflightCountsInsteadOfStating:
    """★★★수를 **손으로 적지 않는다.**

    앞서 나는 「최대 26 호출」이라고 말했는데, 그 26 은 다른 원고(66천자)의
    수를 이 원고(6,823자)에 그대로 옮겨 붙인 것이었다. 숫자를 문장으로만
    말하면 그런 일이 안 잡힌다 — 그래서 **세는 쪽**을 잠근다.
    """

    WORLD = "가상의 근대 이후 어느 시기"

    def test_arm_b_is_one_call_per_chunk_plus_one_merge(self):
        from tools.grounding_audit import preflight_cc_experiment as pf

        calls = pf.arm_b_calls(self.WORLD)
        assert len(calls) == len(ep.bundles()) + 1
        assert sum(1 for c in calls if c["kind"] == "chunk") == len(ep.bundles())
        assert sum(1 for c in calls if c["kind"] == "merge") == 1

    def test_every_chunk_call_has_its_own_identity(self):
        """★payload 가 겹치면 같은 것을 두 번 사는 것이다."""
        from tools.grounding_audit import preflight_cc_experiment as pf

        ids = [c["identity"] for c in pf.arm_b_calls(self.WORLD)
               if c["identity"]]
        assert len(ids) == len(ep.bundles()), "★구간마다 신원이 안 났다"
        assert len(ids) == len(set(ids)), "★같은 신원이 두 번 있다"

    def test_the_merge_identity_is_left_open_not_invented(self):
        """★★구간이 답해야 정해지는 것을 **지어내지 않는다.**

        앞 판에 나는 「13 신원 전부」라고 보고했는데 merge 는 비어 있어
        실제로는 12 였다. 비어 있다는 것 자체를 시험으로 잠근다.
        """
        from tools.grounding_audit import preflight_cc_experiment as pf

        m = [c for c in pf.arm_b_calls(self.WORLD) if c["kind"] == "merge"]
        assert len(m) == 1 and m[0]["identity"] == ""

    def test_the_model_is_resolved_not_hardcoded(self):
        """★앞 판 도구는 화면에 `gpt` 를 **박아** 찍었다 — 모델이 바뀌어도
        같은 글자가 나왔다."""
        from tools.grounding_audit import cc_runner as rr
        from tools.grounding_audit import preflight_cc_experiment as pf

        want = rr.physical_model()
        assert want and want != rr.MODEL_ALIAS, "★alias 가 그대로 물리 모델이다"
        for c in pf.arm_b_calls(self.WORLD):
            assert c["physical"] == want

    def test_only_the_chunk_calls_are_marked_parallel(self):
        """★merge 는 구간 뒤에 선다 — 병렬로 세면 벽시계 이야기가 거짓이 된다."""
        from tools.grounding_audit import preflight_cc_experiment as pf

        for c in pf.arm_b_calls(self.WORLD):
            assert c["parallel"] is (c["kind"] == "chunk")


class TestThePromptCarriesRelationsNotANounList:
    """★★★고정 명사 목록으로 의미 분류를 유도하는 것도 hard prompt 다 (Codex).

    「작품 고유명사가 아니다」는 안전 근거가 못 된다. 목록에 든 낱말 쪽으로
    분류가 쏠리고, 목록 밖 대상이 조용히 `prop` 으로 밀린다.
    ★기존 팩에 같은 표가 있다는 것은 **같은 부채가 남아 있다**는 뜻이다.
    """

    def test_the_owner_table_names_relations_only(self):
        body = gc.load_text("system.md")
        table = body[body.index("owner_type 을 가르는 법"):
                     body.index("## 어떻게 적나")]
        assert "관계" in table
        # ★갈래 이름(enum)은 구조 계약이라 남는다. 그 밖의 **고정 명사 예시**가
        #  없어야 한다 — 있으면 그 낱말 쪽으로 분류가 쏠린다.
        for w in ("옷", "모자", "신발", "장신구", "설비", "구조물",
                  "바닥", "벽면", "간판", "얼굴", "체형", "머리 모양"):
            assert w not in table, f"★고정 명사 예시 {w!r} 가 남았다"

    def test_all_five_owners_still_have_a_rule(self):
        """★positive control — 예시를 뺐다고 **그릴 재료**까지 없애면 안 된다."""
        table = gc.load_text("system.md")
        for owner in cm.OWNERS:
            assert f"`{owner}`" in table, f"★{owner} 를 가를 규칙이 없다"


class TestThePinnedContractHasOneHome:
    """★preflight 에 계약을 다시 적으면 「적힌 값」과 「넘어가는 값」이 갈린다."""

    def test_the_preflight_reads_the_runners_contract(self):
        from tools.grounding_audit import cc_runner as rr
        from tools.grounding_audit import preflight_cc_experiment as pf

        assert pf.PINNED is rr.PINNED


class TestTheManuscriptIsNotPadding:
    """★★★유료 주행 하나가 이걸 드러냈다 (2026-08-31).

    앞 판 원고는 같은 문장을 **22번** 이어 붙여 글자 수를 채웠다. 그래서
    모델이 「사람들」 출현을 22개로 냈고 나는 그것을 결함으로 읽을 뻔했다 —
    **모델이 맞았다.** 반복 원고는 「몇 번 나왔나」 축을 뜻 없게 만들고,
    실제 원고와 안 닮아서 재도 실제를 못 말한다.
    """

    def _sentences(self):
        import re

        return [x.strip() for x in re.split(r"[.。\n]", ep.manuscript())
                if len(x.strip()) > 8]

    def test_no_sentence_repeats(self):
        from collections import Counter

        c = Counter(self._sentences())
        dup = {k: n for k, n in c.items() if n > 1}
        assert not dup, f"★같은 문장이 되풀이된다: {list(dup.items())[:2]}"

    def test_no_planted_word_dominates_the_manuscript(self):
        """★표적 낱말이 원고를 **덮으면** 출현 축이 뜻을 잃는다.

        ★횟수로만 재면 사람 이름처럼 자연히 자주 나오는 것까지 걸린다
        (운전사 12회는 4씬 원고에서 정상이다). **글자 비중**으로 본다 —
        앞 판 채움 글은 「사람들」 하나가 3.9%를 차지했다.
        """
        body = ep.manuscript()
        segs = ep.segment_texts()
        for t in ep.EXPECTED_TARGETS:
            for _sc, word, _n in t["at"]:
                total = sum(v.count(word) for v in segs.values())
                share = total * len(word) / len(body)
                assert share < 0.02, (
                    f"★{word!r} 가 {total}번 · 원고의 {share:.1%} — 덮는다")

    def test_it_still_splits_into_several_chunks(self):
        """★positive control — 반복을 없앤다고 **구간이 하나**가 되면,
        「두 구간에 걸친 같은 실물」 반례가 원고에서 사라진다."""
        assert len(ep.bundles()) >= 2
        ep.assert_planted()


class TestEvidenceIsNotCountedAsAnOccurrence:
    """★★★유료 주행 1회가 드러낸 **가장 큰 것** (Codex, 2026-08-31).

    앞 팩은 `source_quote` 한 칸이 두 일을 겸했다 — 「그 대상을 부른 자리」와
    「그 겉모습을 말해 주는 문장」. 그래서 한 번만 나온 어려운 대상이

        벽에 옛 요금표가 붙어 있다      ← 부른 자리
        숫자 칸이 손으로 덧칠되어 있다   ← 근거 (요금표라는 말이 **없다**)

    **출현 2회**로 세어졌고, `grounding_exception` 이 아니라 반복 출현 축으로
    살았다. 이 실험의 **핵심 규칙을 전혀 못 쟀다.**
    """

    def _row(self):
        return _row("옛 요금표", surface="옛 요금표", owner="location_part",
                    evidence=["값이 바뀔 때마다 앞의 글자를 지우고"])

    def test_the_evidence_does_not_raise_the_occurrence_count(self):
        got = gc.resolve_rows([self._row()], chunk_id="c2",
                              segment_ids=["scene-3"],
                              segments=ep.segment_texts())["rows"][0]
        assert len(got["occurrences"]) == 1, "★근거가 출현으로 세어졌다"
        assert got["evidence_quotes"] == ["값이 바뀔 때마다 앞의 글자를 지우고"]

    def test_it_registers_through_the_exception_axis_not_repetition(self):
        """★★끝점 — 한 번 나온 hard+notice 는 **예외 축**으로 살아야 한다."""
        rows = gc.resolve_rows([self._row()], chunk_id="c2",
                               segment_ids=["scene-3"],
                               segments=ep.segment_texts())["rows"]
        got = cm.reduce_episode(rows, [], segments=ep.segment_texts())
        rec = got["registered"][rows[0]["local_id"]]
        assert rec["registered"] is True
        assert rec["reason"] == "grounding_exception", (
            f"★{rec['reason']!r} 로 살았다 — 반복 출현 축이면 예외 축을 "
            "안 잰 것이다")

    def test_a_fabricated_evidence_quote_quarantines_the_row(self):
        """★★근거가 틀린 행은 **두 축과 의무 판정이 살아남으면 안 된다**
        (Codex) — 그래서 행째로 격리한다."""
        got = gc.resolve_rows([_row("옛 요금표", surface="옛 요금표",
                                    owner="location_part",
                                    evidence=["원고에 없는 근거 문장"])],
                              chunk_id="c2", segment_ids=["scene-3"],
                              segments=ep.segment_texts())
        assert got["rows"] == [], "★근거가 틀린데 두 축이 살아남았다"
        assert got["quarantined"][0]["problems"][0]["kind"] == gc.Q_EVIDENCE

    def test_the_merge_sees_mentions_and_evidence_apart(self):
        import json as _j

        rows = gc.resolve_rows([self._row()], chunk_id="c2",
                               segment_ids=["scene-3"],
                               segments=ep.segment_texts())["rows"]
        sent = _j.loads(gc.build_merge_payload(rows)
                        ["parts"][0]["text"].split("ROWS:\n", 1)[1])[0]
        assert sent["mentions"] and sent["evidence"]
        assert sent["occurrence_count"] == 1


class TestTheProseDistributionIsNotDegenerate:
    """★조합으로 지어도 **같은 비현실 반복**이 날 수 있다 (Codex).

    두 번째 판이 그랬다 — 조각을 골라 이었지만 마침표로 끊어서 세 번째 조각이
    혼자 한 문장이 됐고 **27번** 되풀이됐다. 문장만 보지 말고 **분포**를 본다.
    """

    def _clauses(self):
        import re

        out = []
        for s in re.split(r"[.\n]", ep.manuscript()):
            out += [x.strip() for x in s.split(",") if len(x.strip()) > 3]
        return out

    def test_no_clause_dominates(self):
        from collections import Counter

        c = Counter(self._clauses())
        top, n = c.most_common(1)[0]
        assert n <= max(4, len(c) // 12), (
            f"★한 조각이 {n}번 — 분포가 무너졌다: {top!r}")

    def test_enough_distinct_clauses(self):
        assert len(set(self._clauses())) >= 40

    def test_the_target_sentences_are_not_drowned(self):
        """★positive control — 채움 글이 표적 문장을 덮으면 실험이 무의미하다."""
        segs = ep.segment_texts()
        for t in ep.EXPECTED_TARGETS:
            sc_i, word, _n = t["at"][0]
            assert word in segs[f"scene-{sc_i}"], f"★{word} 가 원고에 없다"


class TestThePromptAndSchemaSayTheSameThing:
    """★지시문이 schema 에 없는 칸을 말하면 모델이 그 칸을 지어낸다."""

    def test_the_prompt_does_not_mention_a_dead_field(self):
        body = gc.load_text("system.md")
        assert "occurrences" not in body, (
            "★새 계약은 `mentions` 인데 지시문이 옛 이름을 말한다")

    def test_every_field_the_prompt_names_is_in_the_schema(self):
        body = gc.load_text("system.md")
        props = (gc.load_schema("chunk_schema.json")
                 ["properties"]["rows"]["items"]["properties"])
        for name in ("mentions", "evidence_quotes", "hard_to_generate",
                     "viewers_would_notice", "visual_brief",
                     "search_terms_native", "language_lock_native"):
            assert f"`{name}`" in body, f"★지시문이 {name} 을 안 말한다"
            assert name in props, f"★schema 에 {name} 이 없다"


class TestTwoIdentitiesNotOne:
    """★★★**획득**과 **해석**을 가른다 (Codex 2026-08-31).

    앞 판은 후처리 계약을 획득 신원에 섞었다. 그러면 **파서를 고친 것만으로
    이미 산 것을 다시 사게** 된다. 그리고 지문 검사가 도구 안에만 있어
    「도구가 자기를 검사하는」 축이었다 — 실제 스텝·재개·config 소비자는
    그 callable 을 안 썼다.
    """

    def _payload(self, **over):
        segs = ep.segment_texts()
        p = gc.build_chunk_payload(["scene-1"], segs, "세계",
                                   shot_catalog=[{"id": "s1#1",
                                                  "scene_id": "scene-1",
                                                  "description": "설명"}])
        p.update(over)
        return p

    def _acq(self, p=None, **over):
        kw = {"model_alias": "gpt", "model_physical": "gpt-5.6-sol",
              "request_contract": {"num_retries": 0}}
        kw.update(over)
        return gc.acquisition_identity(p or self._payload(), **kw)

    def test_the_same_input_gives_the_same_identity(self):
        assert self._acq() == self._acq()

    def test_each_ingredient_moves_the_acquisition_identity(self):
        """★★재료를 **하나씩** 바꿔 본다 — 안 움직이면 재구매가 안 걸린다."""
        base = self._acq()
        segs = ep.segment_texts()

        # ① 원문
        other = gc.build_chunk_payload(["scene-2"], segs, "세계",
                                       shot_catalog=[{"id": "s2#1",
                                                      "scene_id": "scene-2",
                                                      "description": "설명"}])
        assert self._acq(other) != base, "★원문이 바뀌었는데 안 움직인다"

        # ② 세계 사실
        w = gc.build_chunk_payload(["scene-1"], segs, "다른 세계",
                                   shot_catalog=[{"id": "s1#1",
                                                  "scene_id": "scene-1",
                                                  "description": "설명"}])
        assert self._acq(w) != base, "★세계 사실이 바뀌었는데 안 움직인다"

        # ③ 샷 전문
        d = gc.build_chunk_payload(["scene-1"], segs, "세계",
                                   shot_catalog=[{"id": "s1#1",
                                                  "scene_id": "scene-1",
                                                  "description": "다른 설명"}])
        assert self._acq(d) != base, "★샷 설명이 바뀌었는데 안 움직인다"

        # ④ 샷 ID (schema enum 까지 바뀐다)
        i = gc.build_chunk_payload(["scene-1"], segs, "세계",
                                   shot_catalog=[{"id": "s1#9",
                                                  "scene_id": "scene-1",
                                                  "description": "설명"}])
        assert self._acq(i) != base, "★샷 ID 가 바뀌었는데 안 움직인다"

        # ⑤ 모델 · ⑥ 요청 계약
        assert self._acq(model_physical="다른-모델") != base
        assert self._acq(request_contract={"num_retries": 3}) != base

    def test_a_processing_change_does_not_move_the_acquisition(self):
        """★★★후처리만 바뀌면 **재구매 0** 이어야 한다."""
        a = self._acq()
        s1 = gc.processing_stamp(a, "1.000")
        s2 = gc.processing_stamp(a, "2.000")
        assert s1 != s2, "★후처리가 바뀌었는데 해석 지문이 그대로다"
        assert self._acq() == a, "★후처리가 획득 신원을 흔들었다"

    def test_the_processing_stamp_moves_with_the_acquisition_too(self):
        """★획득이 바뀌면 해석도 다시 해야 한다 — 지문이 따라 움직인다."""
        segs = ep.segment_texts()
        other = gc.build_chunk_payload(["scene-2"], segs, "세계")
        assert gc.processing_stamp(self._acq()) \
            != gc.processing_stamp(self._acq(other))

    def test_the_runner_consumes_the_production_callable(self):
        """★도구가 제 신원을 따로 만들면 실제 소비자와 갈린다."""
        from tools.grounding_audit import cc_runner as rr

        p = self._payload()
        assert rr.identity(p["system"], p["parts"][0]["text"], p["schema"],
                           "gpt", "gpt-5.6-sol", {"num_retries": 0}) \
            == self._acq()


class TestTheIdentityFoldsEveryPart:
    """★「나가는 것 그 자체」가 신원이라면 **첫 칸만** 보면 안 된다."""

    def _acq(self, parts):
        return gc.acquisition_identity(
            {"system": "s", "parts": parts, "schema": {}},
            model_alias="gpt", model_physical="p",
            request_contract={"num_retries": 0})

    def test_adding_a_second_part_moves_it(self):
        a = self._acq([{"type": "text", "text": "u"}])
        b = self._acq([{"type": "text", "text": "u"},
                       {"type": "text", "text": "또"}])
        assert a != b, "★뒤 칸을 더했는데 신원이 그대로다"

    def test_changing_a_part_field_moves_it(self):
        a = self._acq([{"type": "text", "text": "u"}])
        b = self._acq([{"type": "image", "text": "u"}])
        assert a != b, "★칸 종류가 바뀌었는데 신원이 그대로다"

    def test_the_runner_and_the_builder_agree_on_the_shape(self):
        """★도구가 만드는 모양과 조립이 만드는 모양이 같아야 신원이 맞는다."""
        from tools.grounding_audit import cc_runner as rr

        p = gc.build_chunk_payload(["scene-1"], ep.segment_texts(), "세계")
        assert rr.identity(p["system"], p["parts"][0]["text"], p["schema"],
                           "gpt", "p", {"num_retries": 0}) \
            == gc.acquisition_identity(p, model_alias="gpt",
                                       model_physical="p",
                                       request_contract={"num_retries": 0})


class TestAllFiveOwnersPassAcquisition:
    """★★고증 의무를 여는 것은 **같은 행에서 hard AND notice** 다.

    어느 갈래에 양성이 하나도 없으면 그 갈래는 참조 획득·참조 소비를 **통째로
    안 지나고**, 그런데도 「다섯 갈래를 다 봤다」로 읽힌다.

    실제로 그랬다 (2026-08-31): 앞 판 fixture 는 양성이 `location_part` 둘과
    `outlook` 하나뿐이라 `prop`·`character`·`location` 이 한 번도 안 지났다.
    """

    def test_the_selection_does_not_filter_by_owner(self):
        """★★진짜 불변식은 「어느 갈래든 **자격이 되면 지나갈 수 있다**」다.

        앞 판은 「**모든** 갈래에 양성이 있어야 한다」로 두고 그러려고 사람에게
        없는 축을 붙였다. 실제 모델은 `운전사` 를 두 축 다 False 로 보고 근거를
        **입은 것**에 놓았는데 그쪽이 맞다 — 세계 사실이 규정한 것은 옷의
        모양이지 사람의 형태가 아니다.

        데이터가 정할 일을 fixture 가 못박으면 그것이 지어낸 정답이다.
        """
        import ast
        import inspect

        from tools.grounding_audit import ref_canary as rc

        tree = ast.parse(inspect.getsource(rc.targets_from))
        for n in ast.walk(tree):
            if isinstance(n, ast.Constant) and isinstance(n.value, str):
                assert n.value not in ("prop", "character", "location",
                                       "location_part", "outlook"), \
                    f"★선별이 갈래 {n.value!r} 를 이름으로 본다"

    def test_several_owners_actually_pass(self):
        """★한 갈래만 지나면 「여러 갈래가 지난다」를 못 본다."""
        from tests.grounding.fixtures import synthetic_episode as ep

        assert len(ep.acquisition_owners()) >= 3, \
            f"★{sorted(ep.acquisition_owners())} 뿐이다"

    def test_every_target_has_a_written_basis(self):
        """★근거 없이 hard&notice 라고 적으면 그것이 지어낸 정답이다."""
        from tests.grounding.fixtures import synthetic_episode as ep

        for t in ep.EXPECTED_TARGETS:
            b = ep.AXIS_BASIS.get(t["key"])
            assert b, f"★{t['key']} 의 두 축 근거가 없다"
            assert len(str(b.get("basis") or "").strip()) > 10

    def test_the_guard_actually_runs_inside_assert_planted(self):
        """★★이 검사가 **죽은 코드**였다 — 다른 함수의 `return` 뒤에 있었다.

        같은 부류를 또 만들지 않게, 검사문이 `assert_planted` 안에 있는지를
        AST 로 본다.
        """
        import ast
        import inspect

        from tests.grounding.fixtures import synthetic_episode as ep

        src = inspect.getsource(ep)
        tree = ast.parse(src)
        fn = next(n for n in ast.walk(tree)
                  if isinstance(n, ast.FunctionDef)
                  and n.name == "assert_planted")
        calls = [n for n in ast.walk(fn) if isinstance(n, ast.Call)
                 and getattr(n.func, "id", "") == "acquisition_owners"]
        assert calls, "★다섯 갈래 검사가 `assert_planted` 안에 없다"

    def test_there_are_both_positives_and_negatives(self):
        """★positive control — 전부 양성이면 음성 갈래를 못 잡는다."""
        from tests.grounding.fixtures import synthetic_episode as ep

        pos = [k for k, v in ep.AXIS_BASIS.items()
               if v["hard"] and v["notice"]]
        neg = [k for k, v in ep.AXIS_BASIS.items()
               if not (v["hard"] and v["notice"])]
        assert len(pos) >= 5 and len(neg) >= 3, \
            f"★양성 {len(pos)} · 음성 {len(neg)}"
