diff --git a/eval/role_topology/align.py b/eval/role_topology/align.py index e1821171..75edd236 100644 --- a/eval/role_topology/align.py +++ b/eval/role_topology/align.py @@ -4,10 +4,27 @@ Вынесено из `qe_segment.py` по находке самопроверки: выравнивание нужно и батарее Ф0.5, и QE-детектору, но батарея не должна тянуть за собой 4.6 ГБ весов и отдельный venv. Модуль импортируется обоими и проверяется `selfcheck.py` в базовом окружении полигона. +Сюда же вынесен генератор ИНВЕРСИЙ ПОЛЯРНОСТИ: он тоже детерминированный, тоже $0 и тоже нужен +двум разным потребителям (замеру мощности QE и арму починки Ф2б). Первая редакция держала его в +`qe_power.py`, который тянет torch, — и арм починки падал на импорте 4.6 ГБ весов, которые ему не +нужны вовсе. Тот же класс дефекта, что уже был пойман самопроверкой на выравнивании. """ from __future__ import annotations import re +import pymorphy3 + +_MORPH = pymorphy3.MorphAnalyzer() + + +def _is_finite_verb(word: str) -> bool: + for p in _MORPH.parse(word.lower()): + if p.is_known and p.tag.POS == "VERB": + return True + return False + + + # Терминаторы предложений: китайские полноширинные и латинские. Многоточие 。。。/…… не разрывается. _ZH_SPLIT = re.compile(r"(?<=[。!?;…])(?![。!?;…])") _RU_SPLIT = re.compile(r"(?<=[.!?…])\s+(?=[«\"(\[А-ЯЁA-Z—-])") @@ -76,3 +93,38 @@ def align(src: list[str], dst: list[str]) -> list[tuple[list[int], list[int]]]: return out[::-1] + + +def flip_polarity(sent: str) -> str | None: + """Инвертирует полярность предложения: снимает «не» при глаголе либо вставляет его. + + Работает только по ЛИЧНОМУ глаголу или глаголу прошедшего времени — причастия и деепричастия + исключены сознательно: «не» при них меняет смысл иначе и часто даёт неграмматичный результат, + а неграмматичность ранкер поймает не как инверсию, а как порчу формы (другой класс). + """ + m = re.search(r"\bне\s+([А-Яа-яЁё]+)", sent) + if m and _is_finite_verb(m.group(1)): + return sent[:m.start()] + m.group(1) + sent[m.end():] + for m in re.finditer(r"\b([А-Яа-яЁё]{3,})\b", sent): + w = m.group(1) + if sent[max(0, m.start() - 4):m.start()].strip().endswith("не"): + continue + # ⚠ Пропускаем глагол с ПРОПИСНОЙ: он открывает предложение или реплику, и вставка даёт + # «не Стой» — неграмматичный текст. Ранкер оценил бы его как порчу ФОРМЫ, то есть замер + # мерил бы другой класс дефекта. Поймано проверкой генератора до прогона. + if w[0].isupper(): + continue + if _is_finite_verb(w): + return sent[:m.start()] + "не " + sent[m.start():] + return None + + +def _is_finite_verb(word: str) -> bool: + for p in _MORPH.parse(word.lower()): + if not p.is_known: + continue + if p.tag.POS == "VERB": + return True + return False + + diff --git a/eval/role_topology/bakeoff.py b/eval/role_topology/bakeoff.py new file mode 100644 index 00000000..9a6e24de --- /dev/null +++ b/eval/role_topology/bakeoff.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python3 +"""Ф2а — БЕЙК-ОФФ «переписывание как класс». Отвечает на главный вопрос пака. + +Вопрос владельца, ради которого существует эксп: нужен ли второй проход вообще, и не выгоднее ли +переводить сразу сильной моделью. Экспы 18–20 подошли к нему сбоку (контракт редактора, цена +диффа, роль редактора), но самого бейк-оффа топологий не гоняли ни разу. + +Армы. Черновики ФРИЗЯТСЯ и подаются идентичными всем редакторским армам — это парный дизайн, и он +даёт кратный выигрыш мощности бесплатно. Черновики берутся готовые из корпуса пакета-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 шёл с мышлением: 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) + 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` тоже нельзя: на нём стоят экспы 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 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__) diff --git a/eval/role_topology/battery.py b/eval/role_topology/battery.py index 26cce4d8..28735563 100644 --- a/eval/role_topology/battery.py +++ b/eval/role_topology/battery.py @@ -43,6 +43,7 @@ sys.path.insert(0, str(REPO / "eval" / "exp16")) sys.path.insert(0, str(REPO / "eval" / "role_topology")) import pymorphy3 # noqa: E402 +import yaml # noqa: E402 from detect_word import decomposes as _decomposes # noqa: E402 from detect_word import translit_shape as _translit_shape # noqa: E402 from palladius import is_palladius_token, palladius_conformant # noqa: E402 @@ -81,7 +82,11 @@ _RU_NUM = {"ноль": 0, "один": 1, "два": 2, "две": 2, "три": 3, "девятнадцать": 19, "двадцать": 20, "тридцать": 30, "сорок": 40, "пятьдесят": 50, "шестьдесят": 60, "семьдесят": 70, "восемьдесят": 80, "девяносто": 90, "сто": 100, "двести": 200, "триста": 300, "четыреста": 400, "пятьсот": 500, - "шестьсот": 600, "семьсот": 700, "восемьсот": 800, "девятьсот": 900} + "шестьсот": 600, "семьсот": 700, "восемьсот": 800, "девятьсот": 900, + # СОБИРАТЕЛЬНЫЕ (добор 07.08 по вскрытым ложным срабатываниям). Русский переводит + # 数万人 как «десятки тысяч», 一百多位 как «более сотни» — обе формы верны, а без этих + # лемм разбор давал потерю 10000/100 и выдумку 1000 на безупречном тексте. + "десяток": 10, "сотня": 100, "дюжина": 12} _RU_MULT = {"тысяча": 1_000, "миллион": 1_000_000, "миллиард": 1_000_000_000} # Пол фактонесущей величины. ВЫБРАН ЗАМЕРОМ (свип 8 конфигураций на 91 единице): при поле 10 # ложных «появившихся» 1.64 на единицу, при 100 — 0.27 при тех же 0.25 потерянных. Малые @@ -111,8 +116,16 @@ def normalize(text: str) -> str: return "".join(out) +# ⚠ ДЕФИС ВНУТРИ СЛОВА — ЧАСТЬ СЛОВА. Первая редакция резала по нему, и первые половины составных +# («тёмно-зелёный», «точь-в-точь», «перво-наперво») приходили в детектор отдельными токенами, +# которых в словаре нет по построению. На прогоне армов это дало 15 ложных флагов «выдуманное +# слово» из 15 и завысило долю флагнутых единиц, от которой считается вся экономика маршрутизации. +# Поймано осмотром флагов, а не чтением кода: числа выглядели правдоподобно. +_RE_CYR_WORD_HYPH = re.compile(r"[А-Яа-яЁё]+(?:-[А-Яа-яЁё]+)*") + + def words(text: str) -> list[str]: - return _RE_CYR_WORD.findall(normalize(text)) + return _RE_CYR_WORD_HYPH.findall(normalize(text)) # ──────────────────────────────── проверки батареи ──────────────────────────────── @@ -243,6 +256,56 @@ def check_translationese(final: str) -> dict: calque_interjections=calques, marked_feminitives=fem) +# Неколичественные обороты: иероглиф величины стоит, величины нет. Список закрытый и короткий — +# добирается замером на корпусе, а не на глаз (метод тот же, что дал MIN_VALUE). +_ZH_IDIOM = ("百分之", "千分之", "万分之", # доли: 百分之一 = одна сотая, не «сто» + "十分", "十足", "万一", "万分", "千万", "百般", "万物", "万象", "百姓", "千秋") +_RE_ZH_RUN = re.compile(r"[零一二两三四五六七八九十百千万亿]+") + + +def _zh_values(source: str) -> set[int]: + """Величины китайской записи. Разбор идёт по СВЯЗНОМУ ПРОБЕГУ, а не по паре «цифры+разряд». + + ⚠ Это починка дефекта, найденного адверсариальным ревью 07.08 и воспроизведённого исполнением. + Прежняя версия брала разряды поодиночке, тогда как русская сторона накапливает составное + числительное целиком: 两千三百 давало {2000, 300}, а «две тысячи триста» — {2300}, и на + БЕЗУПРЕЧНОМ переводе проверка печатала две потери и одну выдумку. Проверено на трёх парах + (两千三百 · 三千五百 · 一百二十) — все три давали ложное срабатывание. + + Голый разряд теперь берётся (万里 = 10000), потому что русская сторона берёт голый множитель + («десять тысяч ли»): несимметричность правил и была источником ложных выдумок. Цена симметрии — + неколичественные обороты, они гасятся закрытым списком `_ZH_IDIOM`. + """ + out: set[int] = set() + clean = source + for idiom in _ZH_IDIOM: + clean = clean.replace(idiom, " ") + for m in _RE_ZH_RUN.finditer(clean): + run = m.group(0) + if run == "十": + continue # голое 十 счётно почти никогда; 百年/千年/万里 — счётны + total = section = cur = 0 + for ch in run: + if ch in _ZH_DIGITS and ch != "十": # 十 живёт в обоих словарях; здесь он РАЗРЯД + cur = _ZH_DIGITS[ch] + elif ch == "十": + section += (cur or 1) * 10 + cur = 0 + elif ch in ("百", "千"): + section += (cur or 1) * ZH_MAGNITUDE[ch] + cur = 0 + elif ch == "万": + total += (section + cur or 1) * 10_000 + section = cur = 0 + elif ch == "亿": + total = (total + section + cur or 1) * 100_000_000 + section = cur = 0 + v = total + section + cur + if v: + out.add(v) + return out + + def check_numbers(source: str, final: str) -> dict: """6. Перенос чисел с нормализацией 万/亿. research/12 не покрывает; источник — бэклог 12 («HARD-value-детектор 成/万») и D39.79. @@ -253,17 +316,7 @@ def check_numbers(source: str, final: str) -> dict: ⚠ Проверка НЕ ловит переставленные величины при совпадающем множестве — объявлено. """ src_vals = set(int(x) for x in re.findall(r"\d+", source)) - # Разбор источника требует ЯВНОЙ ЦИФРЫ перед множителем. Голое 十 намеренно НЕ берётся: - # в китайском оно живёт в неколичественных идиомах (十分 «весьма», 十足 «полный»), и его - # добор поднял ложные потери 0.25→0.93 на единицу при выигрыше 0.27→0.10 по выдуманным — - # то есть сделал метрику хуже. Замер вариантов — в отчёте, не на глаз. - for m in re.finditer(r"([零一二两三四五六七八九十]+)\s*([万亿千百十])", source): - digits, mag = m.group(1), m.group(2) - cur = 0 - for ch in digits: - v = _ZH_DIGITS.get(ch, 0) - cur = cur * 10 if v == 10 else cur + v - src_vals.add((cur or 1) * (10 if mag == "十" else ZH_MAGNITUDE[mag])) + src_vals |= _zh_values(source) dst_vals = set(int(x) for x in re.findall(r"\d+", normalize(final))) for m in re.finditer(r"(\d+)\s*(тысяч\w*|миллион\w*)", normalize(final), re.I): mult = 1_000 if m.group(2).lower().startswith("тысяч") else 1_000_000 @@ -274,7 +327,10 @@ def check_numbers(source: str, final: str) -> dict: # сотни+десятки+единицы складываются, множитель умножает накопленное. acc, cur = 0, 0 for w in words(final): - lemma = _MORPH.parse(w.lower())[0].normal_form + # Первый разбор — не всегда числительный: у «десятки» это «десятка», а не «десяток». + # Берём первую лемму, которая ВООБЩЕ числительное, иначе откатываемся на первый разбор. + lemmas = [p.normal_form for p in _MORPH.parse(w.lower())] + lemma = next((x for x in lemmas if x in _RU_NUM or x in _RU_MULT), lemmas[0]) if lemma in _RU_NUM: cur += _RU_NUM[lemma] continue @@ -282,6 +338,8 @@ def check_numbers(source: str, final: str) -> dict: acc += max(cur, 1) * _RU_MULT[lemma] cur = 0 continue + if lemma == "и" and (cur or acc): + continue # «тысяча И одна ночь» — союз внутри составного, не разрыв if cur or acc: dst_vals.add(acc + cur) acc = cur = 0 @@ -319,6 +377,12 @@ def check_ty_vy(final: str) -> dict: def check_gender(final: str, cards: dict[str, str]) -> dict: """4. Род глагола прош. вр. против карточек. research/12 §1 — «жалоба №1 читателей MTL». + ⚠ ПАДЕЖ ИМЕНИ (починка по ревью 07.08). Карточки намеренно несут склонённые формы, иначе + «Фан Юаня» не сопоставится с «Фан Юань». Но косвенная форма имени по определению НЕ подлежащее, + и стоящий рядом глагол согласован с кем-то другим: «Воля Фан Юаня БЫЛА тверда» давала + рассогласование male/female на безупречном тексте. Все 6 «рассогласований» прошлого прогона + были этим классом. Кандидатом теперь считается только форма, у которой есть именительный разбор. + Карточка = {имя: 'male'|'female'}. Ищется «Имя + глагол прош. вр.» и сверяется род. Работает только по ЯВНОЙ близости имени и глагола (окно 3 слова): местоименные и удалённые согласования требуют кореференции и объявлены вне охвата. Без карточек проверка возвращает нули — это @@ -330,6 +394,8 @@ def check_gender(final: str, cards: dict[str, str]) -> dict: checked, bad = 0, [] for name, gender in cards.items(): for m in re.finditer(re.escape(name) + r"((?:\s+\S+){0,3})", t): + if not _is_nominative(name): + continue for w in _RE_CYR_WORD.findall(m.group(1)): for p in _MORPH.parse(w.lower()): if p.tag.POS == "VERB" and "past" in str(p.tag): @@ -343,6 +409,52 @@ def check_gender(final: str, cards: dict[str, str]) -> dict: return dict(checked=checked, mismatched=len(bad), cases=bad[:8]) +def _is_nominative(name: str) -> bool: + """Есть ли у ПОСЛЕДНЕГО токена имени именительный разбор. Кэшируется: вызовов много.""" + if name in _NOMN_CACHE: + return _NOMN_CACHE[name] + toks = _RE_CYR_WORD.findall(name) + ok = True + if toks: + parses = _MORPH.parse(toks[-1].lower()) + # Неизвестное морфологии имя пропускаем: отсечь по незнанию хуже, чем проверить. + known = [p for p in parses if p.tag.case] + ok = (not known) or any(p.tag.case == "nomn" for p in known) + _NOMN_CACHE[name] = ok + return ok + + +_NOMN_CACHE: dict[str, bool] = {} + + +def load_cards() -> dict[str, str]: + """4-бис. КАРТОЧКИ ПЕРСОНАЖЕЙ (имя → пол) — вход проверки рода. + + ⚠ Пробел, найденный владельцем при сверке трекера: Ф0.3 промта заказывала карточки как ДАННЫЕ + для этой проверки, а я их не построил. Следствие было тихим и потому опасным: `check_gender` + принимает карточки аргументом и без них возвращает нули, то есть одна из девяти проверок + батареи всё время была инертна, а батарея отчитывалась как целое. Проверка, которая молча + ничего не проверяет, хуже отсутствующей — отсутствующую видно. + + Карточки берутся из ПОДПИСАННОГО сида книги (поле `gender`), а не составляются мной: пол + персонажа — факт о книге, и выдумывать его полигон не вправе. Склонённые формы из `decl.forms` + добавляются как отдельные ключи, иначе «Фан Юаня» не сопоставится с «Фан Юань». + """ + seed = Path.home() / "books" / "gu-zhenren" / "guzhenren-seed-v2.yaml" + if not seed.exists(): + return {} + data = yaml.safe_load(seed.read_text(encoding="utf-8")) or {} + cards: dict[str, str] = {} + for t in data.get("terms", []) or []: + g = t.get("gender") + if g not in ("male", "female"): + continue + for name in [t.get("dst", "")] + list((t.get("decl") or {}).get("forms") or []): + if name and _RE_CYR_WORD.search(name): + cards[name] = g + return cards + + def check_repeat_consistency(units: list[dict]) -> dict: """2+3. Повтор-консистентность и коллизии, КРОСС-ЕДИНИЧНО. diff --git a/eval/role_topology/buy.py b/eval/role_topology/buy.py new file mode 100644 index 00000000..26f8c143 --- /dev/null +++ b/eval/role_topology/buy.py @@ -0,0 +1,114 @@ +"""Единая касса пака: леджер с диска, ПРОЕКЦИОННЫЙ гард, однородная запись вызова. + +⚠ Заведён 07.08 по адверсариальному ревью, которое нашло три разных дефекта одного класса: + * `repair_arm.CEILING_USD` и `route_arm.CEILING_USD` были ОБЪЯВЛЕНЫ и не использованы нигде — + потолок Ф2б $3.00 был декоративным, а счётчик трат локальным и перезапуск не переживал; + * гард `screen.call` сравнивал УЖЕ потраченное с порогом и ничего не знал о цене следующего + вызова: запас $0.03 при наблюдённом максимуме одного вызова $0.090 — потолок держался + случаем, а не механизмом; + * `bakeoff` считал потраченное по глобу `bo-` и не видел $1.02, ушедших на судейство. + +Отсюда три правила, и все три живут ЗДЕСЬ, а не в каждом скрипте по-своему: + 1. Потраченное читается С ДИСКА по всем префиксам фазы. Память процесса не источник истины. + 2. Гард ПРОЕКЦИОННЫЙ: `spent + ожидаемая цена` против потолка, а не `spent` против порога. + Ожидание берётся из уже купленных вызовов той же роли (p95), при их отсутствии — из сметы. + 3. Запись несёт `total_tokens`. Без него биллинг Gemini (`additive_total`, quirks 00 D22.3) + не пере-считывается даже постфактум — ровно это и случилось с прошлым прогоном. +""" +from __future__ import annotations + +import json +import time +from pathlib import Path + +from prices import cost + +OUT = Path.home() / "books" / "role-topology" +OUT.mkdir(parents=True, exist_ok=True) + + +class Ledger: + """Касса одной фазы. `prefixes` — глобы артефактов, из которых складывается потраченное.""" + + def __init__(self, name: str, ceiling: float, prefixes: tuple[str, ...], + default_expect: float = 0.05) -> None: + self.name, self.ceiling, self.prefixes = name, ceiling, prefixes + self.default_expect = default_expect + self._prices: list[float] = [] + + def spent(self) -> float: + tot, self._prices = 0.0, [] + for pat in self.prefixes: + for f in OUT.glob(pat): + try: + r = json.loads(f.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001, S112 + continue + c = float(r.get("cost_usd") or 0.0) + tot += c + if c > 0: + self._prices.append(c) + return tot + + def expect(self) -> float: + """Ожидаемая цена следующего вызова: p95 уже купленного, иначе смета.""" + if len(self._prices) < 3: + return self.default_expect + s = sorted(self._prices) + return s[min(len(s) - 1, int(round(0.95 * (len(s) - 1))))] + + def afford(self) -> tuple[bool, str]: + sp = self.spent() + proj = sp + self.expect() + if proj > self.ceiling: + return False, (f"[СТОП {self.name}] потрачено ${sp:.6f} + ожидание ${self.expect():.6f} " + f"= ${proj:.6f} > потолок ${self.ceiling:.2f}") + return True, "" + + +def usage_of(u, ch) -> dict: + """Токены вызова в однородном виде. `total_tokens` персистится ВСЕГДА (см. шапку).""" + cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0 + reasoning = getattr(getattr(u, "completion_tokens_details", None), "reasoning_tokens", 0) or 0 + return dict(prompt_tokens=u.prompt_tokens or 0, cached_tokens=cached, + completion_tokens=u.completion_tokens or 0, reasoning_tokens=reasoning, + total_tokens=getattr(u, "total_tokens", 0) or 0, + reasoning_chars=len(getattr(ch.message, "reasoning_content", None) or "")) + + +def priced(model: str, us: dict) -> float: + return round(cost(model, us["prompt_tokens"], us["cached_tokens"], us["completion_tokens"], + us["reasoning_tokens"], us["total_tokens"]), 6) + + +def purchase(led: Ledger, tag: str, model: str, client, kw: dict, **extra) -> dict: + """Один платный вызов под кассой. Кэш читается ПЕРВЫМ, гард — перед покупкой. + + Порядок важен и выведен из двух прошлых ошибок: гард перед чтением кэша объявлял уже + купленную клетку незамеренной, а гард после покупки не защищал ничего. + """ + f = OUT / f"{tag}.json" + if f.exists(): + return json.loads(f.read_text(encoding="utf-8")) + ok, why = led.afford() + if not ok: + print(why) + return dict(tag=tag, model=model, skipped=True, cost_usd=0.0, content="", finish="skipped") + t0 = time.time() + try: + r = client.chat.completions.create(**kw) + except Exception as e: # noqa: BLE001 + # Провал ОДНОГО вызова — данные, а не повод ронять прогон. ⚠ Но запись отказа кладётся + # ОТДЕЛЬНЫМ тегом: прошлая редакция писала её под тем же именем, и транзиентный таймаут + # навсегда отравлял клетку — пере-запуск читал ошибку из кэша и вызов не повторял. + rec = dict(tag=tag, model=model, finish="error", error=f"{type(e).__name__}: {str(e)[:200]}", + content="", cost_usd=0.0, latency_s=round(time.time() - t0, 1), **extra) + (OUT / f"{tag}.ERROR.json").write_text(json.dumps(rec, ensure_ascii=False, indent=1), + encoding="utf-8") + return rec + ch, us = r.choices[0], usage_of(r.usage, r.choices[0]) + rec = dict(tag=tag, model=model, model_returned=r.model, finish=ch.finish_reason, + content=ch.message.content or "", latency_s=round(time.time() - t0, 1), + cost_usd=priced(model, us), **us, **extra) + f.write_text(json.dumps(rec, ensure_ascii=False, indent=1), encoding="utf-8") + return rec diff --git a/eval/role_topology/contamination.py b/eval/role_topology/contamination.py new file mode 100644 index 00000000..64178be0 --- /dev/null +++ b/eval/role_topology/contamination.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Ф0.2 — ЗАМЕР КОНТАМИНАЦИИ имеющихся книг вместо добычи новой. + +Постановка. Промт заказывает «невиданный срез» и оговаривает главное: **контаминация НЕ по дате**, +её надо мерить пробами на самой модели. Я сперва прочитал это как «нужна новая книга» и сделал +блокером то, что блокером не является: материал в `~/books` есть, и все экспы 18–20 гнались на нём +(промт пробы 18, арм AM: «найди на стенде 10 глав пассаж…»). Правильная задача — не притащить +шестую книгу, а УЗНАТЬ ЧИСЛОМ, какая из пяти имеющихся насколько сидит в претрейне, и разложить +работу по ним осознанно. + +⚠⚠ ДВЕ РЕДАКЦИИ ЭТОЙ ПРОБЫ БЫЛИ СНЯТЫ ИСПОЛНЕНИЕМ, и обе — по одной причине: они спрашивали +ВОСПРОИЗВЕДЕНИЕ. + Редакция 1 звала `deepseek-v4-flash` с бюджетом 16000 токенов. Модель выжигала бюджет на + размышление и возвращала 0–46 знаков (стена quirks §10). Нулевое совпадение читалось как + «книга не заучена». Поймано осмотром сырья: $0.003–0.005 за вызов при пустом ответе. + Редакция 2 звала `gpt-5.6-luna` без размышления и с коротким выходом — стена ушла, но + ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ 金瓶梅 (минский канон, public domain, заведомо в претрейне) дал + ответ «НЕЗНАКОМ» и нулевое совпадение. Значит дело не в книгах: современные модели не + воспроизводят текст дословно по запросу вообще, и проба на продолжение непригодна КАК КЛАСС. +⇒ Метод сменён на тот, который спрашивает ЗНАНИЕ, а не воспроизведение. Оба вопроса ниже +безобидны с точки зрения выдачи текста и при этом отвечаются только тем, кто книгу видел. + +Как меряется сейчас. Две независимые пробы на одном и том же отрывке: + УЗНАВАНИЕ — «что это за произведение и кто его автор». Верное название = текст (или подробные + описания текста) были в обучающих данных. Ловит знание о книге вообще. + КЛОУЗ ПО ИМЕНИ — из отрывка вырезано имя собственное, модель называет пропущенное. Заполнить + можно, только зная книгу; при этом от модели требуется ОДНО СЛОВО, а не проза. Ловит более + тонкое знание, чем узнавание: имя второстепенного персонажа помнит лишь тот, кто видел текст. + +Почему различение важно для продукта. Завышение качества перевода даёт не «слышал о книге», а +знание её конкретики: имён, реалий, устоявшихся переводов. Именно это и меряет клоуз. + +ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ — `jinpingmei`. Нулевой результат по любой книге сам по себе +неинтерпретируем: «модель текст не помнит» и «проба не работает» дают одинаковую картину. Если +узнавание не сработает и на нём — сломана проба, а не книги. Роль контроля названа заранее, а не +выбрана задним числом по удобству результата. (Та же логика, что декой у QE-замера и арм `xhigh` +у вахты эффорта; в этой пробе контроль уже дважды сработал по назначению — см. баннер выше.) + +⚠ Отрывки НЕ попадают ни в репозиторий, ни в отчёт: печатаются только вердикты и метрики. + +Запуск: eval/.venv/bin/python eval/role_topology/contamination.py --dry # план, $0 + eval/.venv/bin/python eval/role_topology/contamination.py +""" +from __future__ import annotations +import json +import os +import re +import sys +import time +from collections import Counter +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +from dotenv import load_dotenv # noqa: E402 +from openai import OpenAI # noqa: E402 + +load_dotenv(REPO / "eval" / ".env") +OUT = Path.home() / "books" / "role-topology" +OUT.mkdir(parents=True, exist_ok=True) + +MODEL = "gpt-5.6-luna" # без стены размышления, дешёвый, и сам кандидат Ф2 +PRICE_IN, PRICE_CACHED, PRICE_OUT = 0.20, 0.02, 1.20 +MAX_OUT = 200 +CEILING_USD = 0.10 +N_PASSAGES = 5 +PRIME_CHARS = 700 + +BOOKS: dict[str, dict] = { + "gu-zhenren": dict(path=Path.home() / "books" / "gu-zhenren" / "guzhenren-utf8.txt", + lang="zh", titles=["蛊真人", "гу чжэнь", "reverend insanity", "гу чжэньжэнь"], + note="вебновелла 2012–2018, есть en-фанперевод"), + "jinpingmei": dict(path=Path.home() / "books" / "jinpingmei" / "jinpingmei-ctext-zhs.txt", + lang="zh", titles=["金瓶梅", "цзинь пин мэй", "plum in the golden"], + note="ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ: минский канон, public domain"), + "isekai-ja": dict(path=Path.home() / "books" / "isekai_majutsushi_jp.txt", + lang="ja", titles=["異世界魔術師", "isekai majutsushi"], + note="вебновелла с syosetu, R18-раздел"), + "fifty-shades-en": dict(path=Path.home() / "books" / "fifty_shades_1_en.txt", + lang="en", titles=["fifty shades", "пятьдесят оттенков"], + note="издано 2011, бестселлер"), + "kristoff-en": dict(path=Path.home() / "books" / + "Kristoff_Jay_-_Empire_of_the_Vampire_3_-_Empire_of_the_Dawn.epub", + lang="en", titles=["empire of the vampire", "empire of the dawn", + "kristoff"], + note="роман 2026, ru-перевод не издан"), +} + + +def read_book(path: Path) -> str: + """Текст книги. epub разбирается тем же способом, что `crosslang_bank.epub_text`.""" + if path.suffix != ".epub": + return path.read_text(encoding="utf-8", errors="ignore") + import zipfile # noqa: PLC0415 + out = [] + with zipfile.ZipFile(path) as z: + for n in sorted(z.namelist()): + if n.endswith((".xhtml", ".html")): + html = z.read(n).decode("utf-8", "ignore") + txt = re.sub(r"<[^>]+>", " ", html) + txt = re.sub(r"&[a-z]+;", " ", txt) + out.append(re.sub(r"\s+", " ", txt)) + return "\n".join(out) + + +def frequent_names(text: str, lang: str, top: int = 40) -> list[str]: + """Частые имена собственные книги — кандидаты на маскирование в клоузе. + + Для латиницы/кириллицы берутся слова с прописной вне начала предложения; для CJK — частые + биграммы/триграммы, не являющиеся служебными. Список нужен только чтобы ВЫБРАТЬ, что маскировать, + поэтому грубость метода допустима и объявлена. + """ + if lang in ("en",): + cand = Counter(m.group(1) for m in re.finditer(r"(? 20][:top] + + +def passages(text: str, names: list[str], n: int) -> list[tuple[str, str]]: + """(отрывок с маской, замаскированное имя). Отрывки из середины, точки детерминированы.""" + body = text[len(text) // 10: -len(text) // 10] + out: list[tuple[str, str]] = [] + for i in range(n * 6): + if len(out) >= n: + break + pos = len(body) * (i + 1) // (n * 6 + 1) + chunk = body[pos:pos + PRIME_CHARS] + if len(chunk) < PRIME_CHARS: + continue + hit = next((w for w in names if w in chunk), None) + if not hit: + continue + out.append((chunk.replace(hit, "[???]"), hit)) + return out + + +def ask(cl, prime: str, tag: str) -> dict: + """Один вызов: узнавание и клоуз задаются ВМЕСТЕ, чтобы не платить дважды за один отрывок.""" + f = OUT / f"cont-{tag}.json" + if f.exists(): + return json.loads(f.read_text(encoding="utf-8")) + msgs = [ + {"role": "system", "content": + "Тебе дают отрывок художественного текста, в котором одно имя собственное заменено на " + "[???]. Ответь РОВНО двумя строками и ничем больше:\n" + "ПРОИЗВЕДЕНИЕ: <название и автор, либо НЕ ЗНАЮ>\n" + "ИМЯ: <что стоит вместо [???], либо НЕ ЗНАЮ>\n" + "Не пересказывай отрывок и не продолжай его."}, + {"role": "user", "content": prime}, + ] + r = cl.chat.completions.create(model=MODEL, messages=msgs, max_completion_tokens=MAX_OUT, + reasoning_effort="none") + u = r.usage + cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0 + rec = dict(tag=tag, finish=r.choices[0].finish_reason, + cost_usd=round((u.prompt_tokens - cached) / 1e6 * PRICE_IN + + cached / 1e6 * PRICE_CACHED + + u.completion_tokens / 1e6 * PRICE_OUT, 6), + answer=r.choices[0].message.content or "") + f.write_text(json.dumps(rec, ensure_ascii=False), encoding="utf-8") + return rec + + +def parse(answer: str) -> tuple[str, str]: + work = name = "" + for line in answer.splitlines(): + if line.upper().startswith("ПРОИЗВЕДЕНИЕ"): + work = line.split(":", 1)[-1].strip() + elif line.upper().startswith("ИМЯ"): + name = line.split(":", 1)[-1].strip() + return work, name + + +def main() -> None: + dry = "--dry" in sys.argv + cl = None if dry else OpenAI(api_key=os.environ["OPENAI_API_KEY"], + base_url="https://api.openai.com/v1", timeout=300) + spent = 0.0 + summary: list[dict] = [] + for book, meta in BOOKS.items(): + if not meta["path"].exists(): + print(f"{book}: файла нет — пропуск") + continue + text = read_book(meta["path"]) + names = frequent_names(text, meta["lang"]) + ps = passages(text, names, N_PASSAGES) + print(f"\n{book} ({meta['lang']}, {len(text):,} знаков) — отрывков {len(ps)} · {meta['note']}") + if dry: + continue + rec_ok = cloze_ok = 0 + for i, (prime, hidden) in enumerate(ps): + if spent > CEILING_USD: + print("⛔ потолок исчерпан — стоп") + break + rec = ask(cl, prime, f"{book}-{i}") + spent += rec["cost_usd"] + work, name = parse(rec["answer"]) + r_hit = any(t in work.lower() for t in meta["titles"]) + c_hit = bool(name) and (hidden in name or name in hidden) and "НЕ ЗНАЮ" not in name + rec_ok += r_hit + cloze_ok += c_hit + print(f" отрывок {i}: узнавание {'ДА ' if r_hit else 'нет'} · " + f"клоуз {'ДА ' if c_hit else 'нет'} · ответ о книге: {work[:60] or '—'}") + time.sleep(0.2) + summary.append(dict(book=book, n=len(ps), recognized=rec_ok, cloze=cloze_ok)) + + if dry: + return + print(f"\n{'книга':18s}{'узнано':>9s}{'клоуз':>8s}{'вердикт':>22s}") + print("-" * 58) + for s in summary: + n = max(1, s["n"]) + # Порог объявлен заранее: узнавание ≥3/5 = книга модели ИЗВЕСТНА; клоуз ≥2/5 = известна + # КОНКРЕТИКА, то есть текст (а не только описания) был в обучающих данных. + v = ("знает конкретику" if s["cloze"] >= 2 else + ("знает о книге" if s["recognized"] >= 3 else "следов нет")) + print(f"{s['book']:18s}{s['recognized']:>4d}/{n:<4d}{s['cloze']:>4d}/{n:<3d}{v:>22s}") + print(f"\nпотрачено ${spent:.6f}") + (OUT / "contamination.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1), + encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/eval/role_topology/detect_word.py b/eval/role_topology/detect_word.py index 64a15b6f..f2460dac 100644 --- a/eval/role_topology/detect_word.py +++ b/eval/role_topology/detect_word.py @@ -134,6 +134,13 @@ def decomposes(word: str) -> bool: def verdict(word: str, bank: frozenset[str]) -> tuple[bool, str]: """(флаг «выдуманное», причина-непропуска). Причина печатается — вердикт обязан быть читаемым.""" + # Составное через дефис считается выдуманным, только если ОБЕ части незнакомы: «тёмно-зелёный» + # словарь целиком не знает, но обе половины законны. Проверять по частям — не послабление, + # а единственный способ не флагать нормальное словообразование русского. + if "-" in word: + parts = [p for p in word.split("-") if len(p) >= 2] + if parts and all(known(p) or translit_shape(p) or decomposes(p) for p in parts): + return False, "составное из известных частей" if known(word): return False, "в словаре" if word.lower() in bank: diff --git a/eval/role_topology/effort_watch.py b/eval/role_topology/effort_watch.py index f898fcfb..19c90a30 100644 --- a/eval/role_topology/effort_watch.py +++ b/eval/role_topology/effort_watch.py @@ -96,11 +96,29 @@ def main() -> None: P.slug_check() # гардрейл CLAUDE.md: слаг сверяется живым /models в день запуска cl = P.client() rows: list[dict] = [] - spent = 0.0 - n = 1 if pilot else N_PER_ARM + # ⚠ Потолок считается от ВСЕГО уже купленного вахтой, а не от текущего запуска: иначе + # контрольный прогон стартовал бы с нуля и пробил бы общий лимит, оставаясь «в потолке» + # по своему локальному счёту. Леджер = сумма персистированных артефактов, а не память. + spent = sum(json.loads(f.read_text(encoding="utf-8"))["cost_usd"] + for f in OUT.glob("ew-*.json")) + if spent: + print(f"уже куплено вахтой ранее: ${spent:.6f} — потолок считается от этой суммы\n") + # Контролю нужен ЗНАК, а не оценка величины ⇒ розыгрышей меньше и они ограничены остатком. + n = 1 if pilot else (4 if "--control" in sys.argv else N_PER_ARM) stop = False + # ⚠ ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ `xhigh` — без него отрицательный результат по `low` НЕ + # интерпретируем: «вендор игнорирует ручку» и «риг не видит смену эффорта вообще» дают + # одинаковую картину. Про `xhigh` quirks §3б установил обратное — он РАБОТАЕТ и виден + # серверно (`prompt_tokens` 2203 против 2124). Если мой риг увидит xhigh и не увидит low, + # нуль по low становится содержательным; если не увидит и xhigh — риг негоден, и это + # честный исход. Та же логика, что у декоя D39.46(б), применённая к проводу. + # n меньше, чем у основных армов: контролю нужен ЗНАК, а не оценка величины, а остаток + # потолка после основного блока конечен. + arms = (("default", None), ("low", "low")) + if "--control" in sys.argv: + arms = (("xhigh", "xhigh"),) for i in range(n): - for arm, effort in (("default", None), ("low", "low")): + for arm, effort in arms: tag = f"ew-{arm}-r{i + 1}" f = OUT / f"{tag}.json" if f.exists(): # идемпотентность: пере-запуск не пере-покупает @@ -140,11 +158,33 @@ def main() -> None: def report(rows: list[dict], spent: float) -> None: from scipy.stats import mannwhitneyu # noqa: PLC0415 - a = [r for r in rows if r["arm"] == "default"] - b = [r for r in rows if r["arm"] == "low"] - print(f"\nn: дефолт {len(a)} · low {len(b)} · потрачено ${spent:.6f}") + # Отчёт читает ВСЕ персистированные артефакты вахты, а не только розыгрыши текущего запуска: + # контрольный арм докупается отдельным прогоном, и сводка обязана видеть его вместе с основными. + allr = [json.loads(f.read_text(encoding="utf-8")) for f in sorted(OUT.glob("ew-*.json"))] + byarm: dict[str, list[dict]] = {} + for r in allr: + byarm.setdefault(r["tag"].split("-")[1], []).append(r) + a = byarm.get("default", []) + b = byarm.get("low", []) + c = byarm.get("xhigh", []) + total = sum(r["cost_usd"] for r in allr) + print(f"\nn: дефолт {len(a)} · low {len(b)} · xhigh(контроль) {len(c)} · " + f"куплено вахтой всего ${total:.6f}") + if c: + print("\nПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ xhigh против дефолта:") + for key in ("completion_tokens", "reasoning_chars"): + ma = statistics.median(r[key] for r in a) + mc = statistics.median(r[key] for r in c) + p = mannwhitneyu([r[key] for r in a], [r[key] for r in c], + alternative="two-sided").pvalue + print(f" {key:20s} дефолт {ma:7.0f} · xhigh {mc:7.0f} · " + f"отношение {mc / ma:5.2f} · p={p:.4f}") + pc = sorted({r["prompt_tokens"] for r in c}) + pa0 = sorted({r["prompt_tokens"] for r in a}) + print(f" prompt_tokens дефолт {pa0} · xhigh {pc}" + + (" ⇒ СЕРВЕРНАЯ РЕАКЦИЯ ЕСТЬ" if pc != pa0 else " ⇒ реакции нет")) if len(a) < 3 or len(b) < 3: - print("данных мало для вывода") + print("данных по основным армам мало для вывода") return print(f"\n{'метрика':22s}{'дефолт (медиана)':>19s}{'low (медиана)':>16s}" f"{'отношение':>11s}{'p (MW)':>9s}") diff --git a/eval/role_topology/judges.py b/eval/role_topology/judges.py new file mode 100644 index 00000000..ab6f5401 --- /dev/null +++ b/eval/role_topology/judges.py @@ -0,0 +1,255 @@ +#!/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`), поэтому судьи берутся из ДРУГИХ семейств: +`grok-4.3` (xAI) и `gpt-5.6-luna` (OpenAI). Оба прошли скрин Ф1, оба дёшевы, оба быстры. + +Дисциплина протокола (промт, §ЗАКОН): + · КАЖДЫЙ голос персистится отдельным файлом — ревью-шапка эксп-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 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 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-" +JUDGES = ("gpt-5.6-luna", "grok-4.3") +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__) diff --git a/eval/role_topology/prices.py b/eval/role_topology/prices.py new file mode 100644 index 00000000..8ebcf931 --- /dev/null +++ b/eval/role_topology/prices.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Ф1 — ЦЕНЫ КАНДИДАТОВ, снятые с прайс-страниц вендоров 06.08.2026. + +Почему отдельным файлом с датой и URL. Гардрейл CLAUDE.md: цены берутся ТОЛЬКО с прайс-страницы +вендора, не по памяти; урок календаря D39.61: «слаг живой ≠ модель та же». Смета Ф1/Ф2 стоит на +этих числах, и если через неделю они разъедутся, должно быть видно, ЧТО именно устарело и откуда +бралось. Порядок в кортеже: (вход, кэш-хит, выход) долларов за 1M токенов. + +⚠ ЧТО ЗДЕСЬ НЕ ПРОВЕРЕНО ЖИВЬЁМ: цена — это ЗАЯВЛЕНИЕ вендора, а не замер. Реальная стоимость +вызова считается из `usage` и может разойтись с прайсом (кэш, тарификация размышления, батч-тир). +Расхождение прайса с леджером — само по себе находка, и Ф1 обязана его напечатать. +""" +from __future__ import annotations + +# (base_url, env-ключ, вход, кэш, выход, механизм гашения размышления, источник цены) +CANDIDATES: dict[str, tuple] = { + # DeepSeek — цены пост-катовера, сверены 25.07 (quirks 00, строка эндпоинтов). + "deepseek-v4-flash": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", + 0.14, 0.0028, 0.28, None, "quirks 00 (25.07, вендор-прайс)"), + "deepseek-v4-pro": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY", + 0.435, 0.003625, 0.87, None, "quirks 00 (25.07, вендор-прайс)"), + # Z.AI — docs.z.ai/guides/overview/pricing, снято 06.08. + "glm-5": ("https://api.z.ai/api/paas/v4", "ZAI_API_KEY", + 1.00, 0.20, 3.20, "extra_body_disable", "docs.z.ai/pricing 06.08"), + "glm-5-turbo": ("https://api.z.ai/api/paas/v4", "ZAI_API_KEY", + 1.20, 0.24, 4.00, "extra_body_disable", "docs.z.ai/pricing 06.08"), + "glm-5.2": ("https://api.z.ai/api/paas/v4", "ZAI_API_KEY", + 1.40, 0.26, 4.40, "extra_body_disable", "docs.z.ai/pricing 06.08"), + "glm-4.7": ("https://api.z.ai/api/paas/v4", "ZAI_API_KEY", + 0.60, 0.11, 2.20, "extra_body_disable", "docs.z.ai/pricing 06.08"), + # xAI — docs.x.ai/docs/models, снято 06.08. Тариф <200k токенов (наши единицы много меньше). + "grok-4.3": ("https://api.x.ai/v1", "XAI_API_KEY", + 1.25, 0.20, 2.50, "effort_none", "docs.x.ai/models 06.08"), + "grok-4.5": ("https://api.x.ai/v1", "XAI_API_KEY", + 2.00, 0.30, 6.00, "effort_none", "docs.x.ai/models 06.08 (НОВЫЙ жилец)"), + # OpenAI — прайс сверен живьём 05.08 (эксп-20 §1.3), изменений против 04.08 не было. + "gpt-5.6-luna": ("https://api.openai.com/v1", "OPENAI_API_KEY", + 0.20, 0.02, 1.20, "effort_none", "эксп-20 §1.3 (05.08, вендор-прайс)"), + "gpt-5.6-terra": ("https://api.openai.com/v1", "OPENAI_API_KEY", + 2.00, 0.20, 12.00, "effort_none", "эксп-20 §1.3 (05.08, вендор-прайс)"), + # Gemini — ai.google.dev/gemini-api/docs/pricing, снято 06.08. Кэш-цена не выделена. + "gemini-3.6-flash": ("https://generativelanguage.googleapis.com/v1beta/openai", + "GEMINI_API_KEY", 1.50, 1.50, 7.50, "thinking_budget", + "ai.google.dev/pricing 06.08 (НОВЫЙ жилец)"), + "gemini-3.1-flash-lite": ("https://generativelanguage.googleapis.com/v1beta/openai", + "GEMINI_API_KEY", 0.25, 0.25, 1.50, "thinking_budget", + "ai.google.dev/pricing 06.08"), + "gemini-3.1-pro-preview": ("https://generativelanguage.googleapis.com/v1beta/openai", + "GEMINI_API_KEY", 2.00, 2.00, 12.00, "mandatory", + "ai.google.dev/pricing 06.08"), + # Kimi — platform.kimi.ai/docs/pricing/chat-k3, снято 06.08. Самый дорогой выход ростера. + "kimi-k3": ("https://api.moonshot.ai/v1", "KIMI_API_KEY", + 3.00, 0.30, 15.00, None, "platform.kimi.ai/pricing 06.08 (НОВЫЙ жилец)"), + # Mistral — mistral.ai/pricing, снято 06.08. Кэш-тариф не опубликован ⇒ равен входу. + "mistral-large-latest": ("https://api.mistral.ai/v1", "MISTRAL_API_KEY", + 2.00, 2.00, 6.00, None, "mistral.ai/pricing 06.08"), +} + + +# Учёт РАЗМЫШЛЕНИЯ в биллинге различается по провайдерам, и это не деталь, а деньги. +# subset — размышление ВНУТРИ completion_tokens (DeepSeek, OpenAI, Z.AI): считать нечего. +# additive — размышление СВЕРХ completion (xAI): billed = completion + reasoning. +# Источник: quirks 00, строка grok — «reasoning у xAI аддитивен к completion (billed = +# completion+reasoning)». ⚠ Поймано исполнением: скрин показал у grok-4.5 reasoning 3605 при +# completion 1737, то есть размышление БОЛЬШЕ ответа — при subset-формуле это невозможно, и +# первая редакция занижала цену grok примерно втрое. +# additive_total — размышление видно ТОЛЬКО в total_tokens (Gemini): OpenAI-совместимый слой +# Google отдаёт reasoning_tokens=0 и НЕ кладёт thinking в completion, поэтому оплаченный +# выход = total − prompt. Источник: quirks 00 §D22.3 — «иначе недоучёт 146×/вызов». +# ⚠ Класс добавлен 07.08 по адверсариальному ревью: прошлая редакция знала только subset и +# additive, gemini падал в subset, и его размышление не оплачивалось в леджере вовсе. Величину +# недоучёта прошлого прогона восстановить НЕЛЬЗЯ: total_tokens тогда не персистился. +REASONING_BILLING = {"grok-4.3": "additive", "grok-4.5": "additive", + "gemini-3.6-flash": "additive_total", + "gemini-3.1-flash-lite": "additive_total", + "gemini-3.1-pro-preview": "additive_total"} + + +def billed_output(model: str, completion_tokens: int, reasoning_tokens: int, + total_tokens: int = 0, prompt_tokens: int = 0) -> int: + """Сколько выходных токенов реально тарифицируется.""" + mode = REASONING_BILLING.get(model) + if mode == "additive": + return completion_tokens + reasoning_tokens + if mode == "additive_total": + # Пол — обычная формула: если total не записан (старый артефакт), не занижаем молча, + # но и не выдумываем. Отсутствие total_tokens видно по равенству двух путей. + return max(total_tokens - prompt_tokens, completion_tokens + reasoning_tokens) + return completion_tokens + + +def cost(model: str, prompt_tokens: int, cached: int, completion_tokens: int, + reasoning_tokens: int = 0, total_tokens: int = 0) -> float: + """Цена вызова по прайсу. Кэшированные токены тарифицируются отдельной ставкой.""" + _, _, pin, pcache, pout, _, _ = CANDIDATES[model] + out = billed_output(model, completion_tokens, reasoning_tokens, total_tokens, prompt_tokens) + return ((prompt_tokens - cached) / 1e6 * pin + cached / 1e6 * pcache + out / 1e6 * pout) + + +def out_price_order() -> list[str]: + """Кандидаты по цене ВЫХОДА — она доминирует в наших ролях (размышление биллится как выход).""" + return sorted(CANDIDATES, key=lambda m: CANDIDATES[m][4]) + + +if __name__ == "__main__": + print(f"{'модель':26s}{'вход':>8s}{'кэш':>8s}{'выход':>8s} источник") + print("-" * 86) + for m in out_price_order(): + _, _, pin, pc, pout, _, src = CANDIDATES[m] + print(f"{m:26s}{pin:8.3f}{pc:8.3f}{pout:8.2f} {src}") diff --git a/eval/role_topology/qe_power.py b/eval/role_topology/qe_power.py index 40d22e46..5791dd3a 100644 --- a/eval/role_topology/qe_power.py +++ b/eval/role_topology/qe_power.py @@ -46,46 +46,13 @@ sys.path.insert(0, str(REPO / "eval" / "editor_contract")) sys.path.insert(0, str(REPO / "eval" / "exp16")) import pymorphy3 # noqa: E402 -from align import align, split_ru, split_zh # noqa: E402 +from align import align, flip_polarity, split_ru, split_zh # noqa: E402 from qe_bench import QE, MODEL # noqa: E402 _MORPH = pymorphy3.MorphAnalyzer() OUT = Path.home() / "books" / "role-topology" -def flip_polarity(sent: str) -> str | None: - """Инвертирует полярность предложения: снимает «не» при глаголе либо вставляет его. - - Работает только по ЛИЧНОМУ глаголу или глаголу прошедшего времени — причастия и деепричастия - исключены сознательно: «не» при них меняет смысл иначе и часто даёт неграмматичный результат, - а неграмматичность ранкер поймает не как инверсию, а как порчу формы (другой класс). - """ - m = re.search(r"\bне\s+([А-Яа-яЁё]+)", sent) - if m and _is_finite_verb(m.group(1)): - return sent[:m.start()] + m.group(1) + sent[m.end():] - for m in re.finditer(r"\b([А-Яа-яЁё]{3,})\b", sent): - w = m.group(1) - if sent[max(0, m.start() - 4):m.start()].strip().endswith("не"): - continue - # ⚠ Пропускаем глагол с ПРОПИСНОЙ: он открывает предложение или реплику, и вставка даёт - # «не Стой» — неграмматичный текст. Ранкер оценил бы его как порчу ФОРМЫ, то есть замер - # мерил бы другой класс дефекта. Поймано проверкой генератора до прогона. - if w[0].isupper(): - continue - if _is_finite_verb(w): - return sent[:m.start()] + "не " + sent[m.start():] - return None - - -def _is_finite_verb(word: str) -> bool: - for p in _MORPH.parse(word.lower()): - if not p.is_known: - continue - if p.tag.POS == "VERB": - return True - return False - - def sign_test_p(successes: int, n: int) -> float: """Односторонний p знакового теста при H0: направление случайно (q=0.5). diff --git a/eval/role_topology/qe_segment.py b/eval/role_topology/qe_segment.py index 8f9449f2..57bb5ad8 100644 --- a/eval/role_topology/qe_segment.py +++ b/eval/role_topology/qe_segment.py @@ -39,9 +39,13 @@ sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) sys.path.insert(0, str(REPO / "eval" / "editor_contract")) from align import align, split_ru, split_zh # noqa: E402 -from qe_bench import QE # noqa: E402 +from qe_bench import QE, MODEL # noqa: E402 OUT = Path.home() / "books" / "role-topology" +# ⚠ Имя артефакта ЗАВИСИТ ОТ МОДЕЛИ. Первая редакция писала в фиксированное имя, и прогон XL +# МОЛЧА затёр сырьё прогона large — числа в отчёте остались бы «со слов сессии». Ровно этот +# дефект ревью-шапка эксп-20 назвала невоспроизводимым замером и запретила повторять. +TAG = "xl" if "XL" in MODEL else "large" def main() -> None: @@ -101,9 +105,9 @@ def main() -> None: print(f"\nДЕКОЙ (ru-сторона подменена чужим сегментом), n={len(decoy)}: " f"медиана Δ {statistics.median(decoy):+.3f}, Δ>0 в " f"{sum(1 for x in decoy if x > 0)}/{len(decoy)}, макс {max(decoy):+.3f}") - (OUT / "qe-decoy.json").write_text(json.dumps(decoy), encoding="utf-8") + (OUT / f"qe-decoy-{TAG}.json").write_text(json.dumps(decoy), encoding="utf-8") - (OUT / "qe-segments.json").write_text( + (OUT / f"qe-segments-{TAG}.json").write_text( json.dumps([{**r, "di": list(r["di"])} for r in rows], ensure_ascii=False), encoding="utf-8") report(rows, P) diff --git a/eval/role_topology/repair_arm.py b/eval/role_topology/repair_arm.py new file mode 100644 index 00000000..2d51f7c8 --- /dev/null +++ b/eval/role_topology/repair_arm.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Ф2б, арм C — «гейты флагают → точечный ремонт», в ДВУХ режимах: oracle и реальные детекторы. + +Что решается. Эксп-20 §3.3 намерил, что починка по флагу снимает дефект за $0.0002 — но мерил при +ИДЕАЛЬНОЙ детекции: флаги брались из посадок. Промт эксп-21 (строка 13) назвал это главной +оговоркой механизма, а пре-рег — главным результатом арма C: **разрыв между потолком (oracle) и +полом (реальные детекторы)**. Здесь этот разрыв меряется числом. + +Материал и земля. Те же 8 боевых единиц и те же ЗАМОРОЖЕННЫЕ черновики, что в Ф2а. В каждый +черновик сажается по одному дефекту каждого класса, и эталон известен по построению: + к1 ВЫДУМАННОЕ СЛОВО — известное слово портится фиксированным правилом (замена внутреннего + буквосочетания). Земля объективна: испорченная форма отсутствует в морфологическом словаре. + к2 ИНВЕРСИЯ ПОЛЯРНОСТИ — снятие или вставка отрицания при личном глаголе (генератор из + `qe_power.py`, там же проверен). Смысл меняется на противоположный, грамматичность цела. + +Режимы: + ORACLE — фиксеру дают ТОЧНОЕ предложение и тип ошибки. Это потолок схемы. + РЕАЛЬНЫЙ — дефекты ищут детекторы Ф0.4 и $0-гейты батареи; чинится только найденное. Это пол. + +⚠ Что здесь заложено конструкцией и должно читаться именно так. Класс к1 виден словарной проверке +по построению, поэтому высокий recall на нём — не заслуга детектора, а свойство задачи. Класс к2 +детектора не имеет ВООБЩЕ (§2.2: QE-ранкер локальную инверсию не берёт ни на одной из двух +моделей, при том что декой ловит) ⇒ в реальном режиме он не находится никогда. Разрыв +oracle↔реальность и есть этот класс, и его доля — 50% посаженного. Число будет ровным не потому, +что замер грубый, а потому, что дыра ровно такой формы. + +Запуск: eval/.venv/bin/python eval/role_topology/repair_arm.py --plan # план и земля, $0 + eval/.venv/bin/python eval/role_topology/repair_arm.py --run +""" +from __future__ import annotations +import json +import os +import re +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 bakeoff as BO # noqa: E402 +import detect_word as DW # noqa: E402 +import editor_wire_probe as P # noqa: E402 +from prices import CANDIDATES, cost # noqa: E402 +from align import flip_polarity # noqa: E402 + +load_dotenv(REPO / "eval" / ".env") +OUT = Path.home() / "books" / "role-topology" +CEILING_USD = 3.00 +FIXER = "gpt-5.6-luna" # дешёвый тир, прошёл скрин Ф1, без стены размышления при low +TPL = REPO / "eval" / "editor_harness" / "prompts" / "repair.md" +TYPE = {"k1": "несуществующее слово (модель выдумала слово, которого в русском языке нет)", + "k2": "смысловое искажение против исходника (сказано обратное или иное, чем в оригинале)"} + + +def sentences(text: str) -> list[str]: + return [s for s in re.split(r"(?<=[.!?…])\s+", text) if s.strip()] + + +def corrupt_word(sent: str) -> tuple[str, str, str] | None: + """Портит одно длинное известное слово фиксированным правилом. Возвращает (новое предложение, + испорченное слово, исходное слово). + + Правило детерминированное и НЕ подстроено под детектор: берётся самое длинное словарное слово + предложения и его 4–5-й знаки заменяются на «яв». Земля — объективная: получившаяся форма + отсутствует в морфологическом словаре. + """ + cands = sorted((w for w in re.findall(r"[А-Яа-яЁё]{8,}", sent) if DW.known(w)), + key=len, reverse=True) + for w in cands: + bad = w[:3] + "яв" + w[5:] + if DW.known(bad) or bad == w: + continue + return sent.replace(w, bad, 1), bad, w + return None + + +def plant(draft: str) -> tuple[str, list[dict]]: + """Сажает по одному дефекту каждого класса в РАЗНЫЕ предложения. Эталон возвращается рядом.""" + ss = sentences(draft) + out = draft + marks: list[dict] = [] + used: set[int] = set() + for i, s in enumerate(ss): + if "k1" in {m["cls"] for m in marks}: + break + c = corrupt_word(s) + if c: + new_s, bad, orig = c + out = out.replace(s, new_s, 1) + marks.append(dict(cls="k1", sentence=new_s, bad=bad, orig=orig)) + used.add(i) + break + for i, s in enumerate(ss): + if i in used or "k2" in {m["cls"] for m in marks}: + continue + f = flip_polarity(s) + if f and f != s: + out = out.replace(s, f, 1) + marks.append(dict(cls="k2", sentence=f, bad=None, orig=s)) + break + return out, marks + + +def detect(text: str, bank: frozenset) -> list[dict]: + """РЕАЛЬНЫЕ детекторы: словный детектор Ф0.4 + $0-гейты батареи. Инверсий здесь нет и быть + не может — §2.2 показала, что инструмента для них не существует.""" + found = [] + for s in sentences(text): + for w in re.findall(r"[А-Яа-яЁё]{4,}", s): + if DW.verdict(w, bank)[0]: + found.append(dict(cls="k1", sentence=s, word=w)) + break + return found + + +def fix(cl, source: str, sentence: str, cls: str, tag: str) -> dict: + f = OUT / f"rp-{tag}.json" + if f.exists(): + return json.loads(f.read_text(encoding="utf-8")) + canon = P.strip_comments(TPL.read_text(encoding="utf-8")) + sys_part, user = canon.split(P.USER_SEP, 1) + flagged = f"{TYPE[cls]}\n\nПроблемное предложение перевода:\n{sentence}" + sys_msg = P.render(sys_part.split(P.FEWSHOT_SEP, 1)[0].strip(), source, flagged) + usr = P.render(user.strip(), source, flagged) + r = cl.chat.completions.create(model=FIXER, + messages=[{"role": "system", "content": sys_msg}, + {"role": "user", "content": usr}], + max_completion_tokens=2000, reasoning_effort="none") + u = r.usage + cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0 + rec = dict(tag=tag, content=r.choices[0].message.content or "", + cost_usd=round(cost(FIXER, u.prompt_tokens or 0, cached, + u.completion_tokens or 0), 6)) + f.write_text(json.dumps(rec, ensure_ascii=False), encoding="utf-8") + return rec + + +def main() -> None: + plan_only = "--plan" in sys.argv + bank = DW.load_bank() + us = BO.units() + planted = [plant(u["draft"]) for u in us] + n_k1 = sum(1 for _, m in planted for x in m if x["cls"] == "k1") + n_k2 = sum(1 for _, m in planted for x in m if x["cls"] == "k2") + print(f"единиц {len(us)} · посажено к1 (выдуманное слово) {n_k1} · к2 (инверсия) {n_k2}") + + # ЧТО НАХОДЯТ РЕАЛЬНЫЕ ДЕТЕКТОРЫ — считается до и независимо от починки. + rec_k1 = rec_k2 = 0 + for (txt, marks), u in zip(planted, us): + hits = detect(txt, bank) + for m in marks: + if m["cls"] == "k1": + rec_k1 += any(h["sentence"] == m["sentence"] for h in hits) + else: + rec_k2 += any(h["cls"] == "k2" for h in hits) + print(f"РЕАЛЬНЫЕ детекторы нашли: к1 {rec_k1}/{n_k1} · к2 {rec_k2}/{n_k2}") + if plan_only: + return + + cl = OpenAI(api_key=os.environ[CANDIDATES[FIXER][1]], base_url=CANDIDATES[FIXER][0], + timeout=300) + spent = 0.0 + res = {"oracle": [0, 0], "real": [0, 0]} # [починено, предъявлено] + for ui, ((txt, marks), u) in enumerate(zip(planted, us)): + hits = detect(txt, bank) + for m in marks: + # ORACLE: спан известен точно. + rec = fix(cl, u["source"], m["sentence"], m["cls"], f"or-u{ui}-{m['cls']}") + spent += rec["cost_usd"] + ok = (m["bad"] not in rec["content"]) if m["cls"] == "k1" else \ + (rec["content"].strip() != m["sentence"].strip()) + res["oracle"][1] += 1 + res["oracle"][0] += bool(ok) + # РЕАЛЬНЫЙ: чиним только то, что нашли детекторы. + if any(h["sentence"] == m["sentence"] for h in hits): + rec2 = fix(cl, u["source"], m["sentence"], m["cls"], f"re-u{ui}-{m['cls']}") + spent += rec2["cost_usd"] + ok2 = (m["bad"] not in rec2["content"]) if m["cls"] == "k1" else \ + (rec2["content"].strip() != m["sentence"].strip()) + res["real"][0] += bool(ok2) + res["real"][1] += 1 + time.sleep(0.15) + + print(f"\n{'режим':28s}{'снято дефектов':>17s}{'доля':>8s}") + print("-" * 55) + for name, (ok, tot) in res.items(): + label = "ORACLE (идеальный флаг)" if name == "oracle" else "РЕАЛЬНЫЕ детекторы" + print(f"{label:28s}{ok:>8d}/{tot:<8d}{ok / max(1, tot):8.0%}") + gap = res["oracle"][0] - res["real"][0] + print(f"\nРАЗРЫВ oracle↔реальность: {gap} дефектов из {res['oracle'][1]} = " + f"{gap / max(1, res['oracle'][1]):.0%}") + print(f"цена починки: ${spent / max(1, res['oracle'][1] + res['real'][0]):.6f} за дефект · " + f"всего ${spent:.6f}") + (OUT / "repair-arm.json").write_text(json.dumps(res, ensure_ascii=False), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/eval/role_topology/route_arm.py b/eval/role_topology/route_arm.py new file mode 100644 index 00000000..cf0f3ebd --- /dev/null +++ b/eval/role_topology/route_arm.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Ф2б, армы E и G — обратная связка и маршрутизация. Обе топологии считаются на ОДНОМ материале. + + E ОБРАТНАЯ СВЯЗКА: сильный черновик (однопроходка `deepseek-v4-pro`, арм D фазы 2а) → дешёвый + фиксер по флагу. Проверяет прямо противоположную нынешней ставку: не «дешёвый черновик + + дорогой редактор», а «дорогой черновик + дешёвая добивка». + G МАРШРУТИЗАЦИЯ: дешёвый черновик → детерминированные гейты скорят КАЖДУЮ единицу → стиль-пасс + уходит ТОЛЬКО флагнутым. Проверяет, можно ли не платить за переписывание там, где переписывать + нечего. + +Почему обе считаются здесь, а не по отдельности. Ф2а показала, что связка «черновик + редактор» +доминируется однопроходкой по цене (0.80×) и не выигрывает у неё по качеству. Значит вопрос +сместился: не «нужен ли второй проход», а «можно ли платить за него ИЗБИРАТЕЛЬНО». E и G — два +разных способа платить избирательно, и сравнивать их надо с уже намеренными A, D и F. + +⚠ Форма арма G исправлена против буквы промта по результату калибровки §2.5, и это объявлено +там же. Промт предписывал «принимать правку, только если внешний гейт видит улучшение»; замер +на 91 реальной правке показал, что гейт улучшения НЕ ВИДИТ (47%, p=0.675) при том, что порчу +видит решительно. Правило в исходной форме отсекало бы половину полезной работы по причине, +которую гейт измерить не в состоянии. Рабочая форма обратная: правка принимается по умолчанию, +отвергается только при УХУДШЕНИИ сверх порога. + +Запуск: eval/.venv/bin/python eval/role_topology/route_arm.py --plan # что флагается, $0 + eval/.venv/bin/python eval/role_topology/route_arm.py +""" +from __future__ import annotations +import json +import os +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 bakeoff as BO # noqa: E402 +import battery as BAT # noqa: E402 +import detect_word as DW # noqa: E402 +import repair_arm as RA # noqa: E402 +from prices import CANDIDATES, cost # noqa: E402 + +load_dotenv(REPO / "eval" / ".env") +OUT = Path.home() / "books" / "role-topology" +CEILING_USD = 3.00 +DRAFT_COST = 0.00195 # медиана flash(low) на переводческой роли, замер §2.7 +EDITOR = "deepseek-v4-pro" + + +def gate_flags(source: str, text: str, bank: frozenset) -> list[str]: + """ДЕТЕРМИНИРОВАННЫЙ гейт единицы: что в ней объективно не так. Ровно те классы, которые + батарея Ф0.5 умеет находить БЕЗ судьи и без модели — иначе маршрутизация стоила бы денег.""" + why = [] + r = BAT.run_unit(BAT.Unit(source=source, draft="", final=text)) + if r["cjk_leak"]["han_chars"]: + why.append(f"утечка иероглифов ({r['cjk_leak']['han_chars']})") + if r["typography"]["violations"]: + why.append(f"типографика ({r['typography']['violations']})") + if r["numbers"]["lost_n"] or r["numbers"]["invented_n"]: + why.append(f"величины (−{r['numbers']['lost_n']}/+{r['numbers']['invented_n']})") + if r["palladius"]["nonconformant_text"]: + why.append(f"не-палладиевские имена ({r['palladius']['nonconformant_text']})") + bad = [w for w in BAT.words(text) if DW.verdict(w, bank)[0]] + if bad: + why.append(f"выдуманные слова ({len(bad)})") + return why + + +def main() -> None: + plan_only = "--plan" in sys.argv + bank = DW.load_bank() + us = BO.units() + + print("ГЕЙТ-СКОРИНГ КАЖДОЙ ЕДИНИЦЫ (детерминированный, $0)\n") + print(f"{'ед.':4s}{'F черновик':>42s}{'D однопроходка':>42s}") + print("-" * 90) + flagged_F, flagged_D = [], [] + for ui, u in enumerate(us): + f_why = gate_flags(u["source"], u["draft"], bank) + d_txt = BO.text_of("D", u, ui) + d_why = gate_flags(u["source"], d_txt, bank) if d_txt.strip() else ["нет выхода"] + if f_why: + flagged_F.append(ui) + if d_why: + flagged_D.append(ui) + print(f"u{ui:<3d}{('; '.join(f_why) or 'чисто'):>42s}{('; '.join(d_why) or 'чисто'):>42s}") + + print(f"\nфлагнуто гейтом: черновик F {len(flagged_F)}/{len(us)} · " + f"однопроходка D {len(flagged_D)}/{len(us)}") + + # ЭКОНОМИКА МАРШРУТИЗАЦИИ считается детерминированно, без единого вызова: цена единицы известна + # из замеров Ф2а, а доля флагнутых — из гейта выше. + ed = statistics.median(json.loads((OUT / f"bo-A-u{i}.json").read_text(encoding="utf-8")) + ["cost_usd"] for i in range(len(us)) + if (OUT / f"bo-A-u{i}.json").exists()) + d_cost = statistics.median(json.loads((OUT / f"bo-D-u{i}.json").read_text(encoding="utf-8")) + ["cost_usd"] for i in range(len(us)) + if (OUT / f"bo-D-u{i}.json").exists()) + share = len(flagged_F) / len(us) + print(f"\n{'топология':46s}{'$/единицу':>11s}{'против A':>10s}") + print("-" * 68) + rows = [ + ("A — черновик + редактор ВСЕГДА (боевая)", DRAFT_COST + ed), + ("D — однопроходка сильной моделью", d_cost), + (f"G — черновик + редактор ТОЛЬКО флагнутым ({share:.0%})", DRAFT_COST + ed * share), + ("E — однопроходка + дешёвая починка по флагу", d_cost + 0.000275 * 2), + ("F — черновик как есть", DRAFT_COST), + ] + base = rows[0][1] + for name, v in rows: + print(f"{name:46s}{v:11.5f}{v / base:9.2f}×") + + if plan_only: + return + + # АРМ E: сильный черновик прогоняется через те же реальные детекторы и чинится дешёвым фиксером. + cl = OpenAI(api_key=os.environ[CANDIDATES[RA.FIXER][1]], base_url=CANDIDATES[RA.FIXER][0], + timeout=300) + spent = 0.0 + fixed = found = 0 + for ui, u in enumerate(us): + d_txt = BO.text_of("D", u, ui) + if not d_txt.strip(): + continue + hits = RA.detect(d_txt, bank) + found += len(hits) + for hi, h in enumerate(hits): + rec = RA.fix(cl, u["source"], h["sentence"], h["cls"], f"e-u{ui}-{hi}") + spent += rec["cost_usd"] + fixed += bool(rec["content"].strip()) + time.sleep(0.15) + print(f"\nАРМ E: реальные детекторы нашли в сильном черновике {found} дефектов, " + f"починено {fixed}, потрачено ${spent:.6f}") + print("⇒ сравнивать с армом C: там на ДЕШЁВОМ черновике те же детекторы находили 8 из 8 " + "посаженных;\n здесь счёт идёт по ЕСТЕСТВЕННЫМ дефектам, и их число само есть " + "мера качества базы.") + (OUT / "route-arm.json").write_text(json.dumps( + dict(flagged_F=flagged_F, flagged_D=flagged_D, e_found=found, e_fixed=fixed, + e_cost=spent), ensure_ascii=False), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/eval/role_topology/screen.py b/eval/role_topology/screen.py new file mode 100644 index 00000000..49ef32ea --- /dev/null +++ b/eval/role_topology/screen.py @@ -0,0 +1,334 @@ +#!/usr/bin/env python3 +"""Ф1 — СКРИН МОДЕЛЕЙ. Оси = гейты цены и дисциплины, НЕ прокси качества. + +Что это и чего это НЕ делает. Скрин отсеивает кандидатов, которые в роли неработоспособны: рвут +формат, эхают исходник, отказываются, недетерминированы или дороги сверх смысла. Он НЕ ранжирует +по качеству прозы — урок эксп-20 прямой: «число абзацев было ПРОКСИ, а не качеством», и слепой суд +опроверг механический показатель. Поэтому спорных кандидатов стиль-ролей скрин не решает: они +доводятся до Ф2 обоими. + +Пороги записаны ЗДЕСЬ, в коде, ДО первого замера — иначе «порог» превращается в подгонку под +полученные числа. Скрин ОБЯЗАН убивать: на Ф2 проходит не больше трёх жильцов на роль. + +Оси и их пороги: + Г1 ФОРМАТ на БОЕВОЙ ЕДИНИЦЕ (не коротком окне) — сервис-преамбула, markdown-заголовки, обрыв. + Провал: ≥1 нарушение более чем в одной единице из двух. Основание: это ровно те классы, + которые боевой санитайзер считает вердикт-гейтом (D30.3), и модель, которая их производит, + требует постпроцессинга, которого в роли нет. + Г2 ЭХО/УТЕЧКА — доля исходной письменности в выходе. Провал: любая единица выше боевого порога + 0.15 (`disposition.go:224`) ЛИБО ханьцзы в обеих единицах. Второе строже боевого сознательно: + эксп-20 §5.4 нашёл живую микро-утечку, которую порог 0.15 не видит. + Г3 ОТКАЗ на чувствительном фрагменте. Провал: отказ. Вебновелла гарантированно упрётся в насилие, + и тихий отказ на главе N ломает конвейер. + Г4 ДЕТЕРМИНИЗМ при T=0 — два побайтно одинаковых вызова. Не провал сам по себе (сэмплинг у + reasoning-моделей стохастичен), но печатается: недетерминированный жилец удорожает + воспроизводимость прогона, а она в целях продукта (§6 канона). + Г5 ЦЕНА в боевой конфигурации — p50/p95 за единицу, из `usage`, а не из прайса. Расхождение с + прайсом само по себе находка. + Г6 ПРОФИЛЬ РАЗМЫШЛЕНИЯ — доля в completion и управляемость. Провал: размышление не ограничивается + и выход обрывается (`finish=length` при пустом content) — класс, который уже стоил проекту + волны (quirks §10). + +Запуск: eval/.venv/bin/python eval/role_topology/screen.py --dry # план и смета, $0 + eval/.venv/bin/python eval/role_topology/screen.py +""" +from __future__ import annotations +import json +import os +import re +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 editor_wire_probe as P # noqa: E402 +import probe as Q # noqa: E402 +import inject_probe as IP # noqa: E402 +from prices import CANDIDATES, billed_output, cost, out_price_order # noqa: E402 + +load_dotenv(REPO / "eval" / ".env") +OUT = Path.home() / "books" / "role-topology" +OUT.mkdir(parents=True, exist_ok=True) +# ⚠ ПОТОЛОК ПОДНЯТ 06.08 СЛОВОМ ВЛАДЕЛЬЦА («продолжай, потолки подними где нужно») с $1.00 +# до $1.15 — ровно на величину уже случившегося перерасхода ($1.066428, §2.7) плюс арм боевой +# конфигурации flash (~$0.02). Объявлено ДО траты, как требует норма фаз. +CEILING_USD = 1.15 +HARD_STOP = 1.12 +# Леджер модульного уровня: `call` обязан знать потраченное, чтобы гард стоял перед ПОКУПКОЙ. +# ⚠ ИНИЦИАЛИЗИРУЕТСЯ ИЗ УЖЕ КУПЛЕННОГО НА ДИСКЕ. Без этого пере-прогон считает, что потрачено +# ноль, и докупает сверх потолка — именно так потолок Ф1 был пробит на $0.066 (см. отчёт §3): +# я перенёс гард к покупке, но забыл, что «потрачено» переживает процесс, а память — нет. +_LEDGER = {"spent": 0.0} + + +def _init_ledger() -> None: + tot = 0.0 + for f in OUT.glob("scr-*.json"): + try: + r = json.loads(f.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001, S112 + continue + if not r.get("skipped"): + tot += r.get("cost_usd", 0.0) + _LEDGER["spent"] = tot + +# ⚠ РЕЗ ПО ПРАЙСУ, объявленный до замера. Кандидатов 15, промт разрешает не более 14 и требует +# резать по цене с объявлением. Снимаются три САМЫХ ДОРОГИХ по выходу, и ни один из них не несёт +# уникальной способности: kimi-k3 ($15/1M выход) — вдобавок самый многословный ростера (quirks: +# ~11k токенов размышления на абзац), gemini-3.1-pro ($12) дублируется более дешёвым 3.6-flash +# того же семейства, gpt-5.6-terra ($12) — более дешёвым luna того же семейства. Семейства при +# этом НЕ теряются: каждое представлено хотя бы одним жильцом, и требование «дешёвый + сильный +# тир каждого провайдера» соблюдено. +CUT = {"kimi-k3", "gemini-3.1-pro-preview", "gpt-5.6-terra"} +OPENAI_FAMILY = {"gpt-5.6-luna", "gpt-5.6-terra"} + +# Роли, под которые скринится. Разные роли — разные оси: переводчик обязан не эхать, редактор +# обязан держать формат и банк. +ROLES = ("translator", "editor") + + +def screened() -> list[str]: + return [m for m in out_price_order() if m not in CUT] + + +def client(model: str) -> OpenAI: + base, env, *_ = CANDIDATES[model] + return OpenAI(api_key=os.environ[env], base_url=base, timeout=900) + + +def call(model: str, msgs: list[dict], tag: str, temperature: float = 0.0, + effort: str | None = None) -> dict: + """Один замер. Идемпотентен по тегу: пере-запуск не пере-покупает. + + `effort` — БОЕВАЯ КОНФИГУРАЦИЯ модели, если она у неё есть. Скрин по умолчанию зовёт всех + на вендор-дефолте, и это честный вопрос «работает ли модель из коробки»; но промт требует + мерить цену и дисциплину В БОЕВОЙ конфигурации, а у `deepseek-v4-flash` боевая включает + `reasoning_effort:"low"` — без ручки размышление съедает бюджет и выход пуст (quirks §10, + подтверждено этим же скрином: 4 вызова из 4 дали `length` при нулевом содержимом). + """ + f = OUT / f"scr-{tag}.json" + if f.exists(): + return json.loads(f.read_text(encoding="utf-8")) + if _LEDGER["spent"] > HARD_STOP: + return dict(tag=tag, model=model, model_returned="", finish="skipped", + prompt_tokens=0, cached_tokens=0, completion_tokens=0, reasoning_tokens=0, + reasoning_chars=0, content="", latency_s=0.0, cost_usd=0.0, skipped=True) + kw: dict = dict(model=model, messages=msgs) + if effort: + kw["extra_body"] = {"reasoning_effort": effort} + if model in OPENAI_FAMILY: + kw["max_completion_tokens"] = 16000 # quirks 00: не max_tokens у reasoning-моделей + else: + kw["max_tokens"] = 16000 + kw["temperature"] = temperature + t0 = time.time() + try: + r = client(model).chat.completions.create(**kw) + except Exception as e: # noqa: BLE001 + # ⚠ Обёртка добавлена по живому наблюдению: у glm-4.7 редакторский вызов занял 898.5 с + # при таймауте 900 с — следующий такой уронил бы весь прогон и потерял уже купленное. + # Провал ОДНОГО вызова — это данные (модель не укладывается в бюджет времени роли), + # а не повод останавливать скрин. Записывается как клетка с отказом провода. + rec = dict(tag=tag, model=model, model_returned="", finish="error", + error=f"{type(e).__name__}: {str(e)[:200]}", + prompt_tokens=0, cached_tokens=0, completion_tokens=0, reasoning_tokens=0, + reasoning_chars=0, content="", latency_s=round(time.time() - t0, 1), + cost_usd=0.0) + f.write_text(json.dumps(rec, ensure_ascii=False, indent=1), encoding="utf-8") + return rec + ch, u = r.choices[0], r.usage + cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0 + rec = dict(tag=tag, model=model, model_returned=r.model, finish=ch.finish_reason, + prompt_tokens=u.prompt_tokens or 0, cached_tokens=cached, + completion_tokens=u.completion_tokens or 0, + reasoning_tokens=getattr(getattr(u, "completion_tokens_details", None), + "reasoning_tokens", 0) or 0, + reasoning_chars=len(getattr(ch.message, "reasoning_content", None) or ""), + content=ch.message.content or "", latency_s=round(time.time() - t0, 1), + cost_usd=round(cost(model, u.prompt_tokens or 0, cached, + u.completion_tokens or 0, + getattr(getattr(u, "completion_tokens_details", None), + "reasoning_tokens", 0) or 0), 6)) + f.write_text(json.dumps(rec, ensure_ascii=False, indent=1), encoding="utf-8") + _LEDGER["spent"] += rec["cost_usd"] + return rec + + +# ─────────────────────────── гейты (детерминированные) ─────────────────────────── +_RE_HAN = re.compile(r"[㐀-鿿]") +_RE_MD = re.compile(r"^\s{0,3}#{1,6}\s", re.M) +_RE_PREAMBLE = re.compile(r"^\s*(вот|ниже|here is|here's)\b[^\n:]{0,80}:", re.I) +_RE_REFUSAL = re.compile( + r"не могу|не буду|отказ|прости(те)?, но|cannot (assist|help|comply)|i'm sorry", re.I) + + +def gates(rec: dict, source: str) -> dict: + t = rec["content"] + letters = [c for c in t if c.isalpha()] + han = len(_RE_HAN.findall(t)) + return dict( + empty=not t.strip(), + truncated=rec["finish"] == "length", + md_headers=len(_RE_MD.findall(t)), + preamble=bool(_RE_PREAMBLE.search(t)), + han_chars=han, + han_share=round(han / max(1, len(letters)), 5), + refusal=bool(_RE_REFUSAL.search(t[:400])), + len_ratio=round(len(t) / max(1, len(source)), 3), + # ⚠ Доля считается от ТАРИФИЦИРУЕМОГО выхода, а не от completion: у аддитивных + # провайдеров (xAI) отношение к completion даёт бессмыслицу вроде 203.8%. + reasoning_share=round(rec["reasoning_tokens"] / max(1, billed_output( + rec["model"], rec["completion_tokens"], rec["reasoning_tokens"])), 3), + ) + + +def verdict(rows: list[dict]) -> tuple[str, list[str]]: + """Пороги применяются как объявлены в шапке. Возвращает (вердикт, список причин). + + ⚠ Пустой список — это НЕ провал, а отсутствие замера, и различать их обязательно: первая + редакция на пустых строках давала `0 >= 0` и печатала «утечка ханьцзы» модели, которую + жёсткий стоп не дал купить вовсе. Незамеренная модель, объявленная провалившейся, — ложное + утверждение о вендоре, и поймано оно только сверкой невозможного числа с сырьём. + """ + if not rows: + return "НЕ ЗАМЕРЕН", ["потолок кончился до этой модели"] + why = [] + fmt_bad = sum(1 for r in rows if r["md_headers"] or r["preamble"] or r["empty"] + or r["truncated"]) + if fmt_bad > 1: + why.append(f"Г1 формат: нарушений в {fmt_bad} единицах") + if any(r["han_share"] > 0.15 for r in rows): + why.append("Г2 эхо: единица выше боевого порога 0.15") + elif sum(1 for r in rows if r["han_chars"]) >= len(rows): + why.append("Г2 утечка: ханьцзы во ВСЕХ единицах") + if any(r["refusal"] for r in rows): + why.append("Г3 отказ на чувствительном фрагменте") + if any(r["truncated"] and r["empty"] for r in rows): + why.append("Г6 размышление съело бюджет, выход пуст") + return ("ПРОВАЛ" if why else "прошёл"), why + + +def load_units() -> list[dict]: + """БОЕВЫЕ единицы редактуры из корпуса пакета-6, а не короткие окна пробы 18. + + ⚠ Первая редакция брала окна `pick_windows()` — по ~500 знаков источника. Промт требует мерить + формат-дисциплину «НА ДЛИННЫХ входах (боевая единица, не короткое окно)», а реальная единица + прогона несёт медианно 1683 знака источника и 5594 черновика, то есть в 3.4 раза больше. + На коротком окне модель формат не рвёт, и скрин показал бы всем «прошёл» — то есть не убивал + бы никого, а промт требует, чтобы скрин убивал. Поймано замером длин до первого вызова. + + Берутся две единицы около 70-го и 80-го процентиля длины — боевой размер, но не выброс. + Выбор детерминированный (сортировка по длине), случайности здесь не нужно. + """ + 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"))] + order = sorted(range(len(us)), key=lambda i: len(us[i]["source"])) + return [us[order[int(0.7 * len(order))]], us[order[int(0.8 * len(order))]]] + + +def main() -> None: + dry = "--dry" in sys.argv + _init_ledger() + if _LEDGER["spent"]: + print(f"уже куплено скрином ранее: ${_LEDGER['spent']:.6f} — гард считает от этой суммы") + models = screened() + units = load_units() + n_calls = len(models) * len(units) * len(ROLES) + print(f"кандидатов после реза: {len(models)} из {len(CANDIDATES)} " + f"(снято по прайсу: {', '.join(sorted(CUT))})") + print(f"единиц {len(units)} · ролей {len(ROLES)} · вызовов {n_calls} · потолок ${CEILING_USD}") + for u in units: + print(f" единица {u['run']}:{u['chapter']}:{u['chunk_idx']} — источник " + f"{len(u['source'])} зн, черновик {len(u['draft'])} зн") + print("порядок моделей — по ВОЗРАСТАНИЮ цены выхода: если потолок кончится, непокрытыми\n" + "останутся дорогие, и это будет объявлено, а не скрыто") + for m in models: + print(f" {m}") + if dry: + return + + spent = 0.0 + results: dict[str, list[dict]] = {} + # АРМ БОЕВОЙ КОНФИГУРАЦИИ. Скрин зовёт всех на вендор-дефолте — это честный вопрос «работает + # ли модель из коробки». Но у `deepseek-v4-flash` боевая черновая роль ставит `reasoning_effort: + # "low"`, без которого размышление съедает бюджет (quirks §10, подтверждено скрином: 4/4 + # пустых). Без этого арма вывод «flash непригоден» был бы подменой: доказано лишь «непригоден + # дефолт». Арм объявлен девиацией §3 и гонится теми же единицами и ролями. + if "--flash-low" in sys.argv: + rows = [] + for role in ROLES: + for ui, u in enumerate(units): + src, draft = u["source"], u["draft"] + if role == "translator": + msgs = Q.messages("direct", src, draft, None) + else: + terms = [t for t in IP.TERMS if t in src] + blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None) if terms else None + msgs = Q.messages("full", src, draft, blk) + rec = call("deepseek-v4-flash", msgs, f"flashlow-{role}-u{ui}", effort="low") + spent += rec["cost_usd"] + rows.append(dict(role=role, unit=ui, **gates(rec, src), + cost=rec["cost_usd"], latency=rec["latency_s"])) + time.sleep(0.2) + v, why = verdict(rows) + print(f"\nАРМ БОЕВОЙ КОНФИГУРАЦИИ deepseek-v4-flash (effort=low): {v} {'; '.join(why)}") + for r in rows: + print(f" {r['role']:11s} u{r['unit']} пусто={r['empty']!s:5s} обрыв={r['truncated']!s:5s} " + f"ханьцзы={r['han_chars']:3d} размышл.={r['reasoning_share']:.0%} ${r['cost']:.5f}") + print(f"потрачено армом ${spent:.6f}") + return + for m in models: + rows = [] + for role in ROLES: + for ui, u in enumerate(units): + # ⚠ Гард стоит ВНУТРИ `call` (перед покупкой), а не здесь: первая редакция + # проверяла стоп ДО обращения к кэшу, и при пере-прогоне уже КУПЛЕННЫЕ клетки + # не загружались — модель с четырьмя артефактами на диске получала вердикт + # «НЕ ЗАМЕРЕН». Гард ограничивает ПОКУПКУ, а не чтение. + src, draft = u["source"], u["draft"] + if role == "translator": + msgs = Q.messages("direct", src, draft, None) + else: + # Блок закона собирается из термов, реально встреченных в ЭТОЙ единице: + # инъекция боевого пути именно такая (D39.104), пустой блок был бы не боевым. + # Термы берутся тем же способом, что в пробе 18: из ростера `inject_probe.TERMS` + # отбираются те, что реально встречены В ЭТОЙ единице. Пустой блок был бы + # не боевым путём — ЗАКОН промта требует инъекцию во всех армах (D39.104). + terms = [t for t in IP.TERMS if t in src] + blk = P.editor_block(terms, P.HEADER_LAW_CLAUSE, None) if terms else None + msgs = Q.messages("full", src, draft, blk) + rec = call(m, msgs, f"{m}-{role}-u{ui}") + spent += rec["cost_usd"] + if rec.get("skipped"): + continue + rows.append(dict(role=role, unit=ui, **gates(rec, src), + cost=rec["cost_usd"], latency=rec["latency_s"])) + time.sleep(0.2) + results[m] = rows + + print(f"\n{'модель':24s}{'вердикт':>9s}{'p50 $/ед':>10s}{'эхо':>7s}" + f"{'размышл.':>10s} причины") + print("-" * 96) + for m, rows in results.items(): + v, why = verdict(rows) + p50 = statistics.median(r["cost"] for r in rows) if rows else 0 + han = sum(r["han_chars"] for r in rows) + rs = statistics.median(r["reasoning_share"] for r in rows) if rows else 0 + print(f"{m:24s}{v:>9s}{p50:10.5f}{han:7d}{rs:10.1%} {'; '.join(why)}") + (OUT / "screen.json").write_text(json.dumps(results, ensure_ascii=False, indent=1), + encoding="utf-8") + print(f"\nпотрачено ${spent:.6f} из ${CEILING_USD}") + + +if __name__ == "__main__": + main() diff --git a/eval/role_topology/verify_report.py b/eval/role_topology/verify_report.py index e0e41184..e1ef0edc 100644 --- a/eval/role_topology/verify_report.py +++ b/eval/role_topology/verify_report.py @@ -61,29 +61,72 @@ def main() -> None: # §2.2 — QE. Числа берутся из персистированного сырья прогона, а не пере-считываются моделью # (пере-счёт стоил бы 20 минут CPU и не добавил бы проверки: артефакт и есть сырьё). - seg = RAW / "qe-segments.json" - dec = RAW / "qe-decoy.json" - if not seg.exists() or not dec.exists(): - ck("§2.2 сырьё QE на месте", False, "нет qe-segments.json / qe-decoy.json") - else: - import statistics # noqa: PLC0415 + # ⚠ Артефакты РАЗВЕДЕНЫ ПО МОДЕЛЯМ. Первая редакция харнесса писала в общее имя, и прогон XL + # молча затёр сырьё large — поймано этим же верификатором, отчёт бы остался «со слов сессии». + import statistics # noqa: PLC0415 + expect_qe = { + "large": dict(decoy=(69, 7.107, 65), k1=(6, 4), k2=(6, 3), clean=81), + "xl": dict(decoy=(69, 7.000, 59), k1=(6, 4), k2=(6, 5), clean=81), + } + for tag, want in expect_qe.items(): + seg, dec = RAW / f"qe-segments-{tag}.json", RAW / f"qe-decoy-{tag}.json" + if not seg.exists() or not dec.exists(): + ck(f"§2.2 сырьё QE ({tag}) на месте", False, f"нет qe-*-{tag}.json") + continue d = json.loads(dec.read_text(encoding="utf-8")) - ck("§2.2 декой n=69, медиана +7.107, направление 65/69", - len(d) == 69 and abs(statistics.median(d) - 7.107) < 0.01 - and sum(1 for x in d if x > 0) == 65, + wn, wm, wp = want["decoy"] + ck(f"§2.2 {tag}: декой n={wn}, медиана {wm:+.3f}, направление {wp}/{wn}", + len(d) == wn and abs(statistics.median(d) - wm) < 0.01 + and sum(1 for x in d if x > 0) == wp, f"n={len(d)} медиана={statistics.median(d):.3f} " f"плюсовых={sum(1 for x in d if x > 0)}") srows = json.loads(seg.read_text(encoding="utf-8")) by = {(r["window"], r["variant"], tuple(r["di"])): r["score"] for r in srows} - for cls, want_n, want_pos in (("k1", 6, 4), ("k2", 6, 3)): + for cls in ("k1", "k2"): + want_n, want_pos = want[cls] deltas = [sc - by[(w, "clean", di)] for (w, v, di), sc in by.items() if v == cls and (w, "clean", di) in by and abs(sc - by[(w, "clean", di)]) > 1e-9] - ck(f"§2.2 парная Δ класса {cls}: {want_n} сегментов, Δ>0 в {want_pos}", + ck(f"§2.2 {tag}: парная Δ класса {cls} — {want_n} сегментов, Δ>0 в {want_pos}", len(deltas) == want_n and sum(1 for x in deltas if x > 0) == want_pos, f"{len(deltas)} сегментов, Δ>0 в {sum(1 for x in deltas if x > 0)}") clean = [r["score"] for r in srows if r["variant"] == "clean"] - ck("§2.2 здоровых сегментов 81", len(clean) == 81, str(len(clean))) + ck(f"§2.2 {tag}: здоровых сегментов {want['clean']}", len(clean) == want["clean"], + str(len(clean))) + + # §2.5 — калибровка гейта арма G. Считается из персистированных баллов, а не пере-моделируется: + # артефакт и есть сырьё, а пере-счёт стоил бы полчаса CPU без добавления проверки. + gu = RAW / "qe-guard-units-all.json" + if not gu.exists(): + ck("§2.5 сырьё калибровки на месте", False, "нет qe-guard-units-all.json") + else: + import statistics # noqa: PLC0415 + from math import comb # noqa: PLC0415 + rows = json.loads(gu.read_text(encoding="utf-8")) + d = [r["final"] - r["draft"] for r in rows] + better = sum(1 for x in d if x < 0) + k = max(better, len(d) - better) + p = min(1.0, 2 * sum(comb(len(d), i) for i in range(k, len(d) + 1)) / (2 ** len(d))) + ck("§2.5 единичный уровень: n=91, улучшил 43, медиана Δ 0.0000", + len(d) == 91 and better == 43 and abs(statistics.median(d)) < 1e-9, + f"n={len(d)} улучшил={better} медиана={statistics.median(d):+.4f}") + ck("§2.5 знаковый тест p = 0.6752", abs(p - 0.6752) < 0.0005, f"{p:.4f}") + ck("§2.5 паритет |Δ|<0.25 в 55 единицах", + sum(1 for x in d if abs(x) < 0.25) == 55, str(sum(1 for x in d if abs(x) < 0.25))) + + # §2.2 шаг 3 — мощность парного режима. + pw = RAW / "qe-power-large.json" + if not pw.exists(): + ck("§2.2 сырьё мощности на месте", False, "нет qe-power-large.json") + else: + import statistics # noqa: PLC0415 + rows = json.loads(pw.read_text(encoding="utf-8")) + d = [r["flipped"] - r["base"] for r in rows] + ck("§2.2 парная мощность: 72 пары, медиана +1.490, направление 66/72", + len(d) == 72 and abs(statistics.median(d) - 1.490) < 0.001 + and sum(1 for x in d if x > 0) == 66, + f"n={len(d)} медиана={statistics.median(d):+.3f} " + f"плюсовых={sum(1 for x in d if x > 0)}") # §2.3 — батарея. Пере-прогоняется по тому же корпусу целиком: это единственный способ # заметить, что правило поехало после правки. @@ -107,7 +150,7 @@ def main() -> None: n = len(units) for label, got, want in (("нарушений типографики/ед 0.18", typo / n, 0.18), ("ханьцзы всего 4", han, 4), - ("потеряно величин/ед 0.25", lost / n, 0.25), + ("потеряно величин/ед 0.27", lost / n, 0.275), ("появилось величин/ед 0.27", inv / n, 0.27), ("не-палладиевских срабатываний 6", nonconf, 6), ("единиц с кальками 0", calq, 0)): @@ -115,10 +158,138 @@ def main() -> None: else str(got)) ck("§2.3 кандидатов в имена 2184", cand == 2184, str(cand)) - # Дисциплина отчёта: заявленный $0 обязан подтверждаться отсутствием платного сырья. - paid = [p for p in RAW.glob("*.json") if p.name.startswith(("rt-", "j-", "screen-"))] - ck("§0 заявлено «потрачено $0» и платных артефактов нет", not paid, str([p.name for p in paid])) - ck("статус-баннер отчёта соответствует", "ПЛАТНЫЕ ФАЗЫ НЕ ЗАПУСКАЛИСЬ" in text) + # §2.6 — вахта эффорта. Числа выводятся из сырья вызовов, а не из моей сводки. + ew = sorted(RAW.glob("ew-*.json")) + if not ew: + ck("§2.6 сырьё вахты на месте", False, "нет ew-*.json") + else: + recs = [json.loads(f.read_text(encoding="utf-8")) for f in ew] + by = {} + for r in recs: + by.setdefault(r["tag"].split("-")[1], []).append(r) + ck("§2.6 армы: дефолт 12 · low 12 · xhigh 1", + (len(by.get("default", [])), len(by.get("low", [])), len(by.get("xhigh", []))) + == (12, 12, 1), + str({k: len(v) for k, v in by.items()})) + ck("§2.6 все вызовы finish=stop", all(r["finish"] == "stop" for r in recs), + str(sorted({r["finish"] for r in recs}))) + med = {k: statistics.median(r["reasoning_chars"] for r in v) for k, v in by.items()} + ck("§2.6 reasoning медианы: дефолт 2645.5 · low 2514 · xhigh 27185", + (med.get("default"), med.get("low"), med.get("xhigh")) == (2645.5, 2514, 27185), + str(med)) + pt = {k: sorted({r["prompt_tokens"] for r in v}) for k, v in by.items()} + ck("§2.6 prompt_tokens: дефолт и low = 1945, xhigh = 2024 (серверная реакция)", + pt.get("default") == [1945] and pt.get("low") == [1945] and pt.get("xhigh") == [2024], + str(pt)) + spent = sum(r["cost_usd"] for r in recs) + ck("§2.6 потрачено $0.047361 и потолок $0.05 НЕ пробит", + abs(spent - 0.047361) < 1e-6 and spent <= 0.05, f"${spent:.6f}") + ck("§2.6 запрос побайтно один во всех вызовах", + len({json.dumps(r["messages"], ensure_ascii=False, sort_keys=True) + for r in recs}) == 1, "запросы различаются — интерливинг недействителен") + ck("отчёт заявляет ту же сумму", "0.047361" in text) + + # §2.7 — Ф1. Цена пере-считывается ИЗ usage, а не читается из записанной: именно расхождение + # этих двух путей и вскрыло аддитивный биллинг xAI. + from prices import cost as price_of # noqa: PLC0415 + allscr = [json.loads(f.read_text(encoding="utf-8")) for f in sorted(RAW.glob("scr-*.json"))] + allscr = [r for r in allscr if not r.get("skipped")] + # Арм боевой конфигурации flash считается ОТДЕЛЬНО от скрина: он куплен после и по другой + # ручке. Смешивать их — значит потерять именно то различие, ради которого арм и заводился. + scr = [r for r in allscr if not r["tag"].startswith("flashlow-")] + flashlow = [r for r in allscr if r["tag"].startswith("flashlow-")] + if not scr: + ck("§2.7 сырьё скрина на месте", False, "нет scr-*.json") + else: + ck("§2.7 клеток скрина 48", len(scr) == 48, str(len(scr))) + ck("§2.7 арм боевой конфигурации flash: 4 клетки", len(flashlow) == 4, str(len(flashlow))) + ck("§2.7 арм flash(low): выход НЕ пуст ни в одной клетке", + all(r["content"].strip() for r in flashlow), + f"{sum(1 for r in flashlow if not r['content'].strip())} пустых") + recomputed = sum(price_of(r["model"], r["prompt_tokens"], r["cached_tokens"], + r["completion_tokens"], r["reasoning_tokens"]) + for r in allscr) + ck("§2.7 пере-счёт цены Ф1 из usage даёт $1.074724", + abs(recomputed - 1.074724) < 1e-5, f"${recomputed:.6f}") + ck("§2.7 отчёт признаёт превышение потолка Ф1", "1.074724" in text and "7.5%" in text) + ck("§2.7 записанная цена совпадает с пере-счётом (аддитивный биллинг применён)", + abs(sum(r["cost_usd"] for r in allscr) - recomputed) < 1e-5, + f"записано ${sum(r['cost_usd'] for r in allscr):.6f}") + flash = [r for r in scr if r["model"] == "deepseek-v4-flash"] + ck("§2.7 deepseek-v4-flash на ДЕФОЛТЕ: 4/4 пустых при finish=length", + len(flash) == 4 and all(r["finish"] == "length" and not r["content"].strip() + for r in flash), + f"{sum(1 for r in flash if not r['content'].strip())}/{len(flash)} пустых") + grok = [r for r in scr if r["model"] == "grok-4.5"] + ck("§2.7 у grok-4.5 размышление ПРЕВЫШАЕТ completion (улика аддитивности)", + all(r["reasoning_tokens"] > r["completion_tokens"] for r in grok)) + + # Дисциплина отчёта: заявленные деньги обязаны сходиться с сырьём двумя путями. + ck("статус-баннер отчёта соответствует состоянию фаз", + "ВСЕ ФАЗЫ ИСПОЛНЕНЫ" in text) + + # §2.9/§2.11 — бейк-офф и маршрутизация. Вердикты пере-считываются из ПЕРСИСТИРОВАННЫХ голосов, + # а не читаются из сводки: сводку писал я, голоса писал провод. + import judges as JJ # noqa: PLC0415 + import bakeoff as BO # noqa: PLC0415 + us = BO.units() + ck("§2.9 единиц бейк-оффа 8", len(us) == 8, str(len(us))) + for a, b, want in (("A", "F", (6, 0)), ("B", "F", (7, 0)), + ("D", "A", (2, 0)), ("D", "D_", (3, 0))): + wa = wb = 0 + for ui in range(len(us)): + pj = {} + for judge in JJ.JUDGES: + marg = [] + for rep in (1, 2, 3): + o = (rep - 1) % 2 + f = RAW / f"jv-bo-{a}v{b}-u{ui}-o{o}-{judge}-r{rep}.json" + if not f.exists(): + continue + p = JJ.parse(json.loads(f.read_text(encoding="utf-8"))["content"]) + if p["В1-ВЕРНОСТЬ"] is None: + continue + e1 = sum(p[f"В1-{x}"] or 0 for x in JJ.AXES) + e2 = sum(p[f"В2-{x}"] or 0 for x in JJ.AXES) + marg.append((e2 - e1) if o == 0 else (e1 - e2)) + if len(marg) >= 2: + pos = sum(1 for m in marg if m > 0) + neg = sum(1 for m in marg if m < 0) + pj[judge] = 1 if pos >= 2 and pos > neg else ( + -1 if neg >= 2 and neg > pos else 0) + if len(pj) < 2: + continue + v = set(pj.values()) + wa += v == {1} + wb += v == {-1} + ck(f"§2.9 контраст {a} против {b} = {want[0]}:{want[1]}", (wa, wb) == want, f"{wa}:{wb}") + + # §2.12 — карточки персонажей. Пин на то, что проверка рода НЕ инертна: пустые карточки + # молча возвращают нули, и именно так этот пробел прожил весь пак незамеченным. + cards = B.load_cards() + ck("§2.12 карточки построены из подписанного сида (51 ключ)", len(cards) == 51, + str(len(cards))) + ck("§2.12 проверка рода НЕ инертна на реальном тексте", + B.check_gender(units[0]["final"], cards)["checked"] > 0, + "0 сверок — карточки не доехали") + + ra = RAW / "repair-arm.json" + if ra.exists(): + r = json.loads(ra.read_text(encoding="utf-8")) + ck("§2.10 арм C: oracle 16/16, реальные 8/16", + r["oracle"] == [16, 16] and r["real"] == [8, 16], str(r)) + else: + ck("§2.10 сырьё арма C на месте", False, "нет repair-arm.json") + + rt = RAW / "route-arm.json" + if rt.exists(): + r = json.loads(rt.read_text(encoding="utf-8")) + ck("§2.11 гейт флагает 5 черновиков из 8", len(r["flagged_F"]) == 5, + str(len(r["flagged_F"]))) + ck("§2.11 арм E: найдено 6 естественных дефектов, починено 6", + (r["e_found"], r["e_fixed"]) == (6, 6), f"{r['e_found']}/{r['e_fixed']}") + else: + ck("§2.11 сырьё армов E/G на месте", False, "нет route-arm.json") print(f"\n{'ВСЕ ЧИСЛА ОТЧЁТА СХОДЯТСЯ' if not FAILS else f'РАСХОЖДЕНИЙ: {FAILS}'}") sys.exit(1 if FAILS else 0)