"""GROUNDING-V2 §2-2 — schema + Sol classifier + planner. ★검색 호출 0.

통과 조건(계획 §2 표): unit 통과 · 바깥 호출 0 ·
classifier 가 referent_specificity·difficulty·confidence·locale/세대를 다 채움.
"""
import json

from app.modules.pipeline import grounding_planner as gp
from unittest.mock import patch

import pytest

from app.modules.pipeline.grounding_planner import (
    decide_route, missing_fields, plan)
from app.modules.pipeline.grounding_subject import (
    build_subject, mint_subject_id, normalize_surface)
from app.modules.prompt_loader import resolve_effective


def _full_record(**over):
    """planner 가 요구하는 칸을 다 채운 record."""
    base = dict(
        grounding_class="generic", discriminability="no",
        referent_specificity="generic_class", difficulty="easy", confidence=0.9,
        visibility_intent="yes", locale="KR", generation="1980s",
        visible_discriminators=["인쇄면"], likely_failure_modes=["현대물로 그림"], generation_difficulty="not_hard")
    base.update(over)
    return base


def _grounded(**over):
    """externally_grounded + 알아보는 칸 — 2×2 를 볼 때의 기본형."""
    base = {"grounding_class": "externally_grounded",
            "discriminability": "yes", "visibility_intent": "yes"}
    base.update(over)
    return _full_record(**base)


# ── subject 발급 (계획 §6 ㉮) ──────────────────────────────────────────────

class TestSubjectId:
    def test_deterministic_across_reruns(self):
        a = dict(project_id="p", episode_id="e", source_anchor="S3 p2",
                 surface_form="종이 승차권", owner_type="prop")
        assert mint_subject_id(**a) == mint_subject_id(**a)

    def test_a0_pack_bump_keeps_same_subject(self):
        """★핵심 — A0 팩을 올려도 같은 원문 대상은 같은 subject 다 (시험 ⑤).

        pack hash 는 provenance 지 identity 가 아니다. 팩이 바뀌면 **같은 subject 의
        새 revision** 이어야 한다.
        """
        a = dict(project_id="p", episode_id="e", source_anchor="S3",
                 surface_form="종이 승차권", owner_type="prop")
        old = build_subject(**a, provenance={"a0_pack_hash": "aaaa", "a0_model": "sol"})
        new = build_subject(**a, provenance={"a0_pack_hash": "bbbb", "a0_model": "sol"})
        assert old["research_subject_id"] == new["research_subject_id"]
        assert old["provenance"] != new["provenance"]

    def test_surface_form_not_ordinal(self):
        """표면형이 다르면 다른 subject — 순번이었다면 순서만 바뀌어도 흔들린다."""
        a = dict(project_id="p", episode_id="e", source_anchor="S3", owner_type="prop")
        assert mint_subject_id(**a, surface_form="종이 승차권") != \
               mint_subject_id(**a, surface_form="요금 상자")

    def test_episode_scoped(self):
        a = dict(project_id="p", source_anchor="S3", surface_form="x", owner_type="prop")
        assert mint_subject_id(**a, episode_id="e1") != mint_subject_id(**a, episode_id="e2")

    def test_normalization_is_letters_only_not_meaning(self):
        """공백·대소문자만 접는다. ★뜻으로 묶지 않는다 — 동의어 병합은 resolution 몫."""
        assert normalize_surface("  Paper  Ticket ") == normalize_surface("paper ticket")
        assert normalize_surface("승차권") != normalize_surface("표")

    @pytest.mark.parametrize("blank", ["", "   "])
    def test_blank_field_is_rejected(self, blank):
        with pytest.raises(ValueError):
            mint_subject_id(project_id="p", episode_id="e", source_anchor=blank,
                            surface_form="x", owner_type="prop")

    def test_starts_unbound(self):
        s = build_subject(project_id="p", episode_id="e", source_anchor="S1",
                          surface_form="x", owner_type="prop")
        assert s["canon_id"] is None and s["bind_state"] == "unbound"


# ── planner route (계약 §2·§3) ────────────────────────────────────────────

class TestRoute:
    def test_generic_no_longer_skips_before_search(self):
        """★★★**`generic` 을 검색 전에 skip 하지 않는다** (계약 §2, 2026-08-30).

        「시대가 겉모습을 안 구속한다」도 **세상 사실**이라, 「검색을 하지
        않는」 단계가 답할 것이 아니다. 그렇게 skip 하면 그 대상은 §4a 의
        출처 확정에 **한 번도 안 닿는다**.
        """
        assert decide_route(_full_record())["route"] == "research"
        assert decide_route(
            _full_record(difficulty="very_hard"))["route"] == "research"

    def test_exact_variant_forces_research_even_when_called_easy(self):
        """★모델이 「쉽다」고 해도 exact referent 는 bypass 하지 않는다."""
        d = decide_route(_full_record(grounding_class="externally_grounded",
                                      discriminability="yes",
                                      referent_specificity="exact_variant",
                                      difficulty="very_easy"))
        assert d["route"] == "research" and d["research_required"] is True

    def test_unique_identity_forces_research(self):
        assert decide_route(_full_record(
            grounding_class="externally_grounded", discriminability="yes",
            referent_specificity="unique_identity"))["route"] == "research"

    def test_uncertain_is_researched_not_closed(self):
        """★「모른다」를 「아니다」로 닫지 않는다."""
        assert decide_route(_grounded(difficulty="uncertain"))["route"] == "research"

    @pytest.mark.parametrize("d", ["medium", "hard", "very_hard"])
    def test_medium_and_up_is_researched(self, d):
        assert decide_route(_grounded(difficulty=d))["route"] == "research"

    def test_visibility_uncertain_is_researched(self):
        """★B 는 확정값이 아니다 — 애매하면 조사한다 (비대칭 때문)."""
        assert decide_route(
            _grounded(visibility_intent="uncertain", difficulty="medium"))["route"] == "research"

    def test_confidence_does_not_change_the_route(self):
        """★confidence 는 **route 를 안 가른다** — 진단·provenance 로만 남는다.

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

        ★숫자를 다른 숫자로 낮추는 것도 답이 아니다. 그건 대조군에 맞추는
        것이고, 어느 값이든 같은 분포 위에서 임의로 자른다 (Codex 판정).
        """
        for conf in (0.01, 0.3, 0.58, 0.67, 0.79, 0.8, 0.9, 0.99):
            assert decide_route(_full_record(confidence=conf))["route"] == \
                decide_route(_full_record(confidence=0.9))["route"], conf

    def test_an_explicit_uncertain_axis_still_researches(self):
        """★「모른다」의 통로는 **스키마의 명시적 `uncertain`** 이다."""
        for axis in ("discriminability", "visibility_intent", "difficulty"):
            for conf in (0.1, 0.9):
                d = decide_route(_full_record(**{axis: "uncertain"},
                                              confidence=conf))
                assert d["route"] == "research", f"{axis}/{conf}"

    def test_the_threshold_constant_is_gone(self):
        """★상수를 남겨 두면 다음 사람이 다시 문턱으로 쓴다."""
        import app.modules.pipeline.grounding_planner as gp

        assert not hasattr(gp, "_MIN_CONFIDENCE_TO_SKIP")

    def test_the_axes_still_decide(self):
        """positive control — confidence 를 뺐다고 축이 안 도는 것은 아니다.

        ★이제 가르는 축은 **B(`visibility_intent`)**다. confidence 가 높든
        낮든 같은 답이어야 한다.
        """
        assert decide_route(_full_record(confidence=0.95))["route"] == "research"
        assert decide_route(_full_record(confidence=0.1))["route"] == "research"
        assert decide_route(
            _full_record(visibility_intent="no", confidence=0.95))["route"] == "skip"


class TestTheTwoByTwo:
    """★계약 §2 의 2×2 네 모서리. externally_grounded 안에서만 성립한다."""

    def test_recognizable_and_no_evidence_is_researched(self):
        assert decide_route(_grounded(discriminability="yes", visibility_intent="yes",
                                      difficulty="hard"))["route"] == "research"

    def test_difficulty_no_longer_decides(self):
        """★`difficulty` 는 **폐기된 세로축**이다 — route 를 안 가른다.

        판정 모델은 「다른 모델이 외부 근거 없이 맞히는가」를 본 적이 없다.
        """
        for d in ("easy", "medium", "very_hard", "uncertain"):
            assert decide_route(_grounded(visibility_intent="yes",
                                          difficulty=d))["route"] == "research"

    def test_the_classifier_a_no_longer_decides(self):
        """★★A 는 **출처로** 확정한다 (계약 §2). 분류기 답으로 안 가른다 —
        그 단계는 검색을 안 하는데 **세상 사실**을 답하고 있었다."""
        for a in ("yes", "no", "uncertain"):
            assert decide_route(_grounded(discriminability=a,
                                          visibility_intent="yes"))["route"] == "research"

    def test_not_recognizable_b_no_is_skipped_regardless_of_difficulty(self):
        assert decide_route(_grounded(discriminability="yes", visibility_intent="no",
                                      difficulty="very_hard"))["route"] == "skip"

    def test_exact_variant_forces_research_but_b_no_still_skips(self):
        """★강제 조사도 **B 를 통과한 뒤**에만 적용된다 — 이번 렌더에 안 보이면
        조사할 이유가 없다(계약 §2 의 `B=no → skip`)."""
        assert decide_route(_grounded(referent_specificity="exact_variant",
                                      difficulty="very_easy"))["route"] == "research"
        assert decide_route(_grounded(referent_specificity="exact_variant",
                                      visibility_intent="no"))["route"] == "skip"

    def test_fictional_with_exact_variant_is_not_a_contradiction(self):
        """★작품 안에서 특정 변형이 정해질 수 있다 — 외부 정답이 없을 뿐이다."""
        d = decide_route(_full_record(grounding_class="fictional",
                                      referent_specificity="exact_variant"))
        assert d["route"] == "design" and d["grounding_controlled"] is True


class TestUnresolvedIsNotSkip:
    @pytest.mark.parametrize("field", [
        # ★폐기한 두 축은 요구하지 않는다 — 목록에서 뺐다
        "grounding_class",
        "referent_specificity", "confidence",
        "locale", "generation", "visible_discriminators", "likely_failure_modes",
    ])
    def test_missing_field_is_unresolved(self, field):
        d = decide_route(_full_record(**{field: None}))
        assert d["route"] == "unresolved"
        assert d["research_required"] is False
        # ★핵심 — 미확정도 gate 대상이다. 아니면 claim 을 못 찾은 순간
        #   하류가 외형을 다시 지어낸다 (계약 §13).
        assert d["grounding_controlled"] is True

    def test_empty_list_counts_as_missing(self):
        assert "visible_discriminators" in missing_fields(
            _full_record(visible_discriminators=[]))

    def test_enum_violation_is_unresolved(self):
        # ★폐기한 축이 아니라 **아직 쓰는 축**에서 잰다
        assert decide_route(
            _full_record(visibility_intent="조금보임"))["route"] == "unresolved"

    def test_a_retired_axis_with_a_bogus_value_is_ignored(self):
        """★★안 쓰는 칸의 값이 이상해도 route 를 막지 않는다 — 그 칸은 이제
        판정에 안 들어간다."""
        assert decide_route(
            _full_record(difficulty="조금어려움"))["route"] == "research"

    def test_skip_is_not_grounding_controlled(self):
        # ★`B=no` 가 이제 skip 을 내는 자리다(`generic` 이 아니라).
        assert decide_route(
            _full_record(visibility_intent="no"))["grounding_controlled"] is False


class TestPlanAggregate:
    def test_counts_and_zero_search(self):
        out = plan([
            _full_record(visibility_intent="no"),                            # skip
            _full_record(grounding_class="externally_grounded",
                         discriminability="yes",
                         referent_specificity="exact_variant"),              # research
            _full_record(grounding_class="fictional"),                       # design
            _full_record(locale=None),                                       # unresolved
        ])
        assert out["counts"] == {"skip": 1, "research": 1,
                                 "design": 1, "unresolved": 1}
        assert out["search_calls"] == 0
        assert len(out["decided"]) == 4


# ── 프롬프트 팩 · 지문 (계획 §8) ──────────────────────────────────────────

class TestPromptPack:
    def test_pack_is_complete_and_pinned(self):
        from app.modules.pipeline.grounding_classifier import load_pack
        pack = load_pack()
        assert set(pack["stems"]) == {"system", "classify_schema"}
        for r in pack["stems"].values():
            assert r["version"] == pack["version"], "pin 이 안 걸려 stem drift 가 났다"
            assert r["raw_content_hash"]

    def test_missing_stem_fails_closed(self):
        from app.modules.pipeline.grounding_classifier import load_pack
        with pytest.raises(FileNotFoundError):
            load_pack(version="999.202601010000")

    def test_schema_requires_every_field_planner_needs(self):
        """★스키마와 planner 요구가 어긋나면 영원히 unresolved 가 된다."""
        from app.modules.pipeline.grounding_classifier import load_pack
        props = load_pack()["stems"]["classify_schema"]["content"][
            "properties"]["classifications"]["items"]
        required = set(props["required"])
        for f in ("grounding_class",
                  "referent_specificity", "confidence",
                  "visibility_intent", "locale", "generation",
                  "visible_discriminators", "likely_failure_modes"):
            assert f in required, f"{f} 가 스키마 required 에 없다"

    def test_resolve_effective_rejects_unknown_kind(self):
        with pytest.raises(ValueError):
            resolve_effective("grounding_classify", "system", kind="bogus")

    def test_resolve_effective_separates_source_and_locator(self):
        r = resolve_effective("grounding_classify", "system", kind="prompt")
        assert r["source"] == "file" and "file_path" in r["locator"]


# ── classifier — 바깥 호출 0 · 미제출을 통과로 안 셈 ──────────────────────

def _fake_llm(*, records):
    def _call(**kwargs):
        return {"classifications": records}
    return _call


def _classified(sid, **over):
    """스키마를 만족하는 판정 한 건."""
    rec = {"research_subject_id": sid, "grounding_class": "generic",
           "discriminability": "no", "referent_specificity": "generic_class",
           "difficulty": "easy", "confidence": 0.9, "visibility_intent": "yes",
           "locale": "KR", "generation": "1980s",
           "visible_discriminators": ["x"], "likely_failure_modes": ["y"], "generation_difficulty": "not_hard",
           "rationale": "z"}
    rec.update(over)
    return rec


def _patch_llm(records):
    """★``_call_structured`` 문 하나만 갈아 끼운다 — ``llm_client`` 를 import 하지 않는다.

    import 하면 litellm 이 가격표를 바깥에서 받아와 「바깥 호출 0」이 깨진다.
    """
    from app.modules.pipeline import grounding_classifier as gc
    return patch.object(gc, "_call_structured", _fake_llm(records=records))


class TestClassifier:
    def test_no_subjects_makes_no_call(self):
        """★후보가 없으면 llm_client 를 **import 조차 하지 않는다**.

        litellm 은 import 시점에 가격표를 바깥에서 받아온다. 검색은 아니지만
        이 단계의 통과 조건이 「바깥 호출 0」이라 그것도 안 나가야 한다.
        """
        import sys
        from app.modules.pipeline import grounding_classifier as gc
        before = "app.modules.llm.llm_client" in sys.modules
        out = gc.classify([])
        assert out["records"] == [] and out["search_calls"] == 0
        if not before:
            assert "app.modules.llm.llm_client" not in sys.modules, \
                "빈 입력인데 llm_client 를 import 했다"

    def test_fingerprint_keeps_raw_and_payload_hash_apart(self):
        """★둘을 한 칸으로 합치면 format 인자가 바뀌어도 지문이 안 움직인다."""
        from app.modules.pipeline import grounding_classifier as gc
        subj = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                              surface_form="종이 승차권", owner_type="prop")]
        sid = subj[0]["research_subject_id"]
        rec = {"research_subject_id": sid, "grounding_class": "externally_grounded",
               "referent_specificity": "exact_variant", "difficulty": "hard",
               "confidence": 0.8, "visibility_intent": "yes", "locale": "KR",
               "generation": "1980s", "visible_discriminators": ["인쇄"],
               "likely_failure_modes": ["현대물"], "generation_difficulty": "not_hard", "rationale": "x"}
        with _patch_llm([rec]):
            out = gc.classify(subj)
        fp = out["fingerprint"]
        raw = {s["raw_content_hash"] for s in fp["stem_sources"].values()}
        assert fp["payload_hash"] not in raw, "payload 지문이 원본 hash 와 겹친다"

    def test_unreturned_subject_is_marked_missing_not_dropped(self):
        """★안 돌아온 후보를 「없는 것」으로 세지 않는다 — 미제출은 미확정이다."""
        from app.modules.pipeline import grounding_classifier as gc
        subj = [
            build_subject(project_id="p", episode_id="e", source_anchor="S1",
                          surface_form="가", owner_type="prop"),
            build_subject(project_id="p", episode_id="e", source_anchor="S2",
                          surface_form="나", owner_type="prop"),
        ]
        rec = {"research_subject_id": subj[0]["research_subject_id"],
               "grounding_class": "generic", "referent_specificity": "generic_class",
               "difficulty": "easy", "confidence": 0.9, "visibility_intent": "yes",
               "locale": "KR", "generation": "1980s",
               "visible_discriminators": ["x"], "likely_failure_modes": ["y"], "generation_difficulty": "not_hard",
               "rationale": "z"}
        with _patch_llm([rec]):
            out = gc.classify(subj)
        assert len(out["records"]) == 2
        missing = [r for r in out["records"] if r.get("classifier_missing")]
        assert len(missing) == 1
        # 그리고 그 미제출 행은 planner 에서 unresolved 로 선다.
        assert decide_route(missing[0])["route"] == "unresolved"

    def test_unknown_id_is_not_accepted(self):
        from app.modules.pipeline import grounding_classifier as gc
        subj = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                              surface_form="가", owner_type="prop")]
        rec = {"research_subject_id": "rs_nonexistent", "grounding_class": "generic",
               "referent_specificity": "generic_class", "difficulty": "easy",
               "confidence": 0.9, "visibility_intent": "yes", "locale": "KR",
               "generation": "1980s", "visible_discriminators": ["x"],
               "likely_failure_modes": ["y"], "generation_difficulty": "not_hard", "rationale": "z"}
        with _patch_llm([rec]):
            out = gc.classify(subj)
        assert all(r["research_subject_id"] != "rs_nonexistent" for r in out["records"])
        assert out["records"][0].get("classifier_missing") is True


# ══ Codex PR #62 BLOCK 6건 — 끝점 시험 ════════════════════════════════════

class TestBlock1ModelIsActuallySol:
    """★이름만 적어 두면 Sol 로 가지 않는다 — 등록 안 된 step 은 gemini-pro 로 떨어진다."""

    def test_step_resolves_to_sol_alias(self):
        from app.modules.llm.llm_client import _resolve_model
        from app.modules.pipeline.grounding_classifier import STEP_NAME
        assert _resolve_model(STEP_NAME) == "gpt", (
            "grounding_classify 가 Sol 로 안 간다 — STEP_MANIFEST/"
            "_PIPELINE_STEP_EXTENSIONS 등록을 확인하라")

    def test_unregistered_step_would_fall_back(self):
        """positive control — 등록이 왜 필요한지 같은 시험 안에서 보인다."""
        from app.modules.llm.llm_client import _resolve_model
        assert _resolve_model("이런_step_은_없다") == "gemini-pro"


class TestBlock2TargetIsNotInThePayloadAnyMore:
    """★★**이미지 모델 좌표는 이제 안 싣는다** (2026-08-30).

    전에는 「이 모델이 근거 없이 맞힐 수 있나」(`difficulty`)를 물었으니
    좌표가 필요했다. 그 축을 없앤 뒤에도 좌표만 남기면 두 가지가 깨진다:

    1. user prompt 가 **스키마에 없는 칸**을 답하라고 시킨다
    2. 이미지 backend 만 바꿔도 같은 분류를 **전부 다시 산다**
    """

    def _subjects(self):
        return [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                              surface_form="가", owner_type="prop")]

    def test_the_image_model_block_is_gone_from_the_prompt(self):
        from app.modules.pipeline.grounding_classifier import build_user_prompt
        text = build_user_prompt(self._subjects())
        assert "판정 대상 이미지 생성 모델" not in text
        assert "difficulty" not in text, \
            "★스키마에 없는 칸을 답하라고 시키고 있다"

    def test_the_call_does_not_even_accept_the_coords(self):
        """★인자를 지워도 **받아 주면** 호출부가 계속 넘긴다."""
        import pytest

        from app.modules.pipeline.grounding_classifier import build_user_prompt
        with pytest.raises(TypeError):
            build_user_prompt(self._subjects(), target_image_provider="g")

    def test_the_fingerprint_no_longer_carries_them(self):
        from app.modules.pipeline import grounding_classifier as gc
        subj = self._subjects()
        rec = _classified(subj[0]["research_subject_id"])
        with _patch_llm([rec]):
            out = gc.classify(subj)
        fp = out["fingerprint"]
        assert not [k for k in fp if k.startswith("target_image")]
        assert not [k for k in out["records"][0] if k.startswith("target_image")]


class TestBlock3JudgeProvenanceIsTheRealModel:
    """★`judge_model` 이 step id 이고 version 이 pack 버전이면 durable evidence 가 거짓이다.

    call_structured 는 Tier 3 에서 다른 provider 로 넘어갈 수 있다.
    """

    def test_records_the_model_the_response_reported(self):
        from app.modules.pipeline import grounding_classifier as gc
        subj = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                              surface_form="가", owner_type="prop")]
        rec = _classified(subj[0]["research_subject_id"])

        def _call(**kwargs):
            # 실제 호출이 fallback 으로 다른 모델에 갔다고 가정한다.
            sink = kwargs.get("usage_sink")
            if sink is not None:
                sink["alias"] = "gpt"
                sink["physical_model"] = "openai/gpt-5.6-sol"
            return {"classifications": [rec]}

        with patch.object(gc, "_call_structured", _call):
            out = gc.classify(subj)
        r = out["records"][0]
        assert r["judge_physical_model"] == "openai/gpt-5.6-sol"
        assert r["judge_model_alias"] == "gpt"
        assert r["judge_step"] == "grounding_classify"
        # prompt 팩 좌표는 **지문의 별도 칸**으로 남는다 — 모델 버전과 안 섞는다.
        fp = out["fingerprint"]
        assert fp["prompt_version"] and fp["prompt_module"] == "grounding_classify"

    def test_no_usage_reported_does_not_become_a_lie(self):
        """★미보고를 「Sol 이 했다」로 채우지 않는다 — 비워 둔다."""
        from app.modules.pipeline import grounding_classifier as gc
        subj = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                              surface_form="가", owner_type="prop")]
        with _patch_llm([_classified(subj[0]["research_subject_id"])]):
            out = gc.classify(subj)
        assert out["records"][0]["judge_physical_model"] is None


class TestBlock4ContractIsImplemented:
    """★Codex 가 든 두 반례가 그대로 재현되면 안 된다."""

    def test_fictional_is_design_regardless_of_confidence(self):
        """★갈래는 **갈래 칸**이 정한다 — 숫자가 아니라."""
        for conf in (0.01, 0.9):
            d = decide_route(_full_record(grounding_class="fictional",
                                          referent_specificity="exact_variant",
                                          confidence=conf))
            assert d["route"] == "design"
            assert d["grounding_controlled"] is True

    def test_externally_grounded_goes_to_sourced_screening(self):
        """★★`difficulty=easy` 로 **건너뛰지 않는다** — 그건 폐기된 세로축이다.
        confidence 도 route 를 안 가른다."""
        for conf in (0.01, 0.9):
            assert decide_route(
                _grounded(difficulty="easy", confidence=conf))["route"] == "research"

    def test_a_no_from_the_classifier_does_not_close_it(self):
        """★★A 를 분류기 답으로 닫지 않는다 — **출처로** 확정한다(계약 §2).

        「외부 기록이 있어도 못 알아본다」는 판단 자체가 세상 사실이라,
        검색을 안 하는 단계가 답할 것이 아니다.
        """
        assert decide_route(_grounded(discriminability="no"))["route"] == "research"

    def test_fictional_is_design_not_skip_and_stays_sealed(self):
        """★「조사 대상 아님」이 「지어내도 됨」으로 읽히면 안 된다."""
        d = decide_route(_full_record(grounding_class="fictional"))
        assert d["route"] == "design"
        assert d["research_required"] is False
        assert d["grounding_controlled"] is True

    def test_uncertain_discriminability_is_researched(self):
        assert decide_route(_grounded(discriminability="uncertain"))["route"] == "research"

    @pytest.mark.parametrize("conf", [-0.1, 1.1, "높음", True])
    def test_out_of_range_confidence_is_unresolved(self, conf):
        assert decide_route(_full_record(confidence=conf))["route"] == "unresolved"

    def test_prompt_documents_the_routing_enums(self):
        """★routing 을 가르는 값의 뜻이 provider prompt 에 없으면 Sol 이 못 맞춘다."""
        from app.modules.pipeline.grounding_classifier import SYSTEM_STEM, load_pack
        text = load_pack()["stems"][SYSTEM_STEM]["content"]
        for token in ("generic_class", "branded_family",
                      "exact_variant", "unique_identity", "discriminability"):
            assert token in text, f"prompt 에 {token} 설명이 없다"


class TestBlock5Bijection:
    """★입력 1건이 records 2건으로 살아나면 다음 유료 검색이 중복 구매한다."""

    def test_duplicate_input_id_is_rejected(self):
        from app.modules.pipeline import grounding_classifier as gc
        s1 = build_subject(project_id="p", episode_id="e", source_anchor="S1",
                           surface_form="가", owner_type="prop")
        with pytest.raises(ValueError):
            gc.classify([s1, dict(s1)])

    def test_duplicate_response_collapses_to_one_unresolved(self):
        from app.modules.pipeline import grounding_classifier as gc
        subj = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                              surface_form="가", owner_type="prop")]
        sid = subj[0]["research_subject_id"]
        rec = _classified(sid)
        with _patch_llm([rec, dict(rec)]):
            out = gc.classify(subj)
        assert len(out["records"]) == 1, "중복 응답이 두 줄로 살아났다"
        r = out["records"][0]
        assert r["classifier_duplicate_count"] == 2
        assert decide_route(r)["route"] == "unresolved"

    def test_one_response_per_input_is_the_normal_case(self):
        from app.modules.pipeline import grounding_classifier as gc
        subj = [build_subject(project_id="p", episode_id="e", source_anchor=f"S{i}",
                              surface_form=f"대상{i}", owner_type="prop")
                for i in range(3)]
        recs = [_classified(s["research_subject_id"]) for s in subj]
        with _patch_llm(list(reversed(recs))):   # 순서가 달라도 id 로 맞춘다
            out = gc.classify(subj)
        assert len(out["records"]) == 3
        assert {r["research_subject_id"] for r in out["records"]} == \
               {s["research_subject_id"] for s in subj}
        assert not any(r.get("classifier_duplicate_count") for r in out["records"])


class TestBlock6RawBytesHash:
    """★`.strip()` 뒤 문자열을 해시하면 래칫이 보는 값과 달라진다."""

    def test_hash_matches_the_file_bytes_the_ratchet_uses(self):
        import hashlib
        from pathlib import Path
        r = resolve_effective("grounding_classify", "system", kind="prompt")
        path = Path(r["locator"]["file_path"])
        assert r["raw_content_hash"] == \
            hashlib.sha256(path.read_bytes()).hexdigest()[:16]

    def test_content_is_still_trimmed_for_the_loader(self):
        r = resolve_effective("grounding_classify", "system", kind="prompt")
        assert r["content"] == r["content"].strip()

    def test_schema_hash_is_bytes_not_reserialized_json(self):
        import hashlib
        from pathlib import Path
        r = resolve_effective("grounding_classify", "classify_schema", kind="schema")
        path = Path(r["locator"]["file_path"])
        assert r["raw_content_hash"] == \
            hashlib.sha256(path.read_bytes()).hexdigest()[:16]
        assert isinstance(r["content"], dict)

    def test_exact_version_db_row_wins_and_moves_the_fingerprint(self):
        """★계획 §8 끝점 — DB winner · locator · 지문 이동 · payload 반영."""
        from app.modules.pipeline import grounding_classifier as gc

        class _Row:
            id = "db-row-1"
            version = gc.PROMPT_PACK_VERSION
            content = "DB 가 이긴 system prompt"
            schema_json = None

        file_only = resolve_effective("grounding_classify", "system", kind="prompt")

        with patch("app.modules.prompt_loader._select_latest_active_row",
                   lambda db, module, name, where_extra="", version=None:
                   _Row() if name == "system" else None):
            db_won = resolve_effective("grounding_classify", "system",
                                       kind="prompt", version=gc.PROMPT_PACK_VERSION,
                                       db=object())

        assert db_won["source"] == "db"
        assert db_won["locator"] == {"db_row_id": "db-row-1"}
        assert db_won["raw_content_hash"] != file_only["raw_content_hash"], \
            "DB row 가 이겼는데 지문이 안 움직인다"
        assert db_won["content"] == "DB 가 이긴 system prompt"


class TestEndToEndClassifyThenPlan:
    """★손으로 만든 record 로 재지 않는다 — classify 산출을 그대로 plan 에 넣는다.

    이 시험이 없어서 classifier 가 judge 칸 이름을 바꾼 뒤 **정상 판정이 전부
    unresolved 로 죽는 것**을 63개 시험이 못 잡았다. 조립 자리가 아니라 끝점이다.
    """

    def _run(self, rec_over):
        from app.modules.pipeline import grounding_classifier as gc
        subj = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                              surface_form="가", owner_type="prop")]
        rec = _classified(subj[0]["research_subject_id"], **rec_over)

        def _call(**kwargs):
            sink = kwargs.get("usage_sink")
            if sink is not None:                    # 정상 Sol 응답을 흉내낸다
                sink["alias"] = "gpt"
                sink["physical_model"] = "openai/gpt-5.6-sol"
            return {"classifications": [rec]}

        with patch.object(gc, "_call_structured", _call):
            out = gc.classify(subj)
        return plan(out["records"])

    def test_normal_grounded_answer_routes_to_research_not_unresolved(self):
        out = self._run({"grounding_class": "externally_grounded",
                         "discriminability": "yes",
                         "referent_specificity": "exact_variant",
                         "difficulty": "hard", "confidence": 0.9})
        d = out["decided"][0]
        assert d["route"] == "research", d["reason"]
        assert out["counts"]["unresolved"] == 0

    def test_normal_generic_answer_now_goes_to_sourced_screening(self):
        """★`generic` 을 검색 **전에** skip 하지 않는다 — 그것도 세상 사실이다."""
        out = self._run({})
        assert out["decided"][0]["route"] == "research", out["decided"][0]["reason"]

    def test_every_planner_required_field_survives_the_classifier(self):
        """★classifier 산출에 planner 가 요구하는 칸이 하나도 안 빠져야 한다."""
        from app.modules.pipeline import grounding_classifier as gc
        subj = [build_subject(project_id="p", episode_id="e", source_anchor="S1",
                              surface_form="가", owner_type="prop")]
        with _patch_llm([_classified(subj[0]["research_subject_id"])]):
            out = gc.classify(subj)
        assert missing_fields(out["records"][0]) == []

    def test_provenance_rides_along_without_gating_the_route(self):
        """judge 칸은 기록으로 남되 route 를 막지 않는다."""
        out = self._run({"grounding_class": "externally_grounded",
                         "discriminability": "yes", "difficulty": "hard"})
        d = out["decided"][0]
        assert d["judge_physical_model"] == "openai/gpt-5.6-sol"
        assert d["route"] == "research"


class TestSpecificityVsClass:
    """★갈래를 먼저 자르면 exact referent 강제가 그 분기로 새어 나간다."""

    @pytest.mark.parametrize("spec", ["exact_variant", "unique_identity"])
    def test_generic_with_exact_specificity_is_a_real_inconsistency(self, spec):
        """generic=「아무 변형이나」 vs exact=「그 변형이어야」 — 정면 충돌이다.

        계약 §3 이 「필드끼리 모순이면 조사」라 research 로 닫는다.
        """
        d = decide_route(_full_record(grounding_class="generic",
                                      referent_specificity=spec))
        assert d["route"] == "research", d["reason"]
        assert d["research_required"] is True

    @pytest.mark.parametrize("spec", ["generic_class", "branded_family"])
    def test_generic_no_longer_short_circuits(self, spec):
        """★`grounding_class=generic` 은 이제 route 를 안 가른다 — 「시대가
        겉모습을 안 구속한다」도 **세상 사실**이라 출처가 답할 것이다."""
        assert decide_route(_full_record(grounding_class="generic",
                                         referent_specificity=spec))["route"] == "research"

    def test_fictional_with_exact_specificity_is_not_the_same_case(self):
        """★모순이 아니다 — 외부 정답이 없을 뿐 작품 안에서는 정해질 수 있다."""
        assert decide_route(_full_record(grounding_class="fictional",
                                         referent_specificity="exact_variant"))["route"] == "design"

    def test_prompt_says_what_the_matrix_actually_does(self):
        """★프롬프트가 「절대 강제」라고 쓰면 실제 행렬과 어긋난다.

        ★2026-08-30: **살아 있는 축**을 요구한다. 전에는 `discriminability` 가
        지문에 있는지를 봤는데, 그 축은 폐기됐고 지금 지문에 한 번 나오는 것은
        「없앴다」는 설명뿐이다 — 그대로 두면 **폐기를 지문 시험이 막는다**.
        """
        from app.modules.pipeline.grounding_classifier import SYSTEM_STEM, load_pack
        text = load_pack()["stems"][SYSTEM_STEM]["content"]
        assert "visibility_intent" in text and "referent_specificity" in text
        assert "generic" in text and "exact_variant" in text


class TestThePromptNoLongerAsksForTheRetiredAxes:
    """★★★지시문이 폐기 축을 **더 묻지 않는다** — 다만 **하나는 되살렸다**.

    `discriminability`(대다수가 구별하는가)는 **계속 폐기**다. 사용자 규칙에
    없는 제3의 문이다.

    ★★`difficulty` 는 **되살렸다**(팩 15, 2026-08-30 저녁). 폐기했더니
    **저빈도 요소를 살리는 유일한 문**이 사라져서, 「원고에 한 번만 나와도
    남겨야 하는 것」(옛 화폐·옛 브랜드 제품·알려진 장소)이 통째로 걸러졌다.
    되살린 것은 옛 5단계 ordinal 이 아니라 `generation_difficulty` **3값**이고,
    **route 를 안 가른다** — 여는 것은 ①보존 ②참고 사진 획득뿐이다.
    """

    @staticmethod
    def _text() -> str:
        from app.modules.pipeline.grounding_classifier import load_pack

        return load_pack()["stems"]["system"]["content"]

    def test_the_recognisability_axis_stays_retired(self):
        # ★「없앴다」고 적은 줄 말고는 안 나와야 한다
        assert self._text().count("discriminability") == 1

    def test_the_generation_difficulty_axis_is_back(self):
        t = self._text()
        assert "generation_difficulty" in t
        assert "not_hard" in t and "uncertain" in t

    def test_it_says_the_axis_does_not_decide_research(self):
        """★이 값으로 조사 여부를 정하면 옛 실패를 그대로 재생한다."""
        t = self._text()
        assert "조사할지 말지를 정하지 않습니다" in t

    def test_the_old_five_step_ordinal_is_not_back(self):
        """★`easy`/`medium` 문턱에서 판정자 눈금이 어긋나 route 가 뒤집혔다."""
        t = self._text()
        for gone in ("very_easy", "very_hard", "medium"):
            assert gone not in t, gone

    def test_the_schema_does_not_require_them(self):
        from app.modules.pipeline.grounding_classifier import load_pack

        item = load_pack()["stems"]["classify_schema"]["content"][
            "properties"]["classifications"]["items"]
        for f in ("discriminability", "difficulty"):
            assert f not in item["properties"], f
            assert f not in item["required"], f

    def test_it_says_why_they_are_gone(self):
        """★금지만 남기면 다음 사람이 되돌린다 — **이유**를 적는다."""
        t = self._text()
        assert "검색을 하지 않습니다" in t
        assert "없앴습니다" in t

    def test_the_uncertainty_channel_is_scoped_to_b(self):
        """★「모른다를 아니다로 닫지 않는다」가 이제 **B** 에 걸린다."""
        t = self._text()
        assert "`visibility_intent: \"uncertain\"`" in t or \
            'visibility_intent` 를 `uncertain`' in t

    def test_it_still_tells_what_to_answer(self):
        """★positive control — 두 축을 빼면서 답할 것까지 지우면 안 된다."""
        t = self._text()
        for f in ("grounding_class", "referent_specificity",
                  "visibility_intent", "confidence"):
            assert f in t, f


class TestTheThreeWayBoundaryIsSpelledOut:
    """★「특정 개체가 아니다」를 `fictional` 로 읽으면 조사가 통째로 빠진다.

    실측(§2-3c): 실재하는 종류의 공간 하나가 세 표본 중 **한 번** `fictional`
    로 가서 route 가 `design` 이 됐다. 「특정 실재 점포가 아니다」를 「창작물」
    로 읽은 것이다. `design` 은 **저작해도 된다**는 닫는 판정이라, 이렇게 새면
    조사해야 할 대상이 조용히 저작으로 넘어간다.
    """

    @staticmethod
    def _text():
        from app.modules.pipeline.grounding_classifier import load_pack
        return load_pack(db=None)["stems"]["system"]["content"]

    def test_it_says_an_unnamed_instance_is_not_fictional(self):
        t = " ".join(self._text().split())
        assert "「특정 개체가 아니다」는 `fictional` 이 아닙니다" in t
        assert "이름이 없어도 **현실에 있는 종류**면 창작물이 아니다" in t

    def test_the_first_question_is_whether_the_kind_exists(self):
        """★가르는 순서를 적는다 — 종류가 현실에 있는가부터."""
        t = " ".join(self._text().split())
        assert "그 종류가 현실에 있는가?" in t
        assert "**종류**이지 그 개체가 실존하느냐가 아닙니다" in t

    def test_it_points_specificity_at_the_right_field(self):
        """★개체를 못 찾는 것은 `referent_specificity` 가 답할 일이다."""
        t = " ".join(self._text().split())
        assert "`referent_specificity` 가 답할 일이다" in t

    def test_the_person_and_their_clothes_are_still_apart(self):
        t = " ".join(self._text().split())
        assert "그 사람이 **입은 것**은 별개 대상" in t

    def test_generic_is_defined_by_era_not_by_naming(self):
        """★`generic` 을 「이름이 없다」로 정의하면 위 혼동이 되살아난다."""
        t = " ".join(self._text().split())
        assert "현실에 있되 **그 시대·지역이 겉모습을 안 구속한다**" in t


class TestTieOnlyMattersWhenItChangesTheAnswer:
    """★§2-3c 패널 실측: `skip×6` **만장**인데 `difficulty` 만 3-3 으로 갈려서
    조사 쪽으로 접혔다 — **아무도 조사라 안 한 것이 조사로 뒤집혔다.**

    동점 축은 「모른다」가 맞다. 다만 그 축을 **어느 값으로 놔도 답이 같으면**
    그 동점은 답을 안 가른다. 모르는 채로도 정할 수 있다.
    조사를 안 사도 되는 것에 시간을 쓰면 안 된다(제약은 돈이 아니라 생성 시간).
    """

    def test_a_retired_axis_does_not_even_enter_the_fold(self):
        """★★2026-08-30: `difficulty` 는 **접기가 아예 안 본다.**

        전에는 「갈렸지만 답을 안 가른다」로 기록했다. 축을 schema 에서만 빼고
        `ROUTING_AXES` 에 두면 그런 칸이 계속 세어지고, 판마다 있고 없고가
        갈리면 그 자체가 `axis_undecided` 를 만든다 (Codex).
        """
        recs = ([_full_record(difficulty="easy")] * 3
                + [_full_record(difficulty="hard")] * 3)
        got = gp.decide_route_from_samples(recs)
        assert got["route"] == "research", got
        assert "difficulty" not in got["axis_undecided"]
        assert "difficulty" not in (got["axis_split"] or {})
        assert got["undecided"] is False

    def test_the_routing_axes_table_no_longer_lists_them(self):
        """★표에 남아 있으면 누군가 다시 센다."""
        assert "difficulty" not in gp.ROUTING_AXES
        assert "discriminability" not in gp.ROUTING_AXES

    def test_a_tie_that_does_change_the_answer_still_closes_to_research(self):
        # ★이제 답을 가르는 축은 **B** 다 — 거기서 갈리면 미확정이다
        recs = ([_grounded(visibility_intent="yes")] * 3
                + [_grounded(visibility_intent="no")] * 3)
        got = gp.decide_route_from_samples(recs)
        assert got["route"] in ("research", "unresolved"), got
        assert got.get("tie_not_load_bearing") is None
        assert got["undecided"] is True

    def test_codex_counterexample_is_untouched(self):
        """네 축이 **전부 다수가 서는** 반례 — 동점이 아예 없어 이 길로 안 온다."""
        recs = [
            _full_record(grounding_class="generic", discriminability="yes",
                         visibility_intent="yes", difficulty="easy"),
            _full_record(grounding_class="externally_grounded",
                         referent_specificity="exact_variant",
                         discriminability="no", visibility_intent="yes",
                         difficulty="medium"),
            _full_record(grounding_class="externally_grounded",
                         referent_specificity="exact_variant",
                         discriminability="yes", visibility_intent="no",
                         difficulty="medium"),
        ]
        got = gp.decide_route_from_samples(recs)
        assert got["route"] == "research", got
        assert got.get("tie_not_load_bearing") is None


class TestPluralityIsNotAMajority:
    """★6표 중 3표는 **다수가 아니다.** 그런데 최다득표는 하나다.

    동점 후보에 최다득표만 넣었더니, 「다수가 안 섰다」가 조용히 그 값으로
    정해졌다 — 실측(held-out): 승차권 천공기가 `easy×3 hard×2 medium×1` 로
    **과반 없이** `skip` 이 됐다. 기대는 `research` 였다.
    """

    def test_three_of_six_does_not_decide_the_axis(self):
        # ★답을 가르는 축(**B**)에서 재야 「다수가 안 섰다」가 뜻을 가진다 —
        #  `difficulty` 는 이제 route 를 안 가르므로 갈려도 답이 안 바뀐다.
        recs = ([_grounded(visibility_intent="yes")] * 3
                + [_grounded(visibility_intent="no")] * 2
                + [_grounded(visibility_intent="uncertain")])
        ax = gp.fold_axes(recs)
        assert "visibility_intent" in ax["undecided"]
        # ★후보는 표본이 낸 값 **전부**다 — 최다득표 하나가 아니다
        assert ax["candidates"]["visibility_intent"] == [
            "no", "uncertain", "yes"]

        got = gp.decide_route_from_samples(recs)
        assert got.get("tie_not_load_bearing") is None, got
        assert got["route"] == "research", got
        assert got["undecided"] is True


class TestVoteAndFoldAreAlwaysCompared:
    """★「다수가 **유일할 때만**」 축 접기와 대조했더니, 3:3 동점에서
    대조가 통째로 빠져 축 접기가 그냥 이겼다 (Codex 반례, HEAD 0ddf1ff5).

        routes = [skip, skip, skip, research, research, research]
        → **skip**

    세 표본이 **서로 다른 축**으로 조사에 갔더니 축별 다수가 전부 skip 쪽
    값이 됐기 때문이다. 문서의 「동점에 research 가 있으면 research」와
    정면으로 어긋난다.
    """

    @staticmethod
    def _split_three_three():
        """skip 3표 + research 3표 — 조사 쪽 셋이 **서로 다른 축**으로 간다.

        ★가르는 축이 **B** 로 바뀌었다(A·difficulty 는 route 를 안 가른다).
        """
        return [
            _full_record(grounding_class="externally_grounded",
                         visibility_intent="no"),
            _full_record(grounding_class="externally_grounded",
                         visibility_intent="no"),
            _full_record(grounding_class="externally_grounded",
                         visibility_intent="no"),
            _full_record(grounding_class="externally_grounded",
                         visibility_intent="uncertain"),
            _full_record(grounding_class="externally_grounded",
                         referent_specificity="exact_variant",
                         visibility_intent="yes"),
            _full_record(grounding_class="externally_grounded",
                         visibility_intent="yes"),
        ]

    def test_the_codex_counterexample(self):
        recs = self._split_three_three()
        assert ([gp.decide_route(r)["route"] for r in recs]
                == ["skip", "skip", "skip", "research", "research", "research"])
        got = gp.decide_route_from_samples(recs)
        assert got["route"] == "research", got

    def test_every_order_of_those_six_gives_the_same_answer(self):
        """★순서를 바꿔도 같아야 한다 — 720가지를 전부 태운다."""
        import itertools

        base = self._split_three_three()
        seen = {gp.decide_route_from_samples(list(p))["route"]
                for p in itertools.permutations(base)}
        assert seen == {"research"}, seen

    def test_the_vote_verdict_has_exactly_one_home(self):
        """★같은 규칙을 두 곳에 적으면 한쪽만 고쳐진다 — 이 판에서 네 번째다."""
        import inspect

        src = inspect.getsource(gp.decide_route_from_samples)
        assert "route_by_vote(" in src
        # 투표 판정을 호출부에서 **다시 만들지** 않는다
        assert "most_common()" not in src, "투표를 여기서 또 센다"

    def test_a_tie_with_no_research_stays_unresolved(self):
        recs = [_full_record(grounding_class="generic"),
                _full_record(grounding_class="fictional")]
        got = gp.decide_route_from_samples(recs)
        assert got["route"] == "unresolved", got


class TestGenerationDifficultyOpensRetentionNotRouting:
    """★★★**되살린 축이 여는 것은 딱 둘** — ①저빈도 보존 ②참고 사진 획득.

    폐기했던 이유(자기평가로 조사 여부를 정하면 안 된다)는 그대로 지킨다:
    **route 는 이 값을 안 본다.** 그래서 「검색 전에 세상 사실을 답하지 마라」와
    안 부딪힌다 — 이 축이 답하는 것은 세상 사실이 아니라 **「이 모델이 만들 수
    있나」**다.

    ★그리고 폐기했을 때 실제로 무너진 것: 원고에 한 번만 나오는 옛 화폐·옛
    브랜드 제품·알려진 장소를 **살릴 문이 없어졌다**.
    """

    @staticmethod
    def _rec(**over):
        base = dict(
            research_subject_id="rs_x",
            grounding_class="externally_grounded",
            referent_specificity="generic_class",
            visibility_intent="yes", confidence=0.9,
            locale="KR", generation="1980s",
            visible_discriminators=["x"], likely_failure_modes=["y"],
            generation_difficulty="not_hard")
        base.update(over)
        return base

    def test_the_route_does_not_look_at_it(self):
        a = gp.decide_route(self._rec(generation_difficulty="hard"))
        b = gp.decide_route(self._rec(generation_difficulty="not_hard"))
        assert a["route"] == b["route"], "★이 값이 route 를 갈랐다"
        assert "difficulty" not in gp.ROUTING_AXES
        assert "generation_difficulty" not in gp.ROUTING_AXES

    def test_hard_opens_reference_acquisition(self):
        assert gp.needs_reference_acquisition(
            self._rec(generation_difficulty="hard")) is True

    def test_not_hard_does_not(self):
        assert gp.needs_reference_acquisition(
            self._rec(generation_difficulty="not_hard")) is False

    def test_uncertain_is_treated_as_hard(self):
        """★모르는 것을 빼면 되돌릴 수 없다. 챙겨 두는 것은 시간만 쓴다."""
        assert gp.needs_reference_acquisition(
            self._rec(generation_difficulty="uncertain")) is True

    def test_a_missing_value_does_not_silently_become_not_hard(self):
        """★칸이 비면 **미확정으로 닫는다** — 그러면 보존 통로가 같이 열린다."""
        rec = self._rec()
        rec.pop("generation_difficulty")
        assert gp.decide_route(rec)["route"] == "unresolved"
        assert gp.needs_reference_acquisition(rec) is False

    def test_an_enum_violation_closes_to_unresolved(self):
        got = gp.decide_route(self._rec(generation_difficulty="아주어려움"))
        assert got["route"] == "unresolved"
        assert "generation_difficulty" in got["reason"]

    def test_the_acquisition_list_is_only_the_hard_ones(self):
        """★★`route` 를 이 값의 대리로 쓰면 **전부** 사게 된다 — 실측에서
        아홉 축이 전부 `research` 였다."""
        recs = [self._rec(research_subject_id="rs_a", generation_difficulty="hard"),
                self._rec(research_subject_id="rs_b", generation_difficulty="not_hard"),
                self._rec(research_subject_id="rs_c", generation_difficulty="uncertain")]
        assert gp.reference_acquisition_ids(recs) == ["rs_a", "rs_c"]
        # 셋 다 route 는 같다 — 그래서 route 로 고르면 셋을 다 산다
        assert len({gp.decide_route(r)["route"] for r in recs}) == 1

    def test_the_planner_requires_the_field(self):
        assert "generation_difficulty" in gp._REQUIRED_FIELDS
