#!/usr/bin/env python3
"""combined JIT 합격 판정 — **#48 과 #45 를 완전히 갈라** 읽는다 (2026-08-29).

## 왜 갈라 읽나

한 주행에 두 PR 이 같이 들어갔다. 표를 섞으면 「그림이 좋아졌다」가 어느
쪽 몫인지 못 가르고, 실은 **어느 쪽 몫도 아니다** — 이번 주행은
`critique_enabled` 가 샷 지문에 직접 접혀(`compute_input_fingerprint` 의
`f"|{roll_count}|{critique_enabled}"`) **6샷이 전부 다시 구워진다.**
그래서 이 도구는 **기록의 모양**만 본다. 그림 품질은 안 본다.

## #48 합격 다섯 축 (2026-08-29 Codex 확정)

    A 슬롯 성공 상태      — `cross_model_order.slots[].ok`
    B route / slot_winner
    C dual 존재 **자격**  — ★두 슬롯이 다 성공했을 때만 필수다.
      한 슬롯이 provider 실패로 단독 생존이면 dual 없음이 **정상**이고,
      그 샷은 실패가 아니라 **미확정**으로 적는다. 「여섯 줄 모두 dual」을
      무조건 요구하면 인프라 실패를 제품 회귀로 오판한다.
    D 라벨별 하드위반 합집합 ↔ top-level `readings`
      ★nonempty 만 보면 부족하다. **첫 슬롯에 없는 라벨 행이 둘째 슬롯에만
       있어도** top-level 에 provenance 와 함께 살아야 한다.
    E adjusted / ranking / winner / selected 정합

★D 의 기대값은 **슬롯 기록에서 직접** 만든다(`slots[].normalized`).
 `combine_select_verdicts` 의 결과로 그 함수를 검사하면 같이 틀린다.

## #45 (S2sh6 만)

사전 지정 한 샷이다. critical 이 안 나오면 `fix_skip_reason` 을 그대로
적고 **「미도달」로 끝낸다** — 주행 뒤에 성공한 샷으로 바꾸지 않는다.

usage:  read_jit_acceptance.py <project_id> <episode_id>
                              [--recipe-dir=<recipe 경로>]

★`--recipe-dir` 는 **양성 확인용**이다 — 주행 **전** 백업(=#48 이전 판)에
 대고 돌려 이 도구가 빨강이 되는지 본다. 안 빨개지면 도구가 못 잡는 것이다.
"""
from __future__ import annotations

import json
import pathlib
import sys
from collections import Counter
from typing import Tuple

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2]))
ROOT = pathlib.Path(__file__).resolve().parents[3]

REPAIR_SHOT = "S2sh6"          # ★사전 지정 — 주행 뒤 안 바꾼다
OK, NG, NA = "✓", "★", "·"


import re

_PROV = re.compile(r"^\[[^\]]+\]\s*")


def _bare(s) -> str:
    """`[gemini-pro] 어쩌고` → `어쩌고`.

    ★유실을 **문안 자체**로 센다. 옛 판(#48 이전)은 첫 슬롯 결과를 그대로
     돌려줘서 provenance 접두가 **없다** — 접두째로 견주면 살아 있는 항목도
     「없다」로 세어 **유실 건수가 부풀려진다.** 양성 확인에서 실제로 그랬다
     (S1sh4 를 3건 유실로 셌는데 gemini 것 1건은 접두만 없을 뿐 살아 있었고
     진짜 유실은 gpt 2건이었다).
    """
    return _PROV.sub("", str(s)).strip()


def _slot_counter(cmo: dict) -> Counter:
    """슬롯 기록에서 **직접** 만든 기대 Counter — `(라벨, 모델, 문안)`.

    `combine_select_verdicts` 의 결과로 그 함수를 검사하면 같이 틀린다.

    ★집합이 아니라 **Counter** 다 (2026-08-29 Codex 재리뷰 BLOCK-1).
     두 슬롯이 **같은 문안**을 냈는데 top-level 에 한 줄만 남으면 집합
     비교는 통과한다 — 프로덕션 `combine_select_verdicts` 는 `[model]` 을
     붙여 **둘 다** 남기므로 그건 유실이다. 모델까지 키에 넣어야 **접두가
     엉뚱한 모델**인 경우도 잡힌다.
    """
    want: Counter = Counter()
    for s in cmo.get("slots") or []:
        if not s.get("ok"):
            continue
        model = str(s.get("model"))
        for r in ((s.get("normalized") or {}).get("readings") or []):
            lab = str(r.get("label"))
            for hv in r.get("hard_violations") or []:
                want[(lab, model, _bare(hv))] += 1
    return want


def _top_counter(r: dict) -> Tuple[Counter, int]:
    """top-level `readings` → 같은 모양의 Counter + provenance 없는 줄 수.

    접두가 없으면 모델을 `None` 으로 둔다 — 그러면 기대와 절대 안 맞아
    **유실로 잡힌다.** 그게 맞다: 어느 슬롯 것인지 못 세면 합집합을
    증명할 수 없다.
    """
    got: Counter = Counter()
    noprov = 0
    for x in r.get("readings") or []:
        lab = str(x.get("label"))
        for hv in x.get("hard_violations") or []:
            m = _PROV.match(str(hv))
            if m:
                got[(lab, m.group(0).strip()[1:-1], _bare(hv))] += 1
            else:
                got[(lab, None, _bare(hv))] += 1
                noprov += 1
    return got, noprov


#: 지금 정책은 **두 슬롯**이다 (`_judge_cross_model_order` 가 g_model 과
#  x_model 둘을 세운다). 슬롯 기록 수가 그와 다르면 이 도구의 전제가
#  깨진 것이므로 조용히 통과시키지 않는다.
EXPECTED_SLOTS = 2


def read_48(recs: dict) -> Tuple[int, int]:
    """→ `(어긋남, 미확정)`.

    ★**둘을 갈라 센다** (2026-08-29 Codex 재리뷰). 종전에는 화면 가운데서
     「#48 미확정」이라 찍어 놓고 마지막 줄은 `bad` 만 보고 **「다섯 축 다
     통과」 + exit 0** 을 냈다. provider 한쪽이 죽은 주행이나 옛 형식
     양성판이 **자동 판정에서는 합격**으로 나온다 — 이 도구가 막아야 할
     결과 오독을 이 도구가 만들고 있었다.
    """
    print("═══ #48 — 초기 선정 기록만 (그림 품질 아님) ═══\n")
    tags = sorted(t for t in recs if "::" not in t and t.startswith("S"))
    bad = unconfirmed = 0
    for t in tags:
        r = recs[t] or {}
        cmo = r.get("cross_model_order") or {}
        slots = cmo.get("slots") or []
        ok_slots = [s for s in slots if s.get("ok")]
        both = len(ok_slots) == EXPECTED_SLOTS
        dual = r.get("dual") or {}
        route = cmo.get("route")
        shot_unconf = []          # 이 샷을 미확정으로 만드는 사유

        print(f"── {t}")
        # A — ★성공 0개는 **실패**, 1개는 미확정, 2개만 판정 자격이 있다.
        print(f"   A 슬롯  {len(ok_slots)}/{len(slots)} 성공  "
              + " · ".join(f"{s.get('model')}({s.get('order')})="
                           + ("ok" if s.get("ok") else "실패")
                           for s in slots))
        if cmo.get("failed"):
            print(f"     실패 사유: {cmo['failed']}")
        if len(slots) != EXPECTED_SLOTS:
            print(f"     {NG} 슬롯 기록이 {len(slots)}개다 — 정책은 "
                  f"{EXPECTED_SLOTS}개. 이 도구의 전제가 깨졌다")
            bad += 1
        if not ok_slots:
            print(f"     {NG} 성공 슬롯 0개 — 판정 자체가 없다")
            bad += 1
        elif len(ok_slots) < EXPECTED_SLOTS:
            shot_unconf.append(f"성공 슬롯 {len(ok_slots)}개")
        # B — ★출력만 하지 않고 **검증한다** (Codex 재리뷰 BLOCK-2).
        #  종전에는 찍기만 해서, 두 슬롯이 성공인데 `slot_winner` 가 한 칸만
        #  있거나 슬롯 결과와 모순돼도 아래 route 검사가 한 값으로 초록이
        #  될 수 있었다.
        sw_rec = cmo.get("slot_winner") or {}
        print(f"   B route={route}  slot_winner={sw_rec}")
        b_bad = []
        if both:
            want_sw = {str(s.get("model")):
                       (s.get("normalized") or {}).get("winner")
                       for s in ok_slots}
            if set(sw_rec) != set(want_sw):
                b_bad.append(f"slot_winner 키 {sorted(sw_rec)} ≠ "
                             f"성공 슬롯 {sorted(want_sw)}")
            for m, w in want_sw.items():
                if sw_rec.get(m) != w:
                    b_bad.append(f"{m}: 기록 {sw_rec.get(m)!r} ≠ "
                                 f"슬롯 normalized.winner {w!r}")
        elif len(ok_slots) == 1:
            order = ok_slots[0].get("order")
            if route != f"single_{order}":
                b_bad.append(f"단독 생존 order={order} 인데 "
                             f"route={route} (기대 single_{order})")
        if b_bad:
            print(f"     {NG} " + " / ".join(b_bad))
            bad += len(b_bad)
        else:
            print(f"     {OK} slot_winner 가 슬롯 결과와 일치"
                  if both else f"     {OK} 단독 생존 route 이름 일치")
        # C — 자격이 있을 때만 필수
        if both:
            if dual:
                print(f"   C {OK} dual 있음 (두 슬롯 성공 → 필수)")
            else:
                print(f"   C {NG} dual 없다 — 두 슬롯이 다 성공했는데 버렸다")
                bad += 1
        else:
            print(f"   C {NA} 두 슬롯이 아니다 — dual 없음이 정상. "
                  f"이 샷은 **#48 미확정**")
        # D — ★**양방향 Counter** 로 정확히 견준다 (Codex 재리뷰 BLOCK-1).
        #  집합 membership 은 ①같은 문안을 두 슬롯이 냈는데 한 줄만 남은
        #  경우 ②접두가 엉뚱한 모델인 경우 ③슬롯에 없는데 top-level 에만
        #  있는 줄(stale) 을 **다 통과**시킨다.
        want = _slot_counter(cmo)
        got, noprov = _top_counter(r)
        if both:
            # ★옛 판(provenance 없음)에는 이 정밀 비교를 **적용하지 않는다.**
            #  전부 유실로 찍혀 「몇 건 잃었나」가 부풀려진다 — 그건 결함이
            #  아니라 형식 차이다. 그 판은 **진단**으로만 적는다.
            if noprov and noprov == sum(got.values()):
                miss_bare = (Counter((l, t) for (l, _m, t) in want.elements())
                             - Counter((l, t) for (l, _m, t) in got.elements()))
                print(f"   D {NA} **옛 형식**(provenance 0/{sum(got.values())}) "
                      "— 정밀 비교 안 한다. 진단만:")
                print(f"       문안만 견주면 유실 {sum(miss_bare.values())}건 "
                      + (" · ".join(f"{l}:{t[:60]}"
                                    for (l, t) in miss_bare) or "없음"))
                print(f"       {NG} provenance 가 없어 어느 슬롯 것인지 못 센다"
                      " → **#48 미확정**")
                shot_unconf.append("옛 형식(provenance 없음)")
            else:
                miss, extra = want - got, got - want
                if miss or extra:
                    print(f"   D {NG} 합집합 불일치 — "
                          f"유실 {sum(miss.values())}건 · "
                          f"근거 없는 줄 {sum(extra.values())}건")
                    for (lab, m, txt), n in sorted(miss.items()):
                        print(f"       유실 {lab}/{m}×{n}: {txt[:90]}")
                    for (lab, m, txt), n in sorted(extra.items(),
                                                   key=lambda kv: str(kv[0])):
                        print(f"       초과 {lab}/{m}×{n}: {txt[:90]}")
                    bad += 1
                elif want:
                    print(f"   D {OK} 라벨·모델·문안·**개수**까지 정확히 일치 "
                          f"({sum(want.values())}건)")
                else:
                    print(f"   D {NA} 두 슬롯 다 하드위반 0건 — 실을 것이 없다")
                if noprov:
                    print(f"     {NG} provenance 없는 줄 {noprov}건")
                    bad += 1
            # ★첫 슬롯에 없던 라벨이 둘째에만 있을 때 살았는지 따로 본다
            f_labs = {str(x.get("label")) for x in
                      ((ok_slots[0].get("normalized") or {}).get("readings")
                       or [])}
            only2 = sorted({lab for (lab, _m, _t) in want} - f_labs)
            if only2:
                got_labs = {lab for (lab, _m, _t) in got}
                lost = [lab for lab in only2 if lab not in got_labs]
                print(f"     ★첫 슬롯에 없던 라벨 {only2} → "
                      + (f"{NG} top-level 에서 유실: {lost}" if lost
                         else f"{OK} top-level 에 살아 있다"))
                bad += len(lost)
        else:
            print(f"   D {NA} 미확정 (두 슬롯이 아니라 합집합을 못 만든다)")
        # E
        adj = (dual or {}).get("adjusted") or {}
        rank = r.get("ranking") or []
        # ★`winner` 칸이 **없는** 기록이 있다 (옛 판은 ranking·selected 만
        #  남긴다). None 으로 두면 route 규칙 검사가 통째로 오작동해
        #  멀쩡한 agree 를 「규칙상 combined」라고 적는다 — 양성 확인에서
        #  실제로 그랬다. 없으면 `ranking[0]` 을 승자로 본다.
        win = r.get("winner") or (rank[0] if rank else None)
        sel = r.get("selected")
        e_bad = []
        if adj and rank:
            want_rank = sorted(adj, key=lambda k: -adj[k])
            if list(rank) != want_rank:
                e_bad.append(f"ranking={rank} ≠ adjusted 순 {want_rank}")
        if rank and win and rank[0] != win:
            e_bad.append(f"ranking[0]={rank[0]} ≠ winner={win}")
        if win and sel and win != sel:
            e_bad.append(f"winner={win} ≠ selected={sel}")
        # route 이름 규칙: raw 두 승자와 합산 승자가 **다** 같을 때만 agree
        sw = list((cmo.get("slot_winner") or {}).values())
        if both and sw:
            should = ("cross_slot_agree"
                      if len(set(sw)) == 1 and win == sw[0]
                      else "cross_slot_combined")
            if route != should:
                e_bad.append(f"route={route} 인데 규칙상 {should}")
        vs = {str(v.get("label")): v.get("score")
              for v in (r.get("verdicts") or [])}
        if adj and vs:
            mism = {k: (vs.get(k), int(round(adj[k] * 1000)))
                    for k in adj
                    if vs.get(k) != int(round(adj[k] * 1000))}
            if mism:
                e_bad.append(f"verdicts 점수가 adjusted×1000 아님: {mism}")
        if e_bad:
            print(f"   E {NG} " + " / ".join(e_bad))
            bad += len(e_bad)
        else:
            print(f"   E {OK} adjusted→ranking→winner→selected 정합 "
                  f"(selected={sel})")
        if shot_unconf:
            unconfirmed += 1
            print(f"   ⇒ {NA} **이 샷은 #48 미확정** — "
                  + " · ".join(shot_unconf) + " (합격으로 안 센다)")
        print()
    return bad, unconfirmed


def read_45(recs: dict, rdir_hint=None) -> None:
    print("═══ #45 — 사전 지정 " + REPAIR_SHOT + " 하나만 ═══\n")
    r = recs.get(REPAIR_SHOT)
    if not isinstance(r, dict):
        print(f"{NG} 내 조회로는 {REPAIR_SHOT} 을 못 찾았다 — 「없다」로 "
              "읽지 마라"); return
    if r.get("bgfirst"):
        print(f"{NG} {REPAIR_SHOT} 이 bgfirst 갈래다 — 사전 지정이 틀렸다")
        return
    print(f"   갈래       표준(bgfirst 없음) {OK}")
    print(f"   선정       {r.get('selected')}")
    keys = [k for k in r if "fix" in k or "critique" in k or "repair" in k
            or "regen" in k or "rejudge" in k]
    for k in sorted(keys):
        v = r[k]
        s = json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v
        print(f"   {k:26s} {s[:160]}")
    if not keys:
        print(f"   {NG} 수리 계열 칸이 하나도 없다 — critique 가 이 샷에 "
              "안 닿았다는 뜻일 수 있다(스텝 로그와 대조하라)")
        return
    # ★도달 기준을 **regenerate 전용 증거**로 좁힌다 (Codex 재리뷰 BLOCK-3).
    #
    #  종전 조건 `fix_rejudge or fix_applied` 는 둘 다 regenerate 전용이
    #  아니다. `fix_rejudge` 는 **edit 갈래에도** 생기고, `fix_applied` 는
    #  ★이 경로에 **아예 없는 칸**이다 — `plate_multiroll`·
    #  `outdoor_place_canon` 의 것이라 여기서는 언제나 None 이었다.
    #  (없는 칸을 봤으니 조건 절반이 죽은 코드였다.)
    #
    #  #45 는 「수리를 편집 대신 **재생성**으로」다. 그래서 둘을 **함께**
    #  요구한다: `repair_mode == "regenerate"` **그리고** `fix_rejudge` 존재.
    #  `fix_rejudge.fix_won` 은 도달 여부가 아니라 **결과**다 — 원본이
    #  이겨 False 여도 재판정까지 간 것은 맞다.
    skip = r.get("fix_skip_reason")
    mode = r.get("repair_mode")
    rj = r.get("fix_rejudge")
    fix_png = rdir_hint / f"{REPAIR_SHOT}_fix.png" if rdir_hint else None
    print(f"\n   repair_mode={mode!r} · fix_rejudge={'있음' if rj else '없음'}"
          + (f" · {REPAIR_SHOT}_fix.png "
             + ("있음" if fix_png.is_file() else "없음")
             if fix_png else ""))
    if skip:
        print(f"   {NA} `fix_skip_reason={skip}` → **#45 미도달**로 적는다. "
              "샷을 바꾸지 않는다.")
    elif mode == "regenerate" and rj:
        print(f"   {OK} repair_mode=regenerate 로 재판정까지 갔다 — **#45 도달**"
              + (f" (fix_won={rj.get('fix_won')} — 결과일 뿐 도달과 무관)"
                 if isinstance(rj, dict) else ""))
    elif rj and mode != "regenerate":
        print(f"   {NG} 재판정은 갔는데 `repair_mode={mode!r}` 다 — "
              "#45(재생성)가 아니라 **edit 갈래**다. 도달로 안 센다.")
    else:
        print(f"   {NA} 도달 여부를 이 칸들로는 못 가른다 — 미확정")


def main() -> int:
    argv = [a for a in sys.argv[1:] if not a.startswith("--")]
    if len(argv) < 2:
        print(__doc__)
        return 2
    override = ""
    for a in sys.argv[1:]:
        if a.startswith("--recipe-dir="):
            override = a.split("=", 1)[1].strip()
    from app.core.config import settings
    rdir = (pathlib.Path(override) if override else
            (pathlib.Path(settings.projects_dir) / argv[0] / "images"
             / argv[1] / "scene" / "recipe"))
    if override:
        print("★--recipe-dir — 양성 확인용 판이다 (live 아님)")
    f = rdir / "records.json"
    if not f.is_file():
        raise SystemExit(f"★records.json 을 못 찾았다: {f}")
    recs = json.loads(f.read_text())
    print(f"기록: {f}\n")
    bad, unconf = read_48(recs)
    read_45(recs, rdir)
    # ★**미확정을 합격으로 바꾸지 않는다** (2026-08-29 Codex 재리뷰).
    #  종전에는 `bad` 만 보고 초록·exit 0 을 냈다. provider 한쪽이 죽은
    #  주행이나 옛 형식 판이 화면 가운데선 「미확정」인데 마지막 줄과
    #  종료코드는 **합격**이었다. 셋을 갈라 낸다.
    print()
    if bad:
        print(f"{NG} #48 — 어긋남 {bad}건"
              + (f" · 미확정 {unconf}샷" if unconf else ""))
        return 1
    if unconf:
        print(f"{NA} #48 — 어긋남 0건이지만 **미확정 {unconf}샷**이다. "
              "합격이라고 쓰지 마라 (종료코드 2)")
        return 2
    print(f"{OK} #48 다섯 축 다 통과 — 미확정 0샷")
    return 0


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