"""GROUNDING-V2 — 분류 결과에서 **코드가** route 를 정한다.

설계: 계약 §2·§3·§6, 계획 §2-2.

★**LLM 이 「조사 필요」라고 말한 것을 그대로 받지 않는다.** classifier 는 관찰
가능한 사실(구별점·난이도·특정성·확신도)만 채우고, **최종 route 는 이 모듈이
필드로 계산한다.** 필드끼리 모순이면 **조사** 쪽으로 닫는다.

세 갈래 (계약 §1):
    externally_grounded  외부 기록의 정답이 시각 정본을 구속 → 조사
    fictional            외부 정답이 없는 창작물            → 디자인 저작 (조사 아님)
    generic              어떤 실제 변형이어도 계약을 안 어김 → 일반 상세화
"""
from __future__ import annotations

from collections import Counter
from itertools import product
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set

PLANNER_CONTRACT_VERSION = 9

#: 대상 특정성 enum (계약 §3 SOT).
REFERENT_SPECIFICITY = (
    "generic_class",     # 한복 · 무브랜드 90년대 차
    "branded_family",    # 특정 모델 없는 나이키
    "exact_variant",     # 2020 쏘나타 · 특정 연식·계급 제복  → 조사 강제
    "unique_identity",   # 알려진 인물 · 명소 · 유물          → 조사 강제
)

#: ★모델의 말만으로 bypass 하면 안 되는 특정성 (계약 §3 「exact referent」).
_FORCE_RESEARCH_SPECIFICITY: Set[str] = {"exact_variant", "unique_identity"}

#: 세 갈래 (계약 §1). ★``fictional`` 은 **조사 대상이 아니지만 「지어내도 된다」가
#: 아니다** — design 저작으로 가고 하류 봉인은 그대로 받는다.
GROUNDING_CLASS = ("externally_grounded", "fictional", "generic")

#: ★confidence 는 **route 를 안 가른다** — 진단·provenance 로만 남는다.
#:
#: 왜 — 보정되지 않은 자기보고다. 실측(§2-3c, 146건)이 0.78~0.86 에 빽빽하고
#: 옛 문턱 0.8 이 **그 한가운데**에 앉았다. 자르면 축 답이 뚜렷한 표본까지 버려
#: **범주적 다수가 뒤집힌다** — 회수권(양성)이 `medium` 표본 둘을 잃고
#: `unresolved` 로 갔다. 숫자를 다른 숫자로 낮추는 것도 답이 아니다. 그건
#: 대조군에 맞추는 것이고, 어느 값이든 같은 분포 위에서 임의로 자른다.
#:
#: ★「모른다」의 통로는 둘이다 — **스키마의 명시적 `uncertain`**(그 자리에서
#: `research`)과 **표본 사이 갈림**(접기가 `unstable` 로 셈). 계약 §3 은
#: 그 둘로 지킨다.
#:
#: ★이 값을 **없애지는 않는다.** 기록에 남겨 두면 나중에 「어느 구간에서
#: 갈렸나」를 볼 수 있다. 다만 **판정에는 안 쓴다.**
#:
#: 옛 값 보존 — 0.6(v1) → 0.8(v2·v3) → **route 에서 제외(v4)**.
_CONFIDENCE_DIAGNOSTIC_ONLY = True

#: visibility_intent — ★B 는 조사 시점에 **확정값이 아니다** (계약 §2).
#: framing_scale 은 shot_staging(19.5)에서야 정해지므로 여기서는 의도값만 쓴다.
VISIBILITY_INTENT = ("yes", "no", "uncertain")

#: gate 를 거는 상태 묶음 (계약 §13). research_required 만으로 좁히면
#: **claim 을 못 찾은 바로 그 순간** 하류가 외형을 다시 지어낸다.
#: ★``design`` 도 포함한다 — 「조사 대상이 아니다」가 「하류가 마음대로 덮어도 된다」로
#: 읽히면 안 된다 (계약 §1). 봉인 대상 = research ∪ design ∪ unresolved.
GROUNDING_CONTROLLED_ROUTES = ("research", "design", "unresolved")

#: ★생성 난이도 — classifier 출력. **3값**이다. 옛 5단계 ordinal
#: (`very_easy`…`very_hard`)은 안 되살린다: `easy`/`medium` 문턱에서 판정자
#: 눈금이 한 칸 어긋나 route 가 뒤집혔다(§2-3c 실측).
GENERATION_DIFFICULTY = ("hard", "not_hard", "uncertain")

#: ★**보존·참조 획득을 여는 값.** `uncertain` 은 `hard` 쪽으로 다룬다 —
#: 모르는 것을 빼면 되돌릴 수 없고, 챙겨 두고 안 쓰는 것은 시간만 쓴다.
RETENTION_DIFFICULTY: Set[str] = {"hard", "uncertain"}


def needs_reference_acquisition(record: Dict[str, Any]) -> bool:
    """★**참고 사진을 찾아 와야 하는 대상인가.**

    ★★이것은 **route 가 아니다.** `research`/`skip` 을 안 가르고,
    `sourced_delta`(세상 사실)도 안 가르고, 조사 완결성도 안 가른다.
    여는 것은 딱 둘이다 — **①저빈도 요소 필터에서 살린다 ②참고 사진을 찾아 온다.**

    ★`route` 를 이 값의 대리로 쓰지 마라. 지금 `PROTECTED_ROUTES` 는 옛 조사
    route 의 뜻이고, 실측에서 아홉 축이 **전부** `research` 로 갔다 — 그것으로
    검색 대상을 고르면 전부 사게 된다.
    """
    return str(record.get("generation_difficulty") or "") in RETENTION_DIFFICULTY


def reference_acquisition_ids(records: Sequence[Dict[str, Any]]) -> list:
    """참고 사진을 찾아 올 ``research_subject_id`` 들. ★순서 고정."""
    out = {str(r.get("research_subject_id") or "") for r in records
           if needs_reference_acquisition(r)}
    out.discard("")
    return sorted(out)


_REQUIRED_FIELDS = (
    # ★★폐기한 두 축(`discriminability`·`difficulty`)은 **요구하지 않는다**
    #  (계약 §2, 2026-08-30). 검색을 안 하는 단계가 세상 사실을 답하던 자리다.
    "grounding_class",
    "referent_specificity", "confidence",
    "visibility_intent", "locale", "generation",
    "visible_discriminators", "likely_failure_modes",
    # ★2026-08-30 저녁: `generation_difficulty` 를 **요구한다.** 이 칸이 비면
    #  「한 번만 나와도 살린다」와 「참고 사진을 찾아 온다」가 **조용히 안 열린다**.
    #  ★단 route 는 안 가른다 — `ROUTING_AXES` 에 넣지 않는다.
    "generation_difficulty",
    # ★★`target_image_*` 도 **요구하지 않는다.** 그 좌표는 폐기한 `difficulty`
    #  (「이 이미지 모델이 근거 없이 맞힐 수 있나」)의 전제였다. 축이 없어진
    #  뒤에도 required 로 남겨 두면, route 를 안 가르는 값이 빠졌다는 이유로
    #  정상 판정이 `unresolved` 로 죽고, 이미지 모델을 바꾼 것만으로 같은
    #  분류를 **다시 사게** 된다.
)
# ★judge_* 는 **provenance 지 routing 입력이 아니다.** required 에 넣었더니
#   classifier 가 이름을 바꾼 순간 정상 판정이 전부 unresolved 로 죽었다 —
#   그런데 손으로 만든 record 로 재고 있어서 시험이 그걸 가렸다.
#   그래서 classify→plan **끝점** 시험을 따로 둔다.


def missing_fields(record: Dict[str, Any]) -> List[str]:
    """classifier 가 안 채운 칸. ★빈 문자열·빈 목록도 미충족으로 센다."""
    out: List[str] = []
    for f in _REQUIRED_FIELDS:
        v = record.get(f)
        if v is None or (isinstance(v, str) and not v.strip()) or (
            isinstance(v, (list, tuple, set)) and len(v) == 0
        ):
            out.append(f)
    return out


def _enum_violations(record: Dict[str, Any]) -> List[str]:
    bad: List[str] = []

    if record.get("referent_specificity") not in REFERENT_SPECIFICITY:
        bad.append("referent_specificity")
    if record.get("visibility_intent") not in VISIBILITY_INTENT:
        bad.append("visibility_intent")
    if record.get("grounding_class") not in GROUNDING_CLASS:
        bad.append("grounding_class")
    # ★enum 밖이면 **route 를 미확정으로 닫는다** — 그러면 보존 통로도 같이
    #  열린다(미확정은 보호 대상). 조용히 `not_hard` 로 읽는 길을 안 만든다.
    if record.get("generation_difficulty") not in GENERATION_DIFFICULTY:
        bad.append("generation_difficulty")

    conf = record.get("confidence")
    if not isinstance(conf, (int, float)) or isinstance(conf, bool) or not 0 <= conf <= 1:
        bad.append("confidence")
    return bad


def decide_route(record: Dict[str, Any]) -> Dict[str, Any]:
    """route 를 계산한다.

    Returns:
        {"route": "research"|"skip"|"unresolved", "reason": str,
         "research_required": bool, "grounding_controlled": bool}

    route 뜻:
        research    실제 조사를 산다
        design      외부 정답이 없다 — **저작**하되 하류 봉인은 유지 (계약 §1 fictional)
        skip        조사 안 한다 (generic 상세화 / 모델이 이미 맞게 만든다)
        unresolved  ★판정을 못 했다. **skip 이 아니다** — 하류가 외형을 지어내지
                    못하게 계속 묶어 둔다 (계약 §13)
    """
    miss = missing_fields(record)
    if miss:
        return _out("unresolved", f"classifier 가 안 채운 칸: {', '.join(miss)}")

    bad = _enum_violations(record)
    if bad:
        return _out("unresolved", f"enum 밖 값: {', '.join(bad)}")

    spec = record["referent_specificity"]
    visibility = record["visibility_intent"]        # B — 이번 렌더에서 보일 의도인가

    # ★★★**검색 전에는 원고에서 아는 것만 본다** (계약 §2, 2026-08-30 수리).
    #  전에는 여기서 `grounding_class=generic` 을 skip 하고, 분류기의
    #  `discriminability` 를 A 로 쓰고, `difficulty` 로 route 를 정했다.
    #  그 셋은 **세상 사실**이라 「검색을 하지 않는」 단계가 답할 것이 아니다 —
    #  그리고 그렇게 skip 된 대상은 §4a 의 **출처 확정에 한 번도 안 닿는다**
    #  (Codex 실측: 조사 스텝이 route=research 인 것만 검색한다).
    #
    #  ```
    #  검색 전 = fictional 여부 · referent_specificity · B
    #  fictional                   → design
    #  B=no                        → skip
    #  현실 대상 + B=yes/uncertain → §4a 조사(출처로 A 확정)
    #  ```
    #  ★최종 research/non-research 는 **sourced delta** 가 낸다.

    # ── ★명시적 `uncertain` 이 먼저다 ────────────────────────────────
    #  프롬프트가 「확신이 없으면 축을 `uncertain` 으로 **그리고** confidence 를
    #  낮게」라고 시킨다. 둘이 같이 오므로 confidence 를 먼저 보면 「uncertain 은
    #  조사」라는 계약 §3 이 조용히 사라진다.
    #  ★이제 보는 축은 **B 와 특정성**뿐이다 — A(`discriminability`)와
    #   `difficulty` 는 route 를 안 가른다.
    explicit = [k for k in ("visibility_intent",) if record.get(k) == "uncertain"]

    # ── 1단계: fictional 은 외부 정답이 없다 (계약 §1) ───────────────
    gclass = record["grounding_class"]
    if gclass == "fictional":
        # ★외부 정답이 **없다**는 뜻이지, 작품 안에서 특정 변형이 못 정해진다는
        #  뜻이 아니다. 조사 대상은 아니지만 **저작**이고 봉인은 유지한다.
        return _out("design", "grounding_class=fictional — 외부 정답이 없다. 저작하되 봉인 유지")

    # ── 2단계: B — 이번 렌더에서 보일 의도인가 ───────────────────────
    if visibility == "no":
        return _out("skip", "visibility_intent=no — 이번 렌더에 안 보인다")
    if explicit:
        return _out("research",
                    "visibility_intent=uncertain — 모른다를 아니다로 닫지 않는다")

    # ── 3단계: 현실 대상 + B=yes → **출처로 A 를 확정한다** ──────────
    #  ★여기서 `research` 는 「조사한다」이지 「시대 차이가 있다」가 아니다.
    #   시대 차이 여부는 §4a 의 sourced delta 가 낸다.
    if spec in _FORCE_RESEARCH_SPECIFICITY:
        return _out("research", f"referent_specificity={spec} — 조사 강제")
    return _out("research",
                "현실 대상이고 이번 렌더에 보인다 — 출처로 A 를 확정한다")


def _out(route: str, reason: str) -> Dict[str, Any]:
    return {
        "contract_version": PLANNER_CONTRACT_VERSION,
        "route": route,
        "reason": reason,
        "research_required": route == "research",
        # ★gate 대상 = research_required ∪ uncertain ∪ unresolved (계약 §13).
        #   uncertain 은 위에서 이미 research 로 접혀 들어온다.
        "grounding_controlled": route in GROUNDING_CONTROLLED_ROUTES,
    }


#: ★route 를 가르는 축들. 이 축이 표본마다 갈리면 **접을 수 없다.**
#: ★2026-08-30: 폐기한 두 축을 **여기서도** 뺐다. schema 만 고치고 이 표를
#: 두면 접기가 없는 칸을 계속 세고, 판마다 있고 없고가 갈리면 그 자체로
#: `axis_undecided` 를 만든다.
ROUTING_AXES = ("grounding_class", "visibility_intent", "referent_specificity")


def fold_axes(records: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
    """★**축을 먼저 접고** route 를 **한 번** 계산한다.

    왜 — route 로 투표하면 **축이 다 달라도 route 가 같으면 안정으로 센다.**

    ★2026-08-30 로 축이 셋으로 줄어 옛 4축 반례는 못 쓴다. **그렇다고 반례가
    사라진 것이 아니다** — 표본이 넷이면 여전히 만들어진다
    (`grounding_class` · `visibility_intent` · `referent_specificity`):

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

    route 투표는 `skip 2 / design 1 / research 1` 이라 **skip** 이다.
    그런데 **축별 다수**는 `externally`(3/4) + B 는 2-2 동점이라
    `uncertain` 으로 닫히고 → 그것은 **research** 다.
    둘이 서로 다른 이유로 skip 을 골랐을 뿐 공통된 근거가 없다.
    그대로 두면 고증이 조용히 우회된다.

    Returns:
        ``folded``  축별 다수로 만든 한 판정 (못 접은 축이 있으면 ``None``)
        ``split``   갈린 축 → 값 목록
        ``undecided`` 다수가 안 선 축 목록
    """
    split: Dict[str, List[str]] = {}
    undecided: List[str] = []
    candidates: Dict[str, List[str]] = {}
    folded: Dict[str, Any] = dict(records[0]) if records else {}
    # ★동점 축을 **비운 판** — 호출부가 후보를 하나씩 넣어 답이 갈리는지 본다.
    decided_only: Dict[str, Any] = dict(records[0]) if records else {}
    for col in ROUTING_AXES:
        vals = [r.get(col) for r in records]
        uniq = sorted({str(v) for v in vals})
        if len(uniq) > 1:
            split[col] = uniq
        c = Counter(vals)
        top = c.most_common()
        best = top[0][1]
        winners = [v for v, n in top if n == best]
        if len(winners) > 1 or best * 2 <= len(records):
            # ★다수가 안 선 축은 **그 축을 모른다**는 뜻이다. 임의로 고르지 않는다.
            undecided.append(col)
            # ★후보는 **표본이 실제로 낸 값 전부**다 — 최다득표만 넣으면 안 된다.
            #  6표 중 3표는 다수가 아닌데(`best*2 <= len`) 최다득표는 하나다.
            #  그 하나만 넣으면 「다수가 안 섰다」가 조용히 그 값으로 정해진다
            #  (실측: 승차권 천공기 easy×3·hard×2·medium×1 이 `skip` 이 됐다).
            candidates[col] = sorted({str(v) for v in vals})
            pick = _undecided_value(col, winners)
            if pick is None:
                folded = None       # 접을 길이 없다 → 통째로 미확정
            elif folded is not None:
                folded[col] = pick
        elif folded is not None:
            folded[col] = winners[0]
            if decided_only is not None:
                decided_only[col] = winners[0]
    return {"folded": folded, "split": split, "undecided": undecided,
            "candidates": candidates, "decided_only": decided_only}


def _undecided_value(col: str, winners: Sequence[Any]) -> Optional[str]:
    """다수가 안 선 축을 **어느 값으로** 접을까. 못 정하면 ``None``.

    ★규칙은 하나 — **조사 쪽으로 닫는다.** 조사 안 한 것은 되돌릴 수 없고,
    조사해 두고 안 쓰는 것은 시간만 쓴다(계약 §3 의 비대칭).

    - `uncertain` 이 있는 축이면 그 값. 그 자체가 조사로 닫힌다
    - 없는 축이면 **후보 중 조사 쪽 값**을 고른다
    - 그것도 없으면 ``None`` — 임의로 고르지 않는다
    """
    if "uncertain" in _AXIS_ENUMS.get(col, ()):
        return "uncertain"
    ward = _AXIS_RESEARCH_WARD.get(col, frozenset())
    hit = [str(w) for w in winners if str(w) in ward]
    return sorted(hit)[0] if hit else None


#: 축마다 쓸 수 있는 값. ★`uncertain` 이 있는 축은 갈렸을 때 그리로 보낸다.
_AXIS_ENUMS = {
    "grounding_class": GROUNDING_CLASS,
    "visibility_intent": VISIBILITY_INTENT,
    "referent_specificity": REFERENT_SPECIFICITY,
}

#: `uncertain` 이 없는 축에서 **조사 쪽**인 값.
_AXIS_RESEARCH_WARD = {
    "referent_specificity": frozenset(_FORCE_RESEARCH_SPECIFICITY),
    "grounding_class": frozenset({"externally_grounded"}),
}


def route_by_vote(answered: "Counter") -> tuple:
    """route 투표 하나로 답을 낸다 — **투표 판정의 정본은 여기 하나뿐**이다.

    ★두 군데서 따로 세면 한쪽만 고쳐진다. 실제로 그랬다(Codex):
    호출부는 「다수가 **유일할 때만**」 축 접기와 대조했고, 3:3 동점이면
    대조를 통째로 건너뛰어 축 접기가 그냥 이겼다. 그래서

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

    이 나왔다. 세 표본이 **서로 다른 축**(discriminability uncertain /
    visibility uncertain / difficulty medium)으로 조사에 갔더니 축별 다수는
    전부 skip 쪽 값이 됐기 때문이다. 문서의 「동점에 research 가 있으면
    research」와 정면으로 어긋난다.
    """
    top = answered.most_common()
    best = top[0][1]
    winners = sorted(r for r, n in top if n == best)
    total = sum(answered.values())
    if len(winners) == 1:
        return winners[0], f"다수결 {best}/{total}"
    if "research" in winners:
        # ★동점 때 비대칭을 쓴다 — 조사 안 한 것은 되돌릴 수 없다.
        return "research", f"동점({'/'.join(winners)}) — 조사 쪽으로 닫는다"
    # ★research 가 없는 동점은 **임의로 고르지 않는다.** 사전순으로 고르면
    #  ('design','skip') 이 design 이 되는데 근거가 없다.
    return "unresolved", f"동점({'/'.join(winners)}) — 조사 쪽이 아니라 고를 근거가 없다"


def decide_route_from_samples(records: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
    """★**표본 여럿을 접어** route 를 정한다. 한 표본으로는 못 정한다.

    왜 — 판정기를 **결정적으로 만들 수 없다.** ``gpt``(Sol) 는
    ``llm_client._NO_TEMPERATURE_ALIASES`` 라 temperature 를 못 내리고,
    gpt-5 계열은 ``temperature=1`` 만 허용한다. 실측에서 같은 입력·같은 팩·같은
    판정자가 판마다 ``discriminability`` 를 yes/uncertain 으로, ``confidence`` 를
    0.82/0.62 로 답했고 route 가 뒤집혔다.

    접는 규칙:

    ```
    ★confidence 는 투표에 안 쓴다        진단·provenance 로만 남는다
    unresolved 표본은 투표에서 뺀다      「skip 을 골랐다」가 아니라 「답을 안 했다」
    답한 표본이 과반 미만이면 unresolved  소수 의견으로 route 를 정하지 않는다
    다수결
    동점 — research 가 후보면 research   비대칭(조사 안 한 것은 되돌릴 수 없다)
         — research 가 없으면 unresolved  임의로 고르지 않는다
    ```

    ★「한 번이라도 research 면 research」는 **안 쓴다.** 그러면 3회 중 1회 새는
    정상 분산만으로도 「과잉 조사를 막는」 음성 축이 **구조적으로 통과 불가**가 된다.

    ★``unstable`` 은 **숨기지 않는다.** 갈렸다는 사실 자체가 진단이다 —
    acceptance 는 이것을 미확정으로 센다. ★production 호출부는
    ``GroundingPlanStep`` 이다(§2-3.5 에서 배선했다) — route 를 따르되
    ``unstable`` 을 체크포인트에 남긴다.
    """
    if not records:
        return {**_out("unresolved", "표본이 없다"), "votes": {}, "unstable": False,
                "undecided": True, "axis_undecided": [], "axis_split": {},
                "confidence_range": None, "sample_count": 0, "unanswered": 0}
    decided = [decide_route(r) for r in records]
    votes = Counter(d["route"] for d in decided)

    # ★`unresolved` 는 **투표에 안 넣는다.** 「skip 을 골랐다」가 아니라
    #  「답을 안 했다」다. 표로 세면 미확정이 결정으로 접힌다 —
    #  실측에서 ('skip','unresolved') 가 skip 으로 접혔다.
    unanswered = votes.get("unresolved", 0)
    answered = Counter({k: v for k, v in votes.items() if k != "unresolved"})
    # ★**축별 갈림**을 따로 센다 — route 가 같아도 축이 흔들렸으면 그것이
    #  진단이다. confidence 를 판정에서 뺀 대신 이것이 「못 정한다」의 근거다.
    axis_split = {}
    for col in ROUTING_AXES:
        vals = {r.get(col) for r in records}
        if len(vals) > 1:
            axis_split[col] = sorted(str(v) for v in vals)
    conf = [r.get("confidence") for r in records if r.get("confidence") is not None]
    # ★`unstable` 은 **route 투표가 갈렸다** — 진단이다.
    #  판정은 이제 **축을 접어서** 낸다(`fold_axes`). 그래서 「못 정했다」의
    #  기준은 `undecided`(축이 갈려 다수를 못 세운 것)다. 둘을 갈라 둔다.
    base = {"votes": dict(votes), "unstable": len(votes) > 1,
            "sample_count": len(records), "unanswered": unanswered,
            "axis_split": axis_split,
            # ★기록만 한다 — 판정에는 안 쓴다.
            "confidence_range": ([min(conf), max(conf)] if conf else None)}

    if not answered:
        return {**_out("unresolved", f"표본 {len(records)}개가 전부 미확정"), **base}

    # ★답한 표본이 과반이 안 되면 **접지도 않는다.** 접고 나서 보면
    #  `records[0]` 이 무엇이냐에 따라 답이 달라진다 — 순서 의존이다(Codex 실측:
    #  `[good,bad,bad]` 는 skip, `[bad,good,bad]` 는 unresolved 였다).
    if sum(answered.values()) * 2 <= len(records):
        return {**_out("unresolved",
                       f"답한 표본이 {sum(answered.values())}/{len(records)} 로 과반 미만"),
                **base}

    # ── ★축을 먼저 접는다 — route 투표만으로는 새는 자리가 있다 ──────
    # ★**답한 표본만** 접는다. `unresolved` 표본은 그 축을 제대로 안 낸
    #  것이므로 축 다수에도 안 넣는다 — 투표에서 뺀 것과 같은 이유다.
    answered_records = [r for r, d in zip(records, decided)
                        if d["route"] != "unresolved"]
    # ★투표 판정을 **먼저** 낸다 — 아래 모든 갈래가 이 하나와 대조한다.
    vote_route, vote_why = route_by_vote(answered)
    base["vote_route"] = vote_route

    ax = fold_axes(answered_records)
    base["axis_undecided"] = ax["undecided"]
    # ★「못 정했다」의 정본. route 투표 갈림이 아니라 **축 다수 실패**다.
    base["undecided"] = bool(ax["undecided"])

    # ── ★동점이 **답을 가르는지** 먼저 본다 ────────────────────────
    #  실측(§2-3c 패널): `skip×6` **만장**인데 `difficulty` 만 3-3 으로 갈려서
    #  조사 쪽으로 접혔고, 아무도 조사라 안 한 것이 조사로 뒤집혔다.
    #  동점 축은 「모른다」가 맞지만, 그 축을 **어느 값으로 놔도 답이 같다면**
    #  그 동점은 답을 안 가른다 — 모르는 채로도 정할 수 있다.
    #  ★Codex 반례는 여기 안 걸린다. 거기선 네 축이 **전부 다수가 서고**
    #   그 조합이 research 다 — 동점 축이 아예 없다.
    if ax["undecided"] and ax["decided_only"] is not None:
        probe_cols = list(ax["candidates"])
        combos = list(product(*(ax["candidates"][c] for c in probe_cols)))
        seen_routes = set()
        for combo in combos:
            trial = dict(ax["decided_only"])
            for c, v in zip(probe_cols, combo):
                trial[c] = v
            seen_routes.add(decide_route(trial)["route"])
        if len(seen_routes) == 1 and combos:
            only = seen_routes.pop()
            # ★여기서도 **투표와 대조한다.** 동점이 답을 안 가른다고 해서
            #  표본 투표와 어긋나도 된다는 뜻은 아니다.
            if only != "unresolved" and only == vote_route:
                # 동점이 답을 안 가른다 → 정할 수 있다. 갈린 사실은 남긴다.
                trial = dict(ax["decided_only"])
                for c, v in zip(probe_cols, combos[0]):
                    trial[c] = v
                return {**decide_route(trial),
                        **{**base, "undecided": False,
                           "tie_not_load_bearing": probe_cols,
                           "reason_source": "folded_axes_tie_immaterial"}}

    if ax["folded"] is None:
        return {**_out("unresolved",
                       f"다수가 안 서고 `uncertain` 도 없는 축: "
                       f"{', '.join(ax['undecided'])}"),
                **base}
    folded_route = decide_route(ax["folded"])

    # ★두 접는 법이 **다른 답을 내면 못 정한 것이다.**
    #
    #  축을 따로따로 다수결하면 **어느 표본도 안 낸 조합**이 나올 수 있다.
    #  실측(held-out): route 투표가 `research×2 skip×1` 인데 축을 접으니
    #  `skip` 이 나왔다 — 다수가 조사라 했는데 **아무도 안 한 말**로 뒤집혔다.
    #  반대로 route 만 보면 「서로 다른 이유로 같은 곳에 간 것」을 안정으로 센다
    #  (Codex 반례). ★어느 한쪽을 믿을 근거가 없다 — 그래서 **갈리면 미확정**이다.
    # ★**항상** 대조한다 — 「다수가 유일할 때만」 대조하면 3:3 동점에서
    #  대조가 통째로 빠져 축 접기가 그냥 이긴다(Codex 반례).
    if folded_route["route"] != vote_route:
        both = [folded_route["route"], vote_route]
        # ★어느 한쪽을 믿을 근거가 없다 → **조사 쪽으로 닫는다**(계약 §3 의
        #  비대칭). 조사 안 한 것은 되돌릴 수 없고, 조사해 두고 안 쓰는 것은
        #  시간만 쓴다. `unresolved` 로 두면 하류는 막히지만 **조사도 안 산다.**
        return {**_out("research" if "research" in both else "unresolved",
                       f"접는 법이 갈렸다 — 축 접기는 {both[0]}, "
                       f"표본 투표는 {both[1]}"),
                **{**base, "fold_conflict": both}}

    if folded_route["route"] != "unresolved":
        # ★축을 접어 낸 판정이 정본이다. route 투표는 **진단**으로만 남는다.
        return {**folded_route,
                **{**base, "reason_source": "folded_axes"}}
    if vote_route == "unresolved":
        return {**_out("unresolved", vote_why), **base}
    reason = next(d["reason"] for d in decided if d["route"] == vote_route)
    return {**_out(vote_route, f"{vote_why}: {reason}"), **base}


def plan(records: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
    """후보 목록 → route 결정 + 집계. ★검색을 하지 않는다."""
    decided: List[Dict[str, Any]] = []
    counts = {"research": 0, "design": 0, "skip": 0, "unresolved": 0}
    for rec in records:
        d = decide_route(rec)
        counts[d["route"]] += 1
        decided.append({**rec, **d})
    return {
        "contract_version": PLANNER_CONTRACT_VERSION,
        "decided": decided,
        "counts": counts,
        "search_calls": 0,  # ★이 단계는 무료다. 값이 아니라 계약이다.
    }
