#!/usr/bin/env python3
"""판정 브리프의 **정적 절을 system 으로 옮겨** 보고, 판정이 흔들리는지 본다.

## 무엇을 재나

판정 호출의 user 는 평균 10,273자인데 그중 **5,760자(56%)가 매 샷
똑같다.** 그 정적 부분은 샷과 무관한 일반 규칙이다 — `BODY & SUPPORT` ·
`CHARACTER REFERENCE ROLE` · 자막·오버레이 금지 등.

- **arm A** — 지금 그대로 (system=팩, user=브리프 전체)
- **arm B** — 정적 줄을 user 에서 빼서 **system 뒤에 붙인다**

★내용은 **그대로다**. 걷는 게 아니라 자리를 옮기는 것이다 — 08-24
 `a675c352` 가 기각한 「설명 걷기」와 다른 축이다.
★그래도 재는 이유: 그때 얻은 교훈이 정확히 이 지점이다 —
 「같은 모양의 문장이라도 **어느 자리에 있느냐가 다르다**.」

## 프로덕션 계약을 그대로 태운다 (2026-08-26 Codex BLOCK 2)

선정 판정은 Gemini 단독이 아니다. `MULTIROLL_GG46_JUDGE_ENABLED=true` 라
**Gemini + grok-4.6 동등 이중**이고, 불일치는 `_judge_gq(...,
equal_disagree_combined=True)` 가 합산한다.

한쪽만 재면 「Gemini 에서 안 흔들린다」가 「프로덕션 선택이 안 바뀐다」로
읽힌다. 그래서 이 도구는 **두 모델을 다 부르고 프로덕션 합산 함수를
그대로 쓴다.**

★★2026-08-29 이후로 **이 문단은 더 이상 프로덕션이 아니다.**
 사용자 지시로 ②는 `_judge_cross_model_order` 가 되었다 — **Gemini 정순 +
 grok 역순 두 콜**이고, 두 모델이 **같은 순서를 보지 않는다.** 여기가
 부르는 `_judge_gq` 는 둘에게 **같은 정순**을 보이는 옛 경로다.

 그래서 이 도구의 결과는 이제 **「옛 이중 판정에서 문장 자리가 판정을
 흔드나」**로만 읽어야 한다. 프로덕션 선택이 안 바뀐다는 말로 읽으면
 안 된다. 지난 결론(2026-08-26)은 옛 경로에서 얻은 것이라 그대로 유효하다.

 고치지 않은 이유: `_judge_cross_model_order` 는 `parts` 를 **자기가**
 만든다(후보 순서를 소유하므로). 이 도구는 arm 별로 미리 조립한 `parts` 를
 넣어 **자리만** 바꿔 가며 재는 구조라 그대로 못 갈아 끼운다. 다시 쓸 일이
 생기면 그때 축을 새로 짠다 — 안 쓸 도구를 미리 옮기지 않는다.

★grok 쪽은 Opik 에 payload 가 0자로 남지만 **재현은 된다.**
 `make_gemini_judge_fn.judge_fn` 은 `parts` 를 한 번 만들어 두 모델에
 그대로 주므로, Gemini span 에서 복원한 system/user 가 곧 공유 입력이다.
 (처음에 「재현 불능」이라고 적었던 것은 틀렸다 — Codex 가 잡았다.)
★Gemini 슬롯은 프로덕션과 같이 `enable_fallback=False` 로 봉인한다.
 안 그러면 safety 강등 시 GPT 가 판정하고 기록만 Gemini 로 남는다.

## 무엇으로 좋고 나쁨을 가르나

판정은 원래도 비결정적이다. 그래서 **A 를 여러 번 돌려 「같은 arm 안에서
얼마나 흔들리는가」를 먼저 재고**, A↔B 차이가 그보다 큰지 본다.
ABBA 순서로 돌려 순서 효과를 없앤다.

## 실패를 버리지 않는다 (2026-08-26 Codex BLOCK 3)

긴 system 때문에 **B 만 실패하는 것**이 바로 재야 할 나쁜 결과다. 그런데
실패를 건너뛰면 그 샷이 분모에서 사라져 살아남은 것만으로 「차이 없음」이
나온다. 그래서

- arm 마다 실패 수와 사유를 센다
- 요청한 회차를 다 못 채우면 **결론을 안 내고 exit 1**

## 쓰는 법

    python tools/prompt_measure/ab_judge_static_to_system.py --dry   # 무료
    python tools/prompt_measure/ab_judge_static_to_system.py 4       # 유료
"""
from __future__ import annotations

import argparse
import json
import sys
from collections import Counter, defaultdict
from typing import Any, Dict, List, Tuple

sys.path.insert(0, "/Users/manta/Documents/Projects/TheRoad-I1/scratchpad")
import _opik_env  # noqa: E402,F401  ★cwd 를 backend 로 고정

from _opik_env import opik_target  # noqa: E402

from app.core.config import settings  # noqa: E402
from app.modules.llm.llm_client import call_structured  # noqa: E402
from app.modules.pipeline.multiroll_gemini import (  # noqa: E402
    JUDGE_MODEL, OPENROUTER_JUDGE_PREFIX, _judge_gq,
)
from app.modules.pipeline.multiroll_select import (  # noqa: E402
    build_judge_schema, roll_labels,
)
from tools.opik_prompt_audit.audit.fetch import (  # noqa: E402
    fetch_spans, fetch_trace_index,
)

OP = "op:still_recipe_judge_geminipro"
STEP_TAG = "multiroll_judge"
SINCE, UNTIL = "2026-08-25T00:00:00", "2026-12-31T00:00:00"


# ── Opik 에서 실발송 payload 복원 ──────────────────────────────

def _쪼갬(span: Dict[str, Any]) -> Tuple[str, List[Any]]:
    """span 의 system 문자열과 **user content 를 원형 그대로** 돌려준다.

    ★user 를 문자열로 납작하게 만들면 **이미지가 사라진다.** 판정은 후보
     그림을 보는 일이라 그러면 재는 것이 달라진다. 원형(list)을 유지한다.
    """
    inp = span.get("input")
    msgs = inp if isinstance(inp, list) else (inp or {}).get("messages") or []
    sys_txt = ""
    user_parts: List[Any] = []
    for m in msgs:
        if not isinstance(m, dict):
            continue
        c = m.get("content")
        if m.get("role") == "system":
            sys_txt += c if isinstance(c, str) else ""
            continue
        if isinstance(c, str):
            user_parts.append({"type": "text", "text": c})
        elif isinstance(c, list):
            user_parts.extend(c)
    return sys_txt, user_parts


def _브리프조각(parts: List[Any]) -> int:
    """브리프는 **첫 텍스트 조각**이다 — 그 뒤는 참조·후보 라벨과 이미지다.

    실제 배열(Opik 실물):
      [0] text 9,223자  THE BRIEF …            ← 여기만 손댄다
      [1] text   211자  REFERENCE — LOCATION…
      [2] image
      [3] text   114자  REFERENCE — CHARACTER…
      [4] image
      [5] text    12자  Candidate A:           ← ★이미지 바로 앞 라벨
      [6] image
      [7] text    12자  Candidate B:
      [8] image
    """
    for i, p in enumerate(parts):
        if isinstance(p, dict) and p.get("type") == "text":
            return i
    return -1


def _정적줄(users: List[List[Any]]) -> set:
    """모든 호출의 **브리프 조각**에 똑같이 나온 줄.

    ★브리프 조각으로 좁히는 이유 (Codex 지적) — `Candidate A:` 같은 **이미지
     바로 앞 라벨**도 매 호출 똑같다. 그것까지 「정적」으로 세어 system 으로
     옮기면 **어느 그림이 A 인지 모르게 된다.** 그러면 A/B 가 재는 것이
     「자리 이동」이 아니라 「라벨 파괴」가 된다.
    """
    줄집합 = []
    for parts in users:
        i = _브리프조각(parts)
        t = parts[i].get("text", "") if i >= 0 else ""
        줄집합.append({ln for ln in t.splitlines() if ln.strip()})
    return set.intersection(*줄집합) if 줄집합 else set()


def _옮김(parts: List[Any], 정적: set) -> Tuple[List[Any], str]:
    """**브리프 조각에서만** 정적 줄을 빼고, 뺀 것을 순서대로 이어 돌려준다.

    ★참조·후보 라벨 조각과 이미지는 **손대지 않는다.** 그 조각들은 바로
     뒤 이미지가 무엇인지 말해 주는 자리라, 건드리면 판정이 그림을 못
     짚는다.
    """
    브리프 = _브리프조각(parts)
    새parts: List[Any] = []
    뺀줄: List[str] = []
    for i, p in enumerate(parts):
        if i != 브리프:
            새parts.append(p)          # 라벨 조각·이미지는 그대로
            continue
        남김, 뺌 = [], []
        for ln in p.get("text", "").splitlines():
            (뺌 if ln in 정적 else 남김).append(ln)
        뺀줄.extend(뺌)
        새parts.append({"type": "text", "text": "\n".join(남김)})
    return 새parts, "\n".join(뺀줄)


# ── 프로덕션 판정을 그대로 부른다 ─────────────────────────────

def _모델슬롯() -> List[str]:
    """프로덕션 G+G46 슬롯. 설정이 비면 **세운다** — 조용히 단독으로
    떨어지면 「이중을 쟀다」가 거짓이 된다(프로덕션 fail-closed 와 같은 뜻).
    """
    grok = (getattr(settings, "grok_judge_model", "") or "").strip()
    if not getattr(settings, "multiroll_gg46_judge_enabled", False):
        raise SystemExit(
            "MULTIROLL_GG46_JUDGE_ENABLED 가 꺼져 있다 — 프로덕션 판정 구성이 "
            "아니라서 이 A/B 결과를 프로덕션에 적용할 수 없다.")
    if not grok:
        raise SystemExit("GROK_JUDGE_MODEL 이 비어 있다 (fail-closed).")
    return [JUDGE_MODEL, OPENROUTER_JUDGE_PREFIX + grok]


def _판정한판(arm: str, sys_p: str, parts: List[Any],
              schema: Dict[str, Any], labels: List[str]) -> Dict[str, Any]:
    """`_judge_gq` 로 **두 모델 + 합산**을 태운다 (2026-08-29 이후 **옛 경로**).

    지금 프로덕션 ②는 `_judge_cross_model_order` 다 — 파일 맨 위 설명 참조.
    """

    def _one(model: str, p, tag_suffix: str) -> Dict[str, Any]:
        tag = f"{STEP_TAG}_ab{arm}{tag_suffix}"
        if model.startswith(OPENROUTER_JUDGE_PREFIX):
            from app.modules.llm.openrouter_vlm_client import (
                ask_openrouter_structured,
            )
            # max_tokens=8000 — 프로덕션과 같은 출력 예산. 줄이면 잘림이
            # 실패로 잡혀 「B 가 나쁘다」로 잘못 읽힌다.
            return ask_openrouter_structured(
                tag, sys_p, p, schema,
                model=model[len(OPENROUTER_JUDGE_PREFIX):], max_tokens=8000)
        # ★프로덕션 GG46 Gemini 슬롯은 봉인이다 — 안 그러면 safety 강등 시
        #  GPT 가 판정하고 기록만 Gemini 로 남아 무엇을 쟀는지 흐려진다.
        return call_structured(
            tag, sys_p, p, schema,
            project_config={tag: {"model": model}},
            schema_name=STEP_TAG, enable_fallback=False)

    return _judge_gq(_모델슬롯(), _one, parts, labels,
                     equal_disagree_combined=True)


def _잼(out: Dict[str, Any]) -> Dict[str, Any]:
    """판정 산출에서 비교할 값을 꺼낸다.

    ★필드 이름을 **추측하지 않는다.** `build_judge_schema` 가 만드는 실제
     모양이다. 처음엔 `issues`/`problems`/`findings` 를 찾도록 짰는데
     실제 이름은 `readings[].hard_violations` 라, 그대로 돌렸으면 지적이
     전부 0 으로 나와 **「차이 없음」이 거짓으로** 나왔을 것이다.

      winner · ranking · all_candidates_fail
      verdicts[] = {label, score, verdict_ko}
      readings[] = {label, direction, built_space, entities,
                    hard_violations[], physics}
      gq         = {route, gap, per_model_winner, models}   ← 이중 판정 기록

    ★`readings` 가 이 작업의 목적과 정확히 맞는 자리다 — `direction` 은
     구도를 읽었는지, `entities` 는 엉뚱한 것을 봤는지 말한다.
    """
    # ★빈 dict 를 돌려주는 방어는 **넣지 않는다.** 그것을 쓰는 쪽이
    #  `m["violations"]` 로 읽으므로 방어가 KeyError 를 만들 뿐이다.
    #  `_judge_gq` 는 dict 아니면 raise 한다 — 그 실패는 실패로 세야 한다.
    gq = out.get("gq") or {}
    점수 = {str(v.get("label")): v.get("score")
            for v in (out.get("verdicts") or []) if isinstance(v, dict)}
    위반 = 빈칸 = 0
    for r in (out.get("readings") or []):
        if not isinstance(r, dict):
            continue
        hv = r.get("hard_violations")
        위반 += len(hv) if isinstance(hv, list) else 0
        # 구도·엔티티·물리를 읽었는지 — 비면 판정이 그 축을 안 본 것이다
        for k in ("direction", "built_space", "entities", "physics"):
            if not str(r.get(k) or "").strip():
                빈칸 += 1
    return {
        "winner": str(out.get("winner") or ""),
        "ranking": list(out.get("ranking") or []),
        "all_fail": bool(out.get("all_candidates_fail")),
        "scores": 점수,
        "violations": 위반,
        "빈칸": 빈칸,
        "route": str(gq.get("route") or ""),
        "per_model": {k.replace(OPENROUTER_JUDGE_PREFIX, ""): v
                      for k, v in (gq.get("per_model_winner") or {}).items()},
        "chars": len(json.dumps(out, ensure_ascii=False)),
    }


# ── 표본 만들기 ───────────────────────────────────────────────

def _표본(episode: str) -> Tuple[
        List[Tuple[str, str, List[Any]]], set, int, int]:
    """(샷이름, system, user parts) 목록 · 정적 줄 · 원본 span 수.

    ★샷 신원은 **부모 trace 의 `still_id`/`scene_index`/`shot_index`** 로
     잡는다 (2026-08-26 Codex 지적). 종전에는 가변 텍스트의 해시를 썼는데,
     그러면 **이미지만 다른 같은 브리프가 한 샷으로 뭉친다.** 판정은 그림을
     보는 일이라 그것을 합칠 이유가 없다.
     판정 span 자체의 metadata 에는 그 세 칸이 없지만 부모 trace 에는 있다.
    """
    B, W, P = opik_target()
    spans = [s for s in fetch_spans(B, W, P, SINCE, UNTIL)
             if (s.get("metadata") or {}).get("episode_id") == episode
             and any(v == OP for v in (s.get("tags") or [])
                     if isinstance(v, str))]
    traces = fetch_trace_index(B, W, P, SINCE, UNTIL)

    쪼갠것 = [_쪼갬(s) for s in spans]
    정적 = _정적줄([u for _, u in 쪼갠것]) if len(spans) >= 2 else set()

    본것: Dict[str, None] = {}
    표본: List[Tuple[str, str, List[Any]]] = []
    부모없음 = 0
    for s, (sy, us) in zip(spans, 쪼갠것):
        md = ((traces.get(s.get("trace_id")) or {}).get("metadata") or {})
        still, sc, sh = (md.get("still_id"), md.get("scene_index"),
                         md.get("shot_index"))
        if not still:
            # ★신원을 못 찾으면 **버린다**. 해시로 대신하면 이미지가 다른
            #  브리프를 합쳐 표본이 조용히 줄어든다.
            부모없음 += 1
            continue
        if still in 본것:
            continue          # 같은 샷 재시도는 조건이 같아 표본만 부풀린다
        본것[still] = None
        표본.append((f"S{sc}sh{sh}", sy, us))
    return 표본, 정적, len(spans), 부모없음


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("n", nargs="?", type=int, default=4,
                    help="샷당 회차 (ABBA 라 4의 배수를 권한다)")
    ap.add_argument("--dry", action="store_true",
                    help="호출하지 않고 무엇을 옮기는지만 본다")
    ap.add_argument("--shots", type=int, default=0,
                    help="표본 샷 수 상한 (0=전부). ★줄이면 결과에 적는다")
    ap.add_argument("--episode", default="8750a231-f2d4-4855-9a5a-8d7f9a3a0bc9")
    a = ap.parse_args()

    표본, 정적, span수, 부모없음 = _표본(a.episode)
    if len(표본) < 2:
        print(f"판정 span {span수}건 → 표본 {len(표본)}샷 — 정적 줄을 못 가린다")
        return 1

    전체샷 = len(표본)
    if a.shots and a.shots < 전체샷:
        표본 = 표본[:a.shots]

    print(f"판정 span {span수}건 · 표본 {len(표본)}/{전체샷}샷 · "
          f"정적 줄 {len(정적)}줄 {sum(len(l)+1 for l in 정적):,}자")
    if 부모없음:
        print(f"★부모 trace 를 못 찾아 버린 span {부모없음}건 — 신원 없이는 "
              f"샷을 못 가른다")
    if a.shots and a.shots < 전체샷:
        print(f"★샷을 {전체샷}개 중 {a.shots}개로 줄였다 — 결론도 그만큼만 "
              f"말한다")

    if a.dry:
        print(f"\n── 옮길 덩어리 {len(정적)}줄 전체 ──")
        브리프 = _브리프조각(표본[0][2])
        차례 = [ln for ln in 표본[0][2][브리프].get("text", "").splitlines()
                if ln in 정적]
        for ln in 차례:
            print(f"  {len(ln):>5}자  {ln[:110]}")
        이름, sy, us = 표본[0]
        새us, 뺀 = _옮김(us, 정적)
        전 = sum(len(p.get("text", "")) for p in us
                 if isinstance(p, dict) and p.get("type") == "text")
        후 = sum(len(p.get("text", "")) for p in 새us
                 if isinstance(p, dict) and p.get("type") == "text")
        img = sum(1 for p in us if isinstance(p, dict)
                  and p.get("type") in ("image_url", "image"))
        남은라벨 = [p.get("text", "")[:20] for p in 새us
                    if isinstance(p, dict) and p.get("type") == "text"
                    and p.get("text", "").startswith("Candidate")]
        print(f"\n── 표본 1건 ({이름}) ──")
        print(f"  system {len(sy):,}자 → {len(sy) + len(뺀):,}자")
        print(f"  user   {전:,}자 → {후:,}자   (이미지 {img}장은 그대로)")
        print(f"  후보 라벨 그대로 있나: {남은라벨}")
        print(f"\n  판정 모델 슬롯: {_모델슬롯()}")
        print("\n★유료 호출은 하지 않았다. 옮길 덩어리가 맞으면 회차를 주고 다시.")
        return 0

    # ★스키마는 **파일이 아니라 코드가 만든다**(`build_judge_schema`).
    #  `load_schema` 로 찾다가 FileNotFoundError 로 죽었다 — dry 만
    #  돌려서는 안 드러나는 자리였다.
    labels = list(roll_labels(2))
    # ★`with_physics=True` — 스틸 판정 4곳이 전부 그렇다
    #  (`still_recipe_service.py:1256·1328·1409·1489`). 기본값 False 로
    #  두면 `readings[].physics` 필수 칸이 빠져 **프로덕션과 다른 계약**을
    #  재게 된다. 씨드·플레이트 판정만 기본값이다.
    #  ★필드 이름은 확인했으면서 **스키마 인자는 추측**했다 — 같은 함정을
    #   한 파일 안에서 두 번 밟았다 (2026-08-26 Codex 확인).
    schema = build_judge_schema(labels, with_physics=True)
    순서 = ["A", "B", "B", "A"]

    집계: Dict[str, Dict[str, Any]] = defaultdict(
        lambda: {"n": 0, "violations": 0, "빈칸": 0, "chars": 0,
                 "점수합": 0.0, "점수n": 0, "all_fail": 0,
                 "route": Counter(), "불일치": 0})
    샷별승자: Dict[str, Dict[str, List[str]]] = defaultdict(
        lambda: {"A": [], "B": []})
    실패: Dict[str, Counter] = {"A": Counter(), "B": Counter()}
    요청수 = len(표본) * a.n

    for si, (이름, sy, us) in enumerate(표본):
        새us, 뺀 = _옮김(us, 정적)
        for i in range(a.n):
            arm = 순서[(i + si) % 4]
            s_p, u_p = (sy, us) if arm == "A" else (sy + "\n\n" + 뺀, 새us)
            try:
                out = _판정한판(arm, s_p, u_p, schema, labels)
            except Exception as exc:  # noqa: BLE001
                # ★버리지 않는다 — 긴 system 때문에 B 만 죽는 것이 바로
                #  재야 할 나쁜 결과다.
                실패[arm][type(exc).__name__] += 1
                print(f"  {이름} {arm} ✗ {type(exc).__name__}: "
                      f"{str(exc)[:160]}", file=sys.stderr)
                continue
            m = _잼(out)
            t = 집계[arm]
            t["n"] += 1
            t["violations"] += m["violations"]
            t["빈칸"] += m["빈칸"]
            t["chars"] += m["chars"]
            t["all_fail"] += int(m["all_fail"])
            t["route"][m["route"]] += 1
            t["불일치"] += int(len(set(m["per_model"].values())) > 1)
            for v in m["scores"].values():
                if isinstance(v, (int, float)):
                    t["점수합"] += v
                    t["점수n"] += 1
            샷별승자[이름][arm].append(m["winner"])
            print(f"  {이름} {arm} · 고른 것 {m['winner']!r} "
                  f"· 순위 {m['ranking']} · 점수 {m['scores']} "
                  f"· 경로 {m['route']} {m['per_model']} "
                  f"· 위반 {m['violations']} · 안 읽은 칸 {m['빈칸']}"
                  f"{' · ★전부 미달' if m['all_fail'] else ''}")

    성공 = sum(집계[x]["n"] for x in ("A", "B"))
    실패수 = sum(sum(c.values()) for c in 실패.values())

    print(f"\n{'arm':<5}{'성공':>5}{'실패':>5}{'위반 평균':>10}{'점수 평균':>10}"
          f"{'안 읽은 칸':>11}{'모델 불일치':>12}{'출력 평균':>10}")
    for arm in ("A", "B"):
        t = 집계[arm]
        f = sum(실패[arm].values())
        if not t["n"]:
            print(f"{arm:<5}{0:>5}{f:>5}   ← 전건 실패 {dict(실패[arm])}")
            continue
        점수 = t["점수합"] / t["점수n"] if t["점수n"] else 0.0
        print(f"{arm:<5}{t['n']:>5}{f:>5}{t['violations']/t['n']:>10.1f}"
              f"{점수:>10.2f}{t['빈칸']/t['n']:>11.1f}"
              f"{t['불일치']/t['n']:>12.2f}{t['chars']//t['n']:>10,}")
    for arm in ("A", "B"):
        if 집계[arm]["n"]:
            print(f"  {arm} 합산 경로: {dict(집계[arm]['route'])}"
                  f" · 전부 미달 {집계[arm]['all_fail']}회")
        if 실패[arm]:
            print(f"  {arm} 실패 사유: {dict(실패[arm])}")
    print("★'안 읽은 칸' = readings 의 direction·built_space·entities·physics")
    print("  중 비어 온 개수. 판정이 그 축을 안 본 것이라 **커지면 나쁘다.**")
    print("★'모델 불일치' = Gemini 와 grok 이 다른 후보를 고른 비율.")
    print("  커지면 합산이 자주 개입한다는 뜻이라 선택이 불안정해진다.")

    print("\n── 선택이 흔들리나 ──")
    같은arm_흔들림 = 다른arm_차이 = 표본수 = 0
    미달샷: List[str] = []
    for 이름, d in 샷별승자.items():
        if len(d["A"]) + len(d["B"]) < a.n:
            미달샷.append(이름)
        a_set, b_set = set(d["A"]), set(d["B"])
        if not a_set or not b_set:
            continue
        표본수 += 1
        같은arm_흔들림 += int(len(a_set) > 1)
        다른arm_차이 += int(a_set != b_set)
        print(f"  {이름}  A={d['A']}  B={d['B']}")

    print(f"\n  요청 {요청수}회 · 성공 {성공} · 실패 {실패수}")
    if 표본수:
        print(f"  A 안에서 흔들린 샷: {같은arm_흔들림}/{표본수}  ← 기준선")
        print(f"  A 와 B 가 다른 샷 : {다른arm_차이}/{표본수}")

    # ★실패가 하나라도 있으면 결론을 안 낸다 (Codex BLOCK 3).
    #  「B 만 죽었다」가 분모에서 사라져 「차이 없음」으로 읽히는 것이
    #  이 도구가 만들 수 있는 가장 나쁜 결과다.
    if 실패수 or 미달샷:
        print(f"\n★결론 없음 — 요청한 회차를 다 못 채웠다.")
        if 미달샷:
            print(f"  회차 미달 샷: {미달샷}")
        print("  실패한 arm 이 한쪽이면 그 자체가 결과다. 사유를 먼저 본다.")
        return 1

    print("\n★A 안에서도 흔들리는 만큼은 판정이 원래 비결정적인 것이다.")
    print(" 그보다 A↔B 차이가 크지 않으면 **차이 없음**으로 읽는다.")
    return 0


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