textmachine/eval/role_topology/verify_report.py

335 lines
22 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
"""ВЕРИФИКАТОР ЧИСЕЛ ОТЧЁТА эксп-21 (вторая редакция). Ненулевой код возврата = отчёт не сдаётся.
Правило: каждое число отчёта пере-выводится ИЗ СЫРЬЯ и сверяется с напечатанным. Верификатор
не читает выводы — он читает артефакты и текст, и падает на расхождении.
Три вещи, которых у первой редакции не было и которые тут есть по её разбору:
* ДЕНЬГИ ВТОРЫМ НЕЗАВИСИМЫМ ПУТЁМ. `prices.cost` считает цену и при записи, и при сверке, поэтому
их совпадение доказывает целостность персистенции, а НЕ правильность правила тарификации:
ошибка внутри `cost` (как отсутствовавшая ветка Gemini) проходит обе стороны незамеченной.
`independent_price` выписывает правило заново, по строкам quirks 00.
* ИСПРАВНОСТЬ РАСКЛАДКИ проверяется равенством числа голосов по порядкам, а не малостью
позиционной форы: зеркало убирает позицию из перевеса АРМА, а не из среднего по всем голосам.
* АРМЫ СВЕРЯЮТСЯ С БУКВОЙ ПРОМТА исполнением — сборкой сообщений, а не чтением докстрингов.
Запуск: eval/.venv/bin/python eval/role_topology/verify_report.py
"""
from __future__ import annotations
import json
import re
import statistics
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
RAW = Path.home() / "books" / "role-topology"
DOC = REPO / "docs" / "experiments" / "21-role-topology.md"
FAILS = 0
def ck(claim: str, ok: bool, got: str = "") -> None:
global FAILS # noqa: PLW0603
if not ok:
FAILS += 1
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {claim}" + ("" if ok else f" — сырьё даёт {got}"))
def independent_price(model: str, r: dict) -> float:
"""Цена по прайсу, посчитанная НЕ через `prices.cost` — второй независимый путь."""
from prices import CANDIDATES # noqa: PLC0415
_, _, pin, pcache, pout, _, _ = CANDIDATES[model]
pt, ca = r.get("prompt_tokens", 0), r.get("cached_tokens", 0)
ct, rt = r.get("completion_tokens", 0), r.get("reasoning_tokens", 0)
tt = r.get("total_tokens", 0)
if model.startswith("grok"):
out = ct + rt # xAI: размышление СВЕРХ completion
elif model.startswith("gemini"):
out = max(tt - pt, ct + rt) # Gemini: размышление только в total (D22.3)
else:
out = ct # DeepSeek/OpenAI/Z.AI: размышление ВНУТРИ completion
return (pt - ca) / 1e6 * pin + ca / 1e6 * pcache + out / 1e6 * pout
def money(pat: str) -> float:
# ⚠ часть файлов зоны — списки-сводки, а не записи покупок; `.get` на них падал.
out = 0.0
for f in RAW.glob(pat):
try:
d = json.loads(f.read_text(encoding="utf-8"))
except (ValueError, OSError):
continue
if isinstance(d, dict):
out += d.get("cost_usd") or 0.0
return out
def main() -> None: # noqa: PLR0915
if not DOC.exists():
ck("отчёт на месте", False, "нет файла")
sys.exit(1)
text = DOC.read_text(encoding="utf-8")
import bakeoff as BO # noqa: PLC0415
import pair_en as E # noqa: PLC0415
import judges as J # noqa: PLC0415
print("=== СТРУКТУРА ЗАМЕРА ===")
us_zh, us_en = BO.units(), E.units()
ck("zh: 16 единиц", len(us_zh) == 16, str(len(us_zh)))
ck("en: 12 единиц, страты 6/6", len(us_en) == 12
and sum(1 for u in us_en if u["stratum"] == "fr") == 6, str(len(us_en)))
ck("zh: единицы уникальны по нормализованной подписи",
len({BO._dedup_sig(u["source"]) for u in us_zh}) == 16)
ck("отбор единиц ДЕТЕРМИНИРОВАН (повторный вызов даёт тот же набор)",
[u["uid"] for u in BO.units()] == [u["uid"] for u in us_zh]
and [u["uid"] for u in E.units()] == [u["uid"] for u in us_en])
print("\n=== АРМЫ ПРОТИВ БУКВЫ ПРОМТА (проверка исполнением) ===")
a_msgs = BO.build_msgs("A", us_zh[0])
ck("арм A идёт БЕЗ банкноты (промт стр.29)",
not any("КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in m["content"] for m in a_msgs))
ck("закон пинится БЕЗ оговорки области (промт стр.29)",
not any("глоссарий обязателен для употреблений" in m["content"]
for arm in BO.ARMS for m in BO.build_msgs(arm, us_zh[0])))
ck("A и A_law различаются ТОЛЬКО банкнотой",
a_msgs[0]["content"] == BO.build_msgs("A_law", us_zh[0])[0]["content"]
and a_msgs[-1]["content"] == BO.build_msgs("A_law", us_zh[0])[-1]["content"])
d, d_ = BO.build_msgs("D", us_zh[0]), BO.build_msgs("D_", us_zh[0])
ck("D и D_ различаются ТОЛЬКО мандатом",
d[1]["content"] == d_[1]["content"] and d[0]["content"] != d_[0]["content"])
bs = [json.loads(f.read_text(encoding="utf-8")) for f in RAW.glob("bo2-B-*.json")]
ck("арм B: мышление ПОГАШЕНО (промт стр.62)",
bool(bs) and all(r.get("reasoning_tokens", 0) == 0 for r in bs),
f"максимум {max((r.get('reasoning_tokens', 0) for r in bs), default=-1)}")
print("\n=== РАСКЛАДКА СУДЕЙ ===")
# Считаются только голоса ЖИВЫХ единиц: на диске лежат ещё голоса единиц, отобранных до
# починки недетерминированного отбора (§3 п.9). Они не участвуют в своде и не должны
# участвовать в контроле баланса — иначе контроль мерит мусор, а не раскладку.
for pref, live in (("jv2-", {u["uid"] for u in us_zh}), ("jve-", {u["uid"] for u in us_en})):
by = {0: 0, 1: 0}
for f in RAW.glob(f"{pref}*.json"):
if f.name.endswith((".ERROR.json", ".NOMSG.json")):
continue
r = json.loads(f.read_text(encoding="utf-8"))
if r.get("uid") in live:
by[r.get("order", 0)] += 1
ck(f"{pref}: голосов порядка 0 и порядка 1 ПОРОВНУ ({by[0]}/{by[1]})",
by[0] == by[1] and by[0] > 0, f"o0={by[0]} o1={by[1]}")
ck("судьи вне семейств, населяющих армы",
not any(j.startswith(("deepseek", "glm")) for j in J.JUDGES), str(J.JUDGES))
bias_zh = BO.position_bias()
ck("позиционная фора zh напечатана в отчёте", f"{bias_zh:+.2f}".replace("+", "+") in text
or f"{abs(bias_zh):.2f}" in text, f"{bias_zh:+.2f}")
print("\n=== ТАБЛИЦЫ ПЕРЕВЕСОВ (пере-вывод из голосов) ===")
for a, b in BO.CONTRASTS:
per = []
for u in us_zh:
pj = []
for judge in J.JUDGES:
bo = BO.margins_by_order(a, b, u["uid"], judge)
if len(bo) < 2:
continue
pj += [statistics.mean(bo[0]), statistics.mean(bo[1])]
if len(pj) == 4:
per.append(statistics.mean(pj))
if not per:
ck(f"zh {a}/{b}: голоса на месте", False, "нет")
continue
eff = statistics.mean(per)
lo, hi = BO.boot_ci(per)
zh_sec = text.split("### 2.1", 1)[-1].split("### 2.2", 1)[0]
printed = re.search(rf"^{re.escape(a)} против {re.escape(b)}\s+(\d+)\s+\d+\s+\d+\s+\d+\s+"
rf"([+-]?[\d.]+)\s+\[", zh_sec, re.M)
ck(f"zh {a}/{b}: перевес {eff:+.2f}, ДИ [{lo:+.2f}, {hi:+.2f}] — совпадает с отчётом",
bool(printed) and abs(float(printed.group(2).replace("", "-")) - eff) < 0.005
and int(printed.group(1)) == len(per),
f"n={len(per)} перевес {eff:+.2f}; в отчёте {printed.group(0)[:40] if printed else ''}")
for a, b in E.CONTRASTS:
per = []
for u in us_en:
pj = []
for judge in J.JUDGES:
bo = E._margins(a, b, u["uid"], judge)
if len(bo) < 2:
continue
pj += [statistics.mean(bo[0]), statistics.mean(bo[1])]
if len(pj) == 4:
per.append(statistics.mean(pj))
if not per:
ck(f"en {a}/{b}: голоса на месте", False, "нет")
continue
eff = statistics.mean(per)
# ⚠ Искать в ГРАНИЦАХ РАЗДЕЛА: метки контрастов в таблицах zh и en одинаковы, и поиск по
# всему файлу возвращает китайскую строку. Поймано исполнением: сверка «совпадает» при
# совпадающих числах падала, потому что сравнивала en-перевес с zh-строкой.
en_sec = text.split("### 2.2", 1)[-1].split("### 2.3", 1)[0]
pr = re.search(rf"^{re.escape(a)} против {re.escape(b)}\s+(\d+)\s+\d+\s+\d+\s+\d+\s+"
rf"([+-]?[\d.]+)\s+\[", en_sec, re.M)
ck(f"en {a}/{b}: перевес {eff:+.2f} на n={len(per)} — совпадает с отчётом",
bool(pr) and abs(float(pr.group(2).replace("", "-")) - eff) < 0.005
and int(pr.group(1)) == len(per), f"n={len(per)} перевес {eff:+.2f}")
print("\n=== ДЕНЬГИ ===")
recs = [json.loads(f.read_text(encoding="utf-8"))
for f in list(RAW.glob("bo2-*.json")) + list(RAW.glob("en2-*.json"))
+ list(RAW.glob("jv2-*.json")) + list(RAW.glob("jve-*.json"))
+ list(RAW.glob("jc-*.json"))]
recs = [r for r in recs if not r.get("skipped") and "prompt_tokens" in r]
# Сверка ПОКЛЕТОЧНАЯ, а не по сумме: записанная цена округлена до 6 знаков, и на 500+ записях
# округление копится до величины, которую тугой допуск на сумму читает как расхождение правила.
worst = max((abs(r["cost_usd"] - independent_price(r["model"], r)) for r in recs), default=0.0)
a_sum = sum(r["cost_usd"] for r in recs)
b_sum = sum(independent_price(r["model"], r) for r in recs)
ck(f"цена сходится ДВУМЯ независимыми путями поклеточно ({len(recs)} записей, "
f"максимальное расхождение ${worst:.9f})",
worst < 1e-6, f"записано ${a_sum:.6f} · независимо ${b_sum:.6f} · худшая клетка ${worst:.9f}")
ck("total_tokens персистирован во ВСЕХ записях",
all("total_tokens" in r for r in recs),
f"{sum(1 for r in recs if 'total_tokens' not in r)} без него")
blocks = [("армы zh", "bo2-*.json", BO.CEIL_ARMS), ("судейство zh", "jv2-*.json", BO.CEIL_JUDGE),
("армы en", "en2-*.json", E.CEIL_ARMS), ("судейство en", "jve-*.json", E.CEIL_JUDGE),
("проба судьи-замены", "jc-*.json", 0.10)]
total = 0.0
for name, pat, ceil in blocks:
v = money(pat)
total += v
ck(f"{name}: ${v:.6f} ≤ потолок ${ceil:.2f} и напечатано в отчёте",
v <= ceil and f"{v:.6f}" in text, f"${v:.6f}")
ck(f"итог ${total:.6f} напечатан в отчёте", f"{total:.6f}" in text, f"${total:.6f}")
print("\n=== ЭКОНОМИКА ===")
# ⚠ Цена печатается ПО СОБСТВЕННОМУ ВЫЗОВУ арма, без ноги черновика. Основание: армы правят
# ЗАМОРОЖЕННЫЙ черновик корпуса пакета-6, чья цена не сохранена, а докупленные `bo2-DRAFT-*`
# с ним побайтно не совпадают — их сложение приписывало бы связке цену другого текста.
for arm in BO.ARMS:
costs = [json.loads((RAW / f"bo2-{arm}-{u['uid']}.json").read_text(encoding="utf-8"))["cost_usd"]
for u in us_zh if (RAW / f"bo2-{arm}-{u['uid']}.json").exists()]
med = statistics.median(costs)
ck(f"{arm}: ${med:.5f}/единицу (собственный вызов) напечатано в отчёте",
f"{med:.5f}" in text, f"{med:.5f}")
same = sum(1 for u in us_zh
if BO.text_of("F", u).strip()
== json.loads((RAW / f"bo2-DRAFT-{u['uid']}.json").read_text(
encoding="utf-8"))["content"].strip())
ck("подмена ноги черновика ОБЪЯВЛЕНА: текст арма F не совпадает с докупленным черновиком",
same == 0 and "не совпадают" in text.replace("побайтно они с корпусными не совпадают",
"не совпадают"),
f"совпало {same}/{len(us_zh)}")
med_draft = statistics.median([BO.draft_cost(u["uid"]) for u in us_zh])
ck(f"пере-замер цены черновой роли ${med_draft:.5f} напечатан отдельным числом",
f"{med_draft:.5f}" in text, f"{med_draft:.5f}")
print("\n=== ПОПРАВКА НА МНОЖЕСТВЕННОСТЬ И РАЗБОР ПО СУДЬЯМ ===")
ps = []
for a, b in BO.CONTRASTS:
per = []
for u in us_zh:
pj = []
for judge in J.JUDGES:
bo = BO.margins_by_order(a, b, u["uid"], judge)
if len(bo) == 2:
pj += [statistics.mean(bo[0]), statistics.mean(bo[1])]
if len(pj) == 2 * len(J.JUDGES):
per.append(statistics.mean(pj))
ps.append(BO.sign_perm_p(per))
order_i = sorted(range(len(ps)), key=lambda i: ps[i])
holm, run = [0.0] * len(ps), 0.0
for rank, i in enumerate(order_i):
run = max(run, ps[i] * (len(ps) - rank))
holm[i] = min(1.0, run)
ck("отчёт объявляет, что НИ ОДИН контраст не проходит Holm 0.05",
all(h >= 0.05 for h in holm) and "Ни один контраст не проходит 0.05" in text,
f"минимальный Holm {min(holm):.4f}")
ck("отчёт печатает разбор ПО СУДЬЯМ (эффект одного судьи не есть свойство арма)",
"ПО СУДЬЯМ" in text or "по судьям" in text)
ck("отчёт печатает разложение ПО ОСЯМ",
"ВЕРНОСТЬ" in text and "разложение по осям" in text.lower())
print("\n=== ТРЕТИЙ КОНТУР ПЕРСИСТИРОВАН ===")
rd = RAW / "blind-keys" / "READINGS.json"
ck("ранги слепого чтения лежат на диске, ВНЕ каталога пакетов", rd.exists())
ck("карта раскладки вынесена из каталога пакетов (слепота держится расположением)",
not list((RAW / "blind").glob("*MAP*")) and bool(list((RAW / "blind-keys").glob("*MAP*"))))
if rd.exists():
d = json.loads(rd.read_text(encoding="utf-8"))
ck("персист несёт оговорку о том, что полные разборы утрачены",
"НЕ персистированы" in d.get("provenance", ""))
ck("рангов zh 16 и en 12", len(d["ranks_zh"]) == 16 and len(d["ranks_en"]) == 12)
print("\n=== БАТАРЕЯ ===")
import battery as B # noqa: PLC0415
ck("check_numbers симметричен: 两千三百 против «две тысячи триста» НЕ даёт дефекта",
not (B.check_numbers("他有两千三百块灵石。", "У него две тысячи триста камней.")["lost"]
or B.check_numbers("他有两千三百块灵石。", "У него две тысячи триста камней.")["invented"]))
ck("check_numbers ловит настоящую потерю: 三千五百 против «три тысячи»",
bool(B.check_numbers("三千五百人", "три тысячи человек")["lost"]))
cards = B.load_cards()
ck("карточки построены из подписанного сида", len(cards) == 51, str(len(cards)))
ck("check_gender отсекает косвенную форму имени",
B.check_gender("Воля Фан Юаня была тверда.", cards)["mismatched"] == 0)
ck("check_gender ловит настоящее рассогласование",
B.check_gender("Фан Юань была спокойна.", cards)["mismatched"] == 1)
print("\n=== ОТКАЗЫ ПРОВОДА ЗАПИСАНЫ, А НЕ ПОТЕРЯНЫ ===")
nomsg = list(RAW.glob("jv2-*.NOMSG.json")) + list(RAW.glob("jve-*.NOMSG.json"))
err = list(RAW.glob("jv2-*.ERROR.json")) + list(RAW.glob("jve-*.ERROR.json"))
ck(f"отказы фильтра ({len(nomsg)}) записаны и объявлены в отчёте",
bool(nomsg) and str(len(nomsg)) in text, str(len(nomsg)))
ck(f"отказы xAI ({len(err)}) записаны ОТДЕЛЬНЫМ тегом (кэш успеха не отравлен)",
all(not (RAW / f.name.replace(".ERROR.json", ".json")).exists()
or json.loads((RAW / f.name.replace(".ERROR.json", ".json")).read_text(
encoding="utf-8")).get("content") for f in err))
# ⚠ ПОТОЛКИ ПРОМТА, А НЕ ТОЛЬКО КАССЫ. Заведено адверсариальным ревью 07.08: гейт сверял
# отчёт с сырьём и возвращал 0, тогда как потолки ФАЗ промта пробиты, а кассы кода
# пере-объявляются каждой редакцией и в сумме выданы выше санкционированного. Проверка НЕ
# запрещает пробой — она требует, чтобы пробой был ОБЪЯВЛЕН в отчёте числом.
# ⚠ ПОКРЫТИЕ ТЕГОВ КАССАМИ. Ревью 07.08: $2.62646 куплено тегами, не входящими ни в один
# глоб ни одной `Ledger` — класс дефекта, который шапка `buy.py` объявляет починенным.
# Проверка не запрещает исторические траты, а требует, чтобы непокрытая сумма была НАЗВАНА.
print("\n=== ПОКРЫТИЕ ТРАТ КАССАМИ ===")
covered = ("bo2-", "bo4-", "jv2-", "bo3-", "jf-", "jr-", "en2-", "jve-", "jc-", "dr-")
unc = 0.0
for f in RAW.glob("*.json"):
if any(f.name.startswith(c) for c in covered):
continue
try:
d = json.loads(f.read_text(encoding="utf-8"))
except (ValueError, OSError):
continue
if isinstance(d, dict):
unc += d.get("cost_usd") or 0.0
# ⚠ ТОЧНАЯ ФОРМА `$X.XXXXX` без цифры следом — та же коллизия подстроки, что уже ловилась
# в потолках фаз: «2.62283» совпадало с «$2.622830» (ценой снятой первой редакции).
ck(f"траты ВНЕ касс ${unc:.5f} названы в отчёте",
re.search(rf"\${unc:.5f}(?!\d)", text) is not None, f"{unc:.5f}")
print("\n=== ПОТОЛКИ ФАЗ ПРОМТА (не касс кода) ===")
phases = {"Ф1 скрин": (["scr-*.json"], 1.00),
"Ф2а бейк-офф": (["bo-*.json", "bo2-*.json", "bo3-*.json", "bo4-*.json",
"jv-*.json", "jv2-*.json", "jf-*.json", "jr-*.json",
"jc-*.json", "en2-*.json", "jve-*.json"], 2.50),
"Ф2б ремонт/маршрут": (["rp-*.json"], 3.00)}
for name, (pats, ceil) in phases.items():
spent = sum(money(x) for x in pats)
over = spent > ceil
ck(f"{name}: ${spent:.5f} против потолка ${ceil:.2f}"
+ (" — ПРОБОЙ обязан быть объявлен числом в отчёте" if over else ""),
# ⚠ ТОЧНАЯ ФОРМА `$X.XXXXX`, а не подстрока: ревью исполнением показало три обхода —
# более длинное число с тем же префиксом, усечённая форма без доллара в постороннем
# контексте, и число рядом со словами «фаза уложилась».
(not over) or re.search(rf"\${spent:.5f}(?!\d)", text) is not None, f"{spent:.5f}")
total = money("*.json")
ck(f"полная сумма ${total:.5f} напечатана в отчёте", f"{total:.5f}" in text, f"{total:.5f}")
print(f"\n{'ВСЕ ЧИСЛА ОТЧЁТА СХОДЯТСЯ' if not FAILS else f'РАСХОЖДЕНИЙ: {FAILS}'}")
sys.exit(1 if FAILS else 0)
if __name__ == "__main__":
main()