#!/usr/bin/env python3 """Ф2а — БЕЙК-ОФФ «переписывание как класс». Отвечает на главный вопрос пака. Вопрос владельца, ради которого существует эксп: нужен ли второй проход вообще, и не выгоднее ли переводить сразу сильной моделью. Экспы 18–20 подошли к нему сбоку (контракт редактора, цена диффа, роль редактора), но самого бейк-оффа топологий не гоняли ни разу. Армы. Черновики ФРИЗЯТСЯ и подаются идентичными всем редакторским армам — это парный дизайн, и он даёт кратный выигрыш мощности бесплатно. Черновики берутся готовые из корпуса пакета-6 (их сделал боевой `deepseek-v4-flash` — ⚠ поля модели в записи корпуса НЕТ, провенанс не проверяем), поэтому арм F не имеет собственной цены: она утрачена вместе с породившим вызовом. F do-nothing пол: черновик как есть. Все редакторские армы мерятся ПРОТИВ НЕГО, а не друг против друга — иначе «B лучше A» ничего не говорит о том, нужен ли второй проход вообще. A боевой бейзлайн: черновик → `deepseek-v4-pro`, полное переписывание. B то же, но редактором `glm-5` (эксп-20 нашёл, что у него второй проход отрабатывает, а у dspro вредит — здесь это проверяется на боевых единицах и парно). D однопроходка: `deepseek-v4-pro` переводит ИСХОДНИК с нуля, черновика не видит. D′ она же БЕЗ мандата перевёрстки — деконфаунд «модель против мандата». Протокол сравнения задан замером Ф0.6, а не вкусом: судьи не выдумывают перевес на идентичных текстах, но их АБСОЛЮТНЫЙ счёт ошибок плавает ⇒ сравниваем только ПАРНО, полным скрещиванием порядков; вердикт требует совпадения знака в ОБОИХ порядках у обоих судей, а несущая статистика — непрерывный перевес по единицам с bootstrap-ДИ и поправкой Holm по семейству контрастов. Контрасты выбраны так, чтобы каждый отвечал на отдельный вопрос, а не наращивал число сравнений: 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 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 # noqa: E402 load_dotenv(REPO / "eval" / ".env") OUT = Path.home() / "books" / "role-topology" CEILING_USD_МЁРТВ = None # ⚠ объявлен и НИКЕМ не читается; # оставлен видимым маркером: охраны здесь нет, покупки идут мимо кассы (ревью 07.08) N_UNITS = 16 # Один голос на КАЖДЫЙ порядок. Позиция гасится точно, а не «в среднем по повторам»: перевес # единицы = среднее двух зеркальных голосов. Мощность добирается ЕДИНИЦАМИ (8→16), а не # повторами — единицы дают и разброс, и обобщение, повторы только разброс. REPS_PER_ORDER = 1 # ⚠ ПОТОЛКИ ВТОРОЙ РЕДАКЦИИ объявлены ДО первой покупки (санкция владельца 07.08 «деньги # потратить разрешаю»). Первая редакция Ф2а стоила $1.3777 и признана негодной по раскладке. # ⚠ ПОТОЛКИ ТРЕТЬЕЙ РЕДАКЦИИ, объявлены в §9 отчёта ДО первой покупки. Кассы читают диск по # префиксам `bo2-*`/`jv2-*`, то есть новые покупки ложатся в ТЕ ЖЕ счётчики; без пере-объявления # первая крупная покупка ушла бы в `skipped` с пустым выходом, а свод молча выбросил бы единицу. # Тихое усечение n — тот же класс, что снял первую редакцию, поэтому потолок поднимается ЗАРАНЕЕ # и печатается, а не подгоняется в момент отказа. CEIL_ARMS = 1.60 CEIL_JUDGE = 3.20 # ⚠ ГЛОБ ОБЯЗАН ПОКРЫВАТЬ ВСЕ ТЕГИ, КОТОРЫЕ ЭТА КАССА ОПЛАЧИВАЕТ. Поймано адверсариальным ревью # 07.08 исполнением: касса глобила только `bo2-*`, а `run_arm3` покупает тегом `bo4-*`, поэтому # $0.29309 армов ТРЕТЬЕЙ редакции прошли мимо потолка — `afford()` считал их несуществующими. # Это ровно тот класс дефекта, который шапка `buy.py` объявляет починенным, и он вернулся через # новый префикс. Правило: заводишь новый тег — добавляешь его в глоб кассы В ТОМ ЖЕ коммите. LED_ARMS = BUY.Ledger("армы Ф2а-2", CEIL_ARMS, ("bo2-*.json", "bo4-*.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) # Ключ (длина, uid), а не одна длина: одинаковая длина иначе разрешалась бы порядком обхода # словаря, то есть неявной стабильностью. Здесь она сейчас есть, а в паре en её не было — # там тот же приём на `set()` дал РАЗНЫЕ наборы единиц в двух вызовах подряд (поймано # исполнением 07.08). Сверено: явный ключ даёт тот же набор, что и прежний, побайтно. keys = sorted(uniq, key=lambda k: (len(uniq[k]["source"]), k)) 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 own_draft(uid: str) -> str: """Черновик, КУПЛЕННЫЙ этим паком и прошедший гейт годности. Пусто — годного нет. ⚠ Заведено 07.08 по адверсариальному ревью. До этого арм F брал ТЕКСТ из корпуса пакета-6, а ЦЕНУ — из докупленных черновиков; тексты не совпадали ни на одной единице (близость ≤0.195), а поля модели в записи корпуса нет, то есть провенанс был непроверяем. Здесь текст и цена происходят из ОДНОГО вызова, и он воспроизводим кодом в дереве. """ best = "" for f in sorted(OUT.glob(f"bo2-DRAFT-{uid}*.json")): c = json.loads(f.read_text(encoding="utf-8")).get("content", "") if c and not draft_reject_reason(c): best = c return best def build_msgs3(arm: str, u: dict) -> list[dict]: """Сообщения арма ТРЕТЬЕЙ редакции: тот же сбор, но черновик — собственный.""" spec = ARMS[arm] if spec["contract"] in ("direct", "direct-reflow"): return build_msgs(arm, u) # однопроходки черновика не видят src = u["source"] 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 return Q.messages(spec["contract"], src, own_draft(u["uid"]), blk) def run_arm3(arm: str, u: dict) -> dict: spec = ARMS[arm] model = spec["model"] kw: dict = dict(model=model, messages=build_msgs3(arm, u), max_tokens=16000, temperature=0.4) if spec["think"] == "off": if not model.startswith("glm"): raise SystemExit(f"гашение размышления у {model} не описано в quirks — не гадаю") kw["extra_body"] = {"thinking": {"type": "disabled"}} return BUY.purchase(LED_ARMS, f"bo4-{arm}-{u['uid']}", model, client(model), kw, arm=arm, uid=u["uid"], edition=3) def text3_of(arm: str, u: dict) -> str: """Выход арма третьей редакции. F3 — сам купленный черновик: текст и цена из одного вызова.""" if arm in ("F", "F3"): return own_draft(u["uid"]) f = OUT / f"bo4-{arm}-{u['uid']}.json" if f.exists(): return json.loads(f.read_text(encoding="utf-8"))["content"] return text_of(arm, u) # однопроходки не пере-гоняются 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"}) # РЕ-ГЕН ПРИ ЭХЕ — боевое лечение по quirks 00 строка 86: на весах 0731 эхо СТОХАСТИЧНО # ПО ВЫЗОВУ (два побайтно одинаковых запроса дали долю ханьцзы 0.000 и 0.831), частота при # `effort:low` — 3 из 20. Поймано исполнением здесь же: первый купленный черновик вернул # исходник целиком. Ре-ген на flash в 7.6 раза дешевле эскалации на pro, поэтому боевая # схема гонит N=1 ре-ген до эскалации — и цена ре-гена ЛОЖИТСЯ В ЭКОНОМИКУ, а не прячется. for attempt in (1, 2, 3): tag = f"bo2-DRAFT-{u['uid']}" + ("" if attempt == 1 else f"-r{attempt}") rec = BUY.purchase(LED_ARMS, tag, DRAFT_MODEL, client(DRAFT_MODEL), kw, arm="DRAFT", uid=u["uid"], attempt=attempt) if rec.get("skipped"): return rec bad = draft_reject_reason(rec["content"]) if not bad: return rec print(f" ⚠ {bad} в черновике {u['uid'][:6]} (попытка {attempt}) — ре-ген") return rec def draft_reject_reason(text: str) -> str: """Годен ли черновик. Пусто = годен. ⚠ ПОЧИНКА 07.08 по адверсариальному ревью. Прежняя версия проверяла ТОЛЬКО письменность ИСХОДНИКА и потому ловила половину класса: артефакт `bo2-DRAFT-34749d6ccc-r2` прошёл гейт и был оплачен, имея долю латиницы 0.79 и кириллицы 0.00 — то есть модель вернула АНГЛИЙСКИЙ перевод вместо русского. Гейт, предлагаемый как боевое лечение эхо-мины, обязан проверять и цель: пустой выход · эхо исходной письменности · выход не на целевом языке. """ t = (text or "").strip() if not t: return "пустой выход" if B.check_cjk_leak(t)["over_prod_threshold"]: return "эхо исходника" letters = [c for c in t if c.isalpha()] if letters and sum(1 for c in letters if "а" <= c.lower() <= "я" or c.lower() == "ё") / len(letters) < 0.5: return "выход не на целевом языке" return "" def draft_cost(uid: str) -> float: """Цена черновика = ВСЕ его попытки. Ре-ген оплачен, значит он в цене топологии.""" tot = 0.0 for f in OUT.glob(f"bo2-DRAFT-{uid}*.json"): tot += json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0) return tot 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: """Средняя разница «ошибки второго варианта − ошибки первого» по ВСЕМ голосам. ⚠ ЧИТАЕТСЯ НЕ ТАК, как я написал в первой редакции этой функции. Зеркало НЕ гонит эту величину к нулю: если судья штрафует вторую позицию, то `e2 − e1 > 0` при любом порядке, и среднее остаётся большим. Зеркало убирает позицию из ПЕРЕВЕСА АРМА (перевес усредняется по двум порядкам), а не из этой статистики. Поэтому число здесь — ИЗМЕРЕННАЯ ВЕЛИЧИНА ФОРЫ, и она полезна как масштаб: с чем сравнивать перевес арма. Исправность раскладки проверяется другим — равенством числа голосов по порядкам (см. `verify_report.check_second_edition`). """ 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 sign_perm_p(xs: list[float]) -> float: """ТОЧНЫЙ двусторонний знаковый перестановочный тест. $0. Заведён 07.08 по адверсариальному ревью. Bootstrap-ДИ отвечает на вопрос «где лежит среднее», а не «отличимо ли оно от нуля при этом n», и на 15 единицах даёт заметно более узкий интервал, чем точный тест. Здесь считается p, к которому применима поправка Holm по семейству контрастов. """ n = len(xs) if n == 0: return float("nan") obs = abs(sum(xs)) if n <= 20: hits = sum(1 for mask in range(1 << n) if abs(sum(x if mask >> i & 1 else -x for i, x in enumerate(xs))) >= obs - 1e-12) return hits / (1 << n) # ⚠ n > 20: ПОЛНЫЙ ПЕРЕБОР 2^n НЕПОДЪЁМЕН, НО ТОЧНЫЙ ОТВЕТ ВСЁ РАВНО ЕСТЬ. Первая редакция # брала здесь Монте-Карло с фиксированным зерном; ревью исполнением показало, что этого не # нужно: перевесы — ЦЕЛЫЕ числа (разности счётчиков ошибок), поэтому распределение суммы ±xᵢ # считается динамикой по достижимым суммам за миллисекунды и ТОЧНО. Цена приближения была # видна в отчёте: одна и та же величина печаталась как 0.1603 и 0.1608 (se Монте-Карло # ≈0.0008), то есть заявление «числа воспроизводятся до знака» на ней не выполнялось. # Дробные значения (их у нас нет, но контракт функции шире) уходят на прежний Монте-Карло. scaled = [x * 2 for x in xs] # полуцелые тоже берём точно if all(abs(v - round(v)) < 1e-9 for v in scaled): ints = [abs(int(round(v))) for v in scaled] target = abs(sum(scaled)) - 1e-9 # dp[s] = число расстановок знаков, дающих сумму s (сдвиг на total для неотрицательности) total = sum(ints) dp = [0] * (2 * total + 1) dp[total] = 1 for v in ints: nxt = [0] * (2 * total + 1) for s, c in enumerate(dp): if c: nxt[s + v] += c nxt[s - v] += c dp = nxt hits = sum(c for s, c in enumerate(dp) if abs(s - total) >= target) return hits / (1 << len(xs)) import random # noqa: PLC0415 rnd = random.Random(20260809) # noqa: S311 trials = 200_000 hits = sum(1 for _ in range(trials) if abs(sum(x if rnd.getrandbits(1) else -x for x in xs)) >= obs - 1e-12) return (hits + 1) / (trials + 1) def cmd_score() -> None: """Свод. $0: читает персистированные голоса и прогоняет батарею по выходам армов.""" us = units() bias = position_bias() print(f"ПОЗИЦИОННАЯ ФОРА СУДЬИ (контроль исправности раскладки): {bias:+.2f} ошибки в пользу " f"первого варианта\n") print("⚠ КОЛОНКИ ВЕРДИКТОВ СПРАВОЧНЫЕ, решения на них не строятся. Правило «знак совпал в обоих\n" "порядках» отказывает при позиционной форе больше эффекта: перевес в порядке 0 есть\n" "истина+P, в порядке 1 — истина−P, и при |P|>|истина| знаки расходятся почти всегда\n" "(отсюда 14 ничьих из 15 на D/D_). Несущая статистика — НЕПРЕРЫВНЫЙ перевес: полусумма\n" "двух порядков, в которой P сокращается тождественно (проверено полом рига: +0.05, p=0.96).\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ГЕЙТ ДО СУДЬИ (детерминированно, $0). Механический брак ловится грепом, и судья не\n" "обязан его замечать — на 126 голосах он трижды выставил ВСЕ НУЛИ тексту с сырой\n" "разметкой или латиницей. Такие клетки помечаются здесь, а не покупаются у судьи.") import re as _re flags = {} for arm in ("F", *ARMS): for u in us: t_ = text_of(arm, u) d = [] if _re.search(r"^\s{0,3}#{1,6}\s|\*\*", t_, _re.M): d.append("markdown") if _re.findall(r"[\u4e00-\u9fff]", t_): d.append("ханьцзы") if _re.findall(r"\b[A-Za-z]{4,}\b", t_): d.append("латиница") if _re.match(r"^\s*(?:вот\s+)?перевод[^\n:]{0,80}:", t_, _re.I): d.append("преамбула") if d: flags.setdefault(arm, []).append((u["uid"][:6], "+".join(d))) for arm in ("F", *ARMS): v = flags.get(arm, []) print(f" {arm:6s} клеток с механическим браком: {len(v):2d} {v[:4]}") print("\nПО СУДЬЯМ ОТДЕЛЬНО. ⚠ Эффект, который несёт ОДИН судья, — свойство судьи, а не армов.") print(f"{'контраст':16s}" + "".join(f"{j.split('-')[0]:>26s}" for j in J.JUDGES)) for a, b in CONTRASTS: cells = [] for judge in J.JUDGES: per = [statistics.mean([statistics.mean(bo[0]), statistics.mean(bo[1])]) for u in us if len(bo := margins_by_order(a, b, u["uid"], judge)) == 2] if not per: cells.append(f"{'—':>26s}") continue lo, hi = boot_ci(per) mark = " " if lo <= 0 <= hi else "*" cells.append(f"{statistics.mean(per):+8.2f} [{lo:+.2f},{hi:+.2f}]{mark:>2s}") print(f"{a + ' против ' + b:16s}" + "".join(cells)) print(" * = ДИ судьи не накрывает ноль. Вывод держится только там, где звёздочки у ОБОИХ.") print("\nПОПРАВКА НА МНОЖЕСТВЕННОСТЬ. В таблице пять контрастов; ДИ выше НЕ скорректированы.") print("Точный знаковый перестановочный тест по единицам + Holm по семейству:") ps = [] for a, b in CONTRASTS: per = [] for u in us: pj = [] for judge in J.JUDGES: bo = margins_by_order(a, b, u["uid"], judge) if len(bo) == 2: pj += [statistics.mean(bo[0]), statistics.mean(bo[1])] if len(pj) == 2 * len(J.JUDGES): per.append(statistics.mean(pj)) ps.append((f"{a} против {b}", sign_perm_p(per), len(per))) order = sorted(range(len(ps)), key=lambda i: ps[i][1]) holm, running = [0.0] * len(ps), 0.0 for rank, i in enumerate(order): running = max(running, ps[i][1] * (len(ps) - rank)) holm[i] = min(1.0, running) for (name, p, n), h in zip(ps, holm, strict=True): print(f" {name:16s} n={n:2d} p={p:.4f} Holm={h:.4f} " f"{'проходит 0.05' if h < 0.05 else 'НЕ проходит 0.05'}") print("\nРАЗЛОЖЕНИЕ ПО ОСЯМ. Сумма осей гасит разнонаправленные эффекты — печатается покомпонентно.") print(f"{'контраст':16s}" + "".join(f"{ax:>22s}" for ax in J.AXES)) for a, b in CONTRASTS: cells = [] for ax in J.AXES: per = [] for u in us: ms = [] for judge in J.JUDGES: for o in (0, 1): f = OUT / f"jv2-{a}v{b}-{u['uid']}-o{o}-{judge}-r1.json" if not f.exists(): continue p = J.parse(json.loads(f.read_text(encoding="utf-8"))["content"]) if p[f"В1-{ax}"] is None: continue e1, e2 = p[f"В1-{ax}"], p[f"В2-{ax}"] ms.append((e2 - e1) if o == 0 else (e1 - e2)) if len(ms) == 2 * len(J.JUDGES): per.append(statistics.mean(ms)) if not per: cells.append(f"{'—':>22s}") continue lo, hi = boot_ci(per) cells.append(f"{statistics.mean(per):+6.2f} [{lo:+.2f},{hi:+.2f}]" + ("*" if not lo <= 0 <= hi else " ")) print(f"{a + ' против ' + b:16s}" + "".join(cells)) 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__)