#!/usr/bin/env python3 """СВОД ОСИ {AXIS_TITLE} — разбор ответов обоих семейств и вердикты. $0. Считает ровно то, что зафризено в пре-реге Д0.3/Д0.4/Д0.6 и эрратах Д0.11/Д0.13, и ничего сверх. Порядок жёсткий, потому что каждый шаг — гейт для следующего: 1. ГОДНОСТЬ ПАЧКИ. Метка без всех четырёх осей · ненулевая оценка без обоснования · цитата, которой нет в своём варианте. Негодное ПОМЕЧАЕТСЯ, а не подчищается. 2. СВОЙ ПОЛ У КАЖДОГО СЕМЕЙСТВА. Пара половин, судимых РАЗНЫМИ сессиями (наборы p1/p2). Порог различимости = 2.8·sd/√n по правилу рига. Шкалы НЕ синхронизируются: у каждого семейства свой пол и свой порог, и это следствие устройства рига, а не компромисс. 3. ГЕЙТ ЧУВСТВИТЕЛЬНОСТИ (assay sensitivity, П-2). Маржевый декой против своего донора. Семейство, чей перевес на нём не пересекает СВОЙ порог, теряет право говорить «не хуже»: его вердикт по H-2а/H-2б понижается до описательного. Без этого гейта «не хуже» неотличимо от слепоты прибора — требование non-inferiority, которого у фазы не было. 4. ГРУБЫЙ ДЕКОЙ. Не пойман — пачка аннулируется целиком (норма Д0.6). 5. ПЕРВИЧНЫЕ КОНТРАСТЫ. Точный знаковый тест (`bakeoff.sign_perm_p`) с поправкой Холма по ТРЁМ объявленным контрастам: A1B/A0 (H-2а) · A3/A0 (H-2б) · A6/A0 (H-1). 6. ПРАВИЛО РАСХОЖДЕНИЯ СЕМЕЙСТВ (П-1). Оба перешли свой порог в одну сторону → CONFIRM. Перешёл один → эскалация к адъюдикации находок. Разошлись знаками → СПОРНО, вердикта нет. ⚠ ЧЕГО ЭТОТ ФАЙЛ НЕ ДЕЛАЕТ. Не выбирает удобное семейство, не сводит шкалы, не пере-считывает пороги после взгляда на боевые перевесы и не чинит негодные пачки. Всё это запрещено пре-регом, и запрет механизирован тем, что пороги считаются ДО контрастов и печатаются рядом с ними. Запуск: itog_d4.py [--gates] # только гейты, без боевых чисел """ from __future__ import annotations import importlib.util import json import re import statistics as st import sys from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") ZONE = Path(__file__).resolve().parent for d in ("dovodka", "editor_tier", "tenant_panel", "role_topology", "bank_arbitration"): sys.path.insert(0, str(REPO / "eval" / d)) def _load(name: str, path: Path): spec = importlib.util.spec_from_file_location(name, path) mod = importlib.util.module_from_spec(spec) sys.modules[name] = mod spec.loader.exec_module(mod) return mod S = _load("sud", ZONE / "sud.py") AJ = sys.modules["absjudge"] # ⚠⚠ ПЕРЕКЛЮЧИТЬ КЛЮЧИ НА СВОЙ ПАК — ИНАЧЕ ДЕКОЙ ВОССТАНАВЛИВАЕТСЯ ЧУЖИМИ ПОСАДКАМИ. # `absjudge.KEYS` — глобаль, по умолчанию указывающая на `~/books/role-topology/blind-keys` # (пак 21). Её переключает `sud.setup()`, но свод его НЕ вызывает: он только импортирует модуль. # Из-за этого `AJ._plant()` в `variant()` брал PLANTS.json ЧУЖОГО пака — 7 посадок вместо наших 6, # и первое правило там другое. Восстановленный так текст декоя не совпадает с тем, что реально # видел судья, поэтому метрика «цитат не найдено в своём варианте» считалась против неверного # текста. Поймано приёмкой 14.08, проверено исполнением: AJ.KEYS указывал на role-topology. # ⚠ ОСЬ — ПАРАМЕТР. Свод, прибитый к d4, на английских ответах читал бы КИТАЙСКИЕ ключи и # китайские каталоги: приманка восстановилась бы чужими посадками, а армы не нашлись бы вовсе. # Ровно этот класс уже стоил фазе одной починки (строка ниже про KEYS был первый случай). AXIS = next((a.split("=", 1)[1] for a in sys.argv if a.startswith("--axis=")), "d4") AJ.KEYS = KEYS_D4 = Path.home() / "books" / "dovodka" / f"blind-keys-{AXIS}" BO = sys.modules.get("bakeoff") or _load("bakeoff", REPO / "eval" / "role_topology" / "bakeoff.py") AX = ("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА") # ⚠ ВТОРАЯ переменная ключей, которая осталась китайской: `AJ.KEYS` выше уже несла ось, # а разбор ответов читал ЭТУ. Дубль путей — тот же класс, что уже ловили в `sud`. KEYS = KEYS_D4 FAMILIES = {"claude": Path.home() / "books" / "judging" / f"sud-{AXIS}", "sol": Path.home() / "books" / "judging" / f"sol-{AXIS}-work"} # ⚠ Первичное семейство и состав панели БЕРУТСЯ У СУДЕЙСКОГО РИГА той же оси, а не дублируются # здесь: две копии списка контрастов разъедутся, и свод напечатает не то семейство, которое # впечатано в слепой ключ. Ключ — источник истины, он писался ДО первого ответа судьи. PRIMARY = S.FAMILY ALL_ARMS_AXIS = {"d4": ("A0", "A4", "A6", "A6F", "A3", "A2", "A1B", "A1"), "d3": ("E0", "D0", "E4", "E1", "E2", "E3", "E6"), "d6": ("J0", "D0", "J2"), "d1": ("A0", "R1")} WHAT_AXIS = { "d4": {"A0": "боевой ПОЛНЫЙ ПЕРЕПИС (эталон)", "A4": "перепис + канон-фиксер ПОСЛЕ", "A6": "dspro-черновик + смысловой контур", "A6F": "dspro-черновик + флаги", "A3": "черновик + смысловой критик", "A2": "однопроходка уравненного мандата", "A1B": "черновик + флаги (батарея+канон)", "A1": "черновик + только канон-флаги"}, "d3": {"E0": "боевая связка (ЭТАЛОН оси)", "D0": "черновик flash (база)", "E4": "связка + канон-фиксер ПОСЛЕ", "E1": "флаги → фиксер (на en это КАНОН-контур)", "E2": "однопроходка уравненного мандата", "E3": "черновик + смысловой критик", "E6": "ВТОРОЙ редактор glm-5 (H-4)"}, "d6": {"J0": "боевая связка (ЭТАЛОН оси)", "D0": "черновик flash (база)", "J2": "однопроходка уравненного мандата"}, "d1": {"A0": "боевой перепис dspro (ЭТАЛОН)", "R1": "gemini-3.1-pro-preview (добивка)"}, } # ⚠ НЕСУЩАЯ СУММА — ВЕРНОСТЬ + ЯЗЫК (эррата Д0.13 П-5). ТЕРМИН отдан детерминированному # канон-гейту: он на этой оси сильнее LLM-судьи, а посадок на ТЕРМИН нет вовсе, то есть # чувствительность судей по ней даже не проверяема. ФОРМА исключена: она наполовину # механизируема и однажды уже перевернула знак вывода, оштрафовав ИСПОЛНЕНИЕ мандата. CARRY = ("ВЕРНОСТЬ", "ЯЗЫК") _norm_rx = re.compile(r"[\s«»„“”\"'`.,!?;:—–-]+") def norm(t: str) -> str: return _norm_rx.sub(" ", (t or "").lower()).strip() def variant(arm: str, u: dict, half: int) -> str: if arm == "CTRLfloor": return S.floor_pair(u, half) if arm == "CTRLdecoy": return AJ._plant(S.C.base_a0(u["uid"]))[0] # noqa: SLF001 return S.text_of(arm, u) def _keys_for(root: Path) -> Path: """Каталог ключей ЭТОГО семейства. Ключи разведены (`sud.py`), потому что эмиссия пачек одного семейства переписывала бы раскладку другого. Свод обязан читать ответы тем же ключом, каким они выпускались, иначе метки сопоставятся с ЧУЖИМИ армами и разбор даст 0 меток — ровно это и случилось на первом прогоне семейства Sol.""" fam = "claude" if root.name.startswith("sud-") else root.name.split("-")[0] return KEYS if fam == "claude" else KEYS.parent / f"{KEYS.name}-{fam}" def read_family(root: Path) -> tuple[dict, list]: """{(uid, arm): {ось: число}} по обоим наборам + список замечаний годности.""" us = {x["uid"]: x for x in S.units()} out: dict = {} bad: list = [] for half, hi in (("p1", 1), ("p2", 2)): # ⚠ Имя ключа несёт ОСЬ. Жёсткое `d4…` на английских ответах открывало бы китайский # ключ: метки не совпали бы, разбор дал бы НОЛЬ меток — и это выглядело бы как «судьи # ничего не прислали», а не как «свод открыл не тот файл». kf = _keys_for(root) / f"{AXIS}{half}-KEY.json" if not kf.exists(): continue key_all = json.loads(kf.read_text(encoding="utf-8")) for f in sorted((root / f"{half}-answers").glob("*.txt")): if ".ANNULLED" in f.name: continue key = key_all.get(f.stem) if not key: continue txt = f.read_text(encoding="utf-8", errors="replace") for lab, meta in key.items(): sc = {} for a in AX: # ⚠ `[ \t]*`, а не `\s*`: `\s` включает перевод строки, и оценка # могла считаться со СЛЕДУЮЩЕЙ строки. Тот же класс `absjudge.py:351` # чинил у себя; здесь он оставался. m = re.search(rf"^{lab}-{a}:[ \t]*(\d+)", txt, re.M) if m: sc[a] = int(m.group(1)) if len(sc) < 4: bad.append((root.name, half, f.stem, lab, "не все оси")) continue w = re.search(rf"^{lab}-ПОЧЕМУ:\s*(.*)$", txt, re.M) why = w.group(1) if w else "" if sum(sc.values()) and not why.strip(): bad.append((root.name, half, f.stem, lab, "ненулевая без обоснования")) var = norm(variant(meta["arm"], us[meta["uid"]], hi)) qs = re.findall(r"«([^»]{6,})»", why) miss = sum(1 for q in qs if norm(q) not in var) out[(meta["uid"], meta["arm"], half)] = dict( sc=sc, carry=sum(sc[a] for a in CARRY), all=sum(sc.values()), quotes=len(qs), quotes_missing=miss) return out, bad def floor_sd(d: dict) -> tuple[float, int]: """Свой пол: половина из p1 против половины из p2 — РАЗНЫЕ сессии по построению.""" diffs = [] for uid in {k[0] for k in d}: a = d.get((uid, "CTRLfloor", "p1")) b = d.get((uid, "CTRLfloor", "p2")) if a and b: diffs.append(a["carry"] - b["carry"]) return (st.pstdev(diffs) if len(diffs) > 1 else float("nan")), len(diffs) def control(d: dict, arm: str) -> list[float]: """Перевес контроля против своего донора A0 в той же пачке.""" out = [] for uid, a, half in list(d): if a != arm: continue base = d.get((uid, S.BASE_ARM, half)) if base: out.append(d[(uid, a, half)]["carry"] - base["carry"]) return out def margins(d: dict, a: str, b: str) -> list[float]: """Перевес ЗА арм `a`: ошибки(b) − ошибки(a), по единицам, усреднённо по наборам.""" out = [] for uid in sorted({k[0] for k in d}): va = [d[(uid, a, h)]["carry"] for h in ("p1", "p2") if (uid, a, h) in d] vb = [d[(uid, b, h)]["carry"] for h in ("p1", "p2") if (uid, b, h) in d] if va and vb: out.append(st.mean(vb) - st.mean(va)) return out def holm(ps: list[float]) -> list[float]: order = sorted(range(len(ps)), key=lambda i: ps[i]) adj = [0.0] * len(ps) run = 0.0 for rank, i in enumerate(order): run = max(run, ps[i] * (len(ps) - rank)) adj[i] = min(1.0, run) return adj ALL_ARMS = ALL_ARMS_AXIS[AXIS] def three_axes(d: dict) -> None: """⚠ ТРИ ОСИ ПО КАЖДОМУ АРМУ — прямое требование заказа (:95): «Ответ по каждому арму печатать ТРЕМЯ осями: судья · канон-покрытие · цена. H-1/H-2а/H-2б сверяются по всем трём, а не по одной удобной». Первая редакция свода печатала ОДНУ судейскую ось по ТРЁМ первичным контрастам, и за этим пропал ответ на прямой вопрос заказа про арм A4 («полировка … немедленный кандидат в прод», :86-87). Поймано приёмкой 14.08. ⚠ Канон считается на ВЫХОДЕ арма, а не на его входе: вход мерился при покупке, а сверяется качество результата. Цена — медиана оплаченной клетки арма из сырья, а не из сметы. """ import json as _j # noqa: PLC0415 import statistics as _st # noqa: PLC0415 us = {x["uid"]: x for x in S.units()} print("\n" + "=" * 78) print("ТРИ ОСИ ПО КАЖДОМУ АРМУ (требование заказа :95) — судья · канон · цена") print("=" * 78) print(f" {'арм':5s}{'судья':>8s}{'канон':>9s}{'$/клетка':>11s} что это") what = WHAT_AXIS[AXIS] _unused = {"A0": "", "A4": "", "A6": "", "A6F": "", "A3": "черновик + смысловой критик", "A2": "однопроходка уравненного мандата", "A1B": "черновик + флаги (батарея+канон)", "A1": "черновик + только канон-флаги"} for arm in ALL_ARMS: sc = [v["carry"] for (u, a, h), v in d.items() if a == arm] if not sc: continue cov = [] for u in us.values(): t = S.C.base_a0(u["uid"]) if arm == S.BASE_ARM else S.text_of(arm, u) if t.strip(): k, n = S.C.BANK.coverage(u["source"], t) if n: cov.append(k / n) pr = [] # ⚠ Глоб по тегу арма захватывал и клетки КРИТИКА (`dv-a-a3-crit-*`), поэтому # медиана «$/клетка» у A3 и A6 считалась по смеси двух ролей. Найдено приёмкой. for f in S.C.OUT.glob(f"{S.C.tag(arm, '')}*.json"): if any(x in f.name for x in (".ERROR", ".LENGTH", ".FLAGSV1", "-crit-")): continue r = _j.loads(f.read_text(encoding="utf-8")) if r.get("cost_usd"): pr.append(r["cost_usd"]) print(f" {arm:5s}{_st.mean(sc):8.2f}{(_st.mean(cov) if cov else 0):9.3f}" f"{(_st.median(pr) if pr else 0):11.5f} {what.get(arm, '')}") print(" судья — ошибок на единицу (ВЕРНОСТЬ+ЯЗЫК), МЕНЬШЕ лучше") print(" канон — доля покрытия банка на ВЫХОДЕ арма, БОЛЬШЕ лучше") print(" цена — медиана оплаченной клетки арма по сырью; A0 куплен прошлыми паками") def main() -> int: gates_only = "--gates" in sys.argv fam: dict = {} print("=" * 78) print(f"СВОД ОСИ {AXIS.upper()} — порядок шагов зафризен пре-регом, изменить его здесь нельзя") print("=" * 78) for name, root in FAMILIES.items(): if not root.exists(): print(f"\n{name}: каталога нет — пропуск") continue d, bad = read_family(root) n_ans = sum(1 for h in ("p1", "p2") for _ in (root / f"{h}-answers").glob("*.txt")) if n_ans and not d: raise SystemExit(f"⛔ у семейства {root.name} есть {n_ans} ответов, но разобрано 0 " f"меток: ключ {_keys_for(root)} не тот, каким выпускались пачки") sd, n_pairs = floor_sd(d) thr = 2.8 * sd / (n_pairs ** 0.5) if n_pairs > 1 else float("nan") coarse, marg = control(d, "CTRLdecoy"), control(d, "CTRLmargin") fam[name] = dict(d=d, sd=sd, thr=thr, n=n_pairs, coarse=coarse, marg=marg, bad=bad) print(f"\n### СЕМЕЙСТВО {name.upper()}") print(f" меток разобрано: {len(d)} · замечаний годности: {len(bad)}") qm = sum(v["quotes_missing"] for v in d.values()) qt = sum(v["quotes"] for v in d.values()) print(f" цитат {qt} · не найдено в своём варианте {qm} ({qm / max(1, qt):.0%})") print(f" 1. СВОЙ ПОЛ (пары, судимые РАЗНЫМИ сессиями): n={n_pairs} · sd={sd:.2f} " f"→ ПОРОГ РАЗЛИЧИМОСТИ {thr:.2f}") if coarse: ok = sum(1 for x in coarse if x > 0) print(f" 2. ГРУБЫЙ декой против донора: n={len(coarse)} медиана {st.median(coarse):+.1f} " f"· пойман {ok}/{len(coarse)}") if marg: mm = st.mean(marg) passes = mm > thr print(f" 3. МАРЖЕВЫЙ декой (гейт чувствительности): n={len(marg)} среднее {mm:+.2f} " f"против своего порога {thr:.2f} → {'ПРОЙДЕН' if passes else '⛔ НЕ ПРОЙДЕН'}") if not passes: print(" ⇒ семейство НЕ имеет права говорить «не хуже»: его вердикты по " "H-2а/H-2б понижаются до ОПИСАТЕЛЬНЫХ (П-2)") if fam.get("claude"): three_axes(fam["claude"]["d"]) if gates_only or not fam: return 0 print("\n" + "=" * 78) print("ПЕРВИЧНЫЕ КОНТРАСТЫ — точный знаковый тест, Холм по трём") print("=" * 78) verdict: dict = {} for name, F in fam.items(): rows = [] for a, b, _what in PRIMARY: m = margins(F["d"], a, b) if len(m) < 3: rows.append((a, b, _what, m, float("nan"))) continue rows.append((a, b, _what, m, BO.sign_perm_p(m))) adj = holm([r[4] if r[4] == r[4] else 1.0 for r in rows]) print(f"\n### {name.upper()} (порог {F['thr']:.2f})") print(f" {'контраст':10s}{'ед.':>5s}{'перевес':>10s}{'p':>9s}{'p Холма':>10s} вердикт") for (a, b, _w, m, p), pa in zip(rows, adj, strict=True): if not m: print(f" {a + '/' + b:10s}{0:5d}{'—':>10s}{'—':>9s}{'—':>10s} нет данных") continue mean = st.mean(m) crossed = abs(mean) > F["thr"] and pa < 0.05 verdict[(name, a)] = (mean, crossed) print(f" {a + '/' + b:10s}{len(m):5d}{mean:+10.2f}{p:9.4f}{pa:10.4f} " f"{'ПЕРЕШЁЛ ПОРОГ' if crossed else 'ниже порога'}") if len(fam) == 2: print("\n" + "=" * 78) print("ПРАВИЛО РАСХОЖДЕНИЯ СЕМЕЙСТВ (П-1, пре-регистрировано до первого ответа)") print("=" * 78) for a, b, what in PRIMARY: c = verdict.get(("claude", a)) s = verdict.get(("sol", a)) if not c or not s: print(f" {a}/{b}: данных обоих семейств нет") continue if c[1] and s[1] and (c[0] > 0) == (s[0] > 0): v = "CONFIRM — оба перешли свой порог в одну сторону" elif c[1] != s[1]: who = "claude" if c[1] else "sol" v = f"ЭСКАЛАЦИЯ К АДЪЮДИКАЦИИ — порог перешло только семейство {who}" elif c[1] and s[1]: v = "⛔ СПОРНО — перешли в ПРОТИВОПОЛОЖНЫЕ стороны, вердикта об арме нет" else: v = "не подтверждено ни одним семейством" print(f" {a}/{b} ({what}):\n claude {c[0]:+.2f} · sol {s[0]:+.2f} → {v}") return 0 if __name__ == "__main__": sys.exit(main())