#!/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__)