textmachine/eval/dovodka/itog_d4.py

353 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())