Add the D4 summary: admissibility gates, own floor per family, an assay-sensitivity gate on the margin decoy, and the pre-registered disagreement rule

This commit is contained in:
heaven 2026-08-13 22:51:44 +03:00
parent b3405ff926
commit e630f97733

245
eval/dovodka/itog_d4.py Normal file
View file

@ -0,0 +1,245 @@
#!/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"]
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
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 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())