285 lines
18 KiB
Python
285 lines
18 KiB
Python
#!/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:
|
||
return sum(json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0) or 0.0
|
||
for f in RAW.glob(pat))
|
||
|
||
|
||
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))
|
||
|
||
print(f"\n{'ВСЕ ЧИСЛА ОТЧЁТА СХОДЯТСЯ' if not FAILS else f'РАСХОЖДЕНИЙ: {FAILS}'}")
|
||
sys.exit(1 if FAILS else 0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|