601 lines
42 KiB
Python
601 lines
42 KiB
Python
#!/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"сдвиг p1−p2 {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())
|