#!/usr/bin/env python3 """exp14 §3 — Fidelity re-gate пере-прогона (D34.3, ОБЯЗАТЕЛЕН; независим от §1-§2). Span-цитирующий, РАЗДЕЛЬНО стиль/верность, охота на КЛАСС ИНВЕРСИЙ РЕДАКТУРЫ (final vs draft vs src: билингв-редактор мог купить гладкость ценой смысла), катастроф-скрин терминов. author≠reviewer: судьи НЕ glm-5 (редактор) и НЕ deepseek (черновик) — кросс-семейно gpt-5-mini + kimi (+ gemini точечно на флагах). leave-one-out. $ персист. Вход: rerun/records.json (source/draft/final). ch5.0/ch20.0 — final пуст (экспорт-баг D35.4a), скорятся отдельно (не в среднем). Выход: exp14/regate_verdicts.jsonl + свод. Использование: exp14_regate.py [--dry] [--limit N] """ from __future__ import annotations import argparse, json, re, sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import exp14_common as C RERUN = Path("/home/ubuntu/books/gu-zhenren/rerun") RECS = json.load(open(RERUN / "records.json")) JUDGES = ["gpt-5-mini", "kimi-k2.6"] # кросс-семейно к glm-5/deepseek; gemini — точечно вне этого прохода CAPS = {"gpt-5-mini": 0.06, "kimi-k2.6": 0.10, "gemini-3.1-pro-preview": 0.30} SYS = ("Ты — билингвальный редактор-эксперт zh→ru, СУДЬЯ ВЕРНОСТИ. Тебе даны: ИСХОДНИК (zh), " "ЧЕРНОВИК (ru) и ФИНАЛ после редактуры (ru). Оцени ВЕРНОСТЬ ФИНАЛА исходнику РАЗДЕЛЬНО от стиля. " "Особо ищи КЛАСС ИНВЕРСИЙ РЕДАКТУРЫ: правки, где ФИНАЛ отошёл от смысла исходника ДАЛЬШЕ черновика " "(редактор внёс смысл, которого в исходнике нет; перевернул полярность/участника/число; выбросил атом). " "Ответь СТРОГО JSON без markdown: " '{"fidelity":0-100,"style":0-100,"n_mistranslation":int,"n_omission":int,' '"n_edit_inversion":int,"catastrophe":true|false,' '"flags":[{"type":"inversion|omission|term|addition","span_src":"≤12 слов zh",' '"quote_final":"≤15 слов ru","why":"кратко"}]}. ' "catastrophe=true при инверсии полярности/участника/действия ИЛИ потере ключевого атома (заголовок/событие/термин).") def run(): ap = argparse.ArgumentParser(); ap.add_argument("--dry", action="store_true") ap.add_argument("--limit", type=int, default=0); a = ap.parse_args() sp = C.Spender(C.DIAG / "regate_costs.jsonl", CAPS) vpath = C.DIAG / "regate_verdicts.jsonl" done = set(); out = [] 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["chunk"], v["judge"])) recs = [r for r in RECS if (r["chapter"], r["chunk_idx"]) not in {(5, 0), (20, 0)} and (r.get("final") or "").strip()] empties = [(r["chapter"], r["chunk_idx"]) for r in RECS if not (r.get("final") or "").strip()] if a.limit: recs = recs[:a.limit] tasks = [(r, jm) for r in recs for jm in JUDGES if (f"{r['chapter']}.{r['chunk_idx']}", jm) not in done] print(f"re-gate: {len(recs)} chunks × {len(JUDGES)} judges = {len(tasks)} new tasks " f"({len(done)} done); empty-final (экспорт-баг, вне скора): {empties}") if a.dry: est = sum(C.predict_cost(jm, 4000, 4000) for _, jm in tasks) print(f"predicted ≤ ${est:.2f}"); return for r, jm in tasks: cid = f"{r['chapter']}.{r['chunk_idx']}" user = (f"ИСХОДНИК (zh):\n{r['source']}\n\nЧЕРНОВИК (ru):\n{r['draft']}\n\n" f"ФИНАЛ после редактуры (ru):\n{r['final']}\n\nОцени верность ФИНАЛА. Ответь JSON.") # gpt-5-mini: reasoning ⊆ completion → нужен запас (medium@4000 съедал бюджет → empty); low@10000 s = C.spec(jm, max_tokens=(16000 if jm == "kimi-k2.6" else 10000), reasoning_effort=("low" if jm == "gpt-5-mini" else None)) in_est = C.count_tokens(SYS + user, "deepseek") ok, pred = sp.guard(jm, in_est, s["max_tokens"]) if not ok: print(f" OVER-CAP {cid} {jm} pred=${pred:.3f}"); continue text, err, usage = C.call(s, [{"role": "system", "content": SYS}, {"role": "user", "content": user}], timeout=300) cst = sp.record({"model": jm, "judge": jm, "keyenv": s["keyenv"], "chunk": cid, "err": err, "usage": usage}) v = parse(text) if not err else {"fidelity": None, "err": err[:60]} v.update(chunk=cid, judge=jm) out.append(v) with open(vpath, "a", encoding="utf-8") as f: f.write(json.dumps(v, ensure_ascii=False) + "\n") fl = len(v.get("flags", []) or []) print(f" {cid:<7} {jm:<12} fid={v.get('fidelity')} style={v.get('style')} " f"cat={v.get('catastrophe')} flags={fl} ${cst:.4f}") summarize(out, empties) def parse(text): m = re.search(r"\{.*\}", text, re.S) if not m: return {"fidelity": None, "parse": text[:80]} try: d = json.loads(m.group(0)) return {k: d.get(k) for k in ("fidelity", "style", "n_mistranslation", "n_omission", "n_edit_inversion", "catastrophe", "flags")} except Exception: f = re.search(r'"fidelity"\s*:\s*(\d+)', m.group(0)) cat = '"catastrophe": true' in m.group(0).lower() or '"catastrophe":true' in m.group(0).lower() return {"fidelity": int(f.group(1)) if f else None, "catastrophe": cat, "parse_fallback": True} def summarize(out, empties): from collections import defaultdict import statistics byj = defaultdict(list); cats = []; invs = [] for v in out: if v.get("fidelity") is not None: byj[v["judge"]].append(v["fidelity"]) if v.get("catastrophe"): cats.append((v["chunk"], v["judge"])) for fl in (v.get("flags") or []): if fl.get("type") == "inversion": invs.append((v["chunk"], v["judge"], fl.get("quote_final", "")[:60])) print("\n=== FIDELITY RE-GATE свод ===") for j, xs in byj.items(): print(f" {j}: mean fidelity {statistics.mean(xs):.1f} (n={len(xs)}, min={min(xs)})") if len(byj) == 2: # leave-one-out js = list(byj) print(f" leave-one-out: без {js[0]} → {statistics.mean(byj[js[1]]):.1f}; без {js[1]} → {statistics.mean(byj[js[0]]):.1f}") print(f" КАТАСТРОФ-флаги: {len(cats)} → {cats[:20]}") print(f" ИНВЕРСИИ РЕДАКТУРЫ: {len(invs)}") for c in invs[:15]: print(f" {c[0]} [{c[1]}]: {c[2]}") print(f" экспорт-баг пустые финалы (вне скора): {empties}") if __name__ == "__main__": run()