textmachine/eval/role_topology/bakeoff.py

675 lines
43 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
"""Ф2а — БЕЙК-ОФФ «переписывание как класс». Отвечает на главный вопрос пака.
Вопрос владельца, ради которого существует эксп: нужен ли второй проход вообще, и не выгоднее ли
переводить сразу сильной моделью. Экспы 1820 подошли к нему сбоку (контракт редактора, цена
диффа, роль редактора), но самого бейк-оффа топологий не гоняли ни разу.
Армы. Черновики ФРИЗЯТСЯ и подаются идентичными всем редакторским армам — это парный дизайн, и он
даёт кратный выигрыш мощности бесплатно. Черновики берутся готовые из корпуса пакета-6 (их сделал
боевой `deepseek-v4-flash` — ⚠ поля модели в записи корпуса НЕТ, провенанс не проверяем),
поэтому арм F не имеет собственной цены: она утрачена вместе с породившим вызовом.
F do-nothing пол: черновик как есть. Все редакторские армы мерятся ПРОТИВ НЕГО, а не друг
против друга — иначе «B лучше A» ничего не говорит о том, нужен ли второй проход вообще.
A боевой бейзлайн: черновик → `deepseek-v4-pro`, полное переписывание.
B то же, но редактором `glm-5` (эксп-20 нашёл, что у него второй проход отрабатывает,
а у dspro вредит — здесь это проверяется на боевых единицах и парно).
D однопроходка: `deepseek-v4-pro` переводит ИСХОДНИК с нуля, черновика не видит.
D она же БЕЗ мандата перевёрстки — деконфаунд «модель против мандата».
Протокол сравнения задан замером Ф0.6, а не вкусом: судьи не выдумывают перевес на идентичных
текстах, но их АБСОЛЮТНЫЙ счёт ошибок плавает ⇒ сравниваем только ПАРНО, полным скрещиванием
порядков; вердикт требует совпадения знака в ОБОИХ порядках у обоих судей, а несущая статистика —
непрерывный перевес по единицам с bootstrap-ДИ и поправкой Holm по семейству контрастов.
Контрасты выбраны так, чтобы каждый отвечал на отдельный вопрос, а не наращивал число сравнений:
A против F — покупает ли боевой второй проход хоть что-то поверх черновика;
B против F — покупает ли его альтернативный жилец;
D против A — дешевле ли и лучше ли одним сильным проходом, чем связкой;
D против D — что из разницы даёт МАНДАТ, а что модель.
Запуск: eval/.venv/bin/python eval/role_topology/bakeoff.py --arms # породить выходы армов
eval/.venv/bin/python eval/role_topology/bakeoff.py --judge # судейство контрастов
eval/.venv/bin/python eval/role_topology/bakeoff.py --score # свод, $0
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import random
import statistics
import sys
import time
from collections import Counter, defaultdict
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "editor_contract"))
sys.path.insert(0, str(REPO / "eval" / "bank_arbitration"))
sys.path.insert(0, str(REPO / "eval" / "editor_harness"))
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
from dotenv import load_dotenv # noqa: E402
from openai import OpenAI # noqa: E402
import battery as B # noqa: E402
import buy as BUY # noqa: E402
import editor_wire_probe as P # noqa: E402
import inject_probe as IP # noqa: E402
import judges as J # noqa: E402
import probe as Q # noqa: E402
from prices import CANDIDATES, cost # noqa: E402
load_dotenv(REPO / "eval" / ".env")
OUT = Path.home() / "books" / "role-topology"
CEILING_USD_МЁРТВ = None # ⚠ объявлен и НИКЕМ не читается;
# оставлен видимым маркером: охраны здесь нет, покупки идут мимо кассы (ревью 07.08)
N_UNITS = 16
# Один голос на КАЖДЫЙ порядок. Позиция гасится точно, а не «в среднем по повторам»: перевес
# единицы = среднее двух зеркальных голосов. Мощность добирается ЕДИНИЦАМИ (8→16), а не
# повторами — единицы дают и разброс, и обобщение, повторы только разброс.
REPS_PER_ORDER = 1
# ⚠ ПОТОЛКИ ВТОРОЙ РЕДАКЦИИ объявлены ДО первой покупки (санкция владельца 07.08 «деньги
# потратить разрешаю»). Первая редакция Ф2а стоила $1.3777 и признана негодной по раскладке.
# ⚠ ПОТОЛКИ ТРЕТЬЕЙ РЕДАКЦИИ, объявлены в §9 отчёта ДО первой покупки. Кассы читают диск по
# префиксам `bo2-*`/`jv2-*`, то есть новые покупки ложатся в ТЕ ЖЕ счётчики; без пере-объявления
# первая крупная покупка ушла бы в `skipped` с пустым выходом, а свод молча выбросил бы единицу.
# Тихое усечение n — тот же класс, что снял первую редакцию, поэтому потолок поднимается ЗАРАНЕЕ
# и печатается, а не подгоняется в момент отказа.
CEIL_ARMS = 1.60
CEIL_JUDGE = 3.20
# ⚠ ГЛОБ ОБЯЗАН ПОКРЫВАТЬ ВСЕ ТЕГИ, КОТОРЫЕ ЭТА КАССА ОПЛАЧИВАЕТ. Поймано адверсариальным ревью
# 07.08 исполнением: касса глобила только `bo2-*`, а `run_arm3` покупает тегом `bo4-*`, поэтому
# $0.29309 армов ТРЕТЬЕЙ редакции прошли мимо потолка — `afford()` считал их несуществующими.
# Это ровно тот класс дефекта, который шапка `buy.py` объявляет починенным, и он вернулся через
# новый префикс. Правило: заводишь новый тег — добавляешь его в глоб кассы В ТОМ ЖЕ коммите.
LED_ARMS = BUY.Ledger("армы Ф2а-2", CEIL_ARMS, ("bo2-*.json", "bo4-*.json"), default_expect=0.03)
LED_JUDGE = BUY.Ledger("судейство Ф2а-2", CEIL_JUDGE, ("jv2-*.json",), default_expect=0.008)
# АРМЫ ВТОРОЙ РЕДАКЦИИ (07.08). Первая редакция отклонялась от промта в четырёх местах, и все
# четыре смещали сравнение в одну сторону — в пользу связки. Ни одно из отклонений не было
# объявлено, три нашло адверсариальное ревью, четвёртое (сила формулировки закона) — сверка
# промта с проводом при разборе находок ревью.
#
# `block`: None — банкноты нет; "law" — ПИНЕННЫЙ промтом закон-блок.
# Промт стр.29: банкнота на переводческом проходе ВСЕХ армов, КРОМЕ байт-боевого бейзлайна;
# текст закона — отправленная строка пробы 18 БЕЗ оговорки области (`HEADER_LAW_PLAIN`,
# байт-идентичен движковому `editor_header`). Первая редакция слала `HEADER_LAW_CLAUSE`,
# то есть закон С оговоркой, и слала его АРМУ A, который по промту идёт без банкноты вовсе.
# Армы D/D_ при этом получали не закон, а мягкий ГЛОССАРИЙ («используй последовательно»)
# вместо императива («ДОЛЖЕН быть передан именно указанной формой») — то есть ось ТЕРМИН
# в контрасте «D против A» мерила силу формулировки, а не топологию.
#
# `think`: "off" — гасить размышление (промт стр.62 задаёт арм B как flash→glm-5-OFF).
# Первая редакция ставила ручку только флешу, и B шёл с мышлением: 170713728 токенов
# размышления и цена ×5.1 против A.
ARMS = {
"A": dict(model="deepseek-v4-pro", contract="full", block=None, think=None),
"B": dict(model="glm-5", contract="full", block="law", think="off"),
"D": dict(model="deepseek-v4-pro", contract="direct-reflow", block="law", think=None),
"D_": dict(model="deepseek-v4-pro", contract="direct", block="law", think=None),
# Деконфаунд банкноты: тот же бейзлайн, но С законом. Отвечает на продуктовый вопрос
# «сколько покупает банк-как-закон», который первая редакция смешала с вопросом о топологии.
"A_law": dict(model="deepseek-v4-pro", contract="full", block="law", think=None),
}
CONTRASTS = [("A", "F"), ("B", "F"), ("D", "A"), ("D", "D_"), ("A_law", "A")]
# Топологии, потребляющие черновик. Их цена = черновик + собственный вызов; у однопроходок ноги
# черновика нет. ⚠ Первая редакция печатала «$/единицу» БЕЗ ноги черновика и объявила связку на
# 17% дешевле однопроходки; цена черновика при этом бралась из двух клеток скрина другой роли
# ($0.00195, разброс 3.4×), а не с этих единиц. Знак вывода зависел от того, какую из двух цифр
# подставить, — значит вывода не было вовсе. Теперь черновик покупается ЗДЕСЬ, на тех же
# единицах, кодом, который лежит в дереве.
CONSUMES_DRAFT = {"F", "A", "A_law", "B"}
DRAFT_MODEL = "deepseek-v4-flash"
OPENAI_FAMILY = {"gpt-5.6-luna"}
def units(n: int = N_UNITS) -> list[dict]:
"""N боевых единиц из корпуса пакета-6: источник + ЗАМОРОЖЕННЫЙ черновик.
Отбор по длине источника, детерминированный: берётся середина распределения, чтобы единицы
были боевого размера и при этом не выбросами. Черновик один и тот же для всех армов — в этом
весь смысл парного дизайна.
⚠ ДВЕ ПОЧИНКИ 07.08 по адверсариальному ревью.
(а) ДЕДУП. В корпусе 91 запись, но лишь 63 уникальных источника: пакет-6 гонял один текст
несколькими прогонами. Прошлый отбор взял пару дублей соседними единицами, и «8 единиц»
были эффективно семью — при этом дубль РАСЩЕПИЛСЯ (арм проигрывал на одной копии и
выигрывал на другой), то есть исход решал черновик, а не арм.
(б) УСТОЙЧИВЫЙ КЛЮЧ. Тег артефакта раньше нёс позиционный индекс `u{i}`, а индекс зависит от
N_UNITS: смена числа единиц молча пере-назначила бы уже купленные выходы другим текстам.
Ключ теперь — хеш источника, и он не зависит ни от N, ни от порядка чтения.
"""
corp = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl"
us = [json.loads(l) for l in corp.read_text(encoding="utf-8").splitlines() if l.strip()]
us = [u for u in us if all((u.get(k) or "").strip() for k in ("source", "draft"))]
uniq: dict[str, dict] = {}
by_sig: dict[str, str] = {}
for u in us:
sig = _dedup_sig(u["source"])
if sig in by_sig:
continue # тот же текст под другим заголовком главы
by_sig[sig] = uid_of(u["source"])
uniq.setdefault(uid_of(u["source"]), u)
# Ключ (длина, uid), а не одна длина: одинаковая длина иначе разрешалась бы порядком обхода
# словаря, то есть неявной стабильностью. Здесь она сейчас есть, а в паре en её не было —
# там тот же приём на `set()` дал РАЗНЫЕ наборы единиц в двух вызовах подряд (поймано
# исполнением 07.08). Сверено: явный ключ даёт тот же набор, что и прежний, побайтно.
keys = sorted(uniq, key=lambda k: (len(uniq[k]["source"]), k))
lo = (len(keys) - n) // 2
out = []
for k in keys[lo:lo + n]:
u = dict(uniq[k])
u["uid"] = k
out.append(u)
return out
def uid_of(source: str) -> str:
return hashlib.sha1(source.strip().encode("utf-8")).hexdigest()[:10] # noqa: S324
_RE_CHAPTER = re.compile(r"^\s*第[零一二两三四五六七八九十百千\d]+[节章回]\s*[:]?\s*")
def _dedup_sig(source: str) -> str:
"""Подпись для БЛИЗКИХ дублей. Точного хеша мало: u3 и u4 прошлого прогона были одним
текстом, у которого одна копия несла заголовок `第八节:`, а другая нет — близость 0.999,
хеши разные. Подпись снимает пробелы и ведущий заголовок главы."""
s = _RE_CHAPTER.sub("", source.strip())
return hashlib.sha1("".join(s.split()).encode("utf-8")).hexdigest() # noqa: S324
def spent(prefix: str) -> float:
tot = 0.0
for f in OUT.glob(f"{prefix}*.json"):
try:
tot += json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0)
except Exception: # noqa: BLE001, S112
continue
return tot
def client(model: str) -> OpenAI:
base, env, *_ = CANDIDATES[model]
return OpenAI(api_key=os.environ[env], base_url=base, timeout=900)
def build_msgs(arm: str, u: dict) -> list[dict]:
"""Сообщения арма собираются ЗДЕСЬ, а не в `probe.messages`.
Причина: `probe.messages` для `direct`/`direct-reflow` зашивает мягкий ГЛОССАРИЙ-блок внутри
себя, и подменить его законом снаружи было нельзя — из-за чего армы получали банкноты разной
силы. Править `probe.messages` тоже нельзя: на нём стоят экспы 1820, их воспроизводимость
важнее удобства. Поэтому определение арма живёт целиком в одном видимом месте.
"""
spec, src, draft = ARMS[arm], u["source"], u["draft"]
terms = [t for t in IP.TERMS if t in src]
blk = P.editor_block(terms, P.HEADER_LAW_PLAIN, None) if (spec["block"] and terms) else None
if spec["contract"] in ("direct", "direct-reflow"):
if spec["contract"] == "direct-reflow":
sys_msg, user = Q.render(Q.HERE / "prompts" / "translator-reflow.md", src, "")
user = user.strip()
else:
sys_msg, user = Q.render_translator(src)
m = [{"role": "system", "content": sys_msg}]
if blk:
m.append({"role": "system", "content": blk})
m.append({"role": "user", "content": user})
return m
return Q.messages(spec["contract"], src, draft, blk)
def own_draft(uid: str) -> str:
"""Черновик, КУПЛЕННЫЙ этим паком и прошедший гейт годности. Пусто — годного нет.
⚠ Заведено 07.08 по адверсариальному ревью. До этого арм F брал ТЕКСТ из корпуса пакета-6,
а ЦЕНУ — из докупленных черновиков; тексты не совпадали ни на одной единице (близость ≤0.195),
а поля модели в записи корпуса нет, то есть провенанс был непроверяем. Здесь текст и цена
происходят из ОДНОГО вызова, и он воспроизводим кодом в дереве.
"""
best = ""
for f in sorted(OUT.glob(f"bo2-DRAFT-{uid}*.json")):
c = json.loads(f.read_text(encoding="utf-8")).get("content", "")
if c and not draft_reject_reason(c):
best = c
return best
def build_msgs3(arm: str, u: dict) -> list[dict]:
"""Сообщения арма ТРЕТЬЕЙ редакции: тот же сбор, но черновик — собственный."""
spec = ARMS[arm]
if spec["contract"] in ("direct", "direct-reflow"):
return build_msgs(arm, u) # однопроходки черновика не видят
src = u["source"]
terms = [t for t in IP.TERMS if t in src]
blk = P.editor_block(terms, P.HEADER_LAW_PLAIN, None) if (spec["block"] and terms) else None
return Q.messages(spec["contract"], src, own_draft(u["uid"]), blk)
def run_arm3(arm: str, u: dict) -> dict:
spec = ARMS[arm]
model = spec["model"]
kw: dict = dict(model=model, messages=build_msgs3(arm, u), max_tokens=16000, temperature=0.4)
if spec["think"] == "off":
if not model.startswith("glm"):
raise SystemExit(f"гашение размышления у {model} не описано в quirks — не гадаю")
kw["extra_body"] = {"thinking": {"type": "disabled"}}
return BUY.purchase(LED_ARMS, f"bo4-{arm}-{u['uid']}", model, client(model), kw,
arm=arm, uid=u["uid"], edition=3)
def text3_of(arm: str, u: dict) -> str:
"""Выход арма третьей редакции. F3 — сам купленный черновик: текст и цена из одного вызова."""
if arm in ("F", "F3"):
return own_draft(u["uid"])
f = OUT / f"bo4-{arm}-{u['uid']}.json"
if f.exists():
return json.loads(f.read_text(encoding="utf-8"))["content"]
return text_of(arm, u) # однопроходки не пере-гоняются
def run_draft(u: dict) -> dict:
"""Черновик боевой конфигурации НА ЭТИХ ЖЕ единицах — нога цены всех связок.
⚠ Мышление НЕ гасится: гардрейл CLAUDE.md («DeepSeek — НИКОГДА не отключать thinking»,
эхо-мина на плотном CJK). Первая редакция мерила черновик скриптом вне дерева, и все восемь
его записей несли `reasoning_tokens=0` — то есть либо провайдер не отчитался, либо мышление
было выключено вопреки гардрейлу. Разрешить это чтением было нельзя: породивший код исчез.
"""
sys_msg, user = Q.render_translator(u["source"])
terms = [t for t in IP.TERMS if t in u["source"]]
msgs = [{"role": "system", "content": sys_msg}]
if terms:
msgs.append({"role": "system", "content": IP.block_for(terms, None, False)})
msgs.append({"role": "user", "content": user})
kw = dict(model=DRAFT_MODEL, messages=msgs, max_tokens=16000,
extra_body={"reasoning_effort": "low"})
# РЕ-ГЕН ПРИ ЭХЕ — боевое лечение по quirks 00 строка 86: на весах 0731 эхо СТОХАСТИЧНО
# ПО ВЫЗОВУ (два побайтно одинаковых запроса дали долю ханьцзы 0.000 и 0.831), частота при
# `effort:low` — 3 из 20. Поймано исполнением здесь же: первый купленный черновик вернул
# исходник целиком. Ре-ген на flash в 7.6 раза дешевле эскалации на pro, поэтому боевая
# схема гонит N=1 ре-ген до эскалации — и цена ре-гена ЛОЖИТСЯ В ЭКОНОМИКУ, а не прячется.
for attempt in (1, 2, 3):
tag = f"bo2-DRAFT-{u['uid']}" + ("" if attempt == 1 else f"-r{attempt}")
rec = BUY.purchase(LED_ARMS, tag, DRAFT_MODEL, client(DRAFT_MODEL), kw,
arm="DRAFT", uid=u["uid"], attempt=attempt)
if rec.get("skipped"):
return rec
bad = draft_reject_reason(rec["content"])
if not bad:
return rec
print(f"{bad} в черновике {u['uid'][:6]} (попытка {attempt}) — ре-ген")
return rec
def draft_reject_reason(text: str) -> str:
"""Годен ли черновик. Пусто = годен.
⚠ ПОЧИНКА 07.08 по адверсариальному ревью. Прежняя версия проверяла ТОЛЬКО письменность
ИСХОДНИКА и потому ловила половину класса: артефакт `bo2-DRAFT-34749d6ccc-r2` прошёл гейт и
был оплачен, имея долю латиницы 0.79 и кириллицы 0.00 — то есть модель вернула АНГЛИЙСКИЙ
перевод вместо русского. Гейт, предлагаемый как боевое лечение эхо-мины, обязан проверять и
цель: пустой выход · эхо исходной письменности · выход не на целевом языке.
"""
t = (text or "").strip()
if not t:
return "пустой выход"
if B.check_cjk_leak(t)["over_prod_threshold"]:
return "эхо исходника"
letters = [c for c in t if c.isalpha()]
if letters and sum(1 for c in letters if "а" <= c.lower() <= "я" or c.lower() == "ё") / len(letters) < 0.5:
return "выход не на целевом языке"
return ""
def draft_cost(uid: str) -> float:
"""Цена черновика = ВСЕ его попытки. Ре-ген оплачен, значит он в цене топологии."""
tot = 0.0
for f in OUT.glob(f"bo2-DRAFT-{uid}*.json"):
tot += json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0)
return tot
def run_arm(arm: str, u: dict) -> dict:
spec = ARMS[arm]
model = spec["model"]
kw: dict = dict(model=model, messages=build_msgs(arm, u), max_tokens=16000, temperature=0.4)
if model == "deepseek-v4-flash":
kw["extra_body"] = {"reasoning_effort": "low"}
if spec["think"] == "off":
# quirks 00, строка 30 (семейство GLM): гашение — `thinking: {"type": "disabled"}`.
# ⚠ DeepSeek этой веткой не гасится НИКОГДА — эхо-мина, гардрейл CLAUDE.md.
if model.startswith("glm"):
kw["extra_body"] = {"thinking": {"type": "disabled"}}
else:
raise SystemExit(f"гашение размышления у {model} не описано в quirks — не гадаю")
return BUY.purchase(LED_ARMS, f"bo2-{arm}-{u['uid']}", model, client(model), kw,
arm=arm, uid=u["uid"], contract=spec["contract"],
block=bool(spec["block"]), think=spec["think"])
def text_of(arm: str, u: dict) -> str:
if arm == "F":
return u["draft"]
f = OUT / f"bo2-{arm}-{u['uid']}.json"
return json.loads(f.read_text(encoding="utf-8"))["content"] if f.exists() else ""
def cmd_arms() -> None:
us = units()
print(f"единиц {len(us)} · армов {len(ARMS)} + черновик · потолок армов ${LED_ARMS.ceiling}")
for u in us:
rec = run_draft(u)
if rec.get("skipped"):
print("⛔ потолок — стоп")
return
print(f" DRAFT {u['uid'][:6]} finish={rec.get('finish','?'):8s} "
f"выход {len(rec['content']):5d} зн размышл {rec.get('reasoning_tokens',0):6d} "
f"${rec['cost_usd']:.5f}")
time.sleep(0.2)
for arm in ARMS:
for u in us:
rec = run_arm(arm, u)
if rec.get("skipped"):
print("⛔ потолок — стоп")
return
print(f" {arm:6s} {u['uid'][:6]} finish={rec.get('finish','?'):8s} "
f"выход {len(rec['content']):5d} зн размышл {rec.get('reasoning_tokens',0):6d} "
f"${rec['cost_usd']:.5f}")
time.sleep(0.2)
print(f"\nпотрачено армами ${LED_ARMS.spent():.6f}")
def cmd_judge() -> None:
"""ПОЛНОЕ СКРЕЩИВАНИЕ ПОРЯДКОВ — закон промта стр.32, нарушенный первой редакцией.
Первая редакция чередовала порядок по повторам (`(rep-1) % 2` при трёх повторах = 0,1,0),
и порядок 0 весил два голоса из трёх, а правило вердикта «перевес в ≥2 из 3» удовлетворялось
голосами ОДНОГО порядка. Позиционная фора судьи оказалась +4.65 ошибки в пользу первого
варианта — больше измеряемых эффектов; на почти идентичной паре D/D_ она составила +3.98,
то есть это свойство судьи, а не армов.
Здесь порядок — внешний цикл, и на каждую клетку (контраст × единица × судья) приходится
РОВНО по одному голосу каждого порядка. Перевес единицы = среднее двух зеркальных голосов,
позиция вычитается тождественно.
"""
us = units()
print(f"единиц {len(us)} · контрастов {len(CONTRASTS)} · судей {len(J.JUDGES)} · "
f"порядков 2 × повторов {REPS_PER_ORDER}")
print(f"голосов к покупке: {len(us) * len(CONTRASTS) * len(J.JUDGES) * 2 * REPS_PER_ORDER} · "
f"потолок ${CEIL_JUDGE}")
n = 0
for a, b in CONTRASTS:
for u in us:
ta, tb = text_of(a, u), text_of(b, u)
if not ta.strip() or not tb.strip():
print(f"{a}/{b} {u['uid'][:6]}: пустой выход арма — клетка пропущена")
continue
for judge in J.JUDGES:
for order in (0, 1):
for rep in range(1, REPS_PER_ORDER + 1):
v1, v2 = (ta, tb) if order == 0 else (tb, ta)
rec = BUY.purchase(
LED_JUDGE, f"jv2-{a}v{b}-{u['uid']}-o{order}-{judge}-r{rep}",
judge, J.client(judge), J.vote_kw(judge, J.packet(u["source"], v1, v2)),
contrast=f"{a}v{b}", uid=u["uid"], order=order, judge=judge, rep=rep)
if rec.get("skipped"):
print("⛔ потолок судейства — стоп")
return
n += 1
time.sleep(0.15)
print(f" контраст {a} против {b}: голосов накоплено {n} · ${LED_JUDGE.spent():.4f}")
print(f"\nголосов {n} · потрачено судейством ${LED_JUDGE.spent():.6f}")
def vote_errors(f: Path) -> tuple[int, int] | None:
"""Счёт ошибок двух вариантов из персистированного голоса. None — голос не разобран."""
if not f.exists():
return None
p = J.parse(json.loads(f.read_text(encoding="utf-8"))["content"])
if p["В1-ВЕРНОСТЬ"] is None:
return None
return (sum(p[f"В1-{ax}"] or 0 for ax in J.AXES), sum(p[f"В2-{ax}"] or 0 for ax in J.AXES))
def margins_by_order(a: str, b: str, uid: str, judge: str) -> dict[int, list[float]]:
"""Перевесы «за арм a», разложенные ПО ПОРЯДКУ. Без этой раскладки позицию не вычесть."""
out: dict[int, list[float]] = {}
for order in (0, 1):
for rep in range(1, REPS_PER_ORDER + 1):
e = vote_errors(OUT / f"jv2-{a}v{b}-{uid}-o{order}-{judge}-r{rep}.json")
if e is None:
continue
e1, e2 = e
out.setdefault(order, []).append((e2 - e1) if order == 0 else (e1 - e2))
return out
def position_bias() -> float:
"""Средняя разница «ошибки второго варианта ошибки первого» по ВСЕМ голосам.
⚠ ЧИТАЕТСЯ НЕ ТАК, как я написал в первой редакции этой функции. Зеркало НЕ гонит эту
величину к нулю: если судья штрафует вторую позицию, то `e2 e1 > 0` при любом порядке, и
среднее остаётся большим. Зеркало убирает позицию из ПЕРЕВЕСА АРМА (перевес усредняется по
двум порядкам), а не из этой статистики. Поэтому число здесь — ИЗМЕРЕННАЯ ВЕЛИЧИНА ФОРЫ,
и она полезна как масштаб: с чем сравнивать перевес арма. Исправность раскладки проверяется
другим — равенством числа голосов по порядкам (см. `verify_report.check_second_edition`).
"""
d = []
for f in OUT.glob("jv2-*.json"):
e = vote_errors(f)
if e:
d.append(e[1] - e[0])
return statistics.mean(d) if d else 0.0
def boot_ci(xs: list[float], reps: int = 5000, alpha: float = 0.05) -> tuple[float, float]:
"""Bootstrap-ДИ среднего по ЕДИНИЦАМ (промт стр.32 требует ДИ, первая редакция его не давала).
Ресемплинг детерминированный: сид фиксирован, иначе отчёт не воспроизводится дословно.
"""
if len(xs) < 2:
return (float("nan"), float("nan"))
rnd = random.Random(20260807)
means = []
for _ in range(reps):
means.append(statistics.mean([xs[rnd.randrange(len(xs))] for _ in range(len(xs))]))
means.sort()
return (means[int(alpha / 2 * reps)], means[int((1 - alpha / 2) * reps) - 1])
def sign_perm_p(xs: list[float]) -> float:
"""Точный двусторонний знаковый перестановочный тест: перебираются ВСЕ 2^n расстановок знаков.
Заведён 07.08 по адверсариальному ревью. Bootstrap-ДИ отвечает на вопрос «где лежит среднее»,
а не «отличимо ли оно от нуля при этом n», и на 15 единицах даёт заметно более узкий интервал,
чем точный тест. Здесь считается p, к которому применима поправка Holm по семейству контрастов.
"""
n = len(xs)
if n == 0 or n > 20:
return float("nan")
obs = abs(sum(xs))
hits = sum(1 for mask in range(1 << n)
if abs(sum(x if mask >> i & 1 else -x for i, x in enumerate(xs))) >= obs - 1e-12)
return hits / (1 << n)
def cmd_score() -> None:
"""Свод. $0: читает персистированные голоса и прогоняет батарею по выходам армов."""
us = units()
bias = position_bias()
print(f"ПОЗИЦИОННАЯ ФОРА СУДЬИ (контроль исправности раскладки): {bias:+.2f} ошибки в пользу "
f"первого варианта\n")
print("⚠ КОЛОНКИ ВЕРДИКТОВ СПРАВОЧНЫЕ, решения на них не строятся. Правило «знак совпал в обоих\n"
"порядках» отказывает при позиционной форе больше эффекта: перевес в порядке 0 есть\n"
"истина+P, в порядке 1 — истинаP, и при |P|>|истина| знаки расходятся почти всегда\n"
"(отсюда 14 ничьих из 15 на D/D_). Несущая статистика — НЕПРЕРЫВНЫЙ перевес: полусумма\n"
"двух порядков, в которой P сокращается тождественно (проверено полом рига: +0.05, p=0.96).\n")
print(f"{'контраст':16s}{'единиц':>8s}{'за первый':>11s}{'за второй':>11s}{'ничья':>8s}"
f"{'перевес':>10s}{'95% ДИ':>18s}")
print("-" * 82)
for a, b in CONTRASTS:
wins_a = wins_b = tie = 0
per_unit: list[float] = []
for u in us:
per_judge, unit_margins = {}, []
for judge in J.JUDGES:
by_order = margins_by_order(a, b, u["uid"], judge)
if len(by_order) < 2:
continue
m0, m1 = statistics.mean(by_order[0]), statistics.mean(by_order[1])
unit_margins += [m0, m1]
# Знак ОБЯЗАН совпасть в обоих порядках: это и есть развязка позиции.
per_judge[judge] = 1 if (m0 > 0 and m1 > 0) else (-1 if (m0 < 0 and m1 < 0) else 0)
if len(per_judge) < len(J.JUDGES):
continue
per_unit.append(statistics.mean(unit_margins))
vals = set(per_judge.values())
wins_a += vals == {1}
wins_b += vals == {-1}
tie += vals not in ({1}, {-1})
n = wins_a + wins_b + tie
if not per_unit:
print(f"{a + ' против ' + b:16s}{0:>8}{'':>11}{'':>11}{'':>8}{'нет голосов':>28s}")
continue
eff = statistics.mean(per_unit)
lo, hi = boot_ci(per_unit)
star = "" if lo <= 0 <= hi else " ←ДИ не накрывает ноль"
print(f"{a + ' против ' + b:16s}{n:8d}{wins_a:11d}{wins_b:11d}{tie:8d}{eff:+10.2f}"
f" [{lo:+.2f}, {hi:+.2f}]{star}")
print("\nГЕЙТ ДО СУДЬИ (детерминированно, $0). Механический брак ловится грепом, и судья не\n"
"обязан его замечать — на 126 голосах он трижды выставил ВСЕ НУЛИ тексту с сырой\n"
"разметкой или латиницей. Такие клетки помечаются здесь, а не покупаются у судьи.")
import re as _re
flags = {}
for arm in ("F", *ARMS):
for u in us:
t_ = text_of(arm, u)
d = []
if _re.search(r"^\s{0,3}#{1,6}\s|\*\*", t_, _re.M):
d.append("markdown")
if _re.findall(r"[\u4e00-\u9fff]", t_):
d.append("ханьцзы")
if _re.findall(r"\b[A-Za-z]{4,}\b", t_):
d.append("латиница")
if _re.match(r"^\s*(?:вот\s+)?перевод[^\n:]{0,80}:", t_, _re.I):
d.append("преамбула")
if d:
flags.setdefault(arm, []).append((u["uid"][:6], "+".join(d)))
for arm in ("F", *ARMS):
v = flags.get(arm, [])
print(f" {arm:6s} клеток с механическим браком: {len(v):2d} {v[:4]}")
print("\nПО СУДЬЯМ ОТДЕЛЬНО. ⚠ Эффект, который несёт ОДИН судья, — свойство судьи, а не армов.")
print(f"{'контраст':16s}" + "".join(f"{j.split('-')[0]:>26s}" for j in J.JUDGES))
for a, b in CONTRASTS:
cells = []
for judge in J.JUDGES:
per = [statistics.mean([statistics.mean(bo[0]), statistics.mean(bo[1])])
for u in us
if len(bo := margins_by_order(a, b, u["uid"], judge)) == 2]
if not per:
cells.append(f"{'':>26s}")
continue
lo, hi = boot_ci(per)
mark = " " if lo <= 0 <= hi else "*"
cells.append(f"{statistics.mean(per):+8.2f} [{lo:+.2f},{hi:+.2f}]{mark:>2s}")
print(f"{a + ' против ' + b:16s}" + "".join(cells))
print(" * = ДИ судьи не накрывает ноль. Вывод держится только там, где звёздочки у ОБОИХ.")
print("\nПОПРАВКА НА МНОЖЕСТВЕННОСТЬ. В таблице пять контрастов; ДИ выше НЕ скорректированы.")
print("Точный знаковый перестановочный тест по единицам + Holm по семейству:")
ps = []
for a, b in CONTRASTS:
per = []
for u in us:
pj = []
for judge in J.JUDGES:
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((f"{a} против {b}", sign_perm_p(per), len(per)))
order = sorted(range(len(ps)), key=lambda i: ps[i][1])
holm, running = [0.0] * len(ps), 0.0
for rank, i in enumerate(order):
running = max(running, ps[i][1] * (len(ps) - rank))
holm[i] = min(1.0, running)
for (name, p, n), h in zip(ps, holm):
print(f" {name:16s} n={n:2d} p={p:.4f} Holm={h:.4f} "
f"{'проходит 0.05' if h < 0.05 else 'НЕ проходит 0.05'}")
print("\nРАЗЛОЖЕНИЕ ПО ОСЯМ. Сумма осей гасит разнонаправленные эффекты — печатается покомпонентно.")
print(f"{'контраст':16s}" + "".join(f"{ax:>22s}" for ax in J.AXES))
for a, b in CONTRASTS:
cells = []
for ax in J.AXES:
per = []
for u in us:
ms = []
for judge in J.JUDGES:
for o in (0, 1):
f = OUT / f"jv2-{a}v{b}-{u['uid']}-o{o}-{judge}-r1.json"
if not f.exists():
continue
p = J.parse(json.loads(f.read_text(encoding="utf-8"))["content"])
if p[f"В1-{ax}"] is None:
continue
e1, e2 = p[f"В1-{ax}"], p[f"В2-{ax}"]
ms.append((e2 - e1) if o == 0 else (e1 - e2))
if len(ms) == 2 * len(J.JUDGES):
per.append(statistics.mean(ms))
if not per:
cells.append(f"{'':>22s}")
continue
lo, hi = boot_ci(per)
cells.append(f"{statistics.mean(per):+6.2f} [{lo:+.2f},{hi:+.2f}]"
+ ("*" if not lo <= 0 <= hi else " "))
print(f"{a + ' против ' + b:16s}" + "".join(cells))
print("\nДЕТЕРМИНИРОВАННАЯ БАТАРЕЯ (первична — шума не имеет)\n")
print(f"{'арм':5s}{'ед.':>5s}{'типогр.':>9s}{'ханьцзы':>9s}{'потеря вел.':>13s}"
f"{'ты/вы микс':>12s}{'род свер.':>11s}{'род ошиб.':>11s}{'$/единицу':>11s}")
print("-" * 86)
cards = B.load_cards()
for arm in ("F", *ARMS):
rows, costs = [], []
for u in us:
t = text_of(arm, u)
if not t.strip():
continue
rows.append(B.run_unit(B.Unit(source=u["source"], draft=u["draft"], final=t,
cards=cards)))
c = draft_cost(u["uid"]) if arm in CONSUMES_DRAFT else 0.0
if arm != "F":
f = OUT / f"bo2-{arm}-{u['uid']}.json"
if f.exists():
c += json.loads(f.read_text(encoding="utf-8"))["cost_usd"]
costs.append(c)
if not rows:
continue
print(f"{arm:5s}{len(rows):5d}"
f"{sum(r['typography']['violations'] for r in rows):9d}"
f"{sum(r['cjk_leak']['han_chars'] for r in rows):9d}"
f"{sum(r['numbers']['lost_n'] for r in rows):13d}"
f"{sum(r['ty_vy']['mixed_in_unit'] for r in rows):12d}"
f"{sum(r['gender']['checked'] for r in rows):11d}"
f"{sum(r['gender']['mismatched'] for r in rows):11d}"
f"{(statistics.median(costs) if costs else 0):11.5f}")
if __name__ == "__main__":
if "--arms" in sys.argv:
cmd_arms()
elif "--judge" in sys.argv:
cmd_judge()
elif "--score" in sys.argv:
cmd_score()
else:
print(__doc__)