#!/usr/bin/env python3
"""exp14 Ступень II — ранжирующая панель финалистов (P0/A-2pass/F-disc) на главах 19/17/18.
РАЗДЕЛЬНО стиль/верность, span-цитаты, ≥N повторов со свапом меток, leave-one-judge-out.
Судьи gpt-5-mini + kimi + gemini(точечно). Self-family (F-disc=openai) снимается leave-one-out.
Guard мемо eval-aggregation: НЕ собирать ложную сходимость.

Использование: exp14_rank.py [--repeats 3] [--dry]
"""
from __future__ import annotations
import argparse, json, random, re, sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import exp14_common as C

ARMS = C.DIAG / "arms"
MAT = json.load(open(C.DIAG / "material.json"))
FINALISTS = {"Fin-A": "P0", "Fin-B": "A-2pass", "Fin-C": "F-disc"}
CHAPTERS = [19, 17, 18]
AXES = {"style": "русская художественность/абзацы/конвенции (естественность прозы, красная строка, тире-диалог)",
        "fidelity": "верность смыслу (нет инверсий полярности/участника, нет потери атомов; можно ценой лёгкой шероховатости)"}
JUDGES = ["gpt-5-mini", "kimi-k2.6", "gemini-3.1-pro-preview"]
CAPS = {"gpt-5-mini": 0.10, "kimi-k2.6": 0.15, "gemini-3.1-pro-preview": 0.30}


def chapter_text(arm, ch):
    chunks = sorted(u["chunk_idx"] for u in MAT["stage2"][str(ch)])
    return "\n\n".join((ARMS / arm / f"{ch}.{ck}.txt").read_text(encoding="utf-8") for ck in chunks)


def run():
    ap = argparse.ArgumentParser(); ap.add_argument("--repeats", type=int, default=3)
    ap.add_argument("--dry", action="store_true"); a = ap.parse_args()
    sp = C.Spender(C.DIAG / "rank_costs.jsonl", CAPS)
    vpath = C.DIAG / "rank_verdicts.jsonl"; out = []; done = set()
    if vpath.exists():
        for l in vpath.read_text(encoding="utf-8").splitlines():
            if l.strip():
                v = json.loads(l); out.append(v); done.add((v["ch"], v["axis"], v["judge"], v["rep"]))
    tasks = []
    for ch in CHAPTERS:
        for axis in AXES:
            for rep in range(a.repeats):
                order = list(FINALISTS); random.Random(f"exp14-rank-{ch}-{axis}-{rep}").shuffle(order)
                labels = {f"V{i+1}": order[i] for i in range(len(order))}
                for jm in JUDGES:
                    if jm == "gemini-3.1-pro-preview" and rep > 0:
                        continue  # gemini точечно: 1 повтор (бюджет €2.6)
                    if (ch, axis, jm, rep) in done:
                        continue
                    tasks.append((ch, axis, rep, jm, labels))
    print(f"rank tasks: {len(tasks)} new ({len(done)} done)")
    if a.dry:
        from collections import Counter
        print(Counter(t[3] for t in tasks)); return
    for ch, axis, rep, jm, labels in tasks:
        blocks = "\n\n".join(f"=== {lab} ===\n{chapter_text(FINALISTS[fin], ch)}" for lab, fin in labels.items())
        sysmsg = ("Ты — литературный судья zh→ru. Оцени ТРИ перевода одной главы по ОДНОЙ оси. "
                  "Ранжируй от лучшего к худшему. Приведи 2-4 span-цитаты-обоснования (≤15 слов каждая). "
                  'Ответь СТРОГО JSON без markdown: {"ranking":["V?","V?","V?"],'
                  '"evidence":[{"label":"V?","quote":"≤15 слов","note":"почему"}]}.')
        user = f"ОСЬ ОЦЕНКИ: {AXES[axis]}\n\nТРИ ВАРИАНТА:\n\n{blocks}\n\nРанжируй по оси. JSON."
        s = C.spec(jm, max_tokens=(16000 if jm == "kimi-k2.6" else 6000))
        in_est = C.count_tokens(sysmsg + user, "glm")
        ok, pred = sp.guard(jm, in_est, s["max_tokens"])
        if not ok:
            print(f"  OVER-CAP {ch} {axis} {jm} pred=${pred:.3f}"); continue
        text, err, usage = C.call(s, [{"role": "system", "content": sysmsg},
                                      {"role": "user", "content": user}], timeout=360)
        cst = sp.record({"model": jm, "judge": jm, "keyenv": s["keyenv"], "ch": ch,
                         "axis": axis, "rep": rep, "err": err, "usage": usage})
        rk = parse_rank(text) if not err else None
        v = {"ch": ch, "axis": axis, "rep": rep, "judge": jm, "labels": labels,
             "ranking_labels": rk, "err": err}
        # map labels→finalist
        if rk:
            v["ranking_fin"] = [labels.get(x) for x in rk]
        out.append(v)
        with open(vpath, "a", encoding="utf-8") as f:
            f.write(json.dumps(v, ensure_ascii=False) + "\n")
        print(f"  ch{ch} {axis:<8} rep{rep} {jm:<18} → {v.get('ranking_fin')} ${cst:.4f}")
    summarize(out)


def parse_rank(text):
    m = re.search(r'"ranking"\s*:\s*(\[[^\]]*\])', text)
    if not m:
        return None
    try:
        arr = json.loads(m.group(1))
        return [str(x) for x in arr]
    except Exception:
        return re.findall(r'V\d', m.group(1))


def summarize(out):
    from collections import defaultdict
    import statistics
    # Borda: 1st=2pts,2nd=1,3rd=0; per axis; per judge for leave-one-out
    def borda(rows):
        pts = defaultdict(list)
        for v in rows:
            rf = v.get("ranking_fin")
            if not rf or len(rf) != 3 or None in rf:
                continue
            for i, fin in enumerate(rf):
                pts[fin].append(2 - i)
        return {f: round(statistics.mean(p), 2) for f, p in pts.items()}
    print("\n=== РАНЖИРОВАНИЕ (Borda mean, 2=лучший) ===")
    for axis in AXES:
        rows = [v for v in out if v["axis"] == axis]
        print(f"\n{axis}: overall {borda(rows)}")
        judges = sorted(set(v["judge"] for v in rows))
        for j in judges:
            print(f"   only {j}: {borda([v for v in rows if v['judge']==j])}")
        for j in judges:  # leave-one-out
            print(f"   leave-out {j}: {borda([v for v in rows if v['judge']!=j])}")


if __name__ == "__main__":
    run()
