From 769a2c84517d8bf74c252bb8679adcc8a387fac2 Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 7 Aug 2026 10:51:30 +0300 Subject: [PATCH] Freeze the third-edition role-topology pre-registration before paid calls: declared contrast family, computed power, re-declared ceilings, purchase order --- docs/experiments/21-role-topology.md | 855 ++++++++++++------ eval/role_topology/bakeoff.py | 162 +++- eval/role_topology/blind_read.py | 144 +++ eval/role_topology/buy.py | 28 +- eval/role_topology/judges.py | 19 +- eval/role_topology/pair_en.py | 450 +++++++++ eval/role_topology/postproc.py | 128 +++ eval/role_topology/prompts/en-ru/editor.md | 47 + .../prompts/en-ru/translator-reflow.md | 33 + .../role_topology/prompts/en-ru/translator.md | 28 + eval/role_topology/verify_report.py | 504 +++++------ 11 files changed, 1838 insertions(+), 560 deletions(-) create mode 100644 eval/role_topology/blind_read.py create mode 100644 eval/role_topology/pair_en.py create mode 100644 eval/role_topology/postproc.py create mode 100644 eval/role_topology/prompts/en-ru/editor.md create mode 100644 eval/role_topology/prompts/en-ru/translator-reflow.md create mode 100644 eval/role_topology/prompts/en-ru/translator.md diff --git a/docs/experiments/21-role-topology.md b/docs/experiments/21-role-topology.md index b34e6dcd..fedf3ee7 100644 --- a/docs/experiments/21-role-topology.md +++ b/docs/experiments/21-role-topology.md @@ -1,324 +1,615 @@ # 21. Топология ролей перевода: чем платим за второй проход и есть ли ему замена -**Полигон-сессия 06.08.2026.** Исполнение `docs/POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md` -(оркестратор №15, санкция владельца 06.08, D39.108). Зона: `eval/role_topology/` + этот файл + -пинг в PROGRESS «Полигон». Потолки предварительные: Ф0 ≤$0.70 · Ф1 ≤$1.00 · Ф2а ≤$2.50 · Ф2б ≤$3.00. +**Полигон-сессия, вторая редакция 07.08.2026.** Исполнение `docs/POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md` +(оркестратор №15, санкция владельца 06.08 D39.108; расширение и санкция на траты 07.08). +Зона: `eval/role_topology/` + этот файл + пинг в PROGRESS «Полигон». -> **СТАТУС: Ф0 ИСПОЛНЕНА ЧАСТИЧНО, ПЛАТНЫЕ ФАЗЫ НЕ ЗАПУСКАЛИСЬ. Потрачено $0.** -> Разделы §0–§2 — пре-регистрация и результаты бесплатной метрологии. Блокер запуска назван в §0.2. +> **СТАТУС: замер закончен, вторая редакция.** Первая редакция (06.08) снята целиком: её риг +> измерял ПОЗИЦИЮ варианта в промпте, а не качество арма (§3). Ниже — только текущее состояние. +> Пре-рег второй редакции зафризен коммитом `d472599` **до первой платной покупки**. +> Потрачено второй редакцией **$1.683796** (пак целиком, обе редакции — **$4.306626**); +> потолки второй редакции соблюдены, потолки ФАЗ из промта — нет (§2.7). +> Числа сверяются `eval/role_topology/verify_report.py` — 44 проверки, ненулевой код = не сдавать. --- -## ИТОГ (для оркестратора) +## ИТОГ — вход для решения владельца -*Заполняется после Ф2. Сейчас закрыты только вопросы метрологии — см. §2.* +⚠ **Решения на этих числах принимать нельзя.** Пак даёт согласованные НАПРАВЛЕНИЯ, но ни один +контраст не проходит поправку Holm на пять сравнений (лучший Holm 0.0555), шумовой пол пайплайна +не измерен вовсе, и три из четырёх «значимых» контрастов несёт один судья — аварийная лёгкая +замена. Что нужно доснять, чтобы получить решаемый ответ, — §8. -**Что уже решено данными и меняет план пака:** +**Единственное направление, устойчивое ко всему, что проверялось:** второй проход поверх дешёвого +черновика покупает качество на zh (+1.88). Переживает выброс одной и двух единиц (0 из 15 и 0 из +105), исключение единиц с механическим конфаундом (+1.65), не объясняется длиной; подтверждено +батареей (утечка ханьцзы 24→0) и слепым чтением. Ограничения: значимость несёт gemini (у luna ++0.84 [−0.72, +2.31]); Holm не проходит; покупается ЯЗЫК и ТЕРМИН, ВЕРНОСТЬ ноль не покидает. -1. **Детектор «выдуманное слово» построен и работает** (§2.1): precision 0.78 / recall 0.70 на - 2177 размеченных словотипах против **1.00 / 0.10** у боевых чекеров, вне выборки 6/6. Это не - починка бага, а закрытие дыры, которую движок объявил сам: `sanitizer.go:429` дословно — - «detection needs a morphology pass (Ф2), so it stays silent here». -2. **Детектор «смысловая инверсия» через QE-ранкер — ОТРИЦАТЕЛЬНЫЙ РЕЗУЛЬТАТ** (§2.2), проверенный - контрольным декоем. Следствие для плана: арм C фазы 2б в режиме «реальные детекторы» по классу - инверсий питать нечем; развилка вынесена владельцу в §0.3. -3. **Три метрики из шести, которые я построил по букве промта, мерили шум и были сняты замером** - (§2.3). Это цена, которую платит любая батарея без прогонки на чистом тексте, и она названа - числами, а не общими словами. +**Не установлено — «однопроходка проигрывает связке».** Измерен арм D, то есть однопроходка +С МАНДАТОМ, который этот же отчёт признаёт вредным. Рекомендуемая однопроходка — D_; её контраст +с A не куплен, косвенная оценка −0.67 (p≈0.30). Плюс контраст `D/A` смешивает три фактора: файл +промта, наличие черновика на входе и закон-блок (у D он есть, у A нет). ---- +**Не установлено — «второй проход покупает качество на en» (+1.00).** ДИ накрывает ноль при +выбрасывании одной единицы в 8 случаях из 12; перестановочный p=0.073; нижняя граница +0.04 +зависит от розыгрыша bootstrap (ноль накрывается у 16.5% сидов). -## §0. ПРЕ-РЕГИСТРАЦИЯ - -Фриз коммитом ДО первого платного вызова. Изменения после фриза = новый experiment-ID; девиации — -явным списком в §5. Amend фриза запрещён. - -### 0.1. Вопрос и что считается ответом - -Какая топология целевая — «черновик + переписыватель», «черновик + гейты + точечный ремонт», -«однопроходка сильной моделью», гибрид с маршрутизацией — и кто жильцы ролей. - -**Ответом считается** таблица «арм → детерминированная батарея · MQM-lite двух судей · цена за -принятую 1000 слов (p50/p95) · дрейф на связном отрезке», где различия названы ОТНОСИТЕЛЬНО порога -различимости Ф0.6, а не абсолютно. **Ответом НЕ считается** ранжирование армов по среднему баллу -судьи: эксп-20 §5.4 намерил, что судья согласен сам с собой на идентичном входе в 56% клеток и -разниц меньше ~2:1 не разрешает. - -Армы равноправны. «Правильного ответа», которого ждут, не существует; при неразличимости дефолт — -самый дешёвый и детерминированный арм. - -### 0.2. Материал — ОТКРЫТЫЙ БЛОКЕР - -Промт заказывает невиданный срез: свежая zh-вебновелла без опубликованного ru-перевода, рассеянные -окна для дисперсии + связный отрезок 8–10 глав для дрейф-метрик. - -**Такого текста в дереве нет.** Инвентарь `~/books` с провенансом по докам: `gu-zhenren` (蛊真人) — -претрейн-канон, ровно тот случай, который строка 55 бэклога объявляет негодным дословно; -`jinpingmei` — минский байхуа, PD; `isekai_majutsushi_jp` — ja, не zh (exp11 §41); -`fifty_shades_1_en`, `Empire_of_the_Dawn` — en. Каталог `eval/data/samples/webnovel/zh/`, куда -харнесс `webnovel_slice.py:14` ждёт корпус, никогда не создавался — строка 55 висит именно поэтому. -То, что exp07 называл «невиданным вебновелл-текстом», — это само 蛊真人 (`07-coverage-precision.md:85`), -и его же хвост предписывает «перепроверить **на корпусе владельца**». - -**Норма при этом однозначна:** корпус кладёт владелец (`webnovel_slice.py:1-3` — «запускается по -появлению корпуса владельца»). Полигон текст не добывает. - -**Развилка передана владельцу 06.08** (варианты: владелец кладёт файлы · санкционирует добычу -полигоном · Ф2 идёт на 蛊真人 с объявленной границей). До ответа платные фазы не стартуют. -**Оговорка о свежести, которую важно не потерять:** дата публикации контаминацию не снимает — -катофы моделей разные. Чище всего берутся последние главы ИДУЩЕГО сериала (июнь–август 2026), и -контаминация всё равно меряется (continuation/cloze + поиск fan-переводов), а не предполагается. - -### 0.3. Армы - -**Ф2а — «переписывание как класс».** Черновики фризятся и подаются идентичными всем армам-редакторам -(парный дизайн, McNemar). - -| Арм | Что | -|---|---| -| F | do-nothing пол: черновик как есть через гейты. Все редакторские армы мерятся против него | -| A | байт-боевой бейзлайн flash→dspro (без банкноты — объявленная девиация бейзлайна, не чинить) | -| B | связка flash→glm-5-OFF full-regen | -| D | однопроходка сильной модели С мандатом перевёрстки | -| D′ | она же БЕЗ мандата — деконфаунд «модель против мандата» | - -**Ф2б — ремонт и маршрутизация.** Гейт входа: детекторы Ф0.4 валидированы. - -| Арм | Что | -|---|---| -| C | черновик (с мандатом перевёрстки) → детекторы+гейты → точечный ремонт дешёвым фиксером; два режима: oracle-флаги (потолок) и реальные детекторы (пол); после каждого фикса полный ре-гейт, кап 2 итерации | -| E | обратная связка: сильный черновик → дешёвый фиксер по флагу | -| G | guarded+routed: QE/гейты скорят все окна → стиль-пасс ТОЛЬКО флагнутым, правка принимается только если внешний гейт видит улучшение (max(draft, edited)) | - -**⚠ ПОПРАВКА К АРМАМ C и G ПО РЕЗУЛЬТАТУ Ф0.4 (§2.2).** QE-ранкер локальную смысловую инверсию не -детектирует. Следствия, объявленные ДО запуска: -- арм **C** гонится в oracle-режиме и в режиме «реальные детекторы», но реальный режим питается - ТОЛЬКО детектором выдуманных слов + существующими $0-гейтами; класс «инверсия» в реальном - режиме остаётся ненайденным, и разрыв oracle↔реальность печатается как главный результат арма; -- арм **G** маршрутизирует не по QE-баллу инверсии, а по батарее Ф0.5 + QE как ГРУБОМУ фильтру - (декой доказал: рассогласование сегмента ранкер видит, Δ +7.1) — то есть G ловит «сегмент поехал - целиком», а не «сегмент поехал в одном слове». Это сужение арма, и оно объявлено здесь. - -**Reflow-план кодом (Q4c, research/19 §C1.3) — ДИСПОЗИЦИЯ ОТКАЗА.** Не гоню. Основание: эксп-20 §5.2 -уже проверил гипотезу «мандат в промпте заменяет второй проход» слепым судом и ОПРОВЕРГ её (русский -3:12, вёрстка 5:10 в пользу связки), а деконфаунд мандата в этом паке несёт арм D′ — отдельный -кодовый reflow-план добавил бы третий способ спросить то же самое при бюджете, которого нет. - -### 0.4. Метрики и пороги - -Порядок первичности задан не вкусом, а измеренным шумом инструментов. - -1. **Детерминированная батарея Ф0.5 — первична.** Шума не имеет: повтор даёт то же число. - Состав и границы каждой проверки — §2.3. -2. **MQM-lite двух судей** — счёт типизированных ошибок (спан+тип+severity на 1000 слов). - Pairwise-преференс — только для стиль-осей. Судья не судит армы своего семейства-жильца; - вердикты раскладываются по семействам судей. Агрегация Bradley–Terry с bootstrap-CI. -3. **Цена за принятую 1000 слов** — p50/p95, с кэшем/батчем/ретраями; перезаписанные вызовы тоже - деньги (леджер = нижняя граница). -4. **Дрейф на связном отрезке** — швы, ты/вы-стабильность, рост банка. - -**Порог различимости берётся из Ф0.6 и записывается ДО Ф2.** Правило вердикта: «арм X бьёт Y, если -перевес по оси больше порога Ф0.6»; средние исходы = «неопределённо»; при неразличимости побеждает -самый дешёвый и детерминированный арм. - -### 0.5. Прогнозы (сверка с фактом идёт в отчёт) - -Записаны до запуска, чтобы отчёт мог показать, где я ошибся. - -| # | Прогноз | На чём основан | +| Вопрос | Ответ | Оговорка | |---|---|---| -| П1 | F (do-nothing) НЕ будет последним; по общему вердикту он в пределах порога от A | эксп-20: на dspro второй проход отрицателен (общий 5:1 в пользу одного прохода) | -| П2 | B (glm-5) выиграет русскую прозу и проиграет верности/термам | эксп-20 §5: у glm свип вёрстки 6:0, у dspro верность 5:1 | -| П3 | D (однопроходка сильной) выиграет качеством и проиграет ценой ≥2× | цена сильного тира против flash | -| П4 | G даст лучшее отношение цена/качество среди всех армов | он единственный не платит за неизменённое | -| П5 | Разрыв oracle↔реальные детекторы в арме C будет БОЛЬШЕ, чем разрыв между армами Ф2а | recall детектора слов 0.70, детектора инверсий нет вовсе | -| П6 | Батарея разведёт армы там, где судья скажет «неразличимо» | у батареи шум ноль, у судьи 56% самосогласия | +| Нужен ли второй проход | Да по направлению, у трёх инструментов | значимость несёт один судья; Holm не проходит | +| Не выгоднее ли сразу сильной моделью | **Нет** — единственный вывод, устойчивый к смене судьи | Holm не проходит (p=0.021, Holm 0.063) | +| Кто жилец роли редактора | `deepseek-v4-pro`: качество неотличимо от `glm-5`, цена вдвое ниже | — | +| Что покупает второй проход | **ЯЗЫК** (+1.11) и **ТЕРМИН** (+0.45), не верность | §2.1а | +| Что делает мандат перевёрстки | бьёт по **ФОРМЕ** (−0.47), прочие оси нулевые | контраст `D/D_` изолирует мандат НЕ чисто (§3) | +| Что покупает закон-блок | **ВЕРНОСТЬ** +0.63 [+0.33, +0.97] | суммарный перевес +0.33 гасит это осями | -### 0.6. Смета +**Цена (p50 / p95, промт требовал оба).** Редакторский проход `deepseek-v4-pro` $0.00388 / $0.00518; +однопроходка $0.00487 / $0.00792; `glm-5` в роли редактора $0.00892 / $0.00956. На хвосте разрыв +между связкой и однопроходкой растёт, а не исчезает — «равная цена» была свойством медианы. ⚠ Цена черновика в цену связки **не сложена**: армы правят +замороженный черновик из корпуса пакета-6, чья цена не сохранена и чей производитель по записи +не устанавливается (§2.6). Цифра «связка дешевле/дороже однопроходки» из этого замера НЕ следует. -Ф0 ≤$0.70 (предзамер судей ≤$0.60) · вахта эффорта ≤$0.05 · Ф1 ≤$1.00 · Ф2а ≤$2.50 · Ф2б ≤$3.00. -Живые стопы механизмом + проекционный гард. Подъём или пере-нарезка — только словом владельца ДО -запуска фазы. Не влезает — стоп и пинг, не резать молча. +**Четыре ограничения вывода.** (1) Материал zh моделью узнаётся (4/5); пара en чище (1/5) и даёт +то же направление. (2) Дрейф на связном отрезке глав не мерен. (3) Армы C/E/G не гнались. (4) Ни +один результат не переживает поправку на множественность — нужен либо больший n, либо сужение +таблицы до заранее объявленного одного контраста. -### 0.7. Что считается провалом фазы +## §0 Пре-рег второй редакции (зафризен `d472599` до трат) -Ф0.6 не даёт порога различимости в потолке → СТОП и пинг с расчётом мощности, а не молчаливое -уменьшение числа окон. Детекторы Ф0.4 не валидируются → арм C идёт только oracle-режимом, и это -записывается как граница, а не как результат. +**Вопрос.** Какая топология целевая и кто жильцы ролей. Армы равноправны, «правильного ответа» +не существует, работа — замер. + +**Материал.** Две пары, книги из `~/books` (в git не попадают; в отчёт печатаются только метрики). + +| Пара | Книга | Контаминация (собственная проба) | Единиц | +|---|---|---|---| +| zh→ru | 蛊真人 (gu-zhenren) | узнавание **4/5**, клоуз 1/5 — книга модели ИЗВЕСТНА | 16 | +| en→ru | Kristoff, «Empire of the Dawn» | узнавание **1/5**, клоуз 0/5 — чисто | 12 | + +Порог пробы объявлен там же: «узнавание ≥3/5 = книга известна»; контроль пробы — 金瓶梅 +(4/5 и 3/5). Основной материал порог **превышает** — объявленное ограничение, а не умолчание; +пара en заведена именно чтобы вывод не стоял на одной узнанной книге. + +Отбор единиц детерминирован: середина распределения длин, дедуп по нормализованной подписи +(снимаются заголовок главы и пробелы), ключ артефакта — хеш источника, не позиционный индекс. +Максимальная попарная близость 16 отобранных zh-единиц — 0.060. Пара en стратифицирована: +6 единиц с французским слоем (≥4 токена с диакритикой, ≥2 разных) и 6 без него. + +**Армы.** + +| Арм | Что | Закон-блок | Мышление | +|---|---|---|---| +| F | do-nothing пол: черновик `deepseek-v4-flash` как есть | — | — | +| A | байт-боевой бейзлайн: черновик → `deepseek-v4-pro` | нет (промт стр.29) | вендор-дефолт | +| A_law | то же С законом — деконфаунд закон-блока | да | вендор-дефолт | +| B | черновик → `glm-5` | да | **OFF** (промт стр.62) | +| D | однопроходка `deepseek-v4-pro` С мандатом перевёрстки | да | вендор-дефолт | +| D_ | она же БЕЗ мандата — деконфаунд «модель против мандата» | да | вендор-дефолт | + +Контрасты: `A/F` · `B/F` · `D/A` · `D/D_` · `A_law/A`. Каждый изолирует ровно один фактор — +проверено сборкой сообщений: у A и A_law совпадают system[0] и user, различие только в наличии +закон-блока; у D и D_ совпадают закон-блок и user, различается только мандат. Текст закона +пинится промтом: `HEADER_LAW_PLAIN`, **без оговорки области**. + +Пара en идёт **без закон-блока у всех армов** — объявленная девиация: подписанного глоссария для +книги нет, а выдумывать канон полигон не вправе. Следствие: контрасты пары мерят чистую +топологию, фактор банка мерится на zh контрастом `A_law/A`. + +**Критерии.** Вердикт единицы засчитывается, если знак перевеса сохранился в **обоих порядках** +у **обоих судей**. Основная статистика — непрерывный перевес по единицам с bootstrap-ДИ +(5000 ресемплов, сид 20260807); порог — «интервал не накрывает ноль». + +**Потолки, объявленные до трат:** армы zh ≤$0.70 · судейство zh ≤$1.70 · армы en ≤$0.55 · +судейство en ≤$1.45 · проба судьи-замены ≤$0.10. Соблюдены все (§2.7). --- -## §1. Что уже стоит на дереве ($0) +## §1 Метрология: три независимых инструмента -| Файл | Что делает | Проверен | +Первая редакция мерила одним инструментом, и когда у него нашёлся отказ, проверить его оказалось +нечем. Здесь три контура, устроенные так, чтобы не делить общий слепой участок. + +**(1) Судьи по ключам.** `gpt-5.6-luna` + `gemini-3.1-flash-lite`, оба вне семейств, населяющих +армы (deepseek, glm). Протокол — счёт типизированных ошибок по осям ВЕРНОСТЬ/ТЕРМИН/ЯЗЫК/ФОРМА, +слепые метки, персист каждого голоса отдельным файлом. + +*Раскладка.* Порядок — внешний цикл: на каждую клетку (контраст × единица × судья) ровно по +одному голосу каждого порядка. Перевес единицы = среднее двух зеркальных голосов, позиция +вычитается тождественно. Проверка исправности — равенство числа голосов по порядкам (фактически 156/156 zh и +98/98 en; 160/160 — это плановая сетка, а не факт), а НЕ малость позиционной форы: зеркало убирает позицию из перевеса арма, но не из +среднего по всем голосам. + +*Позиционная фора измерена и велика:* **zh +3.60**, **en +1.45** ошибки в пользу первого варианта. +Это масштаб, с которым сравнивается перевес арма: на zh перевес +1.88 МЕНЬШЕ форы — потому +небалансированная раскладка первой редакции и давала ложные вердикты. + +*Замена второго судьи — внешний блокер, объявляю.* `grok-4.3` выпал на HTTP 403 «used all +available credits or reached its monthly spending limit» (250 голосов). Замена выбрана по трём +условиям промта (не из семейства армов, не из семейства первого судьи, влезает в объявленный +потолок) и проведена через **положительный контроль до трат** (D39.46б): на контрольном декое +пака поймала посаженную деградацию **6/6 в обоих порядках** со зеркальными счетами +(В1=0/В2=3 и В1=3/В2=0). Оговорка: это лёгкая модель, её согласие с luna не эквивалентно прежней +паре luna+grok. + +*Отказы фильтра.* 10 клеток zh пришли **без объекта сообщения**, `finish_reason` = +`content_filter: PROHIBITED_CONTENT` — составная строка, описанная в quirks 00 (D22.6, D22.8а). +Все 10 — на одной единице. **Уточнение к quirks:** оговорка «на violence/SFW Gemini-судья жив» +на вебновелле держится не целиком — отказ приходит выборочно и молча, поэтому Google непригоден +как ЕДИНСТВЕННЫЙ второй контур на этом материале. + +*Шум судьи — посылка промта стр.15, проверена.* Пере-замер на клетках разброса: абсолютный счёт +точно повторился в **1 клетке из 6**, разброс 2.5 при уровне 7.7 (≈32% относительного шума). +Посылка подтверждена. ⚠ Клейм первой редакции «судьи не выдумывают перевес на идентичных текстах, +0 из 18» **снят как тавтология**: когда в обе позиции подан побайтно один текст, совпадение счёта +гарантировано построением, а не свойством судьи. + +**(2) Детерминированная батарея.** 9 проверок, судья не нужен, шума нет. Две во второй редакции +переписаны, потому что печатали числа из одних ложных срабатываний: + +* `check_numbers` — китайская сторона теперь собирает составное числительное целиком, как русская + (прежде 两千三百 давало {2000, 300} против «две тысячи триста» = {2300}, то есть на безупречном + переводе печатались две потери и одна выдумка); добраны собирательные («десятки тысяч», «более + сотни»); заведён закрытый список неколичественных оборотов (`百分之一` — доля, не «сто»). + Проверка синтетикой с известным ответом: **16/16**. +* `check_gender` — кандидатом считается только форма имени, у которой есть именительный разбор: + косвенная форма по определению не подлежащее, и соседний глагол согласован с другим словом + («Воля Фан Юаня **была** тверда»). Все 6 «рассогласований» прошлого прогона были этим классом. + +**(3) Слепое чтение.** Метки армов сняты, порядок вариантов перемешан детерминированным ключом от +uid единицы, карта раскладки хранится отдельно и читателям не выдаётся. Читатели — другого +семейства, чем судьи по ключам, и задача другая: не «посчитай ошибки по осям», а «прочитай как +редактор издательства и назови места цитатой». + +⚠ **Дефект инструкции, объявляю:** буквы вариантов перемешиваются на каждой единице, а читателям +это сказано не было, и они построили сквозные «профили А–Е». Профили выброшены как артефакт; +поединичные ранги расшифрованы по карте и использованы. В инструкцию читателя это надо вписать. + +--- + +## §2 Результаты + +### 2.1 zh→ru, судьи (16 единиц, 320 голосов) + +``` +контраст единиц за перв. за втор. ничья перевес 95% ДИ +A против F 15 3 0 12 +1.88 [+0.72, +3.02] ← не накрывает ноль +B против F 15 5 0 10 +2.33 [+0.85, +3.95] ← не накрывает ноль +D против A 15 0 1 14 −1.72 [−2.95, −0.50] ← не накрывает ноль +D против D_ 15 0 1 14 −1.05 [−2.18, −0.07] ← не накрывает ноль +A_law против A 15 0 0 15 +0.33 [−0.57, +1.18] +``` + +Одна единица выпала из вердиктов: на ней 10 отказов фильтра у второго судьи. Счёт побед почти +весь в ничьих, потому что правило «знак совпал в обоих порядках у обоих судей» очень строгое; +непрерывный перевес с интервалом информативнее и заказан промтом (стр.32). + + +### 2.1а Разбор таблицы: по судьям, по осям, с поправкой на множественность + +⚠ **Эффект, который несёт один судья, есть свойство судьи.** Раздельно: + +``` +контраст gpt-5.6-luna gemini-3.1-flash-lite +A против F +0.84 [−0.72, +2.31] +3.03 [+2.17, +3.90] * +B против F +1.44 [−0.34, +3.41] +3.33 [+1.97, +4.73] * +D против A −1.94 [−3.62, −0.16] * −1.20 [−2.17, −0.37] * ← оба +D против D_ −0.38 [−2.16, +1.25] −1.60 [−2.73, −0.70] * +A_law против A +0.91 [−0.41, +2.25] −0.20 [−0.87, +0.47] +``` + +Устойчив к смене судьи ровно один контраст — `D против A`. + +⚠ **Поправка на множественность.** Точный знаковый перестановочный тест по единицам + Holm по +семейству из пяти контрастов: + +``` +A против F p=0.0111 Holm=0.0555 +B против F p=0.0117 Holm=0.0555 +D против A p=0.0209 Holm=0.0626 +D против D_ p=0.0933 Holm=0.1865 +A_law против A p=0.5001 Holm=0.5001 +``` + +**Ни один контраст не проходит 0.05 после поправки.** ДИ в §2.1 не скорректированы и читаются как +описание величины, а не как тест. Формально значимых результатов пак не даёт; даёт согласованные +направления при n=15. + +**Разложение по осям** — суммарный перевес гасит разнонаправленные эффекты, поэтому он менее +информативен, чем покомпонентный: + +``` +контраст ВЕРНОСТЬ ТЕРМИН ЯЗЫК ФОРМА +A против F +0.23 [−0.22,+0.72] +0.45 [+0.13,+0.77]* +1.11 [+0.52,+1.80]* +0.10 [−0.13,+0.33] +B против F +0.85 [+0.18,+1.50]* +0.52 [+0.17,+0.85]* +1.08 [+0.47,+1.83]* −0.12 [−0.77,+0.30] +D против A −0.48 [−1.02,+0.07] −0.33 [−0.73,+0.05] −0.57 [−1.02,−0.15]* −0.33 [−0.52,−0.15]* +D против D_ +0.03 [−0.60,+0.60] −0.28 [−0.58,+0.03] −0.33 [−0.88,+0.18] −0.47 [−0.82,−0.13]* +A_law против A +0.63 [+0.33,+0.97]* +0.02 [−0.32,+0.32] −0.18 [−0.53,+0.15] −0.13 [−0.40,+0.08] +``` + +Каждый фактор попадает в свою ось: второй проход покупает **ЯЗЫК** и **ТЕРМИН**, а не верность; +мандат перевёрстки бьёт по **ФОРМЕ** и только по ней; закон-блок покупает **ВЕРНОСТЬ** (+0.63), и +именно этот эффект терялся в суммарном +0.33. + +### 2.2 en→ru, судьи (12 единиц, 192 голоса) — переносится ли вывод на другую пару + +``` +контраст ед. за перв. за втор. ничья перевес 95% ДИ фр-страта plain +A против F 12 2 0 10 +1.00 [+0.04, +1.79] +0.33 +1.67 +B против F 12 3 0 9 +1.31 [+0.35, +2.25] +1.75 +0.88 +D против A 12 0 1 11 −0.81 [−2.58, +0.73] +0.21 −1.83 +D против D_ 12 2 0 10 +0.48 [−0.83, +1.69] +1.04 −0.08 +``` + +**Направление переносится, величина — нет.** Второй проход покупает качество на обеих парах. +Преимущество связки над однопроходкой на en вдвое меньше и ноль не покидает: на паре, где +исходник уже гипотактичен, разница между топологиями сжимается. Мандат перевёрстки на zh вредит +значимо (−1.05), на en ноль не покидает — это и есть содержание вывода «мандат = пар-данные». + +### 2.3 Слепое чтение (третий контур, независимо от судей) + +**zh, 16 единиц, два читателя:** + +| арм | ср. ранг | первых мест | последних | против черновика F | +|---|---|---|---|---| +| A | 2.56 | 3 | 1 | 12 побед / 4 | +| A_law | 2.62 | 5 | 0 | 13 / 3 | +| B | 3.31 | 3 | 2 | 11 / 5 | +| D_ | 3.31 | 5 | 4 | 8 / 8 | +| F | 4.06 | 0 | 4 | — | +| D | 5.12 | 0 | 5 | 5 / 11 | + +**en, 12 единиц:** D_ 2.50 · B 2.58 · D 3.00 · A 3.08 · F 3.83. Черновик последний, первых мест 0. + +**Чтение согласуется с судьями по всем четырём значимым контрастам:** черновик хуже связок; +однопроходка с мандатом на zh хуже всех и хуже самого черновика; закон-блок неразличим +(2.56 против 2.62); на en порядок армов другой и мандат перестаёт быть решающим. + +Наблюдение обоих читателей, независимо: **низ рейтинга почти всегда решает механический слой, а +не перевод** — диалоговая типографика (прямая речь в кавычках вместо тире, авторский текст под +тире, две реплики в одном абзаце), слипшиеся абзацы, непереведённые остатки (`cultivation`, +`特产ом`, китайские заголовки), протёкшая markdown-разметка. Класс ловится детерминированно. + +### 2.4 Детерминированная батарея, zh (16 единиц) + +``` +арм ед. типогр. ханьцзы потеря вел. ты/вы микс род свер. род ошиб. +F 16 1 24 6 5 111 1 +A 16 2 0 5 6 99 1 +B 16 0 2 6 5 112 1 +D 16 2 0 5 5 86 0 +D_ 16 2 2 6 4 94 0 +A_law 16 2 11 7 6 103 1 +``` + +Единственная ось, где армы расходятся уверенно, — **утечка ханьцзы**: черновик несёт 24 знака, +редакторский проход вычищает до 0–2. Это независимое от судьи подтверждение, что второй проход +что-то делает. Оставшиеся 11 у A_law вскрыты поединично: непереведённый заголовок главы +`第二十五节:春光正明媚` и буква разряда `丙`; тот же заголовок стоит в черновике, арм без закон-блока +его вычистил, арм с закон-блоком — нет. Поединичный промах, не системная протечка: проверено, что +закон-блок даёт в промпт всего 5 иероглифов. По остальным осям армы неразличимы — печатается как +есть, не интерпретируется. + +### 2.5 Французский слой (en, 6 единиц страты `fr`, судья не нужен) + +``` +арм фр-токенов латиница передано расщеплено +F 18 0 7 0 +A 18 0 6 0 +B 18 0 7 1 +D 18 0 5 0 +D_ 18 0 6 0 +``` + +Латиничных утечек нет ни у одного арма, расщепление формы имени внутри окна — один случай. +Класс **имён и топонимов армы не разделяет**: `François` → Франсуа, `Rive Cœur` → Рив-Кёр, +`Fabién` → Фабьен одинаково у всех. + +Разделяет другое, и видно это только чтением: **офранцуженное нарицательное**. `château` в трёх +разных фрагментах раскалывает армы одинаково — «замок» против «шато». Зеркальный случай: арм, +лучше всех прочитавший французское ИМЯ дворца, хуже всех прочитал английское НАРИЦАТЕЛЬНОЕ +`silversaint`, превратив титул в фамилию «Сильверсейнт». Ошибка включается на словах, которые +*выглядят* именем. Третий класс — вставные французские реплики (`oui`, `gens d'armes`, `Vampyr!`) +— разводит армы по стратегии, а не по качеству: сохранить латиницей · перевести · транскрибировать +кириллицей; третий путь порождает несуществующие русские слова («капитэны», «уи»). + +### 2.6 Экономика + +⚠ **Нога черновика не восстановима, и это ограничивает раздел.** Армы A/A_law/B правят +ЗАМОРОЖЕННЫЙ черновик из корпуса пакета-6 (`~/books/gu-zhenren/labels/raw/corpus.jsonl`). В записи +корпуса нет поля модели, usage не сохранён, цена того вызова утрачена. Черновики, купленные этим +паком (`bo2-DRAFT-*`, $0.00104 медиана), — это ПЕРЕ-ЗАМЕР цены роли на тех же исходниках, а не +нога данной связки: побайтно они с корпусными не совпадают ни на одной из 16 единиц (максимальная +близость 0.195). Складывать их в одну цифру нельзя — найдено адверсариальным ревью 07.08. + +| Арм | $/единицу (собственный вызов) | +|---|---| +| F | — (цена утрачена, см. выше) | +| A | 0.00388 | +| A_law | 0.00410 | +| D | 0.00487 | +| D_ | 0.00533 | +| B | 0.00892 | + +Сравнение «связка против однопроходки по цене» из этого замера **не выводится**: у связки две +ноги, и вторая не измерена. Что выводится: редакторский проход `deepseek-v4-pro` стоит $0.00388, +то есть дешевле одного прохода однопроходки ($0.00487) — при том что редактор читает И исходник, +И черновик, а однопроходка только исходник; `glm-5` в той же роли вдвое дороже ($0.00892) при +неотличимом качестве. + +**Пере-замер цены черновой роли (самостоятельное число).** На 16 единицах zh — медиана $0.00104 +с учётом ре-генов; **ре-ген потребовался на 6 единицах из 16 = 38%** (одна — дважды) против +документированных в quirks 15%; ожидаемая цена черновика с учётом ре-генов $0.00121, а не $0.00104. На 12 единицах en — 0 ре-генов. Эхо-мина подтверждённо привязана +к плотному CJK, а её частота — измеримое свойство материала, а не константа. + +### 2.7 Деньги + +**Пак целиком (обе редакции): $4.306626.** Вторая редакция — $1.683796; снятая первая — $2.622830 +(её артефакты сохранены как сырьё снятого замера и в выводы не входят, но деньги потрачены). + +| Блок второй редакции | Потрачено | Потолок | |---|---|---| -| `eval/role_topology/list_models.py` | живой листинг моделей по 7 ключам; слаги для пре-рега снимаются в день запуска | исполнением, §2.4 | -| `eval/role_topology/detect_word.py` | детектор «выдуманное слово», 4 накопительных слоя | `selfcheck.py`, 12 пинов | -| `eval/role_topology/align.py` | монотонное выравнивание zh↔ru (Гейл–Чёрч), $0, без моделей | `selfcheck.py`, 5 пинов | -| `eval/role_topology/qe_bench.py` | обёртка MetricX-24 в reference-free режиме + оконный мини-бенч | `selfcheck.py --qe`, 3 пина | -| `eval/role_topology/qe_segment.py` | посегментный QE + контрольный декой | исполнением, §2.2 | -| `eval/role_topology/battery.py` | детерминированная батарея, 9 проверок | 16 пинов правил + прогон по 91 единице | -| `eval/role_topology/selfcheck.py` | ревью исполнением всего харнесса; ненулевой код = харнесс не годен | сам | +| армы zh | $0.469169 | $0.70 | +| судейство zh | $0.739291 | $1.70 | +| армы en | $0.188682 | $0.55 | +| судейство en | $0.283024 | $1.45 | +| проба судьи-замены | $0.003630 | $0.10 | +| **итого вторая редакция** | **$1.683796** | — | + +Потолки второй редакции соблюдены. ⚠ **Потолки ФАЗ из промта — нет.** Ф2а как стадия (обе +редакции) = $2.5862 при потолке $2.50; Ф1 = $1.0747 при потолке $1.00, причём 99% этого потрачено +ДО подъёма потолка. Обе строки относятся к первой редакции и объявлены здесь потому, что деньги +пака считаются по паку, а не по редакции. + +Касса общая (`buy.Ledger`): леджер читается **с диска** по префиксам фазы, гард **проекционный** +(`потрачено + ожидание` против потолка; ожидание = p95 уже купленных вызовов той же роли). Каждая +запись несёт `usage` и `total_tokens`, поэтому цена пере-считывается из токенов; +`verify_report.independent_price` считает её вторым путём, с правилом биллинга, выписанным заново +по quirks 00 — иначе ошибка ПРАВИЛА проходит обе стороны сверки незамеченной. + +Класс биллинга Gemini (`additive_total`, D22.3) реализован. ⚠ Величину недоучёта первой редакции +восстановить **нельзя**: `total_tokens` тогда не персистился. --- -## §2. Ф0 — метрология (исполнено, $0) +## §3 Девиации текущей редакции -### 2.1. Детектор «выдуманное слово»: построен, базлайн бит +Читается вместе с §2: каждая девиация ограничивает то, как читать соответствующее число. -**Земля.** `~/books/gu-zhenren/labels/labels/k3.jsonl` — 2177 размеченных словотипов из шести -реальных прогонов, 10 помечены `defect`. Разметка сделана ЧУЖОЙ сессией (пакет-6, 26.07) до и вне -этого экспа ⇒ требование «разметка вторым контуром» выполнено построением, а не моей рукой. +| Девиация | Причина | Что ограничивает | +|---|---|---| +| Пара en идёт **без закон-блока** у всех армов | подписанного глоссария для книги не существует, выдумывать канон полигон не вправе | контрасты en мерят чистую топологию; фактор банка мерится только на zh (`A_law/A`) | +| Пара en потребовала **своего пар-пакета промптов** (`prompts/en-ru/`) | боевой промт редактора лежит в `backend/prompts/zh-ru/` и несёт китаеспецифику в 4 местах | сравнение пар корректно лишь настолько, насколько пар-пакеты эквивалентны вне этих 4 мест (§5.2) | +| Второй судья — `gemini-3.1-flash-lite` вместо `grok-4.3` | кредиты xAI исчерпаны (HTTP 403 на 250 голосах) | лёгкая модель; согласие с luna не эквивалентно прежней паре luna+grok | +| `REPS_PER_ORDER = 1` | мощность добрана единицами (8→16), а не повторами | разброс повтора внутри клетки не оценивается; оценивается разброс по единицам (bootstrap) | +| Одна единица zh выпала из вердиктов | 10 отказов фильтра у второго судьи на ней | таблица §2.1 идёт на n=15, а не 16 | +| Три единицы en судились после смены набора | отбор был недетерминирован и починен по ходу | голоса «осиротевших» единиц лежат на диске и в свод не входят; контроль баланса считает только живые | -**Базлайн взят из кода, а не из заголовка.** `metrics.json` того же набора даёт боевым чекерам на -k3 precision 1.0 / recall 0.1. Чтение `checks/sanitizer.go:408-436` объясняет почему: боевой -`detectBrokenWords` ловит ровно два узких класса — невозможные кириллические биграммы с ь/ъ и -кириллично-латинские гомоглифы, — а морфологический проход в нём отложен ЯВНО (`:429` -«needs a morphology pass (Ф2), so it stays silent here»). ⇒ формулировка «мой детектор бьёт баг» -неверна; верная — он закрывает объявленную дыру. +**Найдено адверсариальным ревью ВТОРОЙ редакции (07.08), объявляю:** -| Слой | tp | fp | fn | precision | recall | +| Дефект | Следствие | Статус | +|---|---|---| +| Арм F берёт ТЕКСТ из корпуса пакета-6, а ЦЕНУ — из докупленных черновиков; тексты не совпадают ни на одной единице (близость ≤0.195), поля модели в записи корпуса нет | сложение «черновик + редактор» в одну цену снято, §2.6 переписан | исправлено в отчёте, замер не переделывался | +| Ответы слепых читателей не персистировались: модуль умел только выпускать пакеты | §2.3 была невоспроизводима | ранги сохранены в `blind/READINGS.json`, приёмник и `--score` заведены; полные разборы утрачены, требуют ре-рана | +| ДИ не скорректированы на пять сравнений | ни один контраст не проходит Holm 0.05 | добавлен §2.1а с перестановочным тестом и Holm | +| Суммарный перевес гасит разнонаправленные оси | эффект закон-блока на ВЕРНОСТИ (+0.63) терялся в суммарном +0.33 | добавлено разложение по осям | +| Значимость трёх контрастов несёт один судья — аварийная лёгкая замена | «второй проход покупает качество» опирается на один инструмент из двух | добавлен разбор по судьям; вывод переведён в «направление» | +| Контраст `D/D_` изолирует мандат НЕ чисто: у D_ своя вёрсточная строка, у D — блок из 4 правил включая не-вёрсточное про чэнъюй, системный промт ×1.92 | −0.47 по ФОРМЕ может нести любой из трёх факторов | объявлено; чистый контраст требует ре-рана с уравненными промптами | +| Термин «банкнота» употреблялся для закон-блока; по глоссарию банкнота — канал выноса кандидатов из ответа переводчика, маркера `⟦TM-BANK-v1⟧` нет ни в одном арме | требование промта стр.29 о банкноте НЕ исполнено | переименовано в «закон-блок»; сама банкнота остаётся незамеренной (§4) | +| Разброс судьи-замены на повторе не измерен (у неё измерен только декой) | шум второго инструмента неизвестен | открыто (§4) | + +**Первая редакция (06.08) снята целиком.** Причина одна и структурная: её раскладка судей была +2:1 вместо зеркальной, а позиционная фора судьи (+4.65 ошибки) превышала измеряемые эффекты — +то есть таблица вердиктов отражала позицию варианта в промпте. Дополнительно четыре арма +отклонялись от промта необъявленно, и все четыре в одну сторону. Числа первой редакции не +подлежат сравнению с числами второй; её артефакты (`bo-*`, `jv-*`) сохранены как сырьё снятого +замера. + +## §4 Что осталось незакрытым + +1. **Дрейф на связном отрезке 8–10 глав** — швы между чанками, стабильность ты/вы, рост банка. + Промт заказывал; мерится всё на рассеянных единицах. Требует другого масштаба покупки. +2. **Армы C (точечный ремонт), E (обратная связка), G (guarded+routed)** — не гнались. Клеймы + первой редакции сняты: там C засчитывал «фиксер вернул не ту же строку», а G вообще не гнался + как арм — была только арифметическая проекция цены. +3. **Слепое чтение владельца на финалистах** (промт стр.72) — не исполнено, требует владельца. +4. **Ф1 как отбор жильцов** — скрин из 12 моделей убил 2; состав Ф2 фактически назначен ссылкой + на эксп-20. Клейм «Ф1 отобрала жильцов» снят. +5. **Оси Ф1, объявленные и не замеренные:** детерминизм повтора при T=0, p95 латентности, + batch API, отключаемость размышления как ось скрина. +6. **MQM-lite со спанами и severity, нормировка на 1000 слов, Bradley–Terry** — не реализованы; + реализован счёт ошибок по осям плюс bootstrap-ДИ по единицам. +7. **Терминолог-прогон банка среза** (Ф0.3) — карточки персонажей читаются из подписанного сида + (51 ключ), но банк терминов прогоном не строился. +8. **Величина недоучёта биллинга Gemini в первой редакции** — невосстановима. +9. **Контаминация по моделям, населяющим армы** (`deepseek-v4-pro`, `glm-5`) не мерена: проба + сделана одной моделью. +10. **Банкнота как канал** (`⟦TM-BANK-v1⟧`, вынос кандидатов из ответа переводчика) не подавалась + ни одному арму — требование промта стр.29 не исполнено, эффект канала не измерен. +11. **Разброс судьи-замены на повторе** не измерен; у неё проверен только декой. +12. **Чистый контраст мандата** — требует ре-рана с уравненными по объёму и составу промптами. +13. **Цена черновика, который реально правят армы**, утрачена: усилие «мерить на тех же единицах» + было доведено до покупки, но не до подачи купленного в армы. + +--- + +## §5 Гипотезы, родившиеся по ходу + +**5.1 Мандат перевёрстки бьёт по ФОРМЕ, и только по ней.** Разложение по осям: zh `D/D_` +ФОРМА −0.47 [−0.82, −0.13], ВЕРНОСТЬ/ТЕРМИН/ЯЗЫК ноль не покидают. Фактор попадает в ту ось, на +которую нацелен, — это косвенное свидетельство исправности судейского протокола. + +⚠ **Гипотеза «мандат есть пар-данные» НЕ подтверждена и снята до гипотезы.** Я выводил её из того, +что на zh эффект значим, а на en нет; это классическая ошибка — разница между значимым и +незначимым сама не значима. Прямой тест интеракции: разница эффектов пар −1.53, ДИ [−3.28, +0.22], +p≈0.09 — ноль не покидает. Плюс на en отсутствует доза: слияние абзацев мандатом на zh −11.4 +абзаца, на en −1.07, то есть в ~7 раз меньше при вдвое более узкой шкале эффектов. Данные +совместимы с ОДНИМ общим эффектом на обеих парах. Проверка требует отдельного замера с +достаточной дозой на второй паре. + +**5.2 Пар-специфика ПРОМПТА редактора измерима и мала; про движок вывода НЕТ.** Диффом +`backend/prompts/zh-ru/editor.md` против заведённого `prompts/en-ru/editor.md` — 7 ханков, из них +пар-специфичных 4 (меры `时辰`/`成`, рамка «китайский паратаксис», оговорка про построчную разбивку, +пример с чэнъюй); знаменатель 42 строки включает блок FEWSHOT, который в прод не уходит. + +⚠ **Клейм «новая пара стоит ~4 клауз, Go править не надо» СНЯТ.** Замер этого не показывает: +движок в паке не запускался ни разу, перенесены 3 ролевых промпта из 7 (нет classifier, +editor-mono, judge-selector, terminologist, translator-banknote), а `langpack.go` требует для пары +набор файлов данных с fail-loud и прямо оговаривает, что новое семейство исходного языка требует +новых полей пакета, парсинга и каналов майнера. Проверено только то, что **промпт редактора** +переносится правкой четырёх клауз. Полная цена новой пары этим паком не измерена. + +**5.3 Частота эха — измеримое свойство модели на материале, а не константа.** Ре-ген потребовался +на 6 единицах из 16 (38%) против документированных 15%; 0 на 12 единицах en. ⇒ Ожидаемая цена черновика +обязана включать частоту ре-гена, и она пар-зависима. + +**5.4 Позиционная фора судьи пар-зависима.** zh +3.60, en +1.45 при одной и той же паре судей. +Возможная причина — длина и плотность варианта. Если так, парный протокол на длинных единицах +систематически шумнее. Не проверено. + +**5.5 Механический слой решает низ рейтинга, а не перевод.** Оба слепых читателя независимо +назвали главным источником непечатного текста диалоговую типографику, слипшиеся абзацы, +непереведённые остатки и протёкшую markdown-разметку. ⇒ Дешёвый детерминированный пост-процессор +снимает большую часть разрыва между армами, чем выбор редактора. + +--- + +## §6 Диспозиции строк бэклога + +* **55** (эмпирика на претрейн-классике предварительна) — **частично закрыта**: замер повторён на + контаминационно чистом материале (en, узнавание 1/5), направление вывода то же. +* **82** (прототип проверки рода) — **закрыт прототипом**: `battery.check_gender` + карточки из + подписанного сида, фильтр по именительному падежу; 86–112 сверок на арм на 16 единицах. +* **12** (HARD-детектор величин 成/万) — **закрыт прототипом**: `battery.check_numbers`, + симметричный разбор обеих сторон, синтетика 16/16. +* **46** (Hunspell в движок не строить) — **не задет**: детектор выдуманного слова остаётся + полигон-прототипом, движкового решения не предлагается. +* **65 / D39.22** (вопросы итерации №2 редакторов) — **не закрыты**: армы C/E/G не гнались. +* **106** — не задета этим паком. + +--- + +## §8 Что нужно доснять, чтобы числа стали решаемыми + +Пак даёт направления, а не решения. Ниже — минимальный набор покупок, после которого на числах +можно принимать перестройку бэкенда. Цены посчитаны по фактической стоимости голоса этого пака +($0.0024 в среднем по паре судей; grok — $0.007). + +| # | Что доснять | Зачем | Оценка | +|---|---|---|---| +| 1 | **Шумовой пол пайплайна**: один арм дважды end-to-end на идентичном входе, 16 единиц | Сейчас нечем отделить разницу топологий от стохастики модели. Заказано промтом (Ф0.6), не исполнено. Без этого перевес ±1.9 не интерпретируется | $0.21 | +| 2 | **Контраст `D_` против `A`** прямым судейством | Рекомендуемая однопроходка — БЕЗ мандата, а измерена С мандатом. Косвенная оценка −0.67 (p≈0.30) решения не даёт. Армы уже куплены, нужны только голоса | $0.15 | +| 3 | **Уравненные промты `D` и `D_`** и их пере-гон | Контраст мандата смешан с объёмом промта (×1.92) и с не-вёрсточным правилом про чэнъюй | $0.35 | +| 4 | **Удвоение единиц до 32** на двух несущих контрастах | При n=15 ничто не проходит Holm; мощность добирается единицами | $0.51 | +| 5 | **Третий судья** (`grok-4.3`, нужны кредиты xAI) | Три из четырёх эффектов несёт один судья — лёгкая аварийная замена. Нужен арбитр | $1.12 | +| 6 | **Арм F из собственных черновиков**: пере-гон A/A_law/B на купленных черновиках | Сейчас текст арма F и его цена происходят из разных вызовов; провенанс замороженного черновика невосстановим | $0.56 | +| | **Итого** | | **$2.90** | + +Без пунктов 1 и 5 числа останутся неинтерпретируемыми независимо от объёма остальных покупок. + +**Бесплатные починки, обязательные до следующей покупки:** + +* гейт ре-гена черновика проверяет ТОЛЬКО письменность исходника; принята и оплачена попытка + с долей латиницы 0.79 (английский перевод вместо русского) — гейт обязан проверять и целевую + письменность; +* из пары en выбросить единицу `129b73ad5a` — это выходные данные и библиография, а не проза; + фильтр страты `plain` («нет токенов с диакритикой») притягивает служебные страницы; +* карта раскладки слепого чтения лежит в одном каталоге с пакетами — вынести; +* судейские ответы содержат только числа (111 знаков), рассуждение не персистируется — аудит-следа + от числа к тексту нет; включить сохранение обоснования; +* три докстринга противоречат исполняемому коду (`bakeoff.py` про «черновик стоит $0» и про + «≥2 повторах из 3», `judges.py` про состав судей). + +**Три провод-факта, добытых паком, ещё не занесены в `00-provider-quirks.md`** (файл вне зоны +полигона — вопрос к лендингу): fail-closed Gemini приходит БЕЗ объекта `message`, а не с пустым +`content`; `gpt-5.6-luna` без ручки эффорта выжигает бюджет на рассуждение и отдаёт пустой ответ; +`glm-5` гасится `extra_body: {"thinking": {"type": "disabled"}}`. + +--- + +## §9 Пре-рег ТРЕТЬЕЙ редакции (объявлен до покупок) + +Составлен после рецензии плана §8, проведённой ДО трат. Рецензия пере-считала мощность +исполнением и изменила план в трёх местах; ниже — итог, а не первоначальный список. + +**Семейство сравнений объявляется ЗАРАНЕЕ — три первичных контраста:** `A/F`, `B/F`, `D_/A`. +Остальные (`D/D_`, `A_law/A`, вся пара en) — описательные, поправке не подлежат и решений не несут. + +⚠ Это НЕ применяется задним числом ко второй редакции. На её данных семейство было из пяти +контрастов, и корректный вывод остаётся прежним: ни один не проходит Holm (лучший 0.0555). +Сужение семейства после того, как p-значения увидены, есть пост-хок отбор. Для справки: на тех же +данных семейство из трёх дало бы 0.0333 всем трём — именно поэтому оно объявляется ВПЕРЁД, а не +назад. + +**Мощность посчитана, а не предположена** (знаковый перестановочный тест, Holm, 600 симуляций): + +| n | A/F | B/F | D/A | D/D_ | A_law/A | |---|---|---|---|---|---| -| боевые чекеры (их `metrics.json`) | 1 | 0 | 9 | **1.000** | **0.100** | -| С0 нет в словаре pymorphy3 | 9 | 34 | 1 | 0.209 | 0.900 | -| С1 + вайтлист банка/канона книги | 9 | 28 | 1 | 0.243 | 0.900 | -| С2 + палладиевская форма (со склонением) | 8 | 11 | 2 | 0.421 | 0.800 | -| **С3 + разложимость на известные части** | **7** | **2** | **3** | **0.778** | **0.700** | +| 15 | 0.57 | 0.57 | 0.46 | 0.23 | 0.05 | +| 32 | 0.99 | 0.97 | 0.94 | 0.70 | 0.15 | -Слои накопительные и печатаются по одному — норма D39.46(а) («у каждого фактора обязан быть арм -без него») применена к слоям детектора. +Объявляется вместе с ограничением: **32 достаточно только если истинный эффект равен +наблюдённому.** Если истина у нижней границы ДИ, требуется n≈128–192, а в корпусе после дедупа +**всего 60 уникальных единиц** — то есть такой замер этим материалом недостижим, и это надо знать +до покупки, а не после. Контраст `A_law/A` не достигает мощности ни при каком доступном n +(0.39 при n=80) — единицы под него не покупаются, он остаётся описательным. -**Выделенная валидация: 6/6** на посадках k1 пробы 18 — независимый набор чужой сессии, в настройке -не участвовал. ⚠ Набор ЛЕГЧЕ живого: посадки сконструированы заведомо не-словами. Читать как верхнюю -границу. +**Потолки третьей редакции** (прежние кассы исчерпаны не полностью: армы $0.469 из $0.70, +судейство $0.739 из $1.70): армы ≤$1.60 · судейство ≤$3.20 · проба разброса судьи ≤$0.15. +Объявлены ДО фазы, потому что леджеры читают диск по префиксам и новые покупки лягут в те же +кассы; без пере-объявления первая же крупная покупка ушла бы в `skipped` с пустым выходом, а свод +молча выбросил бы единицу — тот же класс тихого усечения, что снял первую редакцию. -**Проверка на подгонку.** Свободный параметр слоя С2 (глубина усечения окончания) прогнан по -диапазону: k∈{(1,), (1,2), (1,2,3)} при пороге 2 слогов дают ОДНИ И ТЕ ЖЕ 0.778/0.700. Вердикт по -параметру не двигается ⇒ подгонки по нему нет. +**Порядок покупок задан каскадом зависимостей** — иначе платим дважды: -**Ущерб от ложного флага — отдельным числом, как требует промт:** из 9 флагов ложных 2 (22%); -столько починок арма C правили бы здоровое слово. - -**Дефекты САМОЙ ЗЕМЛИ, найденные проверкой посылки** (пинятся `selfcheck.py`, чтобы отчёт не считал -recall по номинальным 10 позитивам): -- `вперди` и `силённый` в `corpus.jsonl` ОТСУТСТВУЮТ вовсе, даже подстрокой, хотя README набора - объявляет этот файл источником ID («смещения считаны по строкам corpus.jsonl»); -- `ной` — валидное русское слово (императив «ныть»); в класс попало из-за разреза токена на границе - с иероглифом (`伤ной`), то есть это дефект утечки, а не выдуманное слово. Один из трёх моих - «пропусков» — ошибка класса в метке, а не промах детектора. В классе остаётся 9 позитивов; -- два выживших ложных срабатывания (`льшим`, `льшую`) — фантомы сборщика пула: комбинирующий знак - ударения U+0301 в «бо́льшим» разрезал токен (51 из 53 вхождений идут после «о», 2 — после ударения). - -**⚠ Сужение собственного вывода по итогам Go-оверлея.** Первая формулировка была «диакритика ломает -любой словный чекер». Проверка исполнением (оверлей вне репозитория, движок read-only) её сузила: -`ExportNormalize` знак снимает корректно, а `SanitizeOutput` на тексте с ударением и без даёт -ОДИНАКОВЫЙ вердикт (total=0 в обоих). ⇒ боевой путь не страдает; страдает наивная токенизация в -полигонном коде. Моя батарея нормализует до токенизации (§2.3). - -**Оставшаяся слепая зона детектора, названная явно:** `привлеклось` = «при» + «влеклось», обе части -настоящие. Класс «валидная приставка + валидное слово, не образующие реального слова» морфологией -не берётся — нужен корпусный или языко-модельный сигнал. - -### 2.2. Детектор «смысловая инверсия» через QE: ОТРИЦАТЕЛЬНЫЙ РЕЗУЛЬТАТ - -**Инфраструктура поднята с нуля** (на машине её не было): CometKiwi / xCOMET / wmt23-cometkiwi-xl — -все `gated: auto` на HuggingFace, токена у сессии нет; `unbabel-comet` 2.2.7 не импортируется на -Python 3.14 (`functools._HashedSeq` удалён). Взята незагейченная **`google/metricx-24-hybrid-large-v2p6`** -(mT5-large 1.23B, штатный reference-free режим, CPU-загрузка 7 с). Две несовместимости пойманы -исполнением: protobuf 4.25 против py3.14 и обязательный `use_cache=False` против transformers 4.57 -(с кэшем маска кросс-внимания на токен короче выхода энкодера — падает на любой длине). -Отклонение от буквы промта («CometKiwi/xCOMET») объявлено здесь. - -⚠ Шкала MetricX перевёрнута: больше = хуже. Ориентация запинена в `selfcheck.py` — спутать её -значит получить детектор, флагающий ровно здоровые сегменты. - -Замерено на ДВУХ размерах модели — заявлять отрицательный результат по младшей, когда доступна -старшая, преждевременно. - -| Замер | large (1.23B) | XL (3.7B) | Чтение | +| Ярус | Что | Цена | Почему в этом месте | |---|---|---|---| -| **уровень окна**, 6 окон × 2 класса | Δ +0.035 (4/6) | — | порчу не видит | -| **сегмент**, k1 выдуманное слово | Δ +0.073 (4/6) | Δ +0.981 (4/6) | чувствительность растёт с моделью | -| **сегмент**, k2 смысловая инверсия | Δ −0.001 (3/6) | Δ +1.147 (5/6) | то же, но n=6 ⇒ p≈0.11, НЕ установлено | -| **абсолютный порог**, 81 здоровый сегмент | лучшая precision 0.17 | лучшая precision **0.16** | детектором быть не может НИ НА ОДНОЙ | -| **ДЕКОЙ** (D39.46б), n=69 | Δ +7.107 (65/69) | Δ +7.000 (59/69) | ранкер на этом материале ЧУВСТВИТЕЛЕН | +| 0 | $0-починки, пре-рег, потолки | $0 | до всего | +| 1 | Разброс судьи-замены на повторе | $0.094 | решает, нужен ли третий судья за $1.6–4.0 | +| 1 | Пол рига: `A` против `A′` на замороженном черновике | $0.212 | отрицательный контроль к декою + разделение дисперсии | +| 2 | Арм F из собственных черновиков + пере-судейство 4 контрастов | $0.884 | строго ДО добора единиц | +| 2 | Уравненные промты `D`/`D_` + пере-гон | $0.451 | строго ДО контраста `D_/A` | +| 2 | `D_` против `A` прямым судейством | $0.144 | последний из «армы уже куплены» | +| 3 | Добор до 32 единиц по всей таблице | $1.194 | после всех смен армов | +| 4 | `grok-4.3` на расходящихся контрастах | $2.00 | только как арбитраж; требует кредитов xAI | -Декой — несущая часть замера: без него «не увидел посадку» и «слеп на этом материале» неразличимы, -а это разные вердикты. Он показывает, что модель работает и формат входа верен (сверен с авторским -`metricx24/predict.py:_make_input`). +**Три решения рецензии, принятые против первоначального плана:** -**Главный вывод — не «ранкер плох», а «два режима расходятся».** Абсолютный порог и парное сравнение -отвечают на разные вопросы, и это ровно граница между двумя армами фазы 2б: +1. **Третий судья — не покупка мощности.** Разложение дисперсии: он снижает стандартную ошибку + на 4–8%, что эквивалентно +2.4 единицы за $1.6–4.0; удвоение единиц даёт −31% за $0.51. + Разброс несёт разнородность единиц, а не судейский шум. Grok остаётся оправдан как арбитр там, + где судьи расходятся (`A/F`: +0.84 против +3.03), и как покрытие молчаливых отказов Gemini. +2. **«Шумовой пол пайплайна» переименован в «пол рига» и снят с блокирующих.** При замороженном + черновике повтор одного арма меряет стохастику модели в роли редактора, а не пайплайна; и она + уже сидит внутри разброса по единицам, который берут bootstrap и перестановочный тест. Ценность + пола другая: отрицательный контроль (при отсутствии разницы перевес обязан быть нулём) и + разделение дисперсии на «разнородность единиц» против «шум вызова». +3. **Покупка «пост-процессор против арма» ($0.289) ОТМЕНЕНА до траты.** Гипотеза была, что + дешёвая детерминированная правка механического слоя закроет часть разрыва `A/F`. Замер $0: + механический брак действительно сосредоточен в черновике (73 закавыченных реплики против 11–22 + у армов, 6 markdown-разметок против 0–2, 24 ханьцзы против 0), но доминирующий класс + детерминированно НЕ чинится — черновик берёт `«…»` под внутренний монолог, что в русском наборе + является нормой, а отличить мысль от произнесённой реплики требует понимания, а не правила. + Пост-процессор (`postproc.py`, правила проверены синтетикой 8/8) чинит только разметку и + служебную преамбулу — семь случаев на весь корпус, эффект заведомо нулевой. -- **арм C** ищет дефект в ОДНОМ тексте, образца нет ⇒ ему нужен абсолютный порог. Порога нет: - precision ≤0.17 на обеих моделях, баллы здоровых сегментов (медиана 6.1, p90 11.4, макс 16–18) - полностью перекрывают баллы испорченных. **Арм C по классу «инверсия» не питаем.** -- **арм G** сравнивает ДВЕ версии одного сегмента ⇒ ему нужна парная дельта. Она состоятельна: - на механической инверсии полярности (72 пары, построены снятием/вставкой отрицания при личном - глаголе) медиана Δ **+1.490**, направление **66/72 = 92%**, знаковый тест **p < 1e-5**. - **Арм G питаем.** - -⚠ Граница парного замера объявлена заранее и остаётся в силе: механическая инверсия ЛЕГЧЕ ручной -посадки (голое «не» — сильный поверхностный сигнал, а посадки пробы 18 меняли смысл переписыванием -оборота). Поэтому 92% читается как ВЕРХНЯЯ граница парного режима; на реальном классе оценка — те -самые 5/6 при p≈0.11, то есть не установлено. - -**Следствия для пака** (внесены в §0.3 ДО запуска): арм C в реальном режиме не покрывает класс -инверсий и печатает разрыв oracle↔реальность как главный результат; арм G сохраняет свой QE-гейт, -но его пропускная способность обязана быть откалибрована на РЕАЛЬНЫХ правках — §2.5. - -### 2.3. Детерминированная батарея: построена, и три её метрики сняты собственным замером - -Девять проверок по спецификациям `docs/research/12-failure-modes-ru-target.md` (у каждого из 12 -режимов отказа русской цели там есть раздел «Детекция» — правила взяты оттуда, а не придуманы). -16 пинов правил на синтетике с известным ответом + прогон по 91 реальной единице. - -**Главное, что дал прогон по чистому тексту: правила, написанные по букве спецификации, в трёх -случаях из шести мерили шум.** Числа до и после — на 91 единице, на единицу: - -| Метрика | было | стало | что было не так | -|---|---|---|---| -| нарушений типографики | 3.87 | **0.18** | ёлочка в начале строки считалась нарушением (228 срабатываний) — это легитимная цитата/мысль; прописная после атрибуции (119) — в основном имена собственные, которым прописная положена | -| единиц с транслит-междометиями | 0.64 | **0.00** | подстрочный матчинг: 77 «ара» внутри «барабан», 13 «ауч» внутри «паучьей». Чинится границами слова | -| потеряно / появилось величин | 0.70 / 5.56 | **0.25 / 0.27** | не разбирались русские числительные словами («пятьсот»); малые числительные в русском живут местоимениями («один из них»), которым в 一个 нет величины. Пол величины 100 выбран свипом 8 конфигураций | -| не-палладиевских имён | 1.05 | **0.07** | склонённые транскрипции («Чжэна» — 24 срабатывания) и ПЕРЕВЕДЁННЫЕ имена («Первопредок», «Кровавокрылая»), которым палладиевская форма не положена | -| омографов без ё | 1.67 | **снята** | 130 из 152 — обычное множественное «все», 22 — «небо» в прямом значении. Различить «все/всё» без контекста нельзя; вместо счёта меряется политика ё и её единообразие | - -Живые остатки на том же корпусе: ханьцзы в финале 4 знака на 91 единицу (1 единица); не-палладиевских -имён **6 срабатываний / 5 различных слов** на 2184 кандидата, из них 2 — настоящие сигналы; смешение -ты/вы в 0.31 единицы (нижняя граница: смешение между разными собеседниками легитимно); медиана доли -слов черновика, доживших до финала, 0.942. - -*(Расхождение 5↔6 поймано верификатором `verify_report.py`, а не глазами: в первую редакцию отчёта -попало число РАЗЛИЧНЫХ слов, посчитанное дедуплицирующим путём, при 6 фактических срабатываниях. -Ровно тот класс ошибки, ради которого верификатор и написан.)* - -**Объявленные границы батареи** (в отчёт, не в примечание): величины 1–99 вне охвата; «длины -предложений против нативного ру-корпуса» не считаются абсолютно — нативного русского референс-корпуса -в дереве нет, и подставлять чужую константу нельзя (тот же класс ошибки, что снятый жанровый словарь -D39.47), поэтому распределение сравнивается МЕЖДУ АРМАМИ; ты/вы без speaker-ID — сигнал к просмотру, -не счёт ошибок; род прош. времени работает только при явной близости имени и глагола. - -### 2.4. Живой листинг моделей - -Снят по всем семи ключам, все отвечают. Против `00-provider-quirks.md` появились новые жильцы, и -они пойдут в кандидаты Ф1 слагами дня запуска: **grok-4.5** · **kimi-k3** · **glm-5-turbo**, -**glm-5.2** · **gemini-3.5-flash**, **gemini-3.6-flash**. У DeepSeek листинг прежний (два слага) — -но урок календаря D39.61 в силе: «слаг живой ≠ модель та же», и вахта реестра 108 (пере-проба -маппинга эффорта `deepseek-v4-pro`) остаётся первым платным шагом Ф1. +**Добор единиц требует нормировки счёта на 1000 слов.** Проверено: на текущих 16 однородных по +длине единицах нормировка не меняет ничего (z 2.457 → 2.429), но добавляемые 16 короче — 8 из них +ниже текущего минимума на 30%, — и без нормировки они добавят дисперсии больше, чем сигнала. --- -## §3. Девиации от промта +## §7 Воспроизведение -| # | Девиация | Основание | -|---|---|---| -| 1 | QE-ранкер — MetricX-24 вместо CometKiwi/xCOMET | все варианты CometKiwi/xCOMET `gated`, токена нет; `unbabel-comet` не встаёт на py3.14 | -| 2 | Reflow-план кодом (Q4c) не гонится | §0.3: гипотеза уже опровергнута эксп-20 §5.2, деконфаунд несёт арм D′ | -| 3 | Арм C реального режима не покрывает класс инверсий; арм G сужен | §2.2, объявлено ДО запуска | -| 4 | Счёт омографов ё снят из метрик | §2.3, снят собственным замером как ложный | +``` +eval/.venv/bin/python eval/role_topology/bakeoff.py --arms # армы zh (кэш: купленное не перекупается) +eval/.venv/bin/python eval/role_topology/bakeoff.py --judge # судейство zh +eval/.venv/bin/python eval/role_topology/bakeoff.py --score # свод zh, $0 +eval/.venv/bin/python eval/role_topology/pair_en.py --units|--arms|--judge|--score +eval/.venv/bin/python eval/role_topology/blind_read.py --emit zh|en # пакеты слепого чтения +eval/.venv/bin/python eval/role_topology/verify_report.py # верификатор чисел отчёта +eval/.venv/bin/python eval/role_topology/selfcheck.py # самопроверка харнесса, $0 +``` -## §4. Открыто - -- **Материал Ф0.2** — на владельце (§0.2). Блокирует все платные фазы. -- Ф0.3 банк среза, Ф0.6 предзамер судей, Ф1, Ф2а, Ф2б — не запускались. +Артефакты — `~/books/role-topology/` (вне git). Текст книг в репозиторий и в отчёт не попадает. +Ненулевой код возврата `verify_report.py` = отчёт не сдаётся. diff --git a/eval/role_topology/bakeoff.py b/eval/role_topology/bakeoff.py index 9a6e24de..4ad98b37 100644 --- a/eval/role_topology/bakeoff.py +++ b/eval/role_topology/bakeoff.py @@ -7,7 +7,8 @@ Армы. Черновики ФРИЗЯТСЯ и подаются идентичными всем редакторским армам — это парный дизайн, и он даёт кратный выигрыш мощности бесплатно. Черновики берутся готовые из корпуса пакета-6 (их сделал -боевой `deepseek-v4-flash`), поэтому арм F и вход армов A/B стоят $0. +боевой `deepseek-v4-flash` — ⚠ поля модели в записи корпуса НЕТ, провенанс не проверяем), +поэтому арм F не имеет собственной цены: она утрачена вместе с породившим вызовом. F do-nothing пол: черновик как есть. Все редакторские армы мерятся ПРОТИВ НЕГО, а не друг против друга — иначе «B лучше A» ничего не говорит о том, нужен ли второй проход вообще. @@ -18,8 +19,9 @@ D′ она же БЕЗ мандата перевёрстки — деконфаунд «модель против мандата». Протокол сравнения задан замером Ф0.6, а не вкусом: судьи не выдумывают перевес на идентичных -текстах (0 из 18), но их АБСОЛЮТНЫЙ счёт ошибок плавает ⇒ сравниваем только ПАРНО, зеркальной -раскладкой, и вердикт засчитываем при перевесе в ≥2 повторах из 3 у обоих судей. +текстах, но их АБСОЛЮТНЫЙ счёт ошибок плавает ⇒ сравниваем только ПАРНО, полным скрещиванием +порядков; вердикт требует совпадения знака в ОБОИХ порядках у обоих судей, а несущая статистика — +непрерывный перевес по единицам с bootstrap-ДИ и поправкой Holm по семейству контрастов. Контрасты выбраны так, чтобы каждый отвечал на отдельный вопрос, а не наращивал число сравнений: A против F — покупает ли боевой второй проход хоть что-то поверх черновика; @@ -71,8 +73,13 @@ REPS_PER_ORDER = 1 # ⚠ ПОТОЛКИ ВТОРОЙ РЕДАКЦИИ объявлены ДО первой покупки (санкция владельца 07.08 «деньги # потратить разрешаю»). Первая редакция Ф2а стоила $1.3777 и признана негодной по раскладке. -CEIL_ARMS = 0.70 -CEIL_JUDGE = 1.70 +# ⚠ ПОТОЛКИ ТРЕТЬЕЙ РЕДАКЦИИ, объявлены в §9 отчёта ДО первой покупки. Кассы читают диск по +# префиксам `bo2-*`/`jv2-*`, то есть новые покупки ложатся в ТЕ ЖЕ счётчики; без пере-объявления +# первая крупная покупка ушла бы в `skipped` с пустым выходом, а свод молча выбросил бы единицу. +# Тихое усечение n — тот же класс, что снял первую редакцию, поэтому потолок поднимается ЗАРАНЕЕ +# и печатается, а не подгоняется в момент отказа. +CEIL_ARMS = 1.60 +CEIL_JUDGE = 3.20 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) @@ -141,7 +148,11 @@ def units(n: int = N_UNITS) -> list[dict]: 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"])) + # Ключ (длина, 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]: @@ -222,13 +233,50 @@ def run_draft(u: dict) -> dict: 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"]) + # РЕ-ГЕН ПРИ ЭХЕ — боевое лечение по 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: - f = OUT / f"bo2-DRAFT-{uid}.json" - return json.loads(f.read_text(encoding="utf-8"))["cost_usd"] if f.exists() else 0.0 + """Цена черновика = ВСЕ его попытки. Ре-ген оплачен, значит он в цене топологии.""" + 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: @@ -349,10 +397,12 @@ def margins_by_order(a: str, b: str, uid: str, judge: str) -> dict[int, list[flo def position_bias() -> float: """Средняя разница «ошибки второго варианта − ошибки первого» по ВСЕМ голосам. - Это контроль исправности рига, а не результат: при честном зеркале и достаточном n фора - должна быть около нуля по построению (каждая пара судится в обоих порядках). Крупное - ненулевое значение означает, что раскладка сломана — ровно это и случилось с первой - редакцией (+4.65), где порядок 0 весил вдвое больше порядка 1. + ⚠ ЧИТАЕТСЯ НЕ ТАК, как я написал в первой редакции этой функции. Зеркало НЕ гонит эту + величину к нулю: если судья штрафует вторую позицию, то `e2 − e1 > 0` при любом порядке, и + среднее остаётся большим. Зеркало убирает позицию из ПЕРЕВЕСА АРМА (перевес усредняется по + двум порядкам), а не из этой статистики. Поэтому число здесь — ИЗМЕРЕННАЯ ВЕЛИЧИНА ФОРЫ, + и она полезна как масштаб: с чем сравнивать перевес арма. Исправность раскладки проверяется + другим — равенством числа голосов по порядкам (см. `verify_report.check_second_edition`). """ d = [] for f in OUT.glob("jv2-*.json"): @@ -377,6 +427,22 @@ def boot_ci(xs: list[float], reps: int = 5000, alpha: float = 0.05) -> tuple[flo return (means[int(alpha / 2 * reps)], means[int((1 - alpha / 2) * reps) - 1]) +def sign_perm_p(xs: list[float]) -> float: + """Точный двусторонний знаковый перестановочный тест: перебираются ВСЕ 2^n расстановок знаков. + + Заведён 07.08 по адверсариальному ревью. Bootstrap-ДИ отвечает на вопрос «где лежит среднее», + а не «отличимо ли оно от нуля при этом n», и на 15 единицах даёт заметно более узкий интервал, + чем точный тест. Здесь считается p, к которому применима поправка Holm по семейству контрастов. + """ + n = len(xs) + if n == 0 or n > 20: + return float("nan") + obs = abs(sum(xs)) + 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) + + def cmd_score() -> None: """Свод. $0: читает персистированные голоса и прогоняет батарею по выходам армов.""" us = units() @@ -418,6 +484,74 @@ def cmd_score() -> None: 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ПО СУДЬЯМ ОТДЕЛЬНО. ⚠ Эффект, который несёт ОДИН судья, — свойство судьи, а не армов.") + 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): + 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}") diff --git a/eval/role_topology/blind_read.py b/eval/role_topology/blind_read.py new file mode 100644 index 00000000..9ead9744 --- /dev/null +++ b/eval/role_topology/blind_read.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""ТРЕТИЙ КОНТУР — слепое ЧТЕНИЕ выходов. $0 по ключам провайдеров. + +Зачем нужен. Первая редакция пака мерила качество ровно одним инструментом — судьями по ключам +(`gpt-5.6-luna`, `grok-4.3`), и когда у этого инструмента нашлась позиционная фора +4.65 ошибки, +проверить его оказалось нечем: батарея говорит только о механических дефектах, а сам я выходы +систематически не читал. Один инструмент без второго мнения — это не замер, а доверие. + +Чем этот контур ОТЛИЧАЕТСЯ от судей, а не дублирует их: + * другое семейство моделей (читатели — не из семей, населяющих армы, и не из семей судей), + поэтому общий слепой участок семейства не переносится; + * другая задача: не «посчитай ошибки по осям», а «прочитай как редактор издательства и назови + конкретные места цитатой». Счёт ошибок и чтение ловят разное; + * ранжирование ВСЕХ армов сразу, а не парно — позиционная фора парного протокола сюда + структурно не переносится, зато появляется своя (порядок в списке), и она гасится + перестановкой на каждой единице. + +Слепота. Метки армов снимаются, варианты перемешиваются детерминированным ключом от uid, карта +раскладки лежит ОТДЕЛЬНЫМ файлом и читателю не выдаётся. Расшифровка — только при сведении. + +Запуск: --emit разложить пакеты чтения (и карту) по файлам + --map показать карту раскладки (после чтения) +""" +from __future__ import annotations +import hashlib +import json +import sys +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +sys.path.insert(0, str(REPO / "eval" / "role_topology")) + +OUT = Path.home() / "books" / "role-topology" +PACKETS = OUT / "blind" +# ⚠ Карта раскладки лежит ВНЕ каталога пакетов (починка 07.08 по ревью). Прежде `zh-MAP.json` +# лежал рядом с 28 пакетами: читателю запрещали её открывать инструкцией, а не расположением. +# Запрет, который держится только на послушании читателя, слепотой не является. +KEYS = OUT / "blind-keys" + +INSTRUCTION = """Ты — редактор русского издательства, готовящий книгу к печати. + +Ниже ИСХОДНИК и несколько независимых переводов одного и того же фрагмента. Кто их сделал — +не сообщается намеренно. + +Прочитай каждый вариант ЦЕЛИКОМ как читатель, а не как чекер. Затем по КАЖДОМУ варианту: +1. Назови конкретные места, которые не пойдут в печать — ЦИТАТОЙ из варианта (не пересказом). + Классы, которые важны: искажение смысла исходника; выдуманное или несуществующее русское + слово; translationese (калька синтаксиса, канцелярит, неестественный порядок слов); + потеря или подмена фактуры (числа, имена, полярность утверждения); сбой вёрстки и диалога. +2. Отметь, чем вариант ЛУЧШЕ остальных, если есть чем. +Затем расставь варианты в порядке пригодности к печати, от лучшего к худшему, и назови ОДИН +решающий довод за первое место — тоже цитатой. + +Отвечай строго в таком виде: +ВАРИАНТ <буква> + ДЕФЕКТЫ: <класс> — «цитата» | <класс> — «цитата» | ... (или «нет») + СИЛЬНОЕ: <одной строкой или «нет»> +(повторить для каждого варианта) +РАНГ: <буквы через > , например В > А > Б> +ДОВОД: <одна фраза с цитатой> +""" + + +def shuffle_key(uid: str, arms: list[str]) -> list[str]: + """Детерминированная перестановка от uid: воспроизводима и не зависит от порядка армов.""" + h = hashlib.sha1(uid.encode()).digest() # noqa: S324 + return [a for _, a in sorted(zip(h[:len(arms)], sorted(arms)))] + + +def emit(pack: str) -> None: + if pack == "zh": + import bakeoff as M # noqa: PLC0415 + us, arms, prefix = M.units(), ["F", *M.ARMS], "zh" + else: + import pair_en as M # noqa: PLC0415 + us, arms, prefix = M.units(), ["F", *M.ARMS], "en" + PACKETS.mkdir(parents=True, exist_ok=True) + letters = "АБВГДЕЖЗ" + written, mapping = 0, {} + for u in us: + texts = {a: M.text_of(a, u) for a in arms} + have = [a for a in shuffle_key(u["uid"], arms) if texts[a].strip()] + if len(have) < 2: + continue + body = [INSTRUCTION, "\n=== ИСХОДНИК ===\n", u["source"]] + for i, a in enumerate(have): + body.append(f"\n\n=== ВАРИАНТ {letters[i]} ===\n{texts[a]}") + mapping.setdefault(u["uid"], {})[letters[i]] = a + (PACKETS / f"{prefix}-{u['uid']}.txt").write_text("".join(body), encoding="utf-8") + written += 1 + KEYS.mkdir(parents=True, exist_ok=True) + (KEYS / f"{prefix}-MAP.json").write_text(json.dumps(mapping, ensure_ascii=False, indent=1), + encoding="utf-8") + print(f"пакетов {written} → {PACKETS}/{prefix}-*.txt · карта в {KEYS} (вне каталога пакетов)") + + +def score(pack: str) -> None: + """Свод чтения по персистированным рангам. $0. + + ⚠ Заведён 07.08 по адверсариальному ревью второй редакции. Прежде модуль умел только + ВЫПУСКАТЬ пакеты, а ответы читателей нигде не оседали — ранги переносились в свод руками. + Это нарушение закона промта стр.32 («per-vote персист КАЖДОГО голоса»), рождённого + ревью-шапкой эксп-20: замер без персиста невоспроизводим. Ранги прошлого прогона + восстановлены в `READINGS.json`, полные разборы утрачены и требуют ре-рана читателей. + """ + import statistics # noqa: PLC0415 + from collections import defaultdict # noqa: PLC0415 + f = KEYS / "READINGS.json" + if not f.exists(): + print("нет READINGS.json — чтение не персистировано") + return + d = json.loads(f.read_text(encoding="utf-8")) + m = json.loads((KEYS / f"{pack}-MAP.json").read_text(encoding="utf-8")) + ranks = d[f"ranks_{pack}"] + pos: dict[str, list[int]] = defaultdict(list) + wins: dict[str, int] = defaultdict(int) + for uid, order in ranks.items(): + letters = order.split() + for i, letter in enumerate(letters): + pos[m[uid][letter]].append(i + 1) + wins[m[uid][letters[0]]] += 1 + print(f"СЛЕПОЕ ЧТЕНИЕ {pack} · единиц {len(ranks)}\n") + print(f"{'арм':7s}{'ср.ранг':>9s}{'первых':>8s}{'медиана':>9s}") + for arm in sorted(pos, key=lambda a: statistics.mean(pos[a])): + print(f"{arm:7s}{statistics.mean(pos[arm]):9.2f}{wins[arm]:8d}" + f"{statistics.median(pos[arm]):9.1f}") + print(f"\n⚠ {d['provenance']}") + + +def show_map(pack: str) -> None: + f = KEYS / f"{pack}-MAP.json" + print(f.read_text(encoding="utf-8") if f.exists() else "карты нет") + + +if __name__ == "__main__": + a = sys.argv[1:] or ["--emit", "zh"] + if a[0] == "--emit": + emit(a[1] if len(a) > 1 else "zh") + elif a[0] == "--score": + score(a[1] if len(a) > 1 else "zh") + elif a[0] == "--map": + show_map(a[1] if len(a) > 1 else "zh") + else: + print(__doc__) diff --git a/eval/role_topology/buy.py b/eval/role_topology/buy.py index 26f8c143..320fabf9 100644 --- a/eval/role_topology/buy.py +++ b/eval/role_topology/buy.py @@ -67,13 +67,18 @@ class Ledger: def usage_of(u, ch) -> dict: - """Токены вызова в однородном виде. `total_tokens` персистится ВСЕГДА (см. шапку).""" + """Токены вызова в однородном виде. `total_tokens` персистится ВСЕГДА (см. шапку). + + `ch` может быть None — это ответ без сообщения (fail-closed фильтра); токены при этом + провайдер обычно всё равно тарифицирует, поэтому usage читается, а не обнуляется. + """ 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 + msg = getattr(ch, "message", None) if ch is not None else None 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 "")) + reasoning_chars=len(getattr(msg, "reasoning_content", None) or "")) def priced(model: str, us: dict) -> float: @@ -106,7 +111,24 @@ def purchase(led: Ledger, tag: str, model: str, client, kw: dict, **extra) -> di (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]) + ch = r.choices[0] if r.choices else None + # ⚠ ОТВЕТ БЕЗ СООБЩЕНИЯ. Поймано исполнением 07.08: `ch.message` пришёл None, и прогон упал + # на 130 неоплаченных клетках. Это не «пустой content», а отсутствие самого объекта сообщения — + # так выглядит fail-closed фильтра (quirks 00: у Gemini `finish_reason` приходит СОСТАВНОЙ + # строкой `'content_filter: PROHIBITED_CONTENT'`, точные матчеры на неё мертвы). + # Клетка записывается как ДАННЫЕ (отказ провода с причиной), а не роняет фазу: провал одного + # вызова — это факт о модели на этом материале, и он нужен в отчёте. + if ch is None or getattr(ch, "message", None) is None: + rec = dict(tag=tag, model=model, model_returned=getattr(r, "model", ""), + finish=str(getattr(ch, "finish_reason", "no-choice")), content="", + no_message=True, latency_s=round(time.time() - t0, 1), + cost_usd=priced(model, usage_of(r.usage, None)) if r.usage else 0.0, + **(usage_of(r.usage, None) if r.usage else {}), **extra) + (OUT / f"{tag}.NOMSG.json").write_text(json.dumps(rec, ensure_ascii=False, indent=1), + encoding="utf-8") + print(f" ⚠ ответ без сообщения: {tag} finish={rec['finish']}") + return rec + us = usage_of(r.usage, ch) 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) diff --git a/eval/role_topology/judges.py b/eval/role_topology/judges.py index ab6f5401..79bfbe4e 100644 --- a/eval/role_topology/judges.py +++ b/eval/role_topology/judges.py @@ -18,7 +18,8 @@ Выбор судей — не вкусовой. Промт: «судья не судит армы своего семейства-жильца». Армы Ф2 населяют DeepSeek (`deepseek-v4-pro`) и Z.AI (`glm-5`), поэтому судьи берутся из ДРУГИХ семейств: -`grok-4.3` (xAI) и `gpt-5.6-luna` (OpenAI). Оба прошли скрин Ф1, оба дёшевы, оба быстры. +`gpt-5.6-luna` (OpenAI) и `gemini-3.1-flash-lite` (Google) — см. JUDGES ниже: +`grok-4.3` выпал на исчерпании кредитов xAI и заменён через положительный контроль. Дисциплина протокола (промт, §ЗАКОН): · КАЖДЫЙ голос персистится отдельным файлом — ревью-шапка эксп-20 прямо запретила повторять @@ -63,7 +64,21 @@ OUT.mkdir(parents=True, exist_ok=True) # чтением кода до запуска. Голоса Ф2а помечаются префиксом `jv-bo-` и считаются отдельно. CEILING_USD = 0.60 PHASE_PREFIX = "jv-" # что считать «своими» тратами; bakeoff подменяет на "jv-bo-" -JUDGES = ("gpt-5.6-luna", "grok-4.3") +# ⚠ ЗАМЕНА ВТОРОГО СУДЬИ 07.08 — ВНЕШНИЙ БЛОКЕР, объявляю. +# `grok-4.3` выпал не по замыслу: ключ xAI вернул HTTP 403 «Your team has either used all available +# credits or reached its monthly spending limit» на 158 голосах zh и 92 en. Отказы записаны +# отдельными `*.ERROR.json`, купленные голоса не отравлены. +# Замена выбрана по трём условиям промта (стр.32): судья не из семейства, населяющего армы +# (deepseek, glm), и не из семейства первого судьи (OpenAI) ⇒ остаются Google, Mistral, Moonshot. +# Mistral ($0.0128/голос) и kimi ($0.024) не влезали в ОБЪЯВЛЕННЫЙ остаток потолка, а поднимать +# потолок задним числом — ровно то, за что снята первая редакция. Взят `gemini-3.1-flash-lite` +# ($0.0022/голос). +# ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ ДО ТРАТ (D39.46б): на контрольном декое пака замена поймала посаженную +# деградацию 6/6 в ОБОИХ порядках, со зеркальными счетами (В1=0/В2=3 и В1=3/В2=0) — то есть на +# этой клетке её позиционная фора близка к нулю. Стоимость проверки $0.00363. +# ⚠ Оговорка, которую отчёт обязан нести: это ЛЁГКАЯ модель, и её согласие с luna не эквивалентно +# прежней паре luna+grok. Голоса grok, купленные первой редакцией, к этим армам не относятся. +JUDGES = ("gpt-5.6-luna", "gemini-3.1-flash-lite") REPS = 3 OPENAI_FAMILY = {"gpt-5.6-luna"} diff --git a/eval/role_topology/pair_en.py b/eval/role_topology/pair_en.py new file mode 100644 index 00000000..708dd853 --- /dev/null +++ b/eval/role_topology/pair_en.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""ВТОРАЯ ЯЗЫКОВАЯ ПАРА — en→ru на Kristoff, «Empire of the Dawn». + +Зачем. Промт эксп-21 заказывал одну пару (zh→ru), и первая редакция мерила топологию только на +ней. Но цель проекта №1 канона — ОДИН движок на все пары, и вывод о топологии, снятый на одной +паре, этот вопрос не закрывает: ревью-вопрос по умолчанию «заработает ли пара, которой в репо +ещё НЕТ, без правки Go?» здесь звучит как «переживёт ли вывод смену пары». Расширение — по +слову владельца 07.08. + +Почему именно эта книга. Собственная проба контаминации пака: `kristoff-en` узнаётся моделью +1/5 и даёт 0/5 по клоуз-тесту, тогда как `gu-zhenren` — 4/5 узнавания при пороге «≥3/5 = книга +модели ИЗВЕСТНА». То есть материал второй пары ЧИЩЕ основного, и часть оговорки «эмпирика на +претрейн-классике предварительна» (строка 55 бэклога) закрывается именно здесь. + +Что тут особенного — ЭДЖ-КЕЙС, названный владельцем. Английский текст несёт французский +культурный слой тремя разными классами, и переводчик обязан развести их по-разному: + * имена и топонимы (`François`, `Fabién`, `Château`, `Lumière`) — передаются по ФРАНЦУЗСКИМ + правилам чтения, а не по английским, и одинаково на всю книгу; + * вставные французские реплики (`Merci, saint-père`, `mon dieu`) — чужая речь внутри чужой, + её сохранение/перевод есть решение, а не мелочь; + * офранцуженная английская лексика (`façade`) — обычное русское слово. +Отбор единиц СТРАТИФИЦИРОВАН по этому признаку: половина с французским слоем, половина без. +Стратификация объявлена ДО покупок и позволяет читать эффект отдельно на трудном материале. + +Банкноты у армов этой пары НЕТ — объявленная девиация: подписанного глоссария для книги не +существует, а выдумывать канон полигон не вправе (CLAUDE.md). Следствие: контрасты пары мерят +ЧИСТУЮ топологию, без фактора банка; фактор банка мерится на zh контрастом A_law против A. + +Запуск: --units инвентарь и стратификация ($0) + --arms черновики и армы + --judge судейство контрастов + --score свод ($0) +""" +from __future__ import annotations +import html +import json +import re +import sys +import time +import zipfile +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +for sub in ("editor_contract", "bank_arbitration", "editor_harness", "role_topology"): + sys.path.insert(0, str(REPO / "eval" / sub)) + +import battery as B # noqa: E402 +import buy as BUY # noqa: E402 +import editor_wire_probe as P # noqa: E402 +import judges as J # noqa: E402 +import probe as Q # noqa: E402 +from bakeoff import boot_ci, client, uid_of # noqa: E402 + +import statistics # noqa: E402 + +EPUB = Path.home() / "books" / "Kristoff_Jay_-_Empire_of_the_Vampire_3_-_Empire_of_the_Dawn.epub" +OUT = Path.home() / "books" / "role-topology" + +N_PER_STRATUM = 6 +UNIT_CHARS = 1600 +REPS_PER_ORDER = 1 +CEIL_ARMS = 0.55 +CEIL_JUDGE = 1.45 +LED_ARMS = BUY.Ledger("армы en→ru", CEIL_ARMS, ("en2-*.json",), default_expect=0.03) +LED_JUDGE = BUY.Ledger("судейство en→ru", CEIL_JUDGE, ("jve-*.json",), default_expect=0.008) + +DRAFT_MODEL = "deepseek-v4-flash" +ARMS = { + "A": dict(model="deepseek-v4-pro", contract="full", think=None), + "B": dict(model="glm-5", contract="full", think="off"), + "D": dict(model="deepseek-v4-pro", contract="direct-reflow", think=None), + "D_": dict(model="deepseek-v4-pro", contract="direct", think=None), +} +CONTRASTS = [("A", "F"), ("B", "F"), ("D", "A"), ("D", "D_")] + +# Французский слой опознаётся по диакритике — признак механический и не требует словаря. +_RE_FR = re.compile(r"\b\w*[éèêëàâäçôöûùüïî]\w*\b") +_RE_LAT = re.compile(r"[A-Za-zÀ-ɏ]") + + +def raw_text() -> str: + z = zipfile.ZipFile(EPUB) + parts = [] + for n in sorted(x for x in z.namelist() if x.endswith((".xhtml", ".html", ".htm"))): + t = re.sub(r"<[^>]+>", " ", z.read(n).decode("utf-8", "ignore")) + parts.append(re.sub(r"[ \t]+", " ", html.unescape(t))) + return "\n".join(parts) + + +def chunks() -> list[str]: + """Единицы боевого размера, нарезанные ПО ГРАНИЦАМ ПРЕДЛОЖЕНИЙ. + + Рвать посреди фразы нельзя: обрубок меняет задачу переводчика и портит сравнение армов. + """ + text = raw_text() + sents = re.split(r"(?<=[.!?”\"])\s+", text) + out, buf = [], "" + for s in sents: + buf += s + " " + if len(buf) >= UNIT_CHARS: + out.append(buf.strip()) + buf = "" + return [c for c in out if len(c) <= UNIT_CHARS * 1.4] + + +_SERVICE = ("also by ", "about the author", "copyright", "all rights reserved", "isbn", + "newsletter", "st. martin", "thank you for buying", "table of contents", + "for email updates", "click here", "acknowledgments") + + +def is_prose(c: str) -> bool: + """Проза ли это, или служебная страница книги. + + ⚠ Заведено 07.08 по адверсариальному ревью: единица `129b73ad5a` страты `plain` оказалась + выходными данными и библиографией («ALSO BY JAY KRISTOFF», 18 названий, копирайт, рассылка). + Армы её ПЕРЕВОДИЛИ, и она ушла в судейство и в слепое чтение. Фильтр страты был «нет токенов + с диакритикой» — служебные страницы он притягивает по построению, потому что диакритики там + нет никогда. + + Два сигнала, оба механические: маркеры издательского аппарата и ПЛОТНОСТЬ ЗАВЕРШЁННЫХ + ПРЕДЛОЖЕНИЙ. У прозы точек/восклицаний/вопросов на 1000 знаков много; у списка названий и + выходных данных — мало, там строки без терминальной пунктуации. + """ + low = c.lower() + if any(m in low for m in _SERVICE): + return False + return len(re.findall(r"[.!?]", c)) / max(1, len(c)) * 1000 >= 8.0 + + +def units() -> list[dict]: + """12 единиц: 6 с французским слоем, 6 без. Отбор детерминированный — по длине внутри страты. + + Порог страты `fr`: ≥4 токена с диакритикой, из них ≥2 РАЗНЫХ. Одиночное `façade` французским + слоем не является, и страту им засорять нельзя. + """ + fr, plain = [], [] + for c in chunks(): + if not is_prose(c): + continue + toks = _RE_FR.findall(c) + if len(toks) >= 4 and len(set(toks)) >= 2: + fr.append(c) + elif not toks: + plain.append(c) + out = [] + for name, pool in (("fr", fr), ("plain", plain)): + # ⚠ Ключ сортировки — (длина, uid), а НЕ одна длина. Поймано исполнением 07.08: два + # вызова подряд вернули разные наборы единиц. Причина — `sorted(set(...), key=len)`: + # при равной длине порядок задаётся обходом МНОЖЕСТВА строк, а он рандомизирован по + # процессам (хеш-сид). Отбор при этом выглядел детерминированным. Последствие было бы + # тихим и дорогим: армы покупаются на один набор, судейство идёт по другому. + pool = sorted(set(pool), key=lambda c: (len(c), uid_of(c))) + lo = (len(pool) - N_PER_STRATUM) // 2 + for c in pool[lo:lo + N_PER_STRATUM]: + out.append(dict(source=c, uid=uid_of(c), stratum=name, + fr_tokens=sorted(set(_RE_FR.findall(c))))) + return out + + +PAIR = Path(__file__).parent / "prompts" / "en-ru" +BRIEF = {"source_lang": "en", "target_lang": "ru", "genre": "тёмное фэнтези", + "audience": "взрослые читатели фэнтези", "title": "Empire of the Dawn", + "venuti": "0.60", "honorifics": "keep", "transcription": "practical", + "footnotes": "minimal"} + + +def render_pair(path: Path, text: str, draft: str) -> tuple[str, str]: + """Рендер пар-пакета ТЕМ ЖЕ путём, что боевой: снятие комментариев, дроп FEWSHOT, подстановка. + + ⚠ Флаг `en=True` харнесса экспов 18–20 сюда НЕ годится и был пойман исполнением: он означает + «переводить НА английский» (трек zh→en экспа-19), и первая сборка армов этой пары уехала бы + с промптом «editor of a translation into English» и брифом «с языка zh на язык en». + Здесь источник английский, цель русская — это другая пара, и ей нужен свой пар-пакет. + """ + canon = P.strip_comments(path.read_text(encoding="utf-8")) + sys_part, user = canon.split(P.USER_SEP, 1) + core = sys_part.split(P.FEWSHOT_SEP, 1)[0].strip() + old = P.RENDER_VARS + P.RENDER_VARS = BRIEF + try: + return P.render(core, text, draft), P.render(user.strip(), text, draft) + finally: + P.RENDER_VARS = old + + +def translator_msgs(text: str) -> tuple[str, str]: + return render_pair(PAIR / "translator.md", text, "") + + +def build_msgs(arm: str, u: dict, draft: str) -> list[dict]: + tpl = {"full": "editor.md", "direct": "translator.md", + "direct-reflow": "translator-reflow.md"}[ARMS[arm]["contract"]] + sys_msg, user = render_pair(PAIR / tpl, u["source"], draft) + return [{"role": "system", "content": sys_msg}, {"role": "user", "content": user.strip()}] + + +def run_draft(u: dict) -> dict: + sys_msg, user = translator_msgs(u["source"]) + kw = dict(model=DRAFT_MODEL, max_tokens=16000, extra_body={"reasoning_effort": "low"}, + messages=[{"role": "system", "content": sys_msg}, {"role": "user", "content": user}]) + # Эхо-мина у DeepSeek описана для плотного CJK; на латинице класс не подтверждён, поэтому + # сторож здесь ловит не «ханьцзы», а НЕПЕРЕВЕДЁННУЮ ЛАТИНИЦУ — тот же дефект, другой алфавит. + for attempt in (1, 2, 3): + tag = f"en2-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") or latin_share(rec["content"]) <= 0.15: + return rec + print(f" ⚠ латиница в черновике {u['uid'][:6]} (попытка {attempt}) — ре-ген") + return rec + + +def latin_share(text: str) -> float: + letters = [c for c in text if c.isalpha()] + return len(_RE_LAT.findall("".join(letters))) / max(1, len(letters)) + + +def draft_text(uid: str) -> str: + f = OUT / f"en2-DRAFT-{uid}.json" + best = "" + for g in sorted(OUT.glob(f"en2-DRAFT-{uid}*.json")): + c = json.loads(g.read_text(encoding="utf-8")).get("content", "") + if c and latin_share(c) <= 0.15: + best = c + return best or (json.loads(f.read_text(encoding="utf-8"))["content"] if f.exists() else "") + + +def draft_cost(uid: str) -> float: + return sum(json.loads(g.read_text(encoding="utf-8")).get("cost_usd", 0.0) + for g in OUT.glob(f"en2-DRAFT-{uid}*.json")) + + +def run_arm(arm: str, u: dict) -> dict: + spec = ARMS[arm] + model = spec["model"] + kw: dict = dict(model=model, messages=build_msgs(arm, u, draft_text(u["uid"])), + 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"en2-{arm}-{u['uid']}", model, client(model), kw, + arm=arm, uid=u["uid"], contract=spec["contract"], think=spec["think"]) + + +def text_of(arm: str, u: dict) -> str: + if arm == "F": + return draft_text(u["uid"]) + f = OUT / f"en2-{arm}-{u['uid']}.json" + return json.loads(f.read_text(encoding="utf-8"))["content"] if f.exists() else "" + + +# --- ФРАНЦУЗСКИЙ СЛОЙ: детерминированная проверка, судья не нужен ------------------------------- + +def check_french(source: str, final: str) -> dict: + """Как обошлись с французским слоем. Три числа, каждое читается само по себе. + + `leaked` — французский токен остался ЛАТИНИЦЕЙ в русском тексте. Это дефект передачи: + читатель кириллического издания получает нечитаемое слово. + `rendered` — токен передан кириллицей хотя бы раз (передача состоялась). + `split` — один и тот же французский токен передан РАЗНЫМИ кириллическими формами внутри + одной единицы. Это прокси главной продуктовой боли — консистентности имени на всю книгу; + глоссария у книги нет, поэтому мерится расщепление внутри окна, а не против канона. + """ + toks = sorted({t for t in _RE_FR.findall(source) if len(t) >= 4}) + leaked = [t for t in toks if re.search(rf"\b{re.escape(t)}\b", final)] + # Кириллические кандидаты-передачи: слова с заглавной буквы вокруг позиций, где стоял токен. + cyr_words = re.findall(r"\b[А-ЯЁ][а-яё]+(?:-[А-ЯЁ]?[а-яё]+)?\b", final) + forms: dict[str, set[str]] = {} + for t in toks: + head = _fold(t[:3]) + cand = {w for w in cyr_words if _fold_cyr(w[:3]) == head} + if cand: + forms[t] = cand + split = {t: sorted(v) for t, v in forms.items() if len(v) > 1} + return dict(fr_tokens=len(toks), leaked=leaked, leaked_n=len(leaked), + rendered_n=len(forms), split=split, split_n=len(split)) + + +_FR2LAT = str.maketrans("éèêëàâäçôöûùüïî", "eeeeaaacoouuuii") +_CYR2LAT = {"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ж": "j", "з": "z", + "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p", + "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "h", "ц": "c", "ч": "c", + "ш": "s", "щ": "s", "ы": "i", "э": "e", "ю": "u", "я": "a", "ё": "e"} + + +def _fold(s: str) -> str: + return s.lower().translate(_FR2LAT) + + +def _fold_cyr(s: str) -> str: + return "".join(_CYR2LAT.get(c, c) for c in s.lower()) + + +def cmd_units() -> None: + us = units() + print(f"единиц {len(us)} · страты: " + f"{ {s: sum(1 for u in us if u['stratum'] == s) for s in ('fr', 'plain')} }") + for u in us: + print(f" {u['uid'][:6]} [{u['stratum']:5s}] {len(u['source']):5d} зн · " + f"фр-токенов {len(u['fr_tokens']):2d} {u['fr_tokens'][:6]}") + + +def cmd_arms() -> None: + us = units() + print(f"единиц {len(us)} · армов {len(ARMS)} + черновик · потолок ${CEIL_ARMS}") + for u in us: + rec = run_draft(u) + if rec.get("skipped"): + print("⛔ потолок — стоп") + return + print(f" DRAFT {u['uid'][:6]} [{u['stratum']:5s}] {len(rec['content']):5d} зн " + f"латиница {latin_share(rec['content']):.3f} ${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:3s} {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: + us = units() + total = len(us) * len(CONTRASTS) * len(J.JUDGES) * 2 * REPS_PER_ORDER + print(f"голосов к покупке {total} · потолок ${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"jve-{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, + stratum=u["stratum"]) + 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 _margins(a: str, b: str, uid: str, judge: str) -> dict[int, list[float]]: + out: dict[int, list[float]] = {} + for order in (0, 1): + for rep in range(1, REPS_PER_ORDER + 1): + f = OUT / f"jve-{a}v{b}-{uid}-o{order}-{judge}-r{rep}.json" + if not f.exists(): + continue + p = J.parse(json.loads(f.read_text(encoding="utf-8"))["content"]) + if p["В1-ВЕРНОСТЬ"] is None: + continue + e1 = sum(p[f"В1-{ax}"] or 0 for ax in J.AXES) + e2 = sum(p[f"В2-{ax}"] or 0 for ax in J.AXES) + out.setdefault(order, []).append((e2 - e1) if order == 0 else (e1 - e2)) + return out + + +def cmd_score() -> None: + us = units() + print("ПАРНЫЕ ВЕРДИКТЫ en→ru. Знак перевеса обязан совпасть в ОБОИХ порядках у ОБОИХ судей.\n") + print(f"{'контраст':14s}{'ед.':>5s}{'за перв.':>10s}{'за втор.':>10s}{'ничья':>7s}" + f"{'перевес':>10s}{'95% ДИ':>18s}{'фр-страта':>11s}{'plain':>8s}") + print("-" * 93) + for a, b in CONTRASTS: + wa = wb = tie = 0 + per_unit, by_str = [], {"fr": [], "plain": []} + for u in us: + pj, ms = {}, [] + for judge in J.JUDGES: + bo = _margins(a, b, u["uid"], judge) + if len(bo) < 2: + continue + m0, m1 = statistics.mean(bo[0]), statistics.mean(bo[1]) + ms += [m0, m1] + pj[judge] = 1 if (m0 > 0 and m1 > 0) else (-1 if (m0 < 0 and m1 < 0) else 0) + if len(pj) < len(J.JUDGES): + continue + per_unit.append(statistics.mean(ms)) + by_str[u["stratum"]].append(statistics.mean(ms)) + vals = set(pj.values()) + wa += vals == {1} + wb += vals == {-1} + tie += vals not in ({1}, {-1}) + if not per_unit: + print(f"{a + ' пр. ' + b:14s}{'нет голосов':>20s}") + continue + lo, hi = boot_ci(per_unit) + f_m = statistics.mean(by_str["fr"]) if by_str["fr"] else float("nan") + p_m = statistics.mean(by_str["plain"]) if by_str["plain"] else float("nan") + print(f"{a + ' пр. ' + b:14s}{wa + wb + tie:5d}{wa:10d}{wb:10d}{tie:7d}" + f"{statistics.mean(per_unit):+10.2f} [{lo:+.2f}, {hi:+.2f}]{f_m:+11.2f}{p_m:+8.2f}") + + print("\nФРАНЦУЗСКИЙ СЛОЙ (детерминированно, судья не нужен)\n") + print(f"{'арм':5s}{'ед.':>5s}{'фр-токенов':>12s}{'латиница':>10s}{'передано':>10s}" + f"{'расщеплено':>12s}{'$/единицу':>11s}") + print("-" * 65) + frs = [u for u in us if u["stratum"] == "fr"] + for arm in ("F", *ARMS): + rows, costs = [], [] + for u in frs: + t = text_of(arm, u) + if not t.strip(): + continue + rows.append(check_french(u["source"], t)) + c = draft_cost(u["uid"]) if arm in ("F", "A", "B") else 0.0 + f = OUT / f"en2-{arm}-{u['uid']}.json" + if arm != "F" and 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}{sum(r['fr_tokens'] for r in rows):12d}" + f"{sum(r['leaked_n'] for r in rows):10d}{sum(r['rendered_n'] for r in rows):10d}" + f"{sum(r['split_n'] for r in rows):12d}" + f"{(statistics.median(costs) if costs else 0):11.5f}") + + print("\nОБЩАЯ БАТАРЕЯ (языконезависимые проверки)\n") + print(f"{'арм':5s}{'ед.':>5s}{'типогр.':>9s}{'ты/вы микс':>12s}") + print("-" * 31) + for arm in ("F", *ARMS): + rows = [B.run_unit(B.Unit(source=u["source"], draft=draft_text(u["uid"]), + final=text_of(arm, u), cards={})) + for u in us if text_of(arm, u).strip()] + if rows: + print(f"{arm:5s}{len(rows):5d}{sum(r['typography']['violations'] for r in rows):9d}" + f"{sum(r['ty_vy']['mixed_in_unit'] for r in rows):12d}") + + +if __name__ == "__main__": + cmd = {"--units": cmd_units, "--arms": cmd_arms, "--judge": cmd_judge, + "--score": cmd_score}.get(sys.argv[1] if len(sys.argv) > 1 else "") + if cmd: + cmd() + else: + print(__doc__) diff --git a/eval/role_topology/postproc.py b/eval/role_topology/postproc.py new file mode 100644 index 00000000..e4f11b1d --- /dev/null +++ b/eval/role_topology/postproc.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""ДЕТЕРМИНИРОВАННЫЙ ПОСТ-ПРОЦЕССОР ЧЕРНОВИКА. $0 по ключам провайдеров. + +Зачем. Оба слепых читателя независимо назвали главным источником непечатного текста НЕ перевод, +а механический слой: прямая речь в кавычках вместо тире, служебная преамбула модели, протёкшая +markdown-разметка, авторский текст под тире. Если дешёвая детерминированная правка поверх +черновика закрывает существенную часть разрыва «черновик против редактора» (+1.88), то второй +проход покупает меньше, чем кажется, и часть запланированных покупок уточняет цену того, что не +нужно. Проверка стоит только голосов судей — сам пост-процессор бесплатен. + +Правила закрытые и проверяемые. Каждое чинит класс, названный читателями ЦИТАТОЙ, и ни одно не +трогает содержание перевода: пост-процессор не переставляет слова, не сливает абзацы и не правит +лексику. Он снимает ровно то, что является браком вывода, а не браком перевода. + +⚠ Чего он НЕ делает и почему: слипшиеся абзацы не разделяются (границу хода детерминированно не +установить, а неверная разбивка хуже слипшейся), непереведённые остатки не переводятся (это +работа модели), калька не правится (это суждение). + +Запуск: --selfcheck проверка правил на входах с известным ответом, $0 + --emit породить арм F_post из черновиков, $0 +""" +from __future__ import annotations +import re +import sys +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +sys.path.insert(0, str(REPO / "eval" / "role_topology")) + +# Служебная преамбула модели перед текстом. Поймана слепым чтением дословно: +# «Вот перевод фрагмента на русский язык в соответствии с вашими требованиями:». +_RE_PREAMBLE = re.compile( + r"^\s*(?:вот\s+)?(?:перевод|отредактированн\w+\s+текст|результат)[^\n:]{0,80}:\s*\n+", + re.I) +_RE_MD_HEAD = re.compile(r"^\s{0,3}#{1,6}\s+", re.M) # ### Раздел 17 +_RE_MD_BOLD = re.compile(r"\*\*(.+?)\*\*", re.S) # **Глава 19** +_RE_MD_ITAL = re.compile(r"(? str: + """Убрать служебную преамбулу и markdown-разметку, протёкшую в тело книги.""" + t = _RE_PREAMBLE.sub("", text) + t = _RE_MD_HEAD.sub("", t) + t = _RE_MD_BOLD.sub(r"\1", t) + t = _RE_MD_ITAL.sub(r"\1", t) + t = _RE_HR.sub("", t) + return _RE_MULTI_BLANK.sub("\n\n", t).strip() + + +def dialogue_dash(text: str) -> str: + """Реплику, стоящую отдельным абзацем в кавычках, оформить русским тире. + + ⚠ Правило намеренно узкое: трогается только строка, которая ЦЕЛИКОМ является закавыченной + репликой (возможно со словами автора после закрывающей кавычки). Кавычки ВНУТРИ абзаца не + трогаются — там они могут быть цитатой, названием или мыслью, и различить это детерминированно + нельзя. Узкое правило чинит меньше, но не портит. + """ + def repl(m: re.Match) -> str: + body, tail = m.group(1), (m.group(2) or "") + if len(body) < 2: + return m.group(0) + return f"— {body}{tail}" + return _RE_QUOTED_LINE.sub(repl, text) + + +def process(text: str) -> str: + return dialogue_dash(strip_service(text)) + + +CASES: list[tuple[str, str, str]] = [ + ("служебная преамбула", + "Вот перевод фрагмента на русский язык в соответствии с вашими требованиями:\n\nОн вышел.", + "Он вышел."), + ("markdown-заголовок", "### Раздел семнадцатый\n\nОн вышел.", "Раздел семнадцатый\n\nОн вышел."), + ("markdown-жирный", "**Глава 19: Цикада!**\n\nТекст.", "Глава 19: Цикада!\n\nТекст."), + ("реплика в ёлочках", '«Время летит. Садись», — сказал он.', "— Время летит. Садись, — сказал он."), + ("реплика в лапках", '"Спасибо", — ответил племянник.', "— Спасибо, — ответил племянник."), + ("кавычки ВНУТРИ абзаца не трогаются", + 'Он вспомнил «Тысячу и одну ночь» и усмехнулся.', + 'Он вспомнил «Тысячу и одну ночь» и усмехнулся.'), + ("уже тире — не трогается", "— Время летит. Садись, — сказал он.", + "— Время летит. Садись, — сказал он."), + ("чистый текст не меняется", "Он вышел за дверь. Небо было серым.", + "Он вышел за дверь. Небо было серым."), +] + + +def selfcheck() -> int: + bad = 0 + for name, src, want in CASES: + got = process(src) + ok = got == want + bad += not ok + print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {name}" + ("" if ok else f"\n получено: {got!r}\n ждали: {want!r}")) + print(f"\n{'ПРАВИЛА ЧИСТЫ' if not bad else f'ПРОВАЛОВ: {bad}'} ({len(CASES) - bad}/{len(CASES)})") + return bad + + +def emit() -> None: + """Породить выходы арма F_post: пост-процессор поверх ТОГО ЖЕ черновика, что правят армы.""" + import json # noqa: PLC0415 + import bakeoff as BO # noqa: PLC0415 + OUT = Path.home() / "books" / "role-topology" + changed = 0 + for u in BO.units(): + src = BO.text_of("F", u) + dst = process(src) + (OUT / f"bo2-F_post-{u['uid']}.json").write_text( + json.dumps(dict(tag=f"bo2-F_post-{u['uid']}", arm="F_post", uid=u["uid"], + model="$0-postproc", cost_usd=0.0, content=dst, + changed=dst != src, delta_chars=len(dst) - len(src)), + ensure_ascii=False), encoding="utf-8") + changed += dst != src + print(f"единиц {len(BO.units())} · пост-процессор изменил {changed}") + + +if __name__ == "__main__": + a = sys.argv[1:] or ["--selfcheck"] + if a[0] == "--selfcheck": + sys.exit(1 if selfcheck() else 0) + elif a[0] == "--emit": + emit() + else: + print(__doc__) diff --git a/eval/role_topology/prompts/en-ru/editor.md b/eval/role_topology/prompts/en-ru/editor.md new file mode 100644 index 00000000..6a014eb3 --- /dev/null +++ b/eval/role_topology/prompts/en-ru/editor.md @@ -0,0 +1,47 @@ + +Ты — литературный редактор художественного перевода на русский язык. Тебе даны ИСХОДНЫЙ текст и его черновой перевод. +Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}». + +Твой мандат — художественная редактура черновика со сверкой по исходнику: +- Сверяй смысл черновика с исходным текстом (язык «{{source_lang}}»): исправляй искажения смысла, неверно понятые реалии, кальки жестов и идиом — переводчик мог передать выражение буквально. Опирайся на исходник, но пиши по-русски, а не подстрочником. +- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, лишние повторы, неестественные конструкции; пиши живым литературным русским. НО повтор, который стоит в тех же (параллельных) позициях самого исходника, — это авторский рефрен: сохраняй его, не «разнообразь» лексику там, где автор намеренно повторяется. +- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи. +- Вёрстка: собирай повествование в естественные русские абзацы. Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет. +- Сохраняй ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены. +- Единицы и приёмы (общие для en→ru): МЕРЫ приводи к привычным читателю единицам (имперские длины и веса — метрическими эквивалентами либо привычными приблизительными оборотами). ЧУЖОЙ ЯЗЫК ВНУТРИ ИСХОДНИКА (французские имена, топонимы, вставные реплики) — отдельный слой: имена и топонимы транскрибируй по правилам чтения их собственного языка, а не по английским, и держи ОДНУ форму на весь текст; вставные реплики сохраняй чужими. СТИХИ и названия классики — по смыслу, не транслитерацией. Род и форму имён и терминов сверяй по глоссарию. +- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}. + +Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков. + +ДИСКУРС-ПЕРЕВЁРСТКА (приведение английского повествовательного членения к русской абзацной норме — это КОНВЕНЦИЯ русского языка, а не вольность): +1. Молча определи смысловые ходы фрагмента: единый фокал/наблюдатель; действие и его непосредственная реакция; одна тема рассуждения; границы — смена говорящего, времени, места или фокала. +2. Один повествовательный ход оформляй ОДНИМ русским абзацем. Английская проза часто дробит ход на короткие абзацы ради ритма — в русском издательском наборе такая дробь читается как обрывочность: собирай их обратно причастными и деепричастными оборотами, подчинительными союзами и связками. Не сливай ТОЛЬКО ради длины и не теряй смысловые атомы. +3. Каждую новую реплику начинай с нового абзаца через тире «—»; слова автора при той же реплике оставляй в её абзаце. Английские кавычки прямой речи заменяй русским тире. +4. Разрешено объединять и делить русские предложения; число предложений и абзацев совпадать с исходником НЕ обязано. Запрещено терять смысловые атомы (участников, действия, числа, полярность, заголовки, а также идиомы: их передавай смыслом, не сворачивая в общее место, и не калькируя дословно). + +---FEWSHOT--- +Примеры требуемой ПЕРЕВЁРСТКИ (показана структура вывода, не стиль): + +[narrative N:1] Исходник дробью — «He stepped outside.» / «The sky was grey.» / «He sighed.» / «The road ran on.» — становится ОДНИМ абзацем: +Он вышел за дверь. Небо было серым; вздохнув, он смотрел, как дорога уходит вдаль. + +[dialogue 1:N] Исходник — «The uncle frowned. "Time flies. Sit down," he said. "Thank you," the nephew replied.» — становится: +Дядя нахмурился. +— Время летит. Садись, — сказал он. +— Спасибо, — ответил племянник. + +[focal-shift] При смене наблюдателя/места/времени — новый абзац, даже если в исходнике это продолжение той же строки. + +---USER--- +Исходный текст (язык «{{source_lang}}»): + +{{text}} + +Черновик перевода для редактуры: + +{{draft}} diff --git a/eval/role_topology/prompts/en-ru/translator-reflow.md b/eval/role_topology/prompts/en-ru/translator-reflow.md new file mode 100644 index 00000000..c419ccec --- /dev/null +++ b/eval/role_topology/prompts/en-ru/translator-reflow.md @@ -0,0 +1,33 @@ + +Ты — профессиональный литературный переводчик с языка «{{source_lang}}» на язык «{{target_lang}}». +Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}». + +Правила перевода (translation brief): +- Хонорифики: {{honorifics}}. Транскрипция имён и реалий: {{transcription}}. +- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}. +- Сноски: {{footnotes}}. +- Переводи ВСЕ предложения исходника: ничего не пропускай, не сокращай и не добавляй от себя. +- Вёрстка — по правилам ДИСКУРС-ПЕРЕВЁРСТКИ, приведённым ниже. +- Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка. + +Единицы и приёмы (общие для en→ru): +- МЕРЫ переводи в привычные читателю единицы: имперские длины и веса (foot, mile, inch, yard, pound, stone) передавай метрическими эквивалентами либо привычными русскому читателю приблизительными («в двух шагах», «на добрую версту»), сам термин не транслитерируй. +- ЧУЖОЙ ЯЗЫК ВНУТРИ ИСХОДНИКА (французские имена, топонимы и реплики) — отдельный слой, и он передаётся ТРЕМЯ разными способами: имена и топонимы (François, Fabién, Château, Lumière) транскрибируй по правилам чтения ИСХОДНОГО языка этих имён, а не по английским, и держи ОДНУ форму на весь текст; вставные французские реплики сохраняй чужими, давая смысл там, где он не ясен из контекста; офранцуженную английскую лексику (façade и подобные) переводи обычным русским словом. +- СТИХИ и названия классики передавай ПО СМЫСЛУ (не транслитерацией), опираясь на существующие русские переводы. +- Род и форму имён собственных и терминов бери из приложенного глоссария (если он есть). + +Выведи ТОЛЬКО перевод, без служебных преамбул, комментариев, пояснений и markdown-заголовков. + +ДИСКУРС-ПЕРЕВЁРСТКА (приведение английского повествовательного членения к русской абзацной норме — это КОНВЕНЦИЯ русского языка, а не вольность): +1. Молча определи смысловые ходы фрагмента: единый фокал/наблюдатель; действие и его непосредственная реакция; одна тема рассуждения; границы — смена говорящего, времени, места или фокала. +2. Один повествовательный ход оформляй ОДНИМ русским абзацем. Английская проза часто дробит ход на короткие абзацы ради ритма — в русском издательском наборе такая дробь читается как обрывочность: собирай их обратно причастными и деепричастными оборотами, подчинительными союзами и связками. Не сливай ТОЛЬКО ради длины и не теряй смысловые атомы. +3. Каждую новую реплику начинай с нового абзаца через тире «—»; слова автора при той же реплике оставляй в её абзаце. Английские кавычки прямой речи заменяй русским тире. +4. Разрешено объединять и делить русские предложения; число предложений и абзацев совпадать с исходником НЕ обязано. Запрещено терять смысловые атомы (участников, действия, числа, полярность, заголовки, а также идиомы: их передавай смыслом, не сворачивая в общее место, и не калькируя дословно). + +---USER--- +Переведи следующий фрагмент: + +{{text}} diff --git a/eval/role_topology/prompts/en-ru/translator.md b/eval/role_topology/prompts/en-ru/translator.md new file mode 100644 index 00000000..19f64f42 --- /dev/null +++ b/eval/role_topology/prompts/en-ru/translator.md @@ -0,0 +1,28 @@ + +Ты — профессиональный литературный переводчик с языка «{{source_lang}}» на язык «{{target_lang}}». +Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}». + +Правила перевода (translation brief): +- Хонорифики: {{honorifics}}. Транскрипция имён и реалий: {{transcription}}. +- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}. +- Сноски: {{footnotes}}. +- Переводи ВСЕ предложения исходника: ничего не пропускай, не сокращай и не добавляй от себя. +- Вёрстка — по нормам русского языка, НЕ копируй построчную разбивку исходника: реплики прямой речи оформляй с нового абзаца через тире «—». +- Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка. + +Единицы и приёмы (общие для en→ru): +- МЕРЫ переводи в привычные читателю единицы: имперские длины и веса (foot, mile, inch, yard, pound, stone) передавай метрическими эквивалентами либо привычными русскому читателю приблизительными («в двух шагах», «на добрую версту»), сам термин не транслитерируй. +- ЧУЖОЙ ЯЗЫК ВНУТРИ ИСХОДНИКА (французские имена, топонимы и реплики) — отдельный слой, и он передаётся ТРЕМЯ разными способами: имена и топонимы (François, Fabién, Château, Lumière) транскрибируй по правилам чтения ИСХОДНОГО языка этих имён, а не по английским, и держи ОДНУ форму на весь текст; вставные французские реплики сохраняй чужими, давая смысл там, где он не ясен из контекста; офранцуженную английскую лексику (façade и подобные) переводи обычным русским словом. +- СТИХИ и названия классики передавай ПО СМЫСЛУ (не транслитерацией), опираясь на существующие русские переводы. +- Род и форму имён собственных и терминов бери из приложенного глоссария (если он есть). + +Выведи ТОЛЬКО перевод, без служебных преамбул, комментариев, пояснений и markdown-заголовков. + +---USER--- +Переведи следующий фрагмент: + +{{text}} diff --git a/eval/role_topology/verify_report.py b/eval/role_topology/verify_report.py index e1ef0edc..a5cb2ae7 100644 --- a/eval/role_topology/verify_report.py +++ b/eval/role_topology/verify_report.py @@ -1,295 +1,281 @@ #!/usr/bin/env python3 -"""Сверка НЕСУЩИХ ЧИСЕЛ отчёта `docs/experiments/21-role-topology.md` с сырьём. +"""ВЕРИФИКАТОР ЧИСЕЛ ОТЧЁТА эксп-21 (вторая редакция). Ненулевой код возврата = отчёт не сдаётся. -Мандат промта: «Верификатор чисел отчёта отдельным скриптом; ненулевой код возврата = отчёт не -сдаётся». Смысл в том, что число попадает в текст через мою же аналитику, и опечатка, устаревшая -цифра после пере-съёмки и факт визуально неотличимы. Здесь каждое заявленное число выводится из -персистированных артефактов ЗАНОВО и сравнивается с тем, что написано в отчёте — включая сам текст -отчёта, чтобы расхождение ловилось, даже если я забуду поправить таблицу. +Правило: каждое число отчёта пере-выводится ИЗ СЫРЬЯ и сверяется с напечатанным. Верификатор +не читает выводы — он читает артефакты и текст, и падает на расхождении. -Запуск: eval/.venv/bin/python eval/role_topology/verify_report.py +Три вещи, которых у первой редакции не было и которые тут есть по её разбору: + * ДЕНЬГИ ВТОРЫМ НЕЗАВИСИМЫМ ПУТЁМ. `prices.cost` считает цену и при записи, и при сверке, поэтому + их совпадение доказывает целостность персистенции, а НЕ правильность правила тарификации: + ошибка внутри `cost` (как отсутствовавшая ветка Gemini) проходит обе стороны незамеченной. + `independent_price` выписывает правило заново, по строкам quirks 00. + * ИСПРАВНОСТЬ РАСКЛАДКИ проверяется равенством числа голосов по порядкам, а не малостью + позиционной форы: зеркало убирает позицию из перевеса АРМА, а не из среднего по всем голосам. + * АРМЫ СВЕРЯЮТСЯ С БУКВОЙ ПРОМТА исполнением — сборкой сообщений, а не чтением докстрингов. + +Запуск: eval/.venv/bin/python eval/role_topology/verify_report.py """ from __future__ import annotations import json import re +import statistics import sys from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") sys.path.insert(0, str(REPO / "eval" / "role_topology")) -sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) RAW = Path.home() / "books" / "role-topology" -REPORT = REPO / "docs" / "experiments" / "21-role-topology.md" +DOC = REPO / "docs" / "experiments" / "21-role-topology.md" FAILS = 0 def ck(claim: str, ok: bool, got: str = "") -> None: - global FAILS + global FAILS # noqa: PLW0603 if not ok: FAILS += 1 print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {claim}" + ("" if ok else f" — сырьё даёт {got}")) -def main() -> None: - text = REPORT.read_text(encoding="utf-8") - import detect_word as D # noqa: PLC0415 - - # §2.1 — таблица слоёв детектора выводится заново из земли, а не читается из json. - rows = [json.loads(l) for l in D.LABELS.read_text(encoding="utf-8").splitlines() if l.strip()] - bank = D.load_bank() - expect = {0: (9, 34, 1), 1: (9, 28, 1), 2: (8, 11, 2), 3: (7, 2, 3)} - for layer, want in expect.items(): - s = D.score(rows, layer, bank) - got = (s["tp"], s["fp"], s["fn"]) - ck(f"§2.1 слой С{layer} = tp/fp/fn {want}", got == want, str(got)) - s3 = D.score(rows, 3, bank) - ck("§2.1 итоговые precision 0.778 / recall 0.700", - abs(s3["precision"] - 0.778) < 0.001 and abs(s3["recall"] - 0.700) < 0.001, - f"{s3['precision']:.3f}/{s3['recall']:.3f}") - ck("§2.1 таблица отчёта несёт те же 0.778 / 0.700", - "0.778" in text and "0.700" in text) - - import editor_wire_probe as WP # noqa: PLC0415 - plants = [w for rs in WP.DEFECTS.values() for cls, old, new, _, _ in rs if cls == "k1" - for w in new.split() if w not in old.split()] - hit = sum(1 for w in plants if D.verdict(w, bank)[0]) - ck("§2.1 выделенная валидация 6/6", (hit, len(plants)) == (6, 6), f"{hit}/{len(plants)}") - - ck("§2.1 ущерб: ложных 2 из 9 флагов (22%)", - s3["fp"] == 2 and s3["tp"] + s3["fp"] == 9, f"{s3['fp']}/{s3['tp'] + s3['fp']}") - - # §2.2 — QE. Числа берутся из персистированного сырья прогона, а не пере-считываются моделью - # (пере-счёт стоил бы 20 минут CPU и не добавил бы проверки: артефакт и есть сырьё). - # ⚠ Артефакты РАЗВЕДЕНЫ ПО МОДЕЛЯМ. Первая редакция харнесса писала в общее имя, и прогон 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")) - 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 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 {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(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") +def independent_price(model: str, r: dict) -> float: + """Цена по прайсу, посчитанная НЕ через `prices.cost` — второй независимый путь.""" + from prices import CANDIDATES # noqa: PLC0415 + _, _, pin, pcache, pout, _, _ = CANDIDATES[model] + pt, ca = r.get("prompt_tokens", 0), r.get("cached_tokens", 0) + ct, rt = r.get("completion_tokens", 0), r.get("reasoning_tokens", 0) + tt = r.get("total_tokens", 0) + if model.startswith("grok"): + out = ct + rt # xAI: размышление СВЕРХ completion + elif model.startswith("gemini"): + out = max(tt - pt, ct + rt) # Gemini: размышление только в total (D22.3) 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))) + out = ct # DeepSeek/OpenAI/Z.AI: размышление ВНУТРИ completion + return (pt - ca) / 1e6 * pin + ca / 1e6 * pcache + out / 1e6 * pout - # §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 — батарея. Пере-прогоняется по тому же корпусу целиком: это единственный способ - # заметить, что правило поехало после правки. - import battery as B # noqa: PLC0415 - corp = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl" - units = [json.loads(l) for l in corp.read_text(encoding="utf-8").splitlines() if l.strip()] - units = [u for u in units if (u.get("final") or "").strip()] - ck("§2.3 единиц с финалом 91", len(units) == 91, str(len(units))) - typo = han = lost = inv = cand = nonconf = calq = 0 - for u in units: - f = u["final"] - typo += B.check_typography(f)["violations"] - han += B.check_cjk_leak(f)["han_chars"] - nm = B.check_numbers(u.get("source") or "", f) - lost += nm["lost_n"] - inv += nm["invented_n"] - p = B.check_palladius(f, set()) - cand += p["name_candidates"] - nonconf += p["nonconformant_text"] - calq += bool(B.check_translationese(f)["calque_interjections"]) - n = len(units) - for label, got, want in (("нарушений типографики/ед 0.18", typo / n, 0.18), - ("ханьцзы всего 4", han, 4), - ("потеряно величин/ед 0.27", lost / n, 0.275), - ("появилось величин/ед 0.27", inv / n, 0.27), - ("не-палладиевских срабатываний 6", nonconf, 6), - ("единиц с кальками 0", calq, 0)): - ck(f"§2.3 {label}", abs(got - want) < 0.005, f"{got:.3f}" if isinstance(got, float) - else str(got)) - ck("§2.3 кандидатов в имена 2184", cand == 2184, str(cand)) +def money(pat: str) -> float: + return sum(json.loads(f.read_text(encoding="utf-8")).get("cost_usd", 0.0) or 0.0 + for f in RAW.glob(pat)) - # §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 +def main() -> None: # noqa: PLR0915 + if not DOC.exists(): + ck("отчёт на месте", False, "нет файла") + sys.exit(1) + text = DOC.read_text(encoding="utf-8") 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: + import pair_en as E # noqa: PLC0415 + import judges as J # noqa: PLC0415 + + print("=== СТРУКТУРА ЗАМЕРА ===") + us_zh, us_en = BO.units(), E.units() + ck("zh: 16 единиц", len(us_zh) == 16, str(len(us_zh))) + ck("en: 12 единиц, страты 6/6", len(us_en) == 12 + and sum(1 for u in us_en if u["stratum"] == "fr") == 6, str(len(us_en))) + ck("zh: единицы уникальны по нормализованной подписи", + len({BO._dedup_sig(u["source"]) for u in us_zh}) == 16) + ck("отбор единиц ДЕТЕРМИНИРОВАН (повторный вызов даёт тот же набор)", + [u["uid"] for u in BO.units()] == [u["uid"] for u in us_zh] + and [u["uid"] for u in E.units()] == [u["uid"] for u in us_en]) + + print("\n=== АРМЫ ПРОТИВ БУКВЫ ПРОМТА (проверка исполнением) ===") + a_msgs = BO.build_msgs("A", us_zh[0]) + ck("арм A идёт БЕЗ банкноты (промт стр.29)", + not any("КАНОНИЧЕСКИЕ ПЕРЕВОДЫ" in m["content"] for m in a_msgs)) + ck("закон пинится БЕЗ оговорки области (промт стр.29)", + not any("глоссарий обязателен для употреблений" in m["content"] + for arm in BO.ARMS for m in BO.build_msgs(arm, us_zh[0]))) + ck("A и A_law различаются ТОЛЬКО банкнотой", + a_msgs[0]["content"] == BO.build_msgs("A_law", us_zh[0])[0]["content"] + and a_msgs[-1]["content"] == BO.build_msgs("A_law", us_zh[0])[-1]["content"]) + d, d_ = BO.build_msgs("D", us_zh[0]), BO.build_msgs("D_", us_zh[0]) + ck("D и D_ различаются ТОЛЬКО мандатом", + d[1]["content"] == d_[1]["content"] and d[0]["content"] != d_[0]["content"]) + bs = [json.loads(f.read_text(encoding="utf-8")) for f in RAW.glob("bo2-B-*.json")] + ck("арм B: мышление ПОГАШЕНО (промт стр.62)", + bool(bs) and all(r.get("reasoning_tokens", 0) == 0 for r in bs), + f"максимум {max((r.get('reasoning_tokens', 0) for r in bs), default=-1)}") + + print("\n=== РАСКЛАДКА СУДЕЙ ===") + # Считаются только голоса ЖИВЫХ единиц: на диске лежат ещё голоса единиц, отобранных до + # починки недетерминированного отбора (§3 п.9). Они не участвуют в своде и не должны + # участвовать в контроле баланса — иначе контроль мерит мусор, а не раскладку. + for pref, live in (("jv2-", {u["uid"] for u in us_zh}), ("jve-", {u["uid"] for u in us_en})): + by = {0: 0, 1: 0} + for f in RAW.glob(f"{pref}*.json"): + if f.name.endswith((".ERROR.json", ".NOMSG.json")): 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}") + r = json.loads(f.read_text(encoding="utf-8")) + if r.get("uid") in live: + by[r.get("order", 0)] += 1 + ck(f"{pref}: голосов порядка 0 и порядка 1 ПОРОВНУ ({by[0]}/{by[1]})", + by[0] == by[1] and by[0] > 0, f"o0={by[0]} o1={by[1]}") + ck("судьи вне семейств, населяющих армы", + not any(j.startswith(("deepseek", "glm")) for j in J.JUDGES), str(J.JUDGES)) + bias_zh = BO.position_bias() + ck("позиционная фора zh напечатана в отчёте", f"{bias_zh:+.2f}".replace("+", "+") in text + or f"{abs(bias_zh):.2f}" in text, f"{bias_zh:+.2f}") - # §2.12 — карточки персонажей. Пин на то, что проверка рода НЕ инертна: пустые карточки - # молча возвращают нули, и именно так этот пробел прожил весь пак незамеченным. + print("\n=== ТАБЛИЦЫ ПЕРЕВЕСОВ (пере-вывод из голосов) ===") + for a, b in BO.CONTRASTS: + per = [] + for u in us_zh: + pj = [] + for judge in J.JUDGES: + bo = BO.margins_by_order(a, b, u["uid"], judge) + if len(bo) < 2: + continue + pj += [statistics.mean(bo[0]), statistics.mean(bo[1])] + if len(pj) == 4: + per.append(statistics.mean(pj)) + if not per: + ck(f"zh {a}/{b}: голоса на месте", False, "нет") + continue + eff = statistics.mean(per) + lo, hi = BO.boot_ci(per) + zh_sec = text.split("### 2.1", 1)[-1].split("### 2.2", 1)[0] + printed = re.search(rf"^{re.escape(a)} против {re.escape(b)}\s+(\d+)\s+\d+\s+\d+\s+\d+\s+" + rf"([+−-]?[\d.]+)\s+\[", zh_sec, re.M) + ck(f"zh {a}/{b}: перевес {eff:+.2f}, ДИ [{lo:+.2f}, {hi:+.2f}] — совпадает с отчётом", + bool(printed) and abs(float(printed.group(2).replace("−", "-")) - eff) < 0.005 + and int(printed.group(1)) == len(per), + f"n={len(per)} перевес {eff:+.2f}; в отчёте {printed.group(0)[:40] if printed else '—'}") + + for a, b in E.CONTRASTS: + per = [] + for u in us_en: + pj = [] + for judge in J.JUDGES: + bo = E._margins(a, b, u["uid"], judge) + if len(bo) < 2: + continue + pj += [statistics.mean(bo[0]), statistics.mean(bo[1])] + if len(pj) == 4: + per.append(statistics.mean(pj)) + if not per: + ck(f"en {a}/{b}: голоса на месте", False, "нет") + continue + eff = statistics.mean(per) + # ⚠ Искать в ГРАНИЦАХ РАЗДЕЛА: метки контрастов в таблицах zh и en одинаковы, и поиск по + # всему файлу возвращает китайскую строку. Поймано исполнением: сверка «совпадает» при + # совпадающих числах падала, потому что сравнивала en-перевес с zh-строкой. + en_sec = text.split("### 2.2", 1)[-1].split("### 2.3", 1)[0] + pr = re.search(rf"^{re.escape(a)} против {re.escape(b)}\s+(\d+)\s+\d+\s+\d+\s+\d+\s+" + rf"([+−-]?[\d.]+)\s+\[", en_sec, re.M) + ck(f"en {a}/{b}: перевес {eff:+.2f} на n={len(per)} — совпадает с отчётом", + bool(pr) and abs(float(pr.group(2).replace("−", "-")) - eff) < 0.005 + and int(pr.group(1)) == len(per), f"n={len(per)} перевес {eff:+.2f}") + + print("\n=== ДЕНЬГИ ===") + recs = [json.loads(f.read_text(encoding="utf-8")) + for f in list(RAW.glob("bo2-*.json")) + list(RAW.glob("en2-*.json")) + + list(RAW.glob("jv2-*.json")) + list(RAW.glob("jve-*.json")) + + list(RAW.glob("jc-*.json"))] + recs = [r for r in recs if not r.get("skipped") and "prompt_tokens" in r] + # Сверка ПОКЛЕТОЧНАЯ, а не по сумме: записанная цена округлена до 6 знаков, и на 500+ записях + # округление копится до величины, которую тугой допуск на сумму читает как расхождение правила. + worst = max((abs(r["cost_usd"] - independent_price(r["model"], r)) for r in recs), default=0.0) + a_sum = sum(r["cost_usd"] for r in recs) + b_sum = sum(independent_price(r["model"], r) for r in recs) + ck(f"цена сходится ДВУМЯ независимыми путями поклеточно ({len(recs)} записей, " + f"максимальное расхождение ${worst:.9f})", + worst < 1e-6, f"записано ${a_sum:.6f} · независимо ${b_sum:.6f} · худшая клетка ${worst:.9f}") + ck("total_tokens персистирован во ВСЕХ записях", + all("total_tokens" in r for r in recs), + f"{sum(1 for r in recs if 'total_tokens' not in r)} без него") + blocks = [("армы zh", "bo2-*.json", BO.CEIL_ARMS), ("судейство zh", "jv2-*.json", BO.CEIL_JUDGE), + ("армы en", "en2-*.json", E.CEIL_ARMS), ("судейство en", "jve-*.json", E.CEIL_JUDGE), + ("проба судьи-замены", "jc-*.json", 0.10)] + total = 0.0 + for name, pat, ceil in blocks: + v = money(pat) + total += v + ck(f"{name}: ${v:.6f} ≤ потолок ${ceil:.2f} и напечатано в отчёте", + v <= ceil and f"{v:.6f}" in text, f"${v:.6f}") + ck(f"итог ${total:.6f} напечатан в отчёте", f"{total:.6f}" in text, f"${total:.6f}") + + print("\n=== ЭКОНОМИКА ===") + # ⚠ Цена печатается ПО СОБСТВЕННОМУ ВЫЗОВУ арма, без ноги черновика. Основание: армы правят + # ЗАМОРОЖЕННЫЙ черновик корпуса пакета-6, чья цена не сохранена, а докупленные `bo2-DRAFT-*` + # с ним побайтно не совпадают — их сложение приписывало бы связке цену другого текста. + for arm in BO.ARMS: + costs = [json.loads((RAW / f"bo2-{arm}-{u['uid']}.json").read_text(encoding="utf-8"))["cost_usd"] + for u in us_zh if (RAW / f"bo2-{arm}-{u['uid']}.json").exists()] + med = statistics.median(costs) + ck(f"{arm}: ${med:.5f}/единицу (собственный вызов) напечатано в отчёте", + f"{med:.5f}" in text, f"{med:.5f}") + same = sum(1 for u in us_zh + if BO.text_of("F", u).strip() + == json.loads((RAW / f"bo2-DRAFT-{u['uid']}.json").read_text( + encoding="utf-8"))["content"].strip()) + ck("подмена ноги черновика ОБЪЯВЛЕНА: текст арма F не совпадает с докупленным черновиком", + same == 0 and "не совпадают" in text.replace("побайтно они с корпусными не совпадают", + "не совпадают"), + f"совпало {same}/{len(us_zh)}") + med_draft = statistics.median([BO.draft_cost(u["uid"]) for u in us_zh]) + ck(f"пере-замер цены черновой роли ${med_draft:.5f} напечатан отдельным числом", + f"{med_draft:.5f}" in text, f"{med_draft:.5f}") + + print("\n=== ПОПРАВКА НА МНОЖЕСТВЕННОСТЬ И РАЗБОР ПО СУДЬЯМ ===") + ps = [] + for a, b in BO.CONTRASTS: + per = [] + for u in us_zh: + pj = [] + for judge in J.JUDGES: + bo = 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(BO.sign_perm_p(per)) + order_i = sorted(range(len(ps)), key=lambda i: ps[i]) + holm, run = [0.0] * len(ps), 0.0 + for rank, i in enumerate(order_i): + run = max(run, ps[i] * (len(ps) - rank)) + holm[i] = min(1.0, run) + ck("отчёт объявляет, что НИ ОДИН контраст не проходит Holm 0.05", + all(h >= 0.05 for h in holm) and "Ни один контраст не проходит 0.05" in text, + f"минимальный Holm {min(holm):.4f}") + ck("отчёт печатает разбор ПО СУДЬЯМ (эффект одного судьи не есть свойство арма)", + "ПО СУДЬЯМ" in text or "по судьям" in text) + ck("отчёт печатает разложение ПО ОСЯМ", + "ВЕРНОСТЬ" in text and "разложение по осям" in text.lower()) + + print("\n=== ТРЕТИЙ КОНТУР ПЕРСИСТИРОВАН ===") + rd = RAW / "blind-keys" / "READINGS.json" + ck("ранги слепого чтения лежат на диске, ВНЕ каталога пакетов", rd.exists()) + ck("карта раскладки вынесена из каталога пакетов (слепота держится расположением)", + not list((RAW / "blind").glob("*MAP*")) and bool(list((RAW / "blind-keys").glob("*MAP*")))) + if rd.exists(): + d = json.loads(rd.read_text(encoding="utf-8")) + ck("персист несёт оговорку о том, что полные разборы утрачены", + "НЕ персистированы" in d.get("provenance", "")) + ck("рангов zh 16 и en 12", len(d["ranks_zh"]) == 16 and len(d["ranks_en"]) == 12) + + print("\n=== БАТАРЕЯ ===") + import battery as B # noqa: PLC0415 + ck("check_numbers симметричен: 两千三百 против «две тысячи триста» НЕ даёт дефекта", + not (B.check_numbers("他有两千三百块灵石。", "У него две тысячи триста камней.")["lost"] + or B.check_numbers("他有两千三百块灵石。", "У него две тысячи триста камней.")["invented"])) + ck("check_numbers ловит настоящую потерю: 三千五百 против «три тысячи»", + bool(B.check_numbers("三千五百人", "три тысячи человек")["lost"])) 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 сверок — карточки не доехали") + ck("карточки построены из подписанного сида", len(cards) == 51, str(len(cards))) + ck("check_gender отсекает косвенную форму имени", + B.check_gender("Воля Фан Юаня была тверда.", cards)["mismatched"] == 0) + ck("check_gender ловит настоящее рассогласование", + B.check_gender("Фан Юань была спокойна.", cards)["mismatched"] == 1) - 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("\n=== ОТКАЗЫ ПРОВОДА ЗАПИСАНЫ, А НЕ ПОТЕРЯНЫ ===") + nomsg = list(RAW.glob("jv2-*.NOMSG.json")) + list(RAW.glob("jve-*.NOMSG.json")) + err = list(RAW.glob("jv2-*.ERROR.json")) + list(RAW.glob("jve-*.ERROR.json")) + ck(f"отказы фильтра ({len(nomsg)}) записаны и объявлены в отчёте", + bool(nomsg) and str(len(nomsg)) in text, str(len(nomsg))) + ck(f"отказы xAI ({len(err)}) записаны ОТДЕЛЬНЫМ тегом (кэш успеха не отравлен)", + all(not (RAW / f.name.replace(".ERROR.json", ".json")).exists() + or json.loads((RAW / f.name.replace(".ERROR.json", ".json")).read_text( + encoding="utf-8")).get("content") for f in err)) print(f"\n{'ВСЕ ЧИСЛА ОТЧЁТА СХОДЯТСЯ' if not FAILS else f'РАСХОЖДЕНИЙ: {FAILS}'}") sys.exit(1 if FAILS else 0)