textmachine/eval/dovodka/itog_d4.py

303 lines
18 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
"""СВОД ОСИ Д4 — разбор ответов обоих семейств и вердикты. $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.
AJ.KEYS = KEYS_D4 = Path.home() / "books" / "dovodka" / "blind-keys-d4"
BO = sys.modules.get("bakeoff") or _load("bakeoff", REPO / "eval" / "role_topology" / "bakeoff.py")
AX = ("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА")
KEYS = Path.home() / "books" / "dovodka" / "blind-keys-d4"
FAMILIES = {"claude": Path.home() / "sud-d4", "sol": Path.home() / "sol-d4-work"}
PRIMARY = [("A1B", "A0", "флаговый контур против переписа (H-2а)"),
("A3", "A0", "смысловой контур против переписа (H-2б, ставка владельца)"),
("A6", "A0", "качественный черновик + контур против переписа (H-1)")]
# ⚠ НЕСУЩАЯ СУММАВЕРНОСТЬ + ЯЗЫК (эррата Д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 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)):
key_all = json.loads((KEYS / f"d4{half}-KEY.json").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:
m = re.search(rf"^{lab}-{a}:\s*(\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, "A0", 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 = ("A0", "A4", "A6", "A6F", "A3", "A2", "A1B", "A1")
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 = {"A0": "боевой ПОЛНЫЙ ПЕРЕПИС (эталон)", "A4": "перепис + канон-фиксер ПОСЛЕ",
"A6": "dspro-черновик + смысловой контур", "A6F": "dspro-черновик + флаги",
"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 == "A0" 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 = []
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")):
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("СВОД ОСИ Д4 — порядок шагов зафризен пре-регом, изменить его здесь нельзя")
print("=" * 78)
for name, root in FAMILIES.items():
if not root.exists():
print(f"\n{name}: каталога нет — пропуск")
continue
d, bad = read_family(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())