textmachine/eval/role_topology/bakeoff.py

460 lines
28 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 и вход армов 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 шёл с мышлением: 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)
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` тоже нельзя: на нём стоят экспы 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 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__)