460 lines
28 KiB
Python
460 lines
28 KiB
Python
#!/usr/bin/env python3
|
||
"""Ф2а — БЕЙК-ОФФ «переписывание как класс». Отвечает на главный вопрос пака.
|
||
|
||
Вопрос владельца, ради которого существует эксп: нужен ли второй проход вообще, и не выгоднее ли
|
||
переводить сразу сильной моделью. Экспы 18–20 подошли к нему сбоку (контракт редактора, цена
|
||
диффа, роль редактора), но самого бейк-оффа топологий не гоняли ни разу.
|
||
|
||
Армы. Черновики ФРИЗЯТСЯ и подаются идентичными всем редакторским армам — это парный дизайн, и он
|
||
даёт кратный выигрыш мощности бесплатно. Черновики берутся готовые из корпуса пакета-6 (их сделал
|
||
боевой `deepseek-v4-flash`), поэтому арм F и вход армов A/B стоят $0.
|
||
|
||
F do-nothing пол: черновик как есть. Все редакторские армы мерятся ПРОТИВ НЕГО, а не друг
|
||
против друга — иначе «B лучше A» ничего не говорит о том, нужен ли второй проход вообще.
|
||
A боевой бейзлайн: черновик → `deepseek-v4-pro`, полное переписывание.
|
||
B то же, но редактором `glm-5` (эксп-20 нашёл, что у него второй проход отрабатывает,
|
||
а у dspro вредит — здесь это проверяется на боевых единицах и парно).
|
||
D однопроходка: `deepseek-v4-pro` переводит ИСХОДНИК с нуля, черновика не видит.
|
||
D′ она же БЕЗ мандата перевёрстки — деконфаунд «модель против мандата».
|
||
|
||
Протокол сравнения задан замером Ф0.6, а не вкусом: судьи не выдумывают перевес на идентичных
|
||
текстах (0 из 18), но их АБСОЛЮТНЫЙ счёт ошибок плавает ⇒ сравниваем только ПАРНО, зеркальной
|
||
раскладкой, и вердикт засчитываем при перевесе в ≥2 повторах из 3 у обоих судей.
|
||
|
||
Контрасты выбраны так, чтобы каждый отвечал на отдельный вопрос, а не наращивал число сравнений:
|
||
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 = 2.50
|
||
N_UNITS = 16
|
||
# Один голос на КАЖДЫЙ порядок. Позиция гасится точно, а не «в среднем по повторам»: перевес
|
||
# единицы = среднее двух зеркальных голосов. Мощность добирается ЕДИНИЦАМИ (8→16), а не
|
||
# повторами — единицы дают и разброс, и обобщение, повторы только разброс.
|
||
REPS_PER_ORDER = 1
|
||
|
||
# ⚠ ПОТОЛКИ ВТОРОЙ РЕДАКЦИИ объявлены ДО первой покупки (санкция владельца 07.08 «деньги
|
||
# потратить разрешаю»). Первая редакция Ф2а стоила $1.3777 и признана негодной по раскладке.
|
||
CEIL_ARMS = 0.70
|
||
CEIL_JUDGE = 1.70
|
||
LED_ARMS = BUY.Ledger("армы Ф2а-2", CEIL_ARMS, ("bo2-*.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 шёл с мышлением: 1707–13728 токенов
|
||
# размышления и цена ×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)
|
||
keys = sorted(uniq, key=lambda k: len(uniq[k]["source"]))
|
||
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` тоже нельзя: на нём стоят экспы 18–20, их воспроизводимость
|
||
важнее удобства. Поэтому определение арма живёт целиком в одном видимом месте.
|
||
"""
|
||
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 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"})
|
||
return BUY.purchase(LED_ARMS, f"bo2-DRAFT-{u['uid']}", DRAFT_MODEL, client(DRAFT_MODEL), kw,
|
||
arm="DRAFT", uid=u["uid"])
|
||
|
||
|
||
def draft_cost(uid: str) -> float:
|
||
f = OUT / f"bo2-DRAFT-{uid}.json"
|
||
return json.loads(f.read_text(encoding="utf-8"))["cost_usd"] if f.exists() else 0.0
|
||
|
||
|
||
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:
|
||
"""Средняя разница «ошибки второго варианта − ошибки первого» по ВСЕМ голосам.
|
||
|
||
Это контроль исправности рига, а не результат: при честном зеркале и достаточном n фора
|
||
должна быть около нуля по построению (каждая пара судится в обоих порядках). Крупное
|
||
ненулевое значение означает, что раскладка сломана — ровно это и случилось с первой
|
||
редакцией (+4.65), где порядок 0 весил вдвое больше порядка 1.
|
||
"""
|
||
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 cmd_score() -> None:
|
||
"""Свод. $0: читает персистированные голоса и прогоняет батарею по выходам армов."""
|
||
us = units()
|
||
bias = position_bias()
|
||
print(f"ПОЗИЦИОННАЯ ФОРА СУДЬИ (контроль исправности раскладки): {bias:+.2f} ошибки в пользу "
|
||
f"первого варианта\n")
|
||
print("ПАРНЫЕ ВЕРДИКТЫ. Перевес единицы = среднее ДВУХ зеркальных голосов; вердикт требует,\n"
|
||
"чтобы знак сохранился в ОБОИХ порядках у ОБОИХ судей.\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ДЕТЕРМИНИРОВАННАЯ БАТАРЕЯ (первична — шума не имеет)\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__)
|