674 lines
43 KiB
Python
674 lines
43 KiB
Python
#!/usr/bin/env python3
|
||
"""Ф2а — БЕЙК-ОФФ «переписывание как класс». Отвечает на главный вопрос пака.
|
||
|
||
Вопрос владельца, ради которого существует эксп: нужен ли второй проход вообще, и не выгоднее ли
|
||
переводить сразу сильной моделью. Экспы 18–20 подошли к нему сбоку (контракт редактора, цена
|
||
диффа, роль редактора), но самого бейк-оффа топологий не гоняли ни разу.
|
||
|
||
Армы. Черновики ФРИЗЯТСЯ и подаются идентичными всем редакторским армам — это парный дизайн, и он
|
||
даёт кратный выигрыш мощности бесплатно. Черновики берутся готовые из корпуса пакета-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 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 # 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 шёл с мышлением: 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)
|
||
# Ключ (длина, 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` тоже нельзя: на нём стоят экспы 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 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, strict=True):
|
||
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__)
|