textmachine/eval/role_topology/itog.py

384 lines
23 KiB
Python
Raw Permalink 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
"""ИТОГ ЭКСПА ОДНОЙ КОМАНДОЙ. Пере-считывает КАЖДОЕ число блока «ИТОГ» отчёта из сырья. $0.
ЗАЧЕМ ЗАВЕДЁН. Два независимых аудита 08.08 (снизу вверх и сверху вниз) сошлись на одном дефекте
оснастки: несущие числа отчёта — нога 2×2, контрасты Ф2б, покрытие канона, отборщики арма G —
**в дереве пути воспроизведения не имели**. `absjudge --score` печатает семейство Ф2а и на узком
проходе 4 выдаёт пустую таблицу (в `FAMILY` нет контрастов C2/E2); `verify_report` сторожил
таблицы §2 второй редакции, деньги и батарею — и ни одного числа ИТОГа. То есть решающий слой
отчёта стоял без стража, а часть чисел бралась из stdout прогонов, утраченных инцидентом 08.08.
Это ровно тот класс дефекта, за который снята первая редакция, только для чисел, а не для денег:
число, которое нельзя пере-снять командой из дерева, ничем не отличается от числа по памяти.
ЧТО ЗДЕСЬ НЕ ДЕЛАЕТСЯ. Ничего не покупается и не судится: только счёт по персистированным голосам
и артефактам. Каждый блок печатает провенанс — из какого прохода и по какой формуле взято, — и
явно метит числа, которые в дереве не выводятся ни одним способом.
Запуск: eval/.venv/bin/python eval/role_topology/itog.py
"""
from __future__ import annotations
import json
import re
import statistics as st
import sys
from bisect import bisect_left, bisect_right
from itertools import combinations
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
sys.path.insert(0, str(REPO / "eval" / "editor_harness"))
import absjudge as AJ # noqa: E402
import bakeoff as BO # noqa: E402
import guarded as G # noqa: E402
import phase2b as P2 # noqa: E402
OUT = Path.home() / "books" / "role-topology"
def _E(d: dict, u: str, k: str) -> float:
return d[u][k]["errors"]
def _passes() -> tuple[dict, dict, list[str]]:
a1, a2 = AJ._by_unit_pass("abs"), AJ._by_unit_pass("abs2")
return a1, a2, sorted(set(a1) & set(a2))
def _pooled(a1: dict, a2: dict, common: list[str], a: str, b: str) -> list[float]:
"""Перевес за арм `a` в пуле ДВУХ проходов: сначала среднее по проходам ВНУТРИ единицы.
⚠ Именно так, а не «средние по проходам вычесть»: пул усредняет перевес поединично, поэтому
разница СТРОГОСТИ панелей сокращается вычитанием внутри единицы и в контраст не попадает.
Уровни между панелями несопоставимы (abs 6.90 → abs2 7.92 → abs3 8.46 → abs4 9.22 ошибки на
клетку), сопоставимы только разности — на этом стоит весь ИТОГ.
"""
return [st.mean([_E(a1, u, b) - _E(a1, u, a), _E(a2, u, b) - _E(a2, u, a)])
for u in common
if a in a1[u] and b in a1[u] and a in a2[u] and b in a2[u]]
def _holm(ps: list[float]) -> list[float]:
order = sorted(range(len(ps)), key=lambda i: ps[i])
out, run = [0.0] * len(ps), 0.0
for rank, i in enumerate(order):
run = max(run, min(1.0, ps[i] * (len(ps) - rank)))
out[i] = run
return out
def p1_leg() -> None:
print("═══ ИТОГ п.1 — НОГА 2×2 (пул проходов abs+abs2, перевес против черновика F3) ═══")
a1, a2, common = _passes()
print(f"единиц в обоих проходах: {len(common)}\n")
print(f"{'арм':6s}{'n':>4s}{'перевес':>10s}{'95% ДИ':>19s}{'p':>10s}{'побед':>8s}")
for a in ("A", "B", "D_", "D"):
m = _pooled(a1, a2, common, a, "F3")
lo, hi = BO.boot_ci(m)
print(f"{a:6s}{len(m):4d}{st.mean(m):+10.2f} [{lo:+.2f}, {hi:+.2f}]"
f"{BO.sign_perm_p(m):10.4f}{sum(1 for x in m if x > 0):5d}/{len(m)}")
print("\n⚠ ПОСТ-ХОК. `D_ vs F3` и `D vs F3` в объявленном `absjudge.FAMILY` ОТСУТСТВУЮТ: нога")
print(" собрана аудитом из того же сырья уже после чтения чисел. Поправку Холма они не несут")
print(" и читаются как разведка; несущие контрасты семейства печатает `absjudge --pool`.")
m = _pooled(a1, a2, common, "A_law", "F3")
print(f"\nсправочно `A_law vs F3` (боевой конфиг редактора): {st.mean(m):+.2f}"
f" · по проходам {st.mean([_E(a1, u, 'F3') - _E(a1, u, 'A_law') for u in common]):+.2f}"
f" / {st.mean([_E(a2, u, 'F3') - _E(a2, u, 'A_law') for u in common]):+.2f}")
def p1_robust() -> None:
"""Чем несущие контрасты пробовали убить: L2O, кластеризация по главе, строгость панелей.
⚠ Заведено 08.08. До этого клеймы отчёта «0/120 при удалении любых ДВУХ единиц» и
«кластеризация по главе выживает» СЧИТАЛИСЬ ВНЕ ДЕРЕВА и в репозитории пути не имели —
тот же класс дефекта, ради которого написан этот модуль.
"""
print("\n═══ ИТОГ п.1 — ЧЕМ НЕСУЩИЕ КОНТРАСТЫ ПРОБОВАЛИ УБИТЬ ═══")
a1, a2, common = _passes()
core = [("A", "F3"), ("B", "F3"), ("D", "A")]
k_fam = len(AJ.FAMILY)
print(" LEAVE-TWO-OUT (удаляются любые ДВЕ единицы, Холм по семи гипотезам):")
for a, b in core:
m = _pooled(a1, a2, common, a, b)
bad = sum(1 for c in combinations(range(len(m)), 2)
if BO.sign_perm_p([x for i, x in enumerate(m) if i not in c]) * k_fam >= 0.05)
print(f" {a + ' vs ' + b:10s} вылетов {bad}/{len(list(combinations(range(len(m)), 2)))}")
# КЛАСТЕРИЗАЦИЯ. 16 единиц происходят не из 16 разных глав: часть глав дала по два чанка,
# и такие единицы НЕ независимы. Тест берёт средний перевес ПО ГЛАВЕ и работает на главах.
us = {u["uid"]: u["chapter"] for u in BO.units()}
chapters = sorted({us[u] for u in common})
print(f"\n КЛАСТЕРИЗАЦИЯ ПО ГЛАВЕ ИСТОЧНИКА: {len(common)} единиц из {len(chapters)} глав "
f"({sum(1 for c in chapters if sum(1 for u in common if us[u] == c) > 1)} глав дали >1 чанка)")
ps = []
for a, b in core:
m = dict(zip(common, _pooled(a1, a2, common, a, b), strict=True))
by = [st.mean([m[u] for u in common if us[u] == c]) for c in chapters]
ps.append(BO.sign_perm_p(by))
for (a, b), p, h in zip(core, ps, _holm(ps + [1.0] * (k_fam - len(ps))), strict=False):
print(f" {a + ' vs ' + b:10s} на главах p={p:.4f} · Холм {h:.4f}"
f"{' ✔ выживает' if h < 0.05 else ''}")
print("\n СТРОГОСТЬ ПАНЕЛЕЙ (ошибок на клетку) — уровни между проходами НЕСОПОСТАВИМЫ:")
for nm in ("abs", "abs2", "abs3", "abs4"):
d = AJ._by_unit_pass(nm)
cells = [r["errors"] for v in d.values() for r in v.values()]
if cells:
print(f" {nm:5s} {st.mean(cells):.2f} на {len(cells)} клетках")
print(" ⇒ поэтому ИТОГ сравнивает ТОЛЬКО внутриединичные разности, а не уровни.")
def p2_canon() -> None:
print("\n═══ ИТОГ п.2 — ПОКРЫТИЕ КАНОНА (счётная метрика, окна) ═══")
us = BO.units()
print(f"{'арм':7s}{'канон/исходник':>16s}{'покрытие':>10s}")
for a in ("F3", "A", "A_law", "B", "D", "D_", "C2", "E2"):
s = k = 0
for u in us:
x = P2.text_of(a, u["uid"]) if a in ("C2", "E2") else BO.text3_of(a, u)
for zh, (_v1, _v2, r1, _r2) in BO.IP.TERMS.items():
n = u["source"].count(zh)
if n:
s += n
k += min(len(re.findall(r1, x, re.I)), n)
print(f"{a:7s}{f'{k}/{s}':>16s}{k / s if s else 0:>10.3f}")
print(" доля упоминаний исходника, отражённых КАНОННОЙ формой; s — знаменатель на 16 единиц")
def p3_draft() -> None:
print("\n═══ ИТОГ п.3 — СВОЙ ЧЕРНОВИК ПРОТИВ КОРПУСНОГО (тот же редактор `A`) ═══")
a1, a2, common = _passes()
own = {u: st.mean([_E(a1, u, "A"), _E(a2, u, "A")]) for u in common}
for lbl, cells in (("только CTRLfloorA (клетка ИТОГа)", ("CTRLfloorA",)),
("обе клетки пола CTRLfloorA+B", ("CTRLfloorA", "CTRLfloorB"))):
corp, diff = {}, []
for u in common:
vals = [_E(d, u, c) for d in (a1, a2) for c in cells if c in d[u]]
if not vals:
continue
corp[u] = st.mean(vals)
diff.append(corp[u] - own[u])
print(f" {lbl:34s} корпусный {st.mean(list(corp.values())):.2f} против своего "
f"{st.mean([own[u] for u in corp]):.2f} · хуже "
f"{sum(1 for x in diff if x > 0)}/{len(diff)} · p={BO.sign_perm_p(diff):.4f}")
print(" клетки: CTRLfloorA=`bo2-A` · CTRLfloorB=`bo3-Aprime` (повтор того же корпусного входа)")
print(" ⚠ ИТОГ печатает вариант ПО ОДНОЙ клетке — он в большую сторону; вердикт одинаков.")
def p4_axes() -> None:
print("\n═══ ИТОГ п.4 — ОДНОПРОХОДКА С МАНДАТОМ ПРОТИВ СВЯЗКИ, разложение по осям (пул) ═══")
a1, a2, common = _passes()
import judges as J # noqa: PLC0415
for a, b in (("D", "A"), ("D_", "A")):
row = []
for ax in J.AXES:
m = [st.mean([a1[u][b][ax] - a1[u][a][ax], a2[u][b][ax] - a2[u][a][ax]])
for u in common if a in a1[u] and b in a1[u]]
row.append(f"{ax} {st.mean(m):+.2f}")
tot = _pooled(a1, a2, common, a, b)
lo, hi = BO.boot_ci(tot)
print(f" {a} vs {b}: {st.mean(tot):+.2f} [{lo:+.2f}, {hi:+.2f}] "
f"p={BO.sign_perm_p(tot):.4f} · " + " · ".join(row))
print(" ⚠ Оси разведочны: `absjudge --score` корректирует на 7 гипотез, здесь клеток 28.")
def _qe() -> dict:
f = OUT / "qe-arms.json"
return json.loads(f.read_text(encoding="utf-8")) if f.exists() else {}
def _pearson(xs: list[float], ys: list[float]) -> float:
mx, my = st.mean(xs), st.mean(ys)
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys, strict=True))
den = (sum((x - mx) ** 2 for x in xs) * sum((y - my) ** 2 for y in ys)) ** 0.5
return num / den if den else float("nan")
def _route() -> tuple[dict, list[str], list[str]]:
"""Кого арм G маршрутизировал и чью правку страж ОСТАВИЛ. Ровно логика `guarded.cmd_build`.
⚠ Выигрыш забирают ТОЛЬКО оставленные окна: там, где страж откатил правку к черновику,
арм G получает счёт черновика, то есть ноль выигрыша. Первая версия этого счёта брала все
маршрутизированные и завышала долю до 46% — поймано сверкой с `--build` 08.08.
"""
a1, a2, common = _passes()
gain = dict(zip(common, _pooled(a1, a2, common, "A", "F3"), strict=True))
routed, kept = [], []
for u in BO.units():
uid = u["uid"]
if uid not in gain:
continue
draft, edited = BO.text3_of("F3", u), BO.text3_of("A", u)
if not G.flagged(draft):
continue
routed.append(uid)
if sum(G.flags(edited).values()) <= sum(G.flags(draft).values()):
kept.append(uid)
return gain, routed, kept
def _draft_cost(uid: str) -> float:
return sum(json.loads(f.read_text(encoding="utf-8")).get("cost_usd") or 0.0
for f in OUT.glob(f"bo2-DRAFT-{uid}*.json"))
def _cost_a(uid: str) -> float:
return _draft_cost(uid) + G.cost_of("bo4-A", uid)
def _cost_g(uid: str) -> float:
"""Цена арма G: черновик всегда, редактор — только флагнутым окнам."""
u = next(x for x in BO.units() if x["uid"] == uid)
return _cost_a(uid) if G.flagged(BO.text3_of("F3", u)) else _draft_cost(uid)
def p5_router() -> None:
print("\n═══ ИТОГ п.5 — РОУТИНГ: ТРИ ОТБОРЩИКА ═══")
a1, a2, common = _passes()
gain, routed, picked = _route()
tot = sum(gain.values())
got = sum(gain[u] for u in picked)
print(f" МЕХАНИЧЕСКИЙ отборщик: маршрутизировал {len(routed)}/{len(gain)} окон, страж оставил"
f" правку в {len(picked)} ⇒ забрано {got:.1f} из {tot:.1f} выигрыша = {got / tot:.0%}")
sums = sorted(sum(gain[u] for u in c) for c in combinations(gain, len(picked)))
lo, hi = bisect_left(sums, got) / len(sums), bisect_right(sums, got) / len(sums)
cg = st.mean([_cost_g(u) for u in gain])
ca = st.mean([_cost_a(u) for u in gain])
print(f" цена: G ${cg:.5f} против связки ${ca:.5f} = {cg / ca:.0%} цены за {got / tot:.0%}"
f" выигрыша ⇒ хуже случайного роутинга того же бюджета")
print(f" среди всех C({len(gain)},{len(picked)})={len(sums)} подмножеств того же размера: "
f"персентиль {lo:.1%}{hi:.1%} (середина {(lo + hi) / 2:.1%}), связок {sums.count(got)}")
print(" ⚠ ИТОГ печатает верхнюю границу (51-й); расхождение с 46-м — только конвенция связок.")
q = _qe()
if q:
agree = tot_n = 0
for a, b, _w in AJ.FAMILY:
for u in common:
m = _pooled(a1, a2, [u], a, b)
if not m or m[0] == 0:
continue
dq = q.get(f"{b}|{u}", 0) - q.get(f"{a}|{u}", 0)
tot_n += 1
agree += (dq > 0) == (m[0] > 0)
print(f" QE как СУДЬЯ: знак совпал {agree}/{tot_n} по 7 контрастам семейства × единицы")
xs = [q.get(f"F3|{u}", 0) - q.get(f"A|{u}", 0) for u in common]
ys = [gain[u] for u in common]
print(f" QE против судьи на самом крупном контрасте `A/F3`: r={_pearson(xs, ys):+.2f}")
print(" ⚠ Это АНТИкорреляция, а не слепота: чем сильнее QE «за» редактуру, тем меньше")
print(" её выигрыш по судье. Довод против QE-роутинга сильнее, чем печатал ИТОГ.")
print(" ⚠ Числа ИТОГа «43/91» и «r=+0.06» ни одним вариантом счёта не воспроизводятся —")
print(" они из §15, утраченного инцидентом 08.08; в дереве кода пути к ним НЕТ.")
def p6_guard() -> None:
print("\n═══ ИТОГ п.6 — СТРАЖ guarded APE ═══")
gain, routed, kept = _route()
rej = [u for u in routed if u not in kept]
print(f" страж ОТКЛОНИЛ правок: {len(rej)}" +
" · ".join(f"{u[:10]} выигрыш {gain[u]:+.1f}" for u in rej))
if rej:
print(f" средний выигрыш ОТКЛОНЁННЫХ {st.mean([gain[u] for u in rej]):+.2f} против "
f"{st.mean(list(gain.values())):+.2f} по ВСЕМ единицам ⇒ страж вырезал ЛУЧШИЕ")
print(f" ⚠ n={len(rej)}. ИТОГ числа единиц не называл; вывод стоит на двух правках.")
def p7_2b() -> None:
print("\n═══ ИТОГ п.7 — АРМЫ Ф2б (проход abs4, узкая панель) ═══")
sv = AJ.PASS
AJ.PASS = "abs4"
try:
d = AJ._by_unit()
finally:
AJ.PASS = sv
# СЕМЕЙСТВО ПРОХОДА 4 — пять СУДЕЙСКИХ контрастов.
# ⚠ ОТКЛОНЕНИЕ ОТ ОБЪЯВЛЕННОГО, объявляю: `phase2b.FAMILY` пятым членом называет
# `C_oracle vs C_real` — разрыв потолка и пола схемы. Он НЕ судейский: потолок меряется
# посаженными дефектами и детекторами ($0, `phase2b --oracle`), судье не предъявлялся и
# p-значения не имеет. В судейскую пятёрку вместо него вошёл контроль `A_law vs F3`.
# Направление подмены КОНСЕРВАТИВНО (член семейства сохранён, поправка не ослаблена), но это
# правка семейства после пре-рега, и читать её надо как девиацию, а не как замысел.
fam = [("C2", "A_law", "точечный ремонт против полного переписывания"),
("C2", "F3", "и лучше ли он черновика, который чинит"),
("E2", "D_", "обратная связка против своей базы"),
("E2", "A_law", "обратная связка против прямой"),
("A_law", "F3", "КОНТРОЛЬ: воспроизводится ли эффект редактора")]
rows = []
for a, b, w in fam:
m = [d[u][b]["errors"] - d[u][a]["errors"] for u in d if a in d[u] and b in d[u]]
rows.append((a, b, w, m, BO.sign_perm_p(m)))
holm = _holm([r[4] for r in rows])
print(f"{'контраст':16s}{'n':>3s}{'перевес':>9s}{'95% ДИ':>19s}{'p':>9s}{'p Холма':>10s}")
for (a, b, w, m, p), h in zip(rows, holm, strict=True):
lo, hi = BO.boot_ci(m)
print(f"{a + ' vs ' + b:16s}{len(m):3d}{st.mean(m):+9.2f} [{lo:+.2f}, {hi:+.2f}]"
f"{p:9.4f}{h:10.4f}{'' if h < 0.05 else ' '} {w}")
print(" ⚠ `E2 vs A_law` значим и в таблице ИТОГа НЕ напечатан — восстановлено ревью 08.08.")
def money() -> None:
print("\n═══ ИТОГ — ДЕНЬГИ ═══")
def s(pat: str) -> float:
out = 0.0
for f in OUT.glob(pat):
try:
x = json.loads(f.read_text(encoding="utf-8"))
except (ValueError, OSError):
continue
if isinstance(x, dict):
out += x.get("cost_usd") or 0.0
return out
print(f" пак целиком ${s('*.json'):.5f}")
print(f" фаза Ф2б: армы C/E `p2b-*` ${s('p2b-*.json'):.5f} + старые пробы `rp-*` "
f"${s('rp-*.json'):.5f} = ${s('p2b-*.json') + s('rp-*.json'):.5f}")
print(" ⚠ ИТОГ печатал как расход фазы только `p2b-*`; расход ФАЗЫ включает и `rp-*`.")
empty = {}
for f in OUT.glob("*.json"):
try:
x = json.loads(f.read_text(encoding="utf-8"))
except (ValueError, OSError):
continue
if isinstance(x, dict) and (x.get("cost_usd") or 0) > 0 \
and not (x.get("content") or x.get("answer") or "").strip():
t = f.name.split("-")[0]
e = empty.setdefault(t, [0, 0.0])
e[0] += 1
e[1] += x["cost_usd"]
tot_n = sum(v[0] for v in empty.values())
tot_s = sum(v[1] for v in empty.values())
print(f" оплачено за ПУСТОЙ выход: {tot_n} вызовов ${tot_s:.5f}" +
" · ".join(f"{k} {v[0]} шт ${v[1]:.5f}" for k, v in sorted(empty.items())))
print(" ⚠ Счётчик смотрит ОБА поля выхода (`content` и `answer`): проба контаминации")
print(" пишет в `answer`, и счётчик по одному полю давал ложные $0.19191 на 43 вызова.")
def noise() -> None:
print("\n═══ ИТОГ — ШУМ ═══")
for nm in ("abs", "abs2", "abs4"):
d = AJ._by_unit_pass(nm)
m = [v["CTRLfloorA"]["errors"] - v["CTRLfloorB"]["errors"]
for v in d.values() if "CTRLfloorA" in v and "CTRLfloorB" in v]
if len(m) > 1:
print(f" пол рига {nm:5s}: n={len(m):2d} среднее {st.mean(m):+.2f} · "
f"sd {st.pstdev(m):.2f} — две генерации ОДНОГО арма, один судья")
print(" ⇒ разброс повтора превышает разницу между армами: на ОДНОЙ главе армы не упорядочены.")
print(" ⚠ Разложение шума на судейскую и генерационную компоненты НЕ УСТАНОВЛЕНО:")
print(" `absjudge --pool` печатает один способ счёта, два других дают другой вердикт.")
def main() -> None:
p1_leg()
p1_robust()
p2_canon()
p3_draft()
p4_axes()
p5_router()
p6_guard()
p7_2b()
print("\n═══ ИТОГ п.8 — ДРЕЙФ ═══\n штатная команда: `drift.py --score` (9 общих глав)")
money()
noise()
print("\nВСЁ ВЫШЕ ПЕРЕ-СНЯТО ИЗ СЫРЬЯ, $0. Числа, помеченные ⚠ «в дереве нет», не выводятся.")
if __name__ == "__main__":
main()