textmachine/eval/dovodka/itog_d4.py

601 lines
42 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] # только гейты, без боевых чисел
itog_d4.py --sens # Г5: чувствительность вердикта H-1 к составу пачек ($0)
"""
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]
return S.text_of(arm, u)
# ⚠ БАЗА АРМА — то, поверх чего он работает и что уже оплачено к моменту его вызова. Держится
# ДАННЫМИ, потому что состав панели у каждой оси свой; числа — медианы по сырью паков 22/23.
BASE_COST_BY_AXIS = {
"d4": {"A0": 0.00603, "A1": 0.00096, "A1B": 0.00096, "A3": 0.00096, "A2": 0.0,
"A4": 0.00603, "A6": 0.00387, "A6F": 0.00387},
"d3": {"E0": 0.00096, "E1": 0.00096, "E3": 0.00096, "E4": 0.00096 + 0.00789,
"E2": 0.0, "E6": 0.00096, "D0": 0.0},
"d6": {"J0": 0.00096, "J2": 0.0, "D0": 0.0},
"d1": {"A0": 0.00603, "R1": 0.00096},
}
def _keys_for(root: Path) -> Path:
"""Каталог ключей ЭТОГО семейства. Ключи разведены (`sud.py`), потому что эмиссия пачек
одного семейства переписывала бы раскладку другого. Свод обязан читать ответы тем же ключом,
каким они выпускались, иначе метки сопоставятся с ЧУЖИМИ армами и разбор даст 0 меток —
ровно это и случилось на первом прогоне семейства Sol."""
fam = "claude" if root.name.startswith("sud-") else root.name.split("-")[0]
if fam == "claude":
return KEYS
# ⚠ Ключи разведены по семействам НЕ на всех осях: разводить их начали на Д1/Д3/Д6, после
# аварии с пере-эмиссией, а китайская ось выпускала ОБА семейства одним ключом. Жёсткое
# `-{fam}` роняло свод на sol-d4-work («62 ответа, разобрано 0 меток»), то есть заявленный
# носитель чисел Д4 не воспроизводил ось вовсе. Разведённый ключ — если он есть; иначе общий.
split = KEYS.parent / f"{KEYS.name}-{fam}"
return split if split.exists() else KEYS
# ⚠ СОСТАВ ПАЧЕК — ПАРАМЕТР ТОЛЬКО ДЛЯ ЧУВСТВИТЕЛЬНОСТИ, И ОБА СПИСКА ПУСТЫ ПО УМОЛЧАНИЮ.
# Печатаемый вердикт при этом не меняется ни на знак: `--drop`/`--restore` существуют, чтобы
# пере-считать его ПРИ ДРУГОМ составе и напечатать разницу рядом, а не чтобы подобрать удобный.
# Заведены под Г5 (находка ревизии P13: снятие двух пачек прогона-валидации 11.08 переворачивает
# решение по H-1). Без такого рычага пере-счёт делался копией дерева — то есть числами, которые
# никто не может воспроизвести.
DROP = set(next((a.split("=", 1)[1] for a in sys.argv if a.startswith("--drop=")), "").split(",")) - {""}
RESTORE = set(next((a.split("=", 1)[1] for a in sys.argv
if a.startswith("--restore=")), "").split(",")) - {""}
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")):
tok = f.name.split(".")[0]
if ".ANNULLED" in f.name:
if tok not in RESTORE:
continue
# ⚠ Пачка, отправленная в карантин и ПЕРЕ-СУЖЕННАЯ, лежит на диске дважды.
# Вернуть карантинную копию значит посчитать единицу дважды разными судьями —
# молча, потому что ключ у обеих один. Возвращать можно только то, чему замены нет.
if (f.parent / f"{tok}.txt").exists():
raise SystemExit(f"{tok} пере-суждена: возврат карантинной копии удвоил бы "
f"единицу; --restore допустим только для пачек без замены")
if tok in DROP:
continue
key = key_all.get(tok)
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.MULTILINE)
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.MULTILINE)
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
import statistics as _st
us = {x["uid"]: x for x in S.units()}
print("\n" + "=" * 78)
print("ТРИ ОСИ ПО КАЖДОМУ АРМУ (требование заказа :95) — судья · канон · цена")
print("=" * 78)
print(f" {'арм':5s}{'судья':>8s}{'канон':>9s}{'$/клетка':>11s}{'$/единица':>12s} что это")
what = WHAT_AXIS[AXIS]
_dead = {
"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, per_unit = [], {}
# ⚠ Глоб по тегу арма захватывал и клетки КРИТИКА (`dv-a-a3-crit-*`), поэтому
# медиана «$/клетка» у A3 и A6 считалась по смеси двух ролей. Найдено приёмкой.
for f in S.C.OUT.glob(f"{S.C.tag(arm, '')}*.json"):
# ⚠ Суффиксы карантина перечислены ПОЛНОСТЬЮ. `.STALE` и `.TWIN` заведены фазой Д на ходу
# (устаревшие бесплатные клетки арма и побайтные близнецы пола), и их отсутствие здесь
# тянуло медиану цены вниз нулевыми клетками. Найдено чтением собственных коммитов.
if any(x in f.name for x in (".ERROR", ".LENGTH", ".FLAGSV1", ".STALE", ".TWIN")):
continue
r = _j.loads(f.read_text(encoding="utf-8"))
c = r.get("cost_usd") or 0
# ⚠ ДВЕ РАЗНЫЕ ВЕЛИЧИНЫ, И ПУТАТЬ ИХ ДОРОГО. «$/клетка» — цена ОДНОГО вызова;
# «$/единица» — цена всей работы арма над единицей ПЛЮС база, на которой он стоит.
# У армов с двумя вызовами (критик + фиксер) это отличается втрое: у A3 клетка
# фиксера 0.00644, а единица — 0.02014 плюс черновик. Отчёт печатал первую цифру
# в колонке, по которой сравнивают экономику, и ставка H-2б выглядела средней
# по цене, будучи самой дорогой в панели.
if "-crit-" not in f.name and c:
pr.append(c)
uid = r.get("uid") or f.stem.split("-")[-1]
per_unit[uid] = per_unit.get(uid, 0.0) + c
base = BASE_COST_BY_AXIS.get(AXIS, {}).get(arm, 0.0)
full = (_st.median([v + base for v in per_unit.values() if v > 0])
if any(v > 0 for v in per_unit.values()) else base)
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}{full:12.5f} {what.get(arm, '')}")
print(" судья — ошибок на единицу (ВЕРНОСТЬ+ЯЗЫК), МЕНЬШЕ лучше")
print(" канон — доля покрытия банка на ВЫХОДЕ арма, БОЛЬШЕ лучше")
print(" $/клетка — медиана ОДНОГО оплаченного вызова арма")
print(" $/единица — вся работа арма над единицей ПЛЮС база, на которой он стоит:")
_b = BASE_COST_BY_AXIS.get(AXIS, {})
print(f" {' · '.join(f'{a}+{c:.5f}' for a, c in sorted(_b.items()) if c)}")
print(" (замер по сырью: черновик flash 0.00096, перепис dspro 0.00507)")
def margins_half(d: dict, a: str, b: str) -> list[float]:
"""Перевес ЗА арм `a`, посчитанный ВНУТРИ половины и лишь потом усреднённый по единице.
⚠ Зачем вторая формула рядом со штатной. Наборы p1/p2 судятся РАЗНЫМИ сессиями, и между ними
намерен систематический сдвиг строгости (находка ревизии P07: пол claude даёт +1.8 ошибки в
пользу p2). Штатная `margins` берёт среднее арма по тем половинам, где он ЕСТЬ, — а половины
у армов заполнены не одинаково (`A6` 15/16 против `A0` 30/31). Там, где арм присутствует
только в одной половине, в его число входит уровень ЭТОЙ сессии, и разность двух армов несёт
кусок межсессионного сдвига как будто это разница армов. Здесь разность берётся там, где оба
арма судила ОДНА сессия, поэтому уровень сессии сокращается тождественно.
"""
out = []
for uid in sorted({k[0] for k in d}):
per = [d[(uid, b, h)]["carry"] - d[(uid, a, h)]["carry"]
for h in ("p1", "p2") if (uid, a, h) in d and (uid, b, h) in d]
if per:
out.append(st.mean(per))
return out
def _levels(d: dict) -> dict:
"""Средний счёт боевых армов по СЕССИЯМ судейского журнала: {(проход, судья): (n, среднее)}.
⚠ Это единственный оставшийся способ проверить посылку P13. Находка называла пачки
`0dce349331`/`0ffee70740` «другим режимом замера» на двух основаниях: не замороженный промт и
уровень счёта 9.94 против 6.39 у основной волны. Транскрипта сессии на диске больше нет
(проверено `find` по всем каталогам агентов) — промт сверить не с чем. Уровень сверить можно,
и вопрос ставится честно: выбивается ли д-01 из РАЗБРОСА ОСТАЛЬНЫХ СЕССИЙ или такой шаг между
сессиями у этой оси обычный. Во втором случае снятие двух пачек — не норма, а выбор.
"""
led = json.loads((Path.home() / "books" / "dovodka" / "agent-runs.json").read_text("utf-8"))
battle = set(ALL_ARMS)
out: dict = {}
for r in led:
if r.get("run") != AXIS:
continue
half = r.get("pass_")
sc = [v["carry"] for (uid, arm, h), v in d.items()
if h == half and arm in battle and uid in _uids_of(r, half)]
if sc:
out[(half, r["judge"], tuple(r["tokens"]))] = (len(sc), st.mean(sc))
return out
def _uids_of(run: dict, half: str) -> set:
"""uid единиц, которые судила эта сессия. Пачка названа ТОКЕНОМ, ключ переводит его в uid."""
kf = KEYS / f"{AXIS}{half}-KEY.json"
if not kf.exists():
return set()
key_all = json.loads(kf.read_text(encoding="utf-8"))
out = set()
for tok in run.get("tokens", []):
for meta in (key_all.get(tok) or {}).values():
out.add(meta["uid"])
return out
def sens() -> int:
"""Г5 — ЧУВСТВИТЕЛЬНОСТЬ ВЕРДИКТА H-1 К СОСТАВУ ПАЧЕК. $0, ни одного вызова к моделям.
⚠ ПРАВИЛО РЕШЕНИЯ ОБЪЯВЛЕНО ДО ПРОГОНА (иначе это подбор сценария, а не замер):
вердикт по H-1 считается ПЕРЕ-РЕШЁННЫМ, только если он одинаков во ВСЕХ сценариях, снятие
в которых обосновано нормой проекта. Если знак решения зависит от выбора сценария — печатается
именно это, а не удобная половина. Запас над порогом печатается числом рядом: решение,
выигранное с запасом меньше собственного шага шкалы (1 ошибка), вердиктом не является.
"""
print("=" * 78)
print("Г5 — ЧУВСТВИТЕЛЬНОСТЬ H-1 К СОСТАВУ ПАЧЕК ($0, пере-анализ уже купленных ответов)")
print("=" * 78)
print(" правило решения объявлено в докстринге ДО прогона: вердикт пере-решён только при")
print(" совпадении во ВСЕХ обоснованных сценариях; запас печатается числом\n")
global DROP, RESTORE # состав пачек — параметр сценария, см. их объявление
grid = [
("S0 дерево как есть (что печатает свод)", set(), set()),
("S1 −валидация 11.08 (P13)", {"0dce349331", "0ffee70740"}, set()),
("S2 +69de717f3f назад (база ревизии)", set(), {"69de717f3f"}),
("S3 база ревизии −валидация (счёт P13)", {"0dce349331", "0ffee70740"}, {"69de717f3f"}),
]
keep = (DROP, RESTORE)
rows = []
for name, drop, restore in grid:
DROP, RESTORE = drop, restore
d, _bad = read_family(FAMILIES["claude"])
sd, n = floor_sd(d)
thr = 2.8 * sd / (n ** 0.5) if n > 1 else float("nan")
line = {"name": name, "sd": sd, "n": n, "thr": thr, "d": d}
for a, b, _w in PRIMARY:
m = margins(d, a, b)
mh = margins_half(d, a, b)
line[a] = (len(m), st.mean(m), BO.sign_perm_p(m) if len(m) > 2 else float("nan"),
len(mh), st.mean(mh) if mh else float("nan"),
BO.sign_perm_p(mh) if len(mh) > 2 else float("nan"))
rows.append(line)
DROP, RESTORE = keep
for a, b, what in PRIMARY:
print(f"### КОНТРАСТ {a}/{b}{what}")
print(f" {'сценарий':40s}{'пол n':>6s}{'порог':>7s}{'перевес':>9s}"
f"{'запас':>7s}{'p Холма*':>9s} вердикт")
for r in rows:
mean = r[a][1]
gap = abs(mean) - r["thr"]
adj = holm([r[x][2] if r[x][2] == r[x][2] else 1.0 for x, _y, _z in PRIMARY])
pa = adj[[x for x, _y, _z in PRIMARY].index(a)]
ok = gap > 0 and pa < 0.05
print(f" {r['name']:40s}{r['n']:6d}{r['thr']:7.2f}{mean:+9.2f}{gap:+7.2f}"
f"{pa:9.4f} {'ПЕРЕШЁЛ' if ok else 'ниже порога'}")
print(f" {'—— тот же контраст ВНУТРИ половины (P07)':40s}")
for r in rows:
_k, _m, _p, kh, meanh, ph = r[a]
gap = abs(meanh) - r["thr"]
print(f" {r['name']:40s}{r['n']:6d}{r['thr']:7.2f}{meanh:+9.2f}{gap:+7.2f}"
f"{ph:9.4f} {'ПЕРЕШЁЛ' if gap > 0 and ph < 0.05 else 'ниже порога'} ед.{kh}")
print()
print(" * Холм считается ПО ТРЁМ контрастам внутри своего сценария, как в пре-реге\n")
print("=" * 78)
print("УРОВЕНЬ СУДЕЙСКИХ СЕССИЙ — выбивается ли прогон-валидация из разброса остальных")
print("=" * 78)
lv = _levels(rows[0]["d"])
for half in ("p1", "p2"):
xs = [(j, n, m, t) for (h, j, t), (n, m) in lv.items() if h == half]
if not xs:
continue
vals = [m for _j, _n, m, _t in xs]
print(f" {half}: сессий {len(xs)} · среднее {st.mean(vals):.2f} · "
f"разброс {min(vals):.2f}{max(vals):.2f}"
+ (f" · sd {st.pstdev(vals):.2f}" if len(vals) > 1 else ""))
for j, n, m, t in sorted(xs, key=lambda x: -x[2]):
mark = " ← прогон-валидация 11.08" if "0dce349331" in t else ""
print(f" {j:8s} клеток {n:3d} ошибок/клетку {m:6.2f}{mark}")
print("\n" + "=" * 78)
print("СИСТЕМАТИЧЕСКИЙ СДВИГ НАБОРОВ (P07) — на нём стоит вся формула порога")
print("=" * 78)
d0 = rows[0]["d"]
both = [(v["carry"] - d0[(u, a, "p2")]["carry"])
for (u, a, h), v in d0.items() if h == "p1" and (u, a, "p2") in d0 and a in ALL_ARMS]
fl = [d0[(u, "CTRLfloor", "p1")]["carry"] - d0[(u, "CTRLfloor", "p2")]["carry"]
for u in {k[0] for k in d0}
if (u, "CTRLfloor", "p1") in d0 and (u, "CTRLfloor", "p2") in d0]
print(f" боевые армы, одна единица в обеих половинах: n={len(both)} "
f"сдвиг p1p2 {st.mean(both):+.2f} · sd {st.pstdev(both):.2f}")
print(f" пол (те же половины): n={len(fl)} сдвиг {st.mean(fl):+.2f} · sd {st.pstdev(fl):.2f} "
f"· p={BO.sign_perm_p(fl):.4f}")
print(" ⇒ если сдвиг пола ≈ сдвиг боевых армов, пол меряет РАЗНИЦУ СЕССИЙ, а не шум оценки;")
print(" сбалансированный по половинам контраст этот сдвиг сокращает, несбалансированный — нет")
print("\n БАЛАНС ПОЛОВИН ПО АРМАМ (несбалансированный арм несёт уровень своей сессии):")
for arm in ALL_ARMS:
n1 = sum(1 for (u, a, h) in d0 if a == arm and h == "p1")
n2 = sum(1 for (u, a, h) in d0 if a == arm and h == "p2")
print(f" {arm:5s} p1 {n1:3d} · p2 {n2:3d}" + (" ⚠ перекос" if abs(n1 - n2) > 1 else ""))
# ⚠ КАЛИБРОВКА ПОРОГА ПО ЗНАКУ (находка ревизии №6: R73 против P40). Две выжившие находки
# называли порог смещённым в ПРОТИВОПОЛОЖНЫЕ стороны, и ни одна не проверялась на ЭТОЙ оси:
# R73 мерила на проходе `border` (контраст в 1.40× шумнее пола), P40 рассуждала. Здесь то же
# меряется прямо: шум САМОГО контраста той же структуры — перевес, посчитанный в p1, против
# перевеса в p2. Если он равен полу, порог откалиброван; больше — вердикты смелее данных;
# меньше — прибор строже, чем нужно.
sd0, n0 = floor_sd(d0)
print("\n" + "=" * 78)
print("КАЛИБРОВКА ПОРОГА ПО ЗНАКУ — пол против ШУМА САМОГО КОНТРАСТА (ревизия №6)")
print("=" * 78)
print(f" пол, из которого строится порог: n={n0} sd={sd0:.4f} → порог {2.8 * sd0 / n0**0.5:.4f}")
for a, b, _w in PRIMARY:
xs = []
for u in {k[0] for k in d0}:
if all((u, x, h) in d0 for x in (a, b) for h in ("p1", "p2")):
xs.append((d0[(u, b, "p1")]["carry"] - d0[(u, a, "p1")]["carry"])
- (d0[(u, b, "p2")]["carry"] - d0[(u, a, "p2")]["carry"]))
if len(xs) < 3:
continue
sd = st.pstdev(xs)
own = 2.8 * sd / len(xs) ** 0.5
m = margins(d0, a, b)
print(f" {a}/{b}: n={len(xs):2d} sd={sd:.4f} ({sd / sd0:.2f}× пола) → СВОЙ порог {own:.4f}"
f" · перевес {st.mean(m):+.4f} · запас {abs(st.mean(m)) - own:+.4f}")
print(" ⚠ «свой порог» — не замена объявленному: пре-рег зафризил формулу ПО ПОЛУ, и менять её")
print(" после взгляда на вердикты нельзя. Это проверка КАЛИБРОВКИ, а не второе решающее правило.")
# ⚠ Вторая сторона той же калибровки: пол строится на ДВУХ ГЕНЕРАЦИЯХ, то есть несёт и шум
# порождения, и шум судьи. Чистый шум судьи виден там, где текст ОДИН И ТОТ ЖЕ, а сессии
# разные — на боевых армах, которые лежат в обеих половинах. Если он БОЛЬШЕ пола, порог занижен.
print("\n ЧИСТЫЙ ШУМ СУДЬИ (ОДИН И ТОТ ЖЕ текст, две сессии) против пола:")
for arm in ALL_ARMS:
xs = [d0[(u, arm, "p1")]["carry"] - d0[(u, arm, "p2")]["carry"] for u in {k[0] for k in d0}
if (u, arm, "p1") in d0 and (u, arm, "p2") in d0]
if len(xs) > 2:
print(f" {arm:5s} n={len(xs):2d} среднее {st.mean(xs):+.2f} sd={st.pstdev(xs):.4f}"
f" ({st.pstdev(xs) / sd0:.2f}× пола)")
return 0
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(sens() if "--sens" in sys.argv else main())