Freeze the third-edition role-topology pre-registration before paid calls: declared contrast family, computed power, re-declared ceilings, purchase order

This commit is contained in:
heaven 2026-08-07 10:51:30 +03:00
parent fcdab81a88
commit 769a2c8451
11 changed files with 1838 additions and 560 deletions

View file

@ -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-перевода, рассеянные
окна для дисперсии + связный отрезок 810 глав для дрейф-метрик.
**Такого текста в дереве нет.** Инвентарь `~/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-преференс — только для стиль-осей. Судья не судит армы своего семейства-жильца;
вердикты раскладываются по семействам судей. Агрегация BradleyTerry с 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 знака,
редакторский проход вычищает до 02. Это независимое от судьи подтверждение, что второй проход
что-то делает. Оставшиеся 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. **Дрейф на связном отрезке 810 глав** — швы между чанками, стабильность ты/вы, рост банка.
Промт заказывал; мерится всё на рассеянных единицах. Требует другого масштаба покупки.
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 слов, BradleyTerry** — не реализованы;
реализован счёт ошибок по осям плюс 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` + карточки из
подписанного сида, фильтр по именительному падежу; 86112 сверок на арм на 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≈128192, а в корпусе после дедупа
**всего 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.64.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. **Третий судья — не покупка мощности.** Разложение дисперсии: он снижает стандартную ошибку
на 48%, что эквивалентно +2.4 единицы за $1.64.0; удвоение единиц даёт 31% за $0.51.
Разброс несёт разнородность единиц, а не судейский шум. Grok остаётся оправдан как арбитр там,
где судьи расходятся (`A/F`: +0.84 против +3.03), и как покрытие молчаливых отказов Gemini.
2. **«Шумовой пол пайплайна» переименован в «пол рига» и снят с блокирующих.** При замороженном
черновике повтор одного арма меряет стохастику модели в роли редактора, а не пайплайна; и она
уже сидит внутри разброса по единицам, который берут bootstrap и перестановочный тест. Ценность
пола другая: отрицательный контроль (при отсутствии разницы перевес обязан быть нулём) и
разделение дисперсии на «разнородность единиц» против «шум вызова».
3. **Покупка «пост-процессор против арма» ($0.289) ОТМЕНЕНА до траты.** Гипотеза была, что
дешёвая детерминированная правка механического слоя закроет часть разрыва `A/F`. Замер $0:
механический брак действительно сосредоточен в черновике (73 закавыченных реплики против 1122
у армов, 6 markdown-разметок против 02, 24 ханьцзы против 0), но доминирующий класс
детерминированно НЕ чинится — черновик берёт `«…»` под внутренний монолог, что в русском наборе
является нормой, а отличить мысль от произнесённой реплики требует понимания, а не правила.
Пост-процессор (`postproc.py`, правила проверены синтетикой 8/8) чинит только разметку и
служебную преамбулу — семь случаев на весь корпус, эффект заведомо нулевой.
- **арм C** ищет дефект в ОДНОМ тексте, образца нет ⇒ ему нужен абсолютный порог. Порога нет:
precision ≤0.17 на обеих моделях, баллы здоровых сегментов (медиана 6.1, p90 11.4, макс 1618)
полностью перекрывают баллы испорченных. **Арм 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 фактических срабатываниях.
Ровно тот класс ошибки, ради которого верификатор и написан.)*
**Объявленные границы батареи** (в отчёт, не в примечание): величины 199 вне охвата; «длины
предложений против нативного ру-корпуса» не считаются абсолютно — нативного русского референс-корпуса
в дереве нет, и подставлять чужую константу нельзя (тот же класс ошибки, что снятый жанровый словарь
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` = отчёт не сдаётся.

View file

@ -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}")

View file

@ -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__)

View file

@ -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)

View file

@ -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"}

View file

@ -0,0 +1,450 @@
#!/usr/bin/env python3
"""ВТОРАЯ ЯЗЫКОВАЯ ПАРА — en→ru на Kristoff, «Empire of the Dawn».
Зачем. Промт эксп-21 заказывал одну пару (zhru), и первая редакция мерила топологию только на
ней. Но цель проекта 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` харнесса экспов 1820 сюда НЕ годится и был пойман исполнением: он означает
«переводить НА английский» (трек zhen экспа-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__)

View file

@ -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"(?<!\*)\*(?!\s)(.+?)(?<!\s)\*(?!\*)", re.S)
_RE_HR = re.compile(r"^\s*(?:---+|===+|\*\s*\*\s*\*)\s*$", re.M)
# Реплика, целиком взятая в кавычки на отдельной строке: «…» или "…" в начале абзаца.
_RE_QUOTED_LINE = re.compile(r'^[ \t]*[«"„]\s*(.+?)\s*[»"“]([ \t]*[,.—-].*)?$', re.M)
_RE_MULTI_BLANK = re.compile(r"\n{3,}")
def strip_service(text: str) -> 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__)

View file

@ -0,0 +1,47 @@
<!-- ПАР-ПАКЕТ en→ru, заведён полигоном 07.08 для эксп-21. Производный от боевого
backend/prompts/zh-ru/editor.md. Изменены РОВНО четыре места, и все четыре — те, где
боевой файл говорит о китайском: строка мер, рамка дискурс-перевёрстки, оговорка про
построчную разбивку исходника и пример с чэнъюй. Остальное перенесено дословно.
Смысл замера: если новая пара требует ТОЛЬКО этих правок в данных, общность движка
держится; если потребует правки Go — не держится. -->
Ты — литературный редактор художественного перевода на русский язык. Тебе даны ИСХОДНЫЙ текст и его черновой перевод.
Жанр книги: {{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}}

View file

@ -0,0 +1,33 @@
<!-- ПАР-ПАКЕТ en→ru, арм D (однопроходка С мандатом перевёрстки). Собран так же, как
zh-ru-аналог эксп-20: боевой translator этой пары + блок ДИСКУРС-ПЕРЕВЁРСТКИ из
editor.md ЭТОЙ ЖЕ пары. Формулировка мандата одна на пару — иначе контраст D/D_
мерил бы разницу текстов, а не наличие мандата. -->
Ты — профессиональный литературный переводчик с языка «{{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}}

View file

@ -0,0 +1,28 @@
<!-- ПАР-ПАКЕТ en→ru, заведён полигоном 07.08 для эксп-21. Производный от боевого
backend/prompts/zh-ru/translator.md. Изменён РОВНО один блок — «Единицы и приёмы»,
единственное место файла, где боевой промт говорит о конкретной паре. Все остальные
строки перенесены дословно, чтобы сравнение топологий между парами не смешивалось
с разницей формулировок. Это и есть замер «сколько стоит новая пара»: данные, не Go. -->
Ты — профессиональный литературный переводчик с языка «{{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}}

View file

@ -1,295 +1,281 @@
#!/usr/bin/env python3
"""Сверка НЕСУЩИХ ЧИСЕЛ отчёта `docs/experiments/21-role-topology.md` с сырьём.
"""ВЕРИФИКАТОР ЧИСЕЛ ОТЧЁТА эксп-21 (вторая редакция). Ненулевой код возврата = отчёт не сдаётся.
Мандат промта: «Верификатор чисел отчёта отдельным скриптом; ненулевой код возврата = отчёт не
сдаётся». Смысл в том, что число попадает в текст через мою же аналитику, и опечатка, устаревшая
цифра после пере-съёмки и факт визуально неотличимы. Здесь каждое заявленное число выводится из
персистированных артефактов ЗАНОВО и сравнивается с тем, что написано в отчёте включая сам текст
отчёта, чтобы расхождение ловилось, даже если я забуду поправить таблицу.
Правило: каждое число отчёта пере-выводится ИЗ СЫРЬЯ и сверяется с напечатанным. Верификатор
не читает выводы он читает артефакты и текст, и падает на расхождении.
Три вещи, которых у первой редакции не было и которые тут есть по её разбору:
* ДЕНЬГИ ВТОРЫМ НЕЗАВИСИМЫМ ПУТЁМ. `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:
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}")
import pair_en as E # noqa: PLC0415
import judges as J # noqa: PLC0415
# §2.12 — карточки персонажей. Пин на то, что проверка рода НЕ инертна: пустые карточки
# молча возвращают нули, и именно так этот пробел прожил весь пак незамеченным.
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
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}")
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)