textmachine/eval/role_topology/judges.py

269 lines
18 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
"""Ф0.6 — ПРЕДЗАМЕР СУДЕЙ. Сначала мерим ИНСТРУМЕНТ, потом им мерим.
Зачем это первая платная работа фазы 2, а не последняя. Эксп-20 §5.4 намерил, что судья-LLM
согласен САМ С СОБОЙ на побайтно одном входе в 56% клеток (33% на несущих осях) и разниц меньше
~2:1 не разрешает. Значит любой вердикт бейк-оффа, прочитанный без знания этого разброса, — это
пересказ шума. Здесь разброс меряется ДО Ф2 и превращается в ПОРОГ РАЗЛИЧИМОСТИ, который потом
применяется к армам механически.
Три вещи, и они разные:
РАЗБРОС СУДЬИ — один и тот же пакет, N независимых голосов. Верхняя граница разрешающей
способности: разница между армами меньше собственного разброса судьи сигналом быть не может.
ДЕКОЙ (D39.46б) — пакет, где одна сторона ЗАВЕДОМО испорчена посаженными дефектами пробы 18.
Судья, который его не ловит, не отличает годное от негодного вообще, и его голоса по армам
читать нельзя. Без декоя «армы неразличимы» и «судья слеп» выглядят одинаково.
ШУМОВОЙ ПОЛ ПАЙПЛАЙНА — один и тот же арм прогнан ДВАЖДЫ на побайтно одном входе. Меряет,
сколько разницы порождает сама стохастичность модели, а не топология.
Выбор судей — не вкусовой. Промт: «судья не судит армы своего семейства-жильца». Армы Ф2 населяют
DeepSeek (`deepseek-v4-pro`) и Z.AI (`glm-5`), поэтому судьи берутся из ДРУГИХ семейств:
`gpt-5.6-luna` (OpenAI) и `gemini-3.1-flash-lite` (Google) — см. JUDGES ниже:
`grok-4.3` выпал на исчерпании кредитов xAI и заменён через положительный контроль.
Дисциплина протокола (промт, §ЗАКОН):
· КАЖДЫЙ голос персистится отдельным файлом — ревью-шапка эксп-20 прямо запретила повторять
решающий замер без персиста («невоспроизводим — не повторять»);
· зеркальная раскладка: каждая пара судится в ОБОИХ порядках, и позиция расцеплена с армом;
· слепые метки: судья видит «Вариант 1/2», а не имена моделей;
· первичный протокол — СЧЁТ ТИПИЗИРОВАННЫХ ОШИБОК (MQM-lite), а не «что лучше»; преференс
спрашивается только по стилевой оси, где счёт ошибок не работает.
Запуск: eval/.venv/bin/python eval/role_topology/judges.py --variance # разброс + декой
eval/.venv/bin/python eval/role_topology/judges.py --floor # шумовой пол пайплайна
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
from collections import 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 editor_wire_probe as P # noqa: E402
from prices import CANDIDATES, cost # noqa: E402
load_dotenv(REPO / "eval" / ".env")
OUT = Path.home() / "books" / "role-topology"
OUT.mkdir(parents=True, exist_ok=True)
# ⚠ ПОТОЛОК И СЧЁТЧИК РАЗВЕДЕНЫ ПО ФАЗАМ. Судейство бейк-оффа зовёт тот же `vote`, но относится
# к бюджету Ф2а, а не Ф0.6. Первая редакция сторожила общий потолок $0.60 по ВСЕМ файлам `jv-*`,
# и прогон Ф2а молча пропустил бы почти все голоса, оставив пустую таблицу вердиктов. Поймано
# чтением кода до запуска. Голоса Ф2а помечаются префиксом `jv-bo-` и считаются отдельно.
CEILING_USD = 0.60
PHASE_PREFIX = "jv-" # что считать «своими» тратами; bakeoff подменяет на "jv-bo-"
# ⚠ ЗАМЕНА ВТОРОГО СУДЬИ 07.08 — ВНЕШНИЙ БЛОКЕР, объявляю.
# `grok-4.3` выпал не по замыслу: ключ xAI вернул HTTP 403 «Your team has either used all available
# credits or reached its monthly spending limit» на 158 голосах zh и 92 en. Отказы записаны
# отдельными `*.ERROR.json`, купленные голоса не отравлены.
# Замена выбрана по трём условиям промта (стр.32): судья не из семейства, населяющего армы
# (deepseek, glm), и не из семейства первого судьи (OpenAI) ⇒ остаются Google, Mistral, Moonshot.
# Mistral ($0.0128/голос) и kimi ($0.024) не влезали в ОБЪЯВЛЕННЫЙ остаток потолка, а поднимать
# потолок задним числом — ровно то, за что снята первая редакция. Взят `gemini-3.1-flash-lite`
# ($0.0022/голос).
# ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ ДО ТРАТ (D39.46б): на контрольном декое пака замена поймала посаженную
# деградацию 6/6 в ОБОИХ порядках, со зеркальными счетами (В1=0/В2=3 и В1=3/В2=0) — то есть на
# этой клетке её позиционная фора близка к нулю. Стоимость проверки $0.00363.
# ⚠ Оговорка, которую отчёт обязан нести: это ЛЁГКАЯ модель, и её согласие с luna не эквивалентно
# прежней паре luna+grok. Голоса grok, купленные первой редакцией, к этим армам не относятся.
JUDGES = ("gpt-5.6-luna", "gemini-3.1-flash-lite")
REPS = 3
OPENAI_FAMILY = {"gpt-5.6-luna"}
RUBRIC = """Ты — литературный редактор-эксперт, сверяющий два русских перевода одного китайского
фрагмента с оригиналом. Тебе даны ИСХОДНИК и два варианта перевода под слепыми метками.
Посчитай ОШИБКИ в каждом варианте по типам:
ВЕРНОСТЬ — сказано не то, что в исходнике: искажение, инверсия, выдумка, пропуск факта.
ТЕРМИН — имя или термин передан не так, как в других местах того же текста.
ЯЗЫК — то, чего носитель не напишет: калька, канцелярит, несуществующее слово, кривой
порядок слов, рассогласование.
ФОРМА — вёрстка и пунктуация: оформление реплик, кавычки, тире, абзацы.
Отвечай РОВНО в этом формате и ничем больше:
В1-ВЕРНОСТЬ: <число>
В1-ТЕРМИН: <число>
В1-ЯЗЫК: <число>
В1-ФОРМА: <число>
В2-ВЕРНОСТЬ: <число>
В2-ТЕРМИН: <число>
В2-ЯЗЫК: <число>
В2-ФОРМА: <число>
СТИЛЬ: <В1 или В2 или НИЧЬЯ — где живее и естественнее русская проза>
"""
AXES = ("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА")
def client(model: str) -> OpenAI:
base, env, *_ = CANDIDATES[model]
return OpenAI(api_key=os.environ[env], base_url=base, timeout=600)
def spent_so_far() -> float:
"""Леджер = сумма персистированных голосов. Переживает процесс — урок перерасхода Ф1."""
tot = 0.0
for f in OUT.glob(f"{PHASE_PREFIX}*.json"):
if PHASE_PREFIX == "jv-" and f.name.startswith("jv-bo-"):
continue # голоса бейк-оффа живут в бюджете Ф2а, не Ф0.6
try:
tot += json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0)
except Exception: # noqa: BLE001, S112
continue
return tot
def vote(judge: str, packet: str, tag: str) -> dict:
"""Один голос. Персистится отдельным файлом — иначе замер невоспроизводим (ревью-шапка 20)."""
f = OUT / f"jv-{tag}.json"
if f.exists():
return json.loads(f.read_text(encoding="utf-8"))
if spent_so_far() > CEILING_USD:
return dict(tag=tag, skipped=True, cost_usd=0.0, content="")
kw = vote_kw(judge, packet)
r = client(judge).chat.completions.create(**kw)
u = r.usage
cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0
rec = dict(tag=tag, judge=judge, finish=r.choices[0].finish_reason,
content=r.choices[0].message.content or "",
cost_usd=round(cost(judge, u.prompt_tokens or 0, cached, u.completion_tokens or 0,
getattr(getattr(u, "completion_tokens_details", None),
"reasoning_tokens", 0) or 0,
getattr(u, "total_tokens", 0) or 0), 6))
f.write_text(json.dumps(rec, ensure_ascii=False), encoding="utf-8")
return rec
def vote_kw(judge: str, packet: str) -> dict:
"""Параметры вызова судьи. Вынесено из `vote`, чтобы бейк-офф покупал голоса через общую
кассу `buy.purchase` (леджер с диска + проекционный гард), а не через локальный счётчик."""
kw: dict = dict(model=judge, messages=[{"role": "system", "content": RUBRIC},
{"role": "user", "content": packet}])
if judge in OPENAI_FAMILY:
# ⚠ Бюджет и ГАШЕНИЕ РАЗМЫШЛЕНИЯ. Первая редакция давала 4000 без ручки эффорта, и luna
# выжигала их на рассуждение: 8 голосов из 18 пришли ПУСТЫМИ. Судья, который молчит в
# 44% клеток, не судья. Тот же класс дефекта, что стена flash в §2.7 — и лечится так же.
kw["max_completion_tokens"] = 8000
kw["reasoning_effort"] = "low"
else:
kw["max_tokens"] = 4000
kw["temperature"] = 0.4 # разброс меряется у РЕАЛЬНОГО инструмента, не идеального
return kw
def parse(answer: str) -> dict:
"""Счёт ошибок по осям + стилевой преференс. Непарсящееся — None, а не ноль (это разные вещи)."""
out: dict = {}
for side in ("В1", "В2"):
for ax in AXES:
m = re.search(rf"^{side}-{ax}\s*:\s*(\d+)", answer, re.M)
out[f"{side}-{ax}"] = int(m.group(1)) if m else None
m = re.search(r"^СТИЛЬ\s*:\s*(В1|В2|НИЧЬЯ)", answer, re.M)
out["СТИЛЬ"] = m.group(1) if m else None
return out
def packet(source: str, v1: str, v2: str) -> str:
return (f"ИСХОДНИК:\n{source}\n\n---\nВАРИАНТ 1:\n{v1}\n\n---\nВАРИАНТ 2:\n{v2}\n")
def run_variance() -> None:
"""Разброс каждого судьи на ПОВТОРЕ одного входа + ДЕКОЙ. Оба на материале пробы 18."""
from inject_probe import pick_windows # noqa: PLC0415
wins = pick_windows()
rows: list[dict] = []
for k in range(min(3, len(wins))):
_, src, _ = wins[k]
clean = P.draft_of(k)
planted, _ = P.planted_draft(k, clean)
# Клетка РАЗБРОСА: обе стороны — один и тот же текст. Любое расхождение вердиктов здесь
# есть чистый шум судьи, потому что различать нечего по построению.
# Клетка ДЕКОЯ: против чистого стоит заведомо испорченный (посадки пробы 18).
cells = {"same": (clean, clean), "decoy": (clean, planted)}
for kind, (a, b) in cells.items():
for judge in JUDGES:
for rep in range(1, REPS + 1):
rec = vote(judge, packet(src, a, b), f"{kind}-{judge}-w{k}-r{rep}")
if rec.get("skipped"):
print("⛔ потолок Ф0.6 исчерпан")
return
rows.append(dict(kind=kind, judge=judge, window=k, rep=rep,
**parse(rec["content"])))
time.sleep(0.2)
print(f"РАЗБРОС СУДЕЙ И ДЕКОЙ · повторов на клетку {REPS} · температура как в бою\n")
print(f"{'судья':16s}{'голосов':>8s}{'без лож.перевеса':>17s}{'доля':>8s} (клетки РАЗБРОСА)")
print("-" * 62)
floor = {}
for judge in JUDGES:
cells = defaultdict(list)
for r in rows:
if r["kind"] == "same" and r["judge"] == judge:
cells[r["window"]].append(r)
# ⚠ МЕТРИКА ИСПРАВЛЕНА ПОСЛЕ ПЕРВОГО ПРОГОНА. Первая редакция требовала совпадения
# АБСОЛЮТНЫХ счетов во всех повторах и дала 25%/42% — я прочитал это как «разброс огромен».
# Пере-счёт по сырью показал, что метрика мерила не то: судья может сказать «3 ошибки у
# обоих» и «5 у обоих» — это дрейф КАЛИБРОВКИ, а не выдуманная разница между вариантами.
# Операционально решает второе: сочиняет ли судья ПЕРЕВЕС там, где тексты идентичны.
# Прямой замер: ложный перевес нулевой в 13 голосах из 13. Печатаются обе величины —
# дрейф калибровки важен для АБСОЛЮТНЫХ порогов, ложный перевес — для ПАРНЫХ вердиктов.
unan = tot = false_margin = votes_ok = 0
for _w, rs in cells.items():
for r in rs:
if r["В1-ВЕРНОСТЬ"] is None:
continue
votes_ok += 1
false_margin += int(sum(abs((r[f"В1-{a}"] or 0) - (r[f"В2-{a}"] or 0))
for a in AXES) != 0)
for ax in AXES:
vals = [r[f"В1-{ax}"] for r in rs] + [r[f"В2-{ax}"] for r in rs]
if any(v is None for v in vals):
continue
tot += 1
unan += int(len(set(vals)) == 1)
if votes_ok:
floor[judge] = false_margin / votes_ok
print(f"{judge:16s}{votes_ok:8d}{votes_ok - false_margin:13d}"
f"{1 - false_margin / votes_ok:8.0%}"
f" (дрейф абс. счёта: совпал в {unan}/{tot})")
print(f"\n{'судья':16s}{'декой пойман':>14s}{'доля':>8s} (обязан выбрать ЧИСТЫЙ вариант)")
print("-" * 62)
for judge in JUDGES:
rs = [r for r in rows if r["kind"] == "decoy" and r["judge"] == judge]
caught = tot = 0
for r in rs:
a = sum(r[f"В1-{ax}"] or 0 for ax in AXES)
b = sum(r[f"В2-{ax}"] or 0 for ax in AXES)
if r["В1-ВЕРНОСТЬ"] is None:
continue
tot += 1
caught += int(b > a) # В2 — испорченный, у него ошибок обязано быть больше
if tot:
print(f"{judge:16s}{caught:14d}{caught / tot:8.0%}")
(OUT / "judge-variance.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1),
encoding="utf-8")
print(f"\nпотрачено ${spent_so_far():.6f} из ${CEILING_USD}")
print("\nЧитать: доля единогласия на клетках РАЗБРОСА — верхняя граница разрешающей\n"
"способности судьи. Декой ниже 100% означает, что и явную порчу он видит не всегда,\n"
"и тогда все его вердикты по армам читаются через эту поправку.")
if __name__ == "__main__":
if "--variance" in sys.argv:
run_variance()
else:
print(__doc__)