269 lines
18 KiB
Python
269 lines
18 KiB
Python
#!/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__)
|