Freeze experiment 21 pre-registration and the role-topology harness before any paid call, with detector, battery and verifier results measured at zero cost

This commit is contained in:
heaven 2026-08-06 18:40:37 +03:00
parent 73a7e7a59f
commit 864038a414
13 changed files with 2318 additions and 0 deletions

View file

@ -0,0 +1,324 @@
# 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.
> **СТАТУС: Ф0 ИСПОЛНЕНА ЧАСТИЧНО, ПЛАТНЫЕ ФАЗЫ НЕ ЗАПУСКАЛИСЬ. Потрачено $0.**
> Разделы §0§2 — пре-регистрация и результаты бесплатной метрологии. Блокер запуска назван в §0.2.
---
## ИТОГ (для оркестратора)
*Заполняется после Ф2. Сейчас закрыты только вопросы метрологии — см. §2.*
**Что уже решено данными и меняет план пака:**
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). Это цена, которую платит любая батарея без прогонки на чистом тексте, и она названа
числами, а не общими словами.
---
## §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% самосогласия |
### 0.6. Смета
Ф0 ≤$0.70 (предзамер судей ≤$0.60) · вахта эффорта ≤$0.05 · Ф1 ≤$1.00 · Ф2а ≤$2.50 · Ф2б ≤$3.00.
Живые стопы механизмом + проекционный гард. Подъём или пере-нарезка — только словом владельца ДО
запуска фазы. Не влезает — стоп и пинг, не резать молча.
### 0.7. Что считается провалом фазы
Ф0.6 не даёт порога различимости в потолке → СТОП и пинг с расчётом мощности, а не молчаливое
уменьшение числа окон. Детекторы Ф0.4 не валидируются → арм C идёт только oracle-режимом, и это
записывается как граница, а не как результат.
---
## §1. Что уже стоит на дереве ($0)
| Файл | Что делает | Проверен |
|---|---|---|
| `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` | ревью исполнением всего харнесса; ненулевой код = харнесс не годен | сам |
---
## §2. Ф0 — метрология (исполнено, $0)
### 2.1. Детектор «выдуманное слово»: построен, базлайн бит
**Земля.** `~/books/gu-zhenren/labels/labels/k3.jsonl` — 2177 размеченных словотипов из шести
реальных прогонов, 10 помечены `defect`. Разметка сделана ЧУЖОЙ сессией (пакет-6, 26.07) до и вне
этого экспа ⇒ требование «разметка вторым контуром» выполнено построением, а не моей рукой.
**Базлайн взят из кода, а не из заголовка.** `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»). ⇒ формулировка «мой детектор бьёт баг»
неверна; верная — он закрывает объявленную дыру.
| Слой | tp | fp | fn | precision | recall |
|---|---|---|---|---|---|
| боевые чекеры (их `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** |
Слои накопительные и печатаются по одному — норма D39.46(а) («у каждого фактора обязан быть арм
без него») применена к слоям детектора.
**Выделенная валидация: 6/6** на посадках k1 пробы 18 — независимый набор чужой сессии, в настройке
не участвовал. ⚠ Набор ЛЕГЧЕ живого: посадки сконструированы заведомо не-словами. Читать как верхнюю
границу.
**Проверка на подгонку.** Свободный параметр слоя С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) | ранкер на этом материале ЧУВСТВИТЕЛЕН |
Декой — несущая часть замера: без него «не увидел посадку» и «слеп на этом материале» неразличимы,
а это разные вердикты. Он показывает, что модель работает и формат входа верен (сверен с авторским
`metricx24/predict.py:_make_input`).
**Главный вывод — не «ранкер плох», а «два режима расходятся».** Абсолютный порог и парное сравнение
отвечают на разные вопросы, и это ровно граница между двумя армами фазы 2б:
- **арм 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.
---
## §3. Девиации от промта
| # | Девиация | Основание |
|---|---|---|
| 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, снят собственным замером как ложный |
## §4. Открыто
- **Материал Ф0.2** — на владельце (§0.2). Блокирует все платные фазы.
- Ф0.3 банк среза, Ф0.6 предзамер судей, Ф1, Ф2а, Ф2б — не запускались.

View file

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Детерминированное выравнивание zh↔ru предложениями — $0, БЕЗ моделей и без torch.
Вынесено из `qe_segment.py` по находке самопроверки: выравнивание нужно и батарее Ф0.5, и
QE-детектору, но батарея не должна тянуть за собой 4.6 ГБ весов и отдельный venv. Модуль
импортируется обоими и проверяется `selfcheck.py` в базовом окружении полигона.
"""
from __future__ import annotations
import re
# Терминаторы предложений: китайские полноширинные и латинские. Многоточие 。。。/…… не разрывается.
_ZH_SPLIT = re.compile(r"(?<=[。!?;…])(?![。!?;…])")
_RU_SPLIT = re.compile(r"(?<=[.!?…])\s+(?=[«\"(\[А-ЯЁA-Z—-])")
def split_zh(t: str) -> list[str]:
return [s.strip() for s in _ZH_SPLIT.split(t) if s.strip()]
def split_ru(t: str) -> list[str]:
out = []
for line in t.split("\n"):
line = line.strip()
if not line:
continue
out.extend(s.strip() for s in _RU_SPLIT.split(line) if s.strip())
return out
def align(src: list[str], dst: list[str]) -> list[tuple[list[int], list[int]]]:
"""Монотонное выравнивание по длине (Гейл–Чёрч). Возвращает пары индексов.
Стоимость пары квадрат нормированного отклонения фактического отношения длин от среднего по
паре текстов. Среднее берётся ИЗ САМИХ ТЕКСТОВ, а не константой 2.2 из exp07: константа снята
на другом материале, и подставлять её сюда значит тащить чужую калибровку в новый замер.
"""
if not src or not dst:
return []
ratio = sum(len(d) for d in dst) / max(1, sum(len(s) for s in src))
INF = float("inf")
n, m = len(src), len(dst)
cost = [[INF] * (m + 1) for _ in range(n + 1)]
back: dict[tuple[int, int], tuple[int, int]] = {}
cost[0][0] = 0.0
def pair_cost(si: int, sj: int, di: int, dj: int) -> float:
ls = sum(len(x) for x in src[si:sj]) * ratio
ld = sum(len(x) for x in dst[di:dj])
if ls + ld == 0:
return 0.0
# Пропуск (1-0 / 0-1) штрафуется как полное расхождение — иначе выравнивание охотно
# «теряет» предложения, и потерянный сегмент никогда не будет пред'явлен детектору.
base = ((ls - ld) ** 2) / max(ls + ld, 1.0)
return base + (6.0 if (sj == si or dj == di) else 0.0)
for i in range(n + 1):
for j in range(m + 1):
if cost[i][j] == INF:
continue
for ds, dd in ((1, 1), (1, 2), (2, 1), (1, 0), (0, 1), (2, 2)):
ni, nj = i + ds, j + dd
if ni > n or nj > m or (ds == 0 and dd == 0):
continue
c = cost[i][j] + pair_cost(i, ni, j, nj)
if c < cost[ni][nj]:
cost[ni][nj] = c
back[(ni, nj)] = (i, j)
out: list[tuple[list[int], list[int]]] = []
cur = (n, m)
while cur != (0, 0):
prev = back.get(cur)
if prev is None:
return []
out.append((list(range(prev[0], cur[0])), list(range(prev[1], cur[1]))))
cur = prev
return out[::-1]

View file

@ -0,0 +1,537 @@
#!/usr/bin/env python3
"""Ф0.5 — ДЕТЕРМИНИРОВАННАЯ БАТАРЕЯ. Первичные метрики бейк-оффа там, где судья не нужен.
Зачем она первична. Эксп-20 §5.4 намерил, что судья-LLM согласен сам с собой на ИДЕНТИЧНОМ входе
в 56% клеток (33% на несущих осях) и разниц меньше ~2:1 не разрешает. Значит любой вывод бейк-оффа,
опирающийся только на судью, обречён быть «неопределённо». Детерминированные метрики шума не имеют
вовсе: их повтор даёт то же число. Поэтому топология сравнивается сначала ими, а судья добирает
только те оси, которые кодом не берутся (художественность прозы).
Что меряется и откуда взяты правила. Каждая проверка ниже реализация уже написанной проектом
спецификации, а не моё изобретение: `docs/research/12-failure-modes-ru-target.md` перечисляет
12 режимов отказа русской цели с разделом «Детекция» у каждого, и оттуда взяты и признаки, и
границы применимости. Ссылки стоят у каждой функции.
Генеральность. Русско-специфичные факты (междометия-транслит, кавычки-ёлочки, ты/вы, приставки)
собраны в СЛОВАРЬ `RU` внизу как ДАННЫЕ ЦЕЛИ. Код по паре не ветвится: для de/en меняется таблица,
а не функции. Пар-специфика источника (нормализация /亿, палладиевские слоги) живёт отдельно и
берётся из langpack движка. Ревью-вопрос §0.1 «заработает ли пара, которой в репо нет, без правки
Go» здесь беспредметен (Go нет вовсе), но структурная аналогия соблюдена сознательно.
НОРМАЛИЗАЦИЯ ДО ТОКЕНИЗАЦИИ обязательна и найдена исполнением. Комбинирующий знак ударения
(U+0301) в «бо́льшим» разрезает слово для наивного токенизатора именно так в размеченном наборе
пакета-6 появились фантомные словотипы «льшим»/«льшую». Боевой путь от этого НЕ страдает (проверено
Go-оверлеем: `ExportNormalize` знак снимает, а `SanitizeOutput` на тексте с ударением и без даёт
одинаковый вердикт), но полигонный код обязан снимать его сам.
Запуск (офлайн, $0):
eval/.venv/bin/python eval/role_topology/battery.py --selftest # проверка правил
eval/.venv/bin/python eval/role_topology/battery.py --corpus # прогон по corpus.jsonl
"""
from __future__ import annotations
import json
import re
import statistics
import sys
import unicodedata
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "exp16"))
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
import pymorphy3 # noqa: E402
from detect_word import decomposes as _decomposes # noqa: E402
from detect_word import translit_shape as _translit_shape # noqa: E402
from palladius import is_palladius_token, palladius_conformant # noqa: E402
_MORPH = pymorphy3.MorphAnalyzer()
# ─────────────────────────── ДАННЫЕ ЦЕЛИ (ru). Не код — таблица. ────────────────────────────
RU = {
# research/12 §9: транслит английских/японских междометий — маркер машинного перевода.
# Единичное вхождение легитимно (для YA «вау» нормален), меряется ЧАСТОТА, не факт.
"interjection_calques": ["ауч", "упс", "воу", "вау", "угх", "хах", "ара", "ох ты боже",
"оу", "йес", "хмпф", "аргх", "эйч", "уупс"],
# research/12 §10: русская норма — реплика с абзаца вводится ТИРЕ, кавычки для реплик не
# используются. Дефис вместо тире и прописная после атрибуции — английская схема.
"dialogue_dash": "",
"quote_open": "«",
"quote_close": "»",
# research/12 §11: омографы, где ё обязательна при ЛЮБОЙ политике проекта.
"yo_homographs": [("все", "всё"), ("узнаем", "узнаём"), ("совершенный", "совершённый"),
("небо", "нёбо"), ("осел", "осёл"), ("падеж", "падёж")],
# research/12 §12: маркированные неофеминитивы — регистровый сбой вне современной прозы.
"marked_feminitives": ["авторка", "докторка", "блогерка", "редакторка", "директорка",
"профессорка", "лекторка"],
}
# Источник zh: множители величин. Ошибка здесь = ошибка в фактуре, самый дорогой класс.
ZH_MAGNITUDE = {"": 10_000, "亿": 100_000_000, "": 1_000, "": 100}
_ZH_DIGITS = {"": 0, "": 1, "": 2, "": 2, "": 3, "": 4, "": 5,
"": 6, "": 7, "": 8, "": 9, "": 10}
# Русские числительные (леммы) — данные ЦЕЛИ. Нужны, чтобы «пятьсот» не читалось как потеря
# величины 500: художественный русский пишет числа словами, а исходник — цифрами и иероглифами.
_RU_NUM = {"ноль": 0, "один": 1, "два": 2, "две": 2, "три": 3, "четыре": 4, "пять": 5,
"шесть": 6, "семь": 7, "восемь": 8, "девять": 9, "десять": 10,
"одиннадцать": 11, "двенадцать": 12, "тринадцать": 13, "четырнадцать": 14,
"пятнадцать": 15, "шестнадцать": 16, "семнадцать": 17, "восемнадцать": 18,
"девятнадцать": 19, "двадцать": 20, "тридцать": 30, "сорок": 40, "пятьдесят": 50,
"шестьдесят": 60, "семьдесят": 70, "восемьдесят": 80, "девяносто": 90,
"сто": 100, "двести": 200, "триста": 300, "четыреста": 400, "пятьсот": 500,
"шестьсот": 600, "семьсот": 700, "восемьсот": 800, "девятьсот": 900}
_RU_MULT = {"тысяча": 1_000, "миллион": 1_000_000, "миллиард": 1_000_000_000}
# Пол фактонесущей величины. ВЫБРАН ЗАМЕРОМ (свип 8 конфигураций на 91 единице): при поле 10
# ложных «появившихся» 1.64 на единицу, при 100 — 0.27 при тех же 0.25 потерянных. Малые
# числительные в русском живут местоимениями («один из них»), которым в 一个 нет величины.
MIN_VALUE = 100
_RE_CYR_WORD = re.compile(r"[А-Яа-яЁё]+")
_RE_LAT_WORD = re.compile(r"[A-Za-z]+")
_RE_HAN = re.compile(r"[㐀-鿿]")
def normalize(text: str) -> str:
"""NFC + снятие комбинирующих знаков НА КИРИЛЛИЧЕСКОЙ базе (см. баннер модуля).
Зеркалит боевой `stripCombiningOnCyrillic` (`checks/sanitizer.go:746`): знак на латинской
базе (é во французском имени) сохраняется, на кириллической снимается.
"""
out, prev_cyr = [], False
for ch in unicodedata.normalize("NFC", text):
if unicodedata.category(ch) == "Mn":
if prev_cyr:
continue
out.append(ch)
continue
out.append(ch)
prev_cyr = bool(_RE_CYR_WORD.match(ch))
return "".join(out)
def words(text: str) -> list[str]:
return _RE_CYR_WORD.findall(normalize(text))
# ──────────────────────────────── проверки батареи ────────────────────────────────
def check_palladius(final: str, bank_dst: set[str]) -> dict:
"""1. Палладий-линт. research/12 не покрывает транскрипцию — источник D39.46 Z-B3 и exp16.
Кандидат в имена: слово с ПРОПИСНОЙ в середине предложения, незнакомое морфологии. Если оно
не сегментируется палладиевскими слогами это либо не-палладиевская транскрипция (нарушение
конвенции), либо порча. Строки банка проверяются отдельно: `palladius_conformant` требует
конформности КАЖДОГО кириллического слова строки (Фан Юань обе части).
"""
txt = normalize(final)
bad_text, checked = [], 0
for m in re.finditer(r"(?<![.!?…]\s)(?<!^)\b([А-ЯЁ][а-яё]{2,})", txt, re.M):
w = m.group(1)
if any(p.is_known for p in _MORPH.parse(w.lower())):
continue
# ⚠ ПЕРЕВЕДЁННОЕ имя — не нарушение конвенции, а другой класс. «Первопредок», «Виноцвет»,
# «Кровавокрылая» палладиевскими быть не обязаны: их перевели, а не транскрибировали.
# Разложимость на русские части и есть признак перевода — этот фильтр снял 6 из 13
# выживших срабатываний, и все шесть были ложными.
if _decomposes(w.lower()):
continue
checked += 1
# ⚠ Голая проверка на слоги давала 96 ложных из 2192 — все склонённые транскрипции
# («Чжэна», «Мочэнем»): русское окончание слогом Палладия не является. Порог стема здесь
# 1 слог, а не 2: кандидат УЖЕ опознан как имя, а односложные транскрипции нормальны
# (Чжэн · Фан · Сун) — при пороге 2 одно «Чжэна» давало 24 ложных.
if not _translit_shape(w, stem_min_syllables=1):
bad_text.append(w)
bad_bank = [d for d in bank_dst if _RE_CYR_WORD.search(d) and not palladius_conformant(d)]
return dict(name_candidates=checked, nonconformant_text=len(bad_text),
nonconformant_text_words=sorted(set(bad_text))[:12],
nonconformant_bank=len(bad_bank))
def check_cjk_leak(final: str, threshold: float = 0.02) -> dict:
"""7. Утечка ханьцзы. Порог НИЖЕ боевого 0.15 — это прямое требование промта Ф0.5.
Боевой `classify` флагает эхо при доле исходной письменности >0.15 (`disposition.go:224`), а
эксп-20 §5.4 нашёл живую МИКРО-утечку 19 знаков на окно (доля ~0.005), которую этот порог не
видит. Здесь считается и доля, и АБСОЛЮТНОЕ число второе и есть чувствительная метрика.
"""
t = normalize(final)
han = _RE_HAN.findall(t)
letters = [c for c in t if c.isalpha()]
share = len(han) / max(1, len(letters))
return dict(han_chars=len(han), han_share=round(share, 5),
over_probe_threshold=share > threshold,
over_prod_threshold=share > 0.15)
def check_typography(final: str) -> dict:
"""8. Русская издательская типографика. research/12 §10 (диалоги) + §11 (ё).
Считаются НАРУШЕНИЯ, а не наличие: реплика, введённая кавычкой или дефисом вместо тире;
прямые ASCII-кавычки; дефис на месте тире между пробелами. Признаки взяты дословно из
раздела «Детекция» §10 («запрет " и “ в диалогах, em-dash + пробел в начале реплики»).
"""
t = normalize(final)
lines = [l.strip() for l in t.split("\n") if l.strip()]
ascii_quotes = t.count('"') + t.count("") + t.count("")
hyphen_as_dash = len(re.findall(r"(?<= )-(?= )", t))
dash_openers = sum(1 for l in lines if l.startswith(RU["dialogue_dash"]))
# ⚠ Ёлочка в начале строки НЕ нарушение и из счёта убрана: 228 «нарушений» на 91 единице
# оказались легитимными цитатами и внутренней речью, для которых «…» — русская норма.
# Нарушение — только АНГЛИЙСКАЯ кавычка как ввод реплики (research/12 §10 «запрет " и “»).
ascii_openers = sum(1 for l in lines if l[:1] in ('"', ""))
guillemet_openers = sum(1 for l in lines if l[:1] == RU["quote_open"])
# Схема Розенталя: авторские слова после реплики идут со СТРОЧНОЙ («— …, — сказал он»).
# ⚠ Прописная сама по себе нарушением не является — имя собственное обязано быть с прописной
# (119 срабатываний на корпусе были в основном именами). Флагается только случай, когда с
# прописной стоит ИЗВЕСТНЫЙ ГЛАГОЛ РЕЧИ/ДЕЙСТВИЯ, то есть заведомо не имя.
upper_after_attr = 0
for m in re.finditer(r"[,!?…]\s+—\s+([А-ЯЁ][а-яё]+)", t):
w = m.group(1).lower()
if any(p.tag.POS == "VERB" for p in _MORPH.parse(w) if p.is_known):
upper_after_attr += 1
return dict(lines=len(lines), dash_openers=dash_openers,
guillemet_openers=guillemet_openers, ascii_openers=ascii_openers,
ascii_quotes=ascii_quotes, hyphen_as_dash=hyphen_as_dash,
upper_after_attribution=upper_after_attr,
violations=ascii_openers + ascii_quotes + hyphen_as_dash + upper_after_attr)
def check_yo(final: str) -> dict:
"""11. Консистентность ё. research/12 §11: смесь хуже любой из двух политик.
Меряются две разные вещи: (а) СМЕСЬ доля ё-содержащих слов, где ё вообще возможна;
(б) ОМОГРАФЫ без ё, где она обязательна при любой политике (все/всё, узнаем/узнаём).
"""
ws = [w.lower() for w in words(final)]
with_yo = sum(1 for w in ws if "ё" in w)
# ⚠ Сырой счёт омографов без ё был ложной метрикой (152 на 91 единице): «все» без ё чаще
# всего ПРАВИЛЬНО — это множественное «все», а не «всё». Различить их без разбора контекста
# нельзя, поэтому меряется не омограф, а ПОЛИТИКА: текст, который использует ё где-то, но не
# везде, непоследователен (research/12 §11: «смесь хуже любой из двух политик»). Омограф
# считается нарушением ТОЛЬКО в таком тексте — там ё уже объявлена автором как употребляемая.
# ⚠ СЧЁТ ОМОГРАФОВ СНЯТ С РОЛИ НАРУШЕНИЯ — замером, а не рассуждением: из 152 срабатываний на
# 91 единице 130 дало обычное множественное «все» и 22 — «небо» в прямом значении. То есть
# правило почти целиком ложное, и различить «все/всё» без разбора контекста нельзя.
# Остаётся то, что детерминированно ИЗМЕРИМО: политика ё и её единообразие между единицами
# (research/12 §11: «смесь хуже любой из двух политик»). Омографы печатаются справочно.
homograph_seen = sum(1 for w in ws if any(w == bare for bare, _ in RU["yo_homographs"]))
return dict(words=len(ws), words_with_yo=with_yo,
yo_policy="есть" if with_yo else "нет", homographs_seen=homograph_seen)
def check_translationese(final: str) -> dict:
"""9. Translationese-прокси. research/12 §9 (междометия-транслит) + §12 (неофеминитивы).
ГРАНИЦА, объявленная заранее: «длины предложений против НАТИВНОГО ру-корпуса» промт просит,
но нативного русского референс-корпуса в дереве нет, и подставлять чужую константу нельзя
(тот же класс ошибки, что снятый жанровый словарь D39.47). Поэтому распределение длин
печатается КАК ЕСТЬ и сравнивается МЕЖДУ АРМАМИ для бейк-оффа этого достаточно, для
абсолютного вердикта «это translationese» нет, и так и читается.
"""
# ⚠ Матчинг ТОЛЬКО по границам слова. Подстрочный `in` давал 77 «ара» (внутри «барабан»,
# «характер») и 13 «ауч» (внутри «паучьей») на 91 единице — то есть метрика мерила бы шум.
toks = Counter(w.lower() for w in words(final))
calques = {c: toks[c] for c in RU["interjection_calques"] if toks[c]}
fem = {f: toks[f] for f in RU["marked_feminitives"] if toks[f]}
sents = [s for s in re.split(r"(?<=[.!?…])\s+", normalize(final)) if s.strip()]
lens = [len(s.split()) for s in sents] or [0]
return dict(sentences=len(sents), sent_len_median=statistics.median(lens),
sent_len_p90=sorted(lens)[int(0.9 * (len(lens) - 1))],
calque_interjections=calques, marked_feminitives=fem)
def check_numbers(source: str, final: str) -> dict:
"""6. Перенос чисел с нормализацией 万/亿. research/12 не покрывает; источник — бэклог 12
(«HARD-value-детектор /») и D39.79.
Из источника извлекаются величины в двух записях арабской и китайской иероглифической
(三万 = 30000). Из перевода арабские и русские числительные-множители. Сравниваются
МНОЖЕСТВА величин: пропавшая или появившаяся величина есть дефект фактуры.
Проверка НЕ ловит переставленные величины при совпадающем множестве объявлено.
"""
src_vals = set(int(x) for x in re.findall(r"\d+", source))
# Разбор источника требует ЯВНОЙ ЦИФРЫ перед множителем. Голое 十 намеренно НЕ берётся:
# в китайском оно живёт в неколичественных идиомах (十分 «весьма», 十足 «полный»), и его
# добор поднял ложные потери 0.25→0.93 на единицу при выигрыше 0.27→0.10 по выдуманным —
# то есть сделал метрику хуже. Замер вариантов — в отчёте, не на глаз.
for m in re.finditer(r"([零一二两三四五六七八九十]+)\s*([万亿千百十])", source):
digits, mag = m.group(1), m.group(2)
cur = 0
for ch in digits:
v = _ZH_DIGITS.get(ch, 0)
cur = cur * 10 if v == 10 else cur + v
src_vals.add((cur or 1) * (10 if mag == "" else ZH_MAGNITUDE[mag]))
dst_vals = set(int(x) for x in re.findall(r"\d+", normalize(final)))
for m in re.finditer(r"(\d+)\s*(тысяч\w*|миллион\w*)", normalize(final), re.I):
mult = 1_000 if m.group(2).lower().startswith("тысяч") else 1_000_000
dst_vals.add(int(m.group(1)) * mult)
# ⚠ РУССКИЕ ЧИСЛИТЕЛЬНЫЕ СЛОВАМИ. Без них проверка объявляла потерянными 64 величины на 91
# единице — почти все ложно: русский художественный текст пишет «пятьсот», а не «500».
# Разбор идёт по ЛЕММЕ (склонение снимает морфология), накопление — как в русском счёте:
# сотни+десятки+единицы складываются, множитель умножает накопленное.
acc, cur = 0, 0
for w in words(final):
lemma = _MORPH.parse(w.lower())[0].normal_form
if lemma in _RU_NUM:
cur += _RU_NUM[lemma]
continue
if lemma in _RU_MULT:
acc += max(cur, 1) * _RU_MULT[lemma]
cur = 0
continue
if cur or acc:
dst_vals.add(acc + cur)
acc = cur = 0
if cur or acc:
dst_vals.add(acc + cur)
# ⚠ ПОЛ ВЕЛИЧИНЫ. Без него метрика мерила шум: «выдуманными» выходили 1·2·3·4·5 — это русские
# числительные в местоименной роли («один из них»), которым в 一个 источника не соответствует
# никакая величина. Замер на 91 единице: 82 случая «1», 65 «2», 51 «3». Порог 10 оставляет
# ФАКТОНЕСУЩИЕ величины (ранги, расстояния, счёт камней) — ровно тот класс, ради которого
# заведена строка 12 бэклога («HARD-value-детектор 成/万»).
# Цена объявлена: потеря величины 19 этой проверкой НЕ ловится.
src_big = {v for v in src_vals if v >= MIN_VALUE}
dst_big = {v for v in dst_vals if v >= MIN_VALUE}
return dict(src_values=len(src_big), dst_values=len(dst_big),
lost=sorted(src_big - dst_big)[:8], invented=sorted(dst_big - src_big)[:8],
lost_n=len(src_big - dst_big), invented_n=len(dst_big - src_big))
def check_ty_vy(final: str) -> dict:
"""5. Ты/вы. research/12 §2: переход ты↔вы внутри сцены — СОБЫТИЕ, а не шум.
Полная атрибуция пар «ктокому» требует speaker-ID (research/15: на zh 85% спикер, адресат
на 1015 пп хуже) и здесь честно НЕ делается. Меряется наблюдаемое детерминированно: смешение
обеих форм в ОДНОЙ единице. Это нижняя граница дефекта смешение между разными собеседниками
легитимно, поэтому число читается как сигнал к просмотру, а не как счёт ошибок.
"""
ws = [w.lower() for w in words(final)]
ty = sum(1 for w in ws if w in ("ты", "тебя", "тебе", "тобой", "твой", "твоя", "твоё",
"твои", "твоего", "твоей"))
vy = sum(1 for w in ws if w in ("вы", "вас", "вам", "вами", "ваш", "ваша", "ваше",
"ваши", "вашего", "вашей"))
return dict(ty=ty, vy=vy, mixed_in_unit=bool(ty and vy))
def check_gender(final: str, cards: dict[str, str]) -> dict:
"""4. Род глагола прош. вр. против карточек. research/12 §1 — «жалоба №1 читателей MTL».
Карточка = {имя: 'male'|'female'}. Ищется «Имя + глагол прош. вр.» и сверяется род. Работает
только по ЯВНОЙ близости имени и глагола (окно 3 слова): местоименные и удалённые согласования
требуют кореференции и объявлены вне охвата. Без карточек проверка возвращает нули это
вход строки 82 бэклога, а не заглушка.
"""
if not cards:
return dict(checked=0, mismatched=0, cases=[])
t = normalize(final)
checked, bad = 0, []
for name, gender in cards.items():
for m in re.finditer(re.escape(name) + r"((?:\s+\S+){0,3})", t):
for w in _RE_CYR_WORD.findall(m.group(1)):
for p in _MORPH.parse(w.lower()):
if p.tag.POS == "VERB" and "past" in str(p.tag):
g = "female" if "femn" in str(p.tag) else (
"male" if "masc" in str(p.tag) else None)
if g:
checked += 1
if g != gender:
bad.append((name, w, gender, g))
break
return dict(checked=checked, mismatched=len(bad), cases=bad[:8])
def check_repeat_consistency(units: list[dict]) -> dict:
"""2+3. Повтор-консистентность и коллизии, КРОСС-ЕДИНИЧНО.
Единственная метрика батареи, которая работает не на одной единице, а на всём прогоне
и ровно она отвечает на продуктовую цель «консистентные термины на всю книгу». Считается по
строкам банка: сколько РАЗНЫХ русских строк встретилось для одного исходного терма (расхождение)
и сколько разных исходных термов схлопнулось в одну русскую строку (слипание).
"""
src2dst: dict[str, Counter] = defaultdict(Counter)
for u in units:
for src, dst in u.get("term_pairs", []):
src2dst[src][dst] += 1
divergent = {s: dict(c) for s, c in src2dst.items() if len(c) > 1}
dst2src: dict[str, set] = defaultdict(set)
for s, c in src2dst.items():
for d in c:
dst2src[d].add(s)
merged = {d: sorted(ss) for d, ss in dst2src.items() if len(ss) > 1}
return dict(terms=len(src2dst), divergent=len(divergent), merged=len(merged),
divergent_examples=dict(list(divergent.items())[:5]),
merged_examples=dict(list(merged.items())[:5]))
def check_noop(draft: str, final: str) -> dict:
"""10. No-op метрика (стандарт APE): что второй проход НЕ ТРОНУЛ и что он изменил.
Эксп-20 §4 намерил, что редактор переписывает 65% слов черновика. Само по себе это ни хорошо,
ни плохо важно, покупает ли изменение качество. Поэтому дельта качества обязана считаться
ОТДЕЛЬНО на изменённых и неизменённых сегментах (иначе выигрыш на одном сегменте маскируется
порчей на другом). Здесь считается разметка сегментов; сама дельта в связке с батареей.
"""
import difflib # noqa: PLC0415
from align import split_ru # noqa: PLC0415
a, b = split_ru(normalize(draft)), split_ru(normalize(final))
sm = difflib.SequenceMatcher(None, a, b, autojunk=False)
kept = sum(bl.size for bl in sm.get_matching_blocks())
dw, fw = normalize(draft).split(), normalize(final).split()
smw = difflib.SequenceMatcher(None, dw, fw, autojunk=False)
kept_w = sum(bl.size for bl in smw.get_matching_blocks())
return dict(draft_sentences=len(a), final_sentences=len(b), sentences_untouched=kept,
sentence_untouched_share=round(kept / max(1, len(a)), 4),
word_untouched_share=round(kept_w / max(1, len(dw)), 4))
# ──────────────────────────────── драйвер ────────────────────────────────
@dataclass
class Unit:
source: str
draft: str
final: str
cards: dict = field(default_factory=dict)
bank_dst: set = field(default_factory=set)
term_pairs: list = field(default_factory=list)
def run_unit(u: Unit) -> dict:
return {
"palladius": check_palladius(u.final, u.bank_dst),
"cjk_leak": check_cjk_leak(u.final),
"typography": check_typography(u.final),
"yo": check_yo(u.final),
"translationese": check_translationese(u.final),
"numbers": check_numbers(u.source, u.final),
"ty_vy": check_ty_vy(u.final),
"gender": check_gender(u.final, u.cards),
"noop": check_noop(u.draft, u.final) if u.draft else None,
}
def selftest() -> int:
"""Проверка ПРАВИЛ на синтетике с известным ответом. Правило без такого теста — это мнение."""
fails = 0
def ck(name: str, ok: bool, got: object = "") -> None:
nonlocal fails
if not ok:
fails += 1
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {name}" + ("" if ok else f"{got}"))
ck("нормализация: ударение на кириллице снимается",
normalize("бо́льшим") == "большим", normalize("бо́льшим"))
ck("нормализация: диакритика на латинице сохраняется",
"́" in normalize("Amélie") or "é" in normalize("Amélie"))
ck("токенизация: слово с ударением НЕ разрезано",
words("с бо́льшим напором") == ["с", "большим", "напором"], words("с бо́льшим напором"))
t = check_typography('«Я устала», - сказала она.\n"Пойдём домой."')
ck("типографика: прямые кавычки и дефис-вместо-тире пойманы",
t["ascii_quotes"] >= 2 and t["hyphen_as_dash"] == 1, t)
t2 = check_typography("— Я устала, — сказала она.\n— Пойдём домой.")
ck("типографика: правильный диалог нарушений не даёт",
t2["violations"] == 0 and t2["dash_openers"] == 2, t2)
# ⚠ Первая редакция этого теста утверждала ПОТЕРЮ на «三万 → тридцать тысяч» и проходила,
# пока числительные словами не разбирались. Это была проверка бага, а не правила: русский
# художественный текст пишет число словом, и это НЕ потеря. Тест переписан.
n = check_numbers("他有三万块灵石,还有 250 枚。", "У него тридцать тысяч камней и 250 штук.")
ck("числа: 三万 сходится с «тридцать тысяч», ложной потери нет",
n["lost_n"] == 0 and n["invented_n"] == 0, n)
n2 = check_numbers("他有 250 枚。", "У него 250 штук.")
ck("числа: совпадение не даёт ложных", n2["lost_n"] == 0 and n2["invented_n"] == 0, n2)
n3 = check_numbers("他有三万块灵石。", "У него было немного камней.")
ck("числа: НАСТОЯЩАЯ потеря величины поймана", n3["lost"] == [30000], n3)
n4 = check_numbers("他有一些石头。", "У него было пятьсот камней.")
ck("числа: выдуманная величина поймана", n4["invented"] == [500], n4)
c = check_cjk_leak("Обычный русский текст с одним 蛊 знаком.")
ck("утечка: один иероглиф виден абсолютным счётом, но не боевым порогом",
c["han_chars"] == 1 and not c["over_prod_threshold"], c)
tr = check_translationese("Ауч! Это было больно. Упс.")
ck("translationese: транслит-междометия найдены",
set(tr["calque_interjections"]) >= {"ауч", "упс"}, tr)
g = check_gender("Фан Юань подошёл к двери. Мо Янь сказала тихо.",
{"Фан Юань": "male", "Мо Янь": "female"})
ck("род: согласованные формы не флагаются", g["checked"] >= 2 and g["mismatched"] == 0, g)
g2 = check_gender("Мо Янь подошёл к двери.", {"Мо Янь": "female"})
ck("род: рассогласование поймано", g2["mismatched"] == 1, g2)
v = check_ty_vy("— Ты придёшь? — Вы ошибаетесь.")
ck("ты/вы: смешение в одной единице замечено", v["mixed_in_unit"], v)
r = check_repeat_consistency([
{"term_pairs": [("", "гу"), ("方源", "Фан Юань")]},
{"term_pairs": [("", "червь"), ("方正", "Фан Юань")]},
])
ck("консистентность: расхождение и слипание найдены",
r["divergent"] == 1 and r["merged"] == 1, r)
nn = check_noop("Он ушёл. Она осталась.", "Он ушёл. Она стояла на месте.")
ck("no-op: нетронутое предложение посчитано",
nn["sentences_untouched"] == 1 and nn["draft_sentences"] == 2, nn)
print(f"\n{'ПРАВИЛА БАТАРЕИ ЧИСТЫ' if not fails else f'ПРОВАЛОВ: {fails}'}")
return fails
def run_corpus() -> None:
"""Прогон по 91 реальной единице пакета-6 — проверка, что батарея не падает и не флагает всё."""
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()]
agg: Counter = Counter()
per_unit = []
for u in units:
if not (u.get("final") or "").strip():
continue
res = run_unit(Unit(source=u.get("source") or "", draft=u.get("draft") or "",
final=u["final"]))
per_unit.append(res)
agg["units"] += 1
agg["typo_violations"] += res["typography"]["violations"]
agg["han_chars"] += res["cjk_leak"]["han_chars"]
agg["han_units"] += bool(res["cjk_leak"]["han_chars"])
agg["pal_nonconf"] += res["palladius"]["nonconformant_text"]
agg["pal_candidates"] += res["palladius"]["name_candidates"]
agg["num_lost"] += res["numbers"]["lost_n"]
agg["num_invented"] += res["numbers"]["invented_n"]
agg["calque_units"] += bool(res["translationese"]["calque_interjections"])
agg["tyvy_mixed"] += res["ty_vy"]["mixed_in_unit"]
agg["yo_mixed_policy"] += (res["yo"]["yo_policy"] == "есть")
print(f"единиц с финалом: {agg['units']}\n")
print(f"{'метрика':40s}{'всего':>9s}{'на единицу':>12s}")
print("-" * 61)
for label, key in (("нарушений типографики", "typo_violations"),
("ханьцзы в финале (знаков)", "han_chars"),
("единиц с ханьцзы", "han_units"),
("кандидатов в имена проверено", "pal_candidates"),
("не-палладиевских среди них", "pal_nonconf"),
("потерянных величин", "num_lost"),
("появившихся величин", "num_invented"),
("единиц с транслит-междометиями", "calque_units"),
("единиц со смешением ты/вы", "tyvy_mixed"),
("единиц с политикой ё", "yo_mixed_policy")):
print(f"{label:40s}{agg[key]:9d}{agg[key] / max(1, agg['units']):12.2f}")
noops = [r["noop"]["word_untouched_share"] for r in per_unit if r["noop"]]
if noops:
print(f"\nno-op: медиана доли слов черновика, доживших до финала = "
f"{statistics.median(noops):.3f} (n={len(noops)})")
out = Path.home() / "books" / "role-topology"
out.mkdir(parents=True, exist_ok=True)
(out / "battery-corpus.json").write_text(json.dumps(dict(agg), ensure_ascii=False, indent=1),
encoding="utf-8")
if __name__ == "__main__":
if "--corpus" in sys.argv:
run_corpus()
else:
sys.exit(selftest())

View file

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Ф0.4 детектор №1 — «ВЫДУМАННОЕ СЛОВО» (класс k3 размеченного набора пакета-6).
Зачем он существует. Эксп-20 §3.3 намерил, что точечная починка по флагу снимает дефект за
$0.0002 но мерил при ИДЕАЛЬНОЙ детекции: флаги брались из посадок, а не от детектора. Промт
эксп-21 (строка 13) это и называет главной оговоркой: «детекторов выдуманное слово и смысловая
инверсия не существует». Арм C фазы 2б без такого детектора неизмерим его «реальный» режим
некому питать. Здесь строится прототип ПОЛИГОНА (не движковое решение: пин строки 46 «Hunspell в
движок не строить» не задет это Python-зонд, как весь eval/).
Земля. `~/books/gu-zhenren/labels/labels/k3.jsonl` 2177 размеченных словотипов из шести реальных
прогонов, 10 из них помечены `defect`. Разметка сделана ЧУЖОЙ сессией (пакет-6, 26.07) до и вне
этого экспа второй контур по отношению ко мне выполнен построением, а не моей рукой.
БАЗЛАЙН, который надо бить, лежит там же в `metrics.json`: боевые чекеры дают на k3
**precision 1.0, recall 0.1** (ловят 1 дефект из 10).
Устройство. Слои проверяются НАКОПИТЕЛЬНО, и каждый печатается отдельной строкой норма D39.46(а)
(«у каждого измеряемого фактора обязан быть арм БЕЗ него») применена к слоям детектора: вклад
каждого виден, а не заявлен.
С0 нет в словаре pymorphy3 сырой сигнал
С1 + банк/канон книги OK подписанное имя и его склонённые формы не выдуманы
С2 + палладиевская транскрипция OK транскрипция ВНЕ банка тоже не выдуманное слово
С3 + декомпозиция OK продуктивная деривация/сложение (высоко+рангового)
Разделяющий принцип, на котором стоит С3. Выдуманные слова этого класса ПОРЧА одного реального
слова (впердивпереди, подамлюподавлю): они не разбираются на известные части. Легитимные
незнакомые словарю слова наоборот, СЛОЖЕНИЯ и приставочные производные известных частей. То есть
разделяет не редкость слова, а разложимость. Этот принцип пар-нейтрален: приставки и соединительные
гласные данные русской ЦЕЛИ (`internal/lang/data/target-ru.txt` держит ту же категорию фактов),
а не факт о китайской книге ревью-вопрос §0.1 («заработает ли на паре, которой в репо нет»)
отвечается «да»: сменится файл цели, не код. Источники С1/С2 тоже данные: банк книги и
пар-таблица Палладия (`configs/langpacks/zh-ru/palladius.txt`).
Первая редакция С3 была ЧИСТЫМ УБЫТКОМ и это поймано исполнением, а не рассуждением: жадный
цикл «известное начало + любой хвост» гасил 6 настоящих дефектов из 10 (силённый «сил»+«ённый»),
уводя recall 0.900.30. Цикл снят; снятие приставки требует, чтобы ХВОСТ был известным словом.
Запуск (офлайн, $0): eval/.venv/bin/python eval/role_topology/detect_word.py
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "exp16"))
import pymorphy3 # noqa: E402
import yaml # noqa: E402
from palladius import is_palladius_token # noqa: E402
LABELS = Path.home() / "books" / "gu-zhenren" / "labels" / "labels" / "k3.jsonl"
_MORPH = pymorphy3.MorphAnalyzer()
# Русские приставки и первые части сложений. Данные ЦЕЛИ (ru), не книги и не пары: для →de/→en
# сменится этот список, Go-кода в этом прототипе нет вовсе. Порядок — длинные первыми (жадный съём).
PREFIXES = sorted([
"высоко", "низко", "средне", "полу", "сверх", "около", "противо", "лже", "псевдо", "квази",
"анти", "супер", "ультра", "архи", "экстра", "много", "мало", "перво", "едино", "обще",
"само", "взаимо", "равно", "разно", "все", "одно", "двух", "трёх", "трех", "четырёх",
"четырех", "пяти", "шести", "семи", "восьми", "девяти", "десяти", "пере", "пред", "через",
"чрез", "около", "недо", "небез", "небес", "меж", "между", "сопро", "разо", "воз", "вос",
"низ", "нис", "без", "бес", "раз", "рас", "из", "ис", "над", "под", "при", "про", "пре",
"трудно", "легко", "быстро", "медленно", "древне", "ново", "старо", "прото",
"не", "по", "за", "на", "об", "от", "до", "вы", "в", "с", "у", "о",
], key=len, reverse=True)
# Продуктивные словообразовательные суффиксы русского. ЗАКРЫТЫЙ список — в этом его смысл:
# произвольный хвост превращает слой в «известное начало + что угодно», а это ровно та редакция,
# которая гасила настоящие дефекты. Данные ЦЕЛИ, не пары и не книги.
SUFFIXES = ("ов", "ев", "ск", "лив", "чив", "ист", "оват", "еват", "аст", "аль", "ичн", "иан",
"енн", "онн", "ическ", "ическ", "инск", "овск", "евск", "ушк", "юшк", "оньк", "еньк")
def known(word: str) -> bool:
"""Слово есть в морфологическом словаре (любой разбор — словарный, а не предсказанный)."""
return any(p.is_known for p in _MORPH.parse(word))
def translit_shape(word: str, stem_min_syllables: int = 2) -> bool:
"""Форма палладиевской транскрипции — с учётом РУССКОГО СКЛОНЕНИЯ поверх неё.
Голая проверка на слоги ломается на «цунях»/«чжэнем»: русское окончание не является
палладиевским слогом. Поэтому пробуем и сам токен, и его усечения на 13 знака с конца
ровно длина русской флексии. Усечённый стем обязан остаться 3 знаков, иначе от слова
остаётся огрызок, который сегментируется чем угодно.
`stem_min_syllables` сколько слогов требовать от УСЕЧЁННОГО стема, и это не косметика:
2 (по умолчанию) для суждения о ПРОИЗВОЛЬНОМ слове. Порог 1 там слишком щедр: замер на
k3 показал, что он всасывает выдуманные слова («подамлю»).
1 для кандидата, о котором УЖЕ известно, что он имя (прописная + нет в словаре).
Односложные транскрипции норма ономастики (Чжэн · Фан · Сун), и порог 2 давал 24
ложных срабатывания на одном «Чжэна» в палладий-линте батареи.
Разные пороги для разных вопросов осознанное решение, а не расхождение.
"""
if is_palladius_token(word, min_syllables=1):
return True
w = word.lower()
return any(len(w) - k >= 3 and is_palladius_token(w[:-k], min_syllables=stem_min_syllables)
for k in (1, 2, 3))
def decomposes(word: str) -> bool:
"""Слово = продуктивная приставка/первая часть + ИЗВЕСТНОЕ СЛОВО.
Требование «хвост известен» несущее, а не косметическое: без него слой гасит настоящие
дефекты (см. баннер). Хвост берётся длиной 4, чтобы «си»+«лённый» и подобные обрезки не
считались разбором. Соединительная гласная (о/е в «высок-О-ранговый») и орфографический
ъ/ь на шве («трёх-Ъ-ярусном») снимаются одной попыткой каждая иначе «женьшеневого» и
«первокамень» не собираются. Рекурсия не заводится: цепочки приставок в этом классе нет,
а каждая лишняя степень свободы стоит recall.
"""
w = word.lower().replace("ё", "е")
for p in PREFIXES:
if not w.startswith(p):
continue
tail = w[len(p):]
for t in (tail, tail.lstrip("ъь"), "о" + tail, "е" + tail):
if len(t) >= 4 and known(t):
return True
# Суффиксальная деривация от известного корня: «женьшен-евого» ← женьшень. В отличие от снятой
# первой редакции хвост здесь не произвольный, а из закрытого списка продуктивных суффиксов —
# именно это не даёт слою съесть «сил-ённый».
for cut in range(len(w) - 3, 3, -1):
head, suf = w[:cut], w[cut:]
if not any(suf.startswith(s) for s in SUFFIXES):
continue
if known(head) or known(head + "ь") or known(head + "й") or known(head + "а"):
return True
return False
def verdict(word: str, bank: frozenset[str]) -> tuple[bool, str]:
"""(флаг «выдуманное», причина-непропуска). Причина печатается — вердикт обязан быть читаемым."""
if known(word):
return False, "в словаре"
if word.lower() in bank:
return False, "строка банка/канона книги"
if translit_shape(word):
return False, "палладиевская транскрипция"
if decomposes(word):
return False, "разложимо на известные части"
return True, "-"
def score(rows: list[dict], layers: int, bank: frozenset[str]) -> dict:
"""Прогон детектора при УРЕЗАННОМ числе слоёв — так меряется вклад каждого слоя, а не заявляется."""
tp = fp = fn = tn = 0
fp_words, fn_words = [], []
for r in rows:
w = r["word"]
flag = not known(w)
if flag and layers >= 1 and w.lower() in bank:
flag = False
if flag and layers >= 2 and translit_shape(w):
flag = False
if flag and layers >= 3 and decomposes(w):
flag = False
gold = r["label"] == "defect"
if flag and gold:
tp += 1
elif flag and not gold:
fp += 1
fp_words.append(w)
elif not flag and gold:
fn += 1
fn_words.append(w)
else:
tn += 1
return dict(tp=tp, fp=fp, fn=fn, tn=tn, fp_words=fp_words, fn_words=fn_words,
precision=(tp / (tp + fp) if tp + fp else None),
recall=(tp / (tp + fn) if tp + fn else None))
def load_bank() -> frozenset[str]:
"""Вайтлист имён/термов книги: dst и склонённые формы из сида + подписанные строки глоссария.
Берутся ОТДЕЛЬНЫЕ кириллические слова, а не строки целиком: банк хранит «Фан Юаня», а детектор
судит словотип. `decl.forms` сида закрывает падежи там, где они выписаны; чего в сиде нет,
подхватывает слой С2 (форма транскрипции).
"""
words: set[str] = set()
def eat(s: str) -> None:
for w in re.findall(r"[А-Яа-яЁё]+", s or ""):
if len(w) >= 2:
words.add(w.lower())
seed = Path.home() / "books" / "gu-zhenren" / "guzhenren-seed-v2.yaml"
if seed.exists():
data = yaml.safe_load(seed.read_text(encoding="utf-8")) or {}
for t in data.get("terms", []) or []:
eat(t.get("dst", ""))
for f in ((t.get("decl") or {}).get("forms") or []):
eat(f)
k6 = LABELS.parent / "k6.jsonl"
if k6.exists():
for line in k6.read_text(encoding="utf-8").splitlines():
if line.strip():
r = json.loads(line)
if r.get("label") == "ok":
eat(r.get("dst", ""))
return frozenset(words)
def main() -> None:
rows = [json.loads(l) for l in LABELS.read_text(encoding="utf-8").splitlines() if l.strip()]
pos = sum(1 for r in rows if r["label"] == "defect")
bank = load_bank()
print(f"земля: {len(rows)} словотипов, дефектов {pos}, чистых {len(rows) - pos}")
print(f"вайтлист банка/канона: {len(bank)} словоформ")
print("базлайн боевых чекеров (labels/metrics.json): precision 1.0000 recall 0.1000 fp 0\n")
print(f"{'слой':38s}{'tp':>4s}{'fp':>5s}{'fn':>4s}{'precision':>11s}{'recall':>9s}")
print("-" * 71)
names = ["С0 нет в словаре", "С1 + банк/канон", "С2 + палладий", "С3 + декомпозиция"]
last = None
for i, nm in enumerate(names):
s = score(rows, i, bank)
p = f"{s['precision']:.4f}" if s["precision"] is not None else ""
r = f"{s['recall']:.4f}" if s["recall"] is not None else ""
print(f"{nm:38s}{s['tp']:4d}{s['fp']:5d}{s['fn']:4d}{p:>11s}{r:>9s}")
last = s
print("\nвыжившие ложные срабатывания:", ", ".join(sorted(last["fp_words"])) or "нет")
print("пропущенные дефекты:", ", ".join(sorted(last["fn_words"])) or "нет")
# Ущерб от ложного флага — ОТДЕЛЬНОЕ число, как требует промт: ремонт по ложному флагу правит
# здоровый текст, то есть портит. Считаем в терминах арма C: сколько починок будет ложными.
n_flag = last["tp"] + last["fp"]
if n_flag:
print(f"\nущерб: из {n_flag} флагов ЛОЖНЫХ {last['fp']} "
f"({last['fp'] / n_flag:.0%}) — столько починок арма C правили бы здоровое слово")
# ВЫДЕЛЕННАЯ ВАЛИДАЦИЯ. Слои выше настраивались, глядя на те же 2177 строк, на которых и
# меряются, — это подгонка, и одних in-sample чисел мало. Посадки k1 пробы 18 построены ЧУЖОЙ
# сессией под другую задачу и в настройке не участвовали ⇒ годятся как held-out.
# ⚠ Набор ЛЕГЧЕ живого: посадки сконструированы заведомо не-словами, а реальные ошибки моделей
# бывают ближе к настоящему слову. Читать как верхнюю границу, не как оценку прода.
sys.path.insert(0, str(REPO / "eval" / "bank_arbitration"))
import editor_wire_probe as WP # noqa: E402,PLC0415
plants = [w for rows in WP.DEFECTS.values() for cls, old, new, _, _ in rows if cls == "k1"
for w in new.split() if w not in old.split()]
hit = sum(1 for w in plants if verdict(w, bank)[0])
print(f"\nвыделенная валидация (посадки k1 пробы 18, в настройке не участвовали): "
f"{hit}/{len(plants)} = {hit / len(plants):.2f}")
out = Path.home() / "books" / "role-topology"
out.mkdir(parents=True, exist_ok=True)
(out / "detect-word-k3.json").write_text(
json.dumps({**last, "holdout_plants": [hit, len(plants)]}, ensure_ascii=False, indent=1),
encoding="utf-8")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""ВАХТА РЕЕСТРА 108 — пере-проба маппинга `reasoning_effort` у `deepseek-v4-pro`.
Почему это первый платный шаг пака. Вся эмпирика экспов 1820 стоит на квирке §3а («на pro ручка
`low` не работает, маппится в `high`»). Реестр загейченных триггеров (строка 108 бэклога) поставил
дату-триггер: вендор объявил смену маппинга эффорта у pro на «early August 2026», и триггер ПРОШЁЛ.
Замер 05.08 попытался его снять, получил «диапазоны не пересекаются при n=3» и был ОТОЗВАН
адверсариальным ревью того же дня: нулевой контраст (два блока ДЕФОЛТА на побайтно одном запросе)
разделялся так же (p=0.100, тот же размер эффекта ×1.47), то есть решающее правило срабатывало при
ОТСУТСТВИИ вмешательства.
Дизайн задан не мной, а хвостом того отзыва (`00-provider-quirks.md` §3б, «Что нужно»): интерливинг
дефолт/`low` в ОДНОМ блоке (защита от временного дрейфа 6 дефолтных розыгрышей одного запроса
уехали 195215720 знаков размышления строго монотонно) и n10 на арм.
ОБЪЯВЛЕННОЕ ОТКЛОНЕНИЕ ОТ ПРОШЛОЙ ПРОБЫ: вход КОМПАКТНЕЕ (~800 токенов против 2124).
Основание арифметика потолка, посчитанная по сырью, а не на глаз: медиана вызова прошлой пробы
$0.004573, и 24 вызова дали бы $0.11 при потолке вахты $0.05. Клейм под проверкой («вендорский
маппинг эффорта») от размера входа не зависит, а quirks §7 показывает, что размышление сильно
масштабируется длиной плотного ханьского входа компактный вход даёт БОЛЬШЕ розыгрышей на доллар,
то есть больше мощности. Сравнимость с отозванными числами прошлой пробы при этом теряется и она
не нужна: проба самодостаточна, оба арма едут на побайтно одном входе.
Гардрейл: thinking у DeepSeek НЕ отключается ни при каких условиях (эхо-мина, CLAUDE.md). Здесь
меняется только УРОВЕНЬ эффорта, тумблер не трогается.
Запуск:
eval/.venv/bin/python eval/role_topology/effort_watch.py --pilot # 2 вызова, проекция цены
eval/.venv/bin/python eval/role_topology/effort_watch.py # полный блок
"""
from __future__ import annotations
import hashlib
import json
import statistics
import sys
import time
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
# ⚠ ПОРЯДОК ВСТАВОК ОБРАТНЫЙ ЖЕЛАЕМОМУ ПРИОРИТЕТУ: `insert(0, …)` кладёт последний путь ПЕРВЫМ.
# В `eval/editor_contract/` лежит СВОЙ `probe.py` без функции `render`, и при обратном порядке он
# затеняет нужный `editor_harness/probe.py` — эту же ловушку документирует самопроверка эксп-20.
sys.path.insert(0, str(REPO / "eval" / "editor_contract"))
sys.path.insert(0, str(REPO / "eval" / "bank_arbitration"))
sys.path.insert(0, str(REPO / "eval" / "editor_harness"))
import editor_wire_probe as P # noqa: E402
import probe as Q # noqa: E402 только рендер промпта
# ⚠ ВЫЗОВ ИДЁТ ЧЕРЕЗ `editor_wire_probe.call`, а НЕ через `editor_harness.probe.call`, и это не
# вкусовщина: у второго нет параметра `extra`, а его `think_low` для DeepSeek — no-op (ручка там
# реализована только для провайдеров с `control: effort_none`, `probe.py:170-180`). Первая
# редакция этого файла звала именно его и упала бы на первом вызове; хуже того, при другой
# сигнатуре она молча прогнала бы ОБА арма одинаково и «не нашла разницы». Поймано чтением кода
# до запуска — ровно то, ради чего мандат самопроверки требует ревью ИСПОЛНЕНИЕМ.
MODEL = "deepseek-v4-pro"
N_PER_ARM = 12
CEILING_USD = 0.05 # потолок вахты из промта эксп-21
OUT = Path.home() / "books" / "role-topology"
OUT.mkdir(parents=True, exist_ok=True)
# ⚠ `P.RAW` подменяется НЕ на импорте, а в `main()` ПОСЛЕ сборки запроса: из того же каталога
# читаются готовые черновики пробы 18 (`draft_of` → `inj-w*-B.json`), и ранняя подмена ломала
# чтение. Тоже поймано до первого платного вызова.
def build_request() -> list[dict]:
"""Побайтно ОДИН запрос для всех розыгрышей обоих армов. Реалистичный редакторский вызов.
Берётся боевой редакторский промпт и КОРОТКОЕ окно так вызов остаётся тем же классом работы,
что в проде, но стоит втрое дешевле длинного. Текст книги в репозиторий не попадает: он
читается из `~/books` тем же кодом, что и остальные пробы.
"""
from inject_probe import pick_windows # noqa: PLC0415
_, src, _ = pick_windows()[5] # самое короткое окно набора
draft = P.draft_of(5)
# Урезаем до первых ~1200 знаков источника и соответствующей доли черновика: цель — компактный,
# но связный редакторский вход, а не полное окно.
src, draft = src[:1200], draft[:1600]
sys_msg, user = Q.render(Q.CONTRACTS["full"], src, draft)
return [{"role": "system", "content": sys_msg}, {"role": "user", "content": user}]
def main() -> None:
pilot = "--pilot" in sys.argv
msgs = build_request()
body = json.dumps(msgs, ensure_ascii=False, sort_keys=True).encode("utf-8")
sha = hashlib.sha256(body).hexdigest()[:12]
approx_in = sum(len(m["content"]) for m in msgs) // 3
print(f"запрос sha {sha} · ~{approx_in} токенов входа (оценка по знакам)")
print(f"арм A = вендор-дефолт (параметр НЕ шлётся) · арм B = reasoning_effort:'low'")
print(f"порядок ИНТЕРЛИВНЫЙ (A,B,A,B,…) — защита от временного дрейфа, quirks §3б\n")
P.RAW = OUT # см. комментарий у OUT: подмена только ПОСЛЕ сборки запроса
P.slug_check() # гардрейл CLAUDE.md: слаг сверяется живым /models в день запуска
cl = P.client()
rows: list[dict] = []
spent = 0.0
n = 1 if pilot else N_PER_ARM
stop = False
for i in range(n):
for arm, effort in (("default", None), ("low", "low")):
tag = f"ew-{arm}-r{i + 1}"
f = OUT / f"{tag}.json"
if f.exists(): # идемпотентность: пере-запуск не пере-покупает
rec = json.loads(f.read_text(encoding="utf-8"))
else:
# ПРОЕКЦИОННЫЙ ГАРД: стоп ДО вызова, который потолок пробьёт, а не после.
projected = spent + (statistics.mean(r["cost"] for r in rows) if rows else 0.005)
if projected > CEILING_USD:
print(f"\n⛔ ПРОЕКЦИЯ ${projected:.4f} пробила бы потолок ${CEILING_USD}"
f"стоп механизмом на {len(rows)} вызовах")
stop = True
break
rec = P.call(cl, msgs, tag, model=MODEL,
extra={"reasoning_effort": effort} if effort else None)
spent += rec["cost_usd"]
rows.append(dict(arm=arm, draw=i + 1, cost=rec["cost_usd"],
prompt_tokens=rec["prompt_tokens"],
completion_tokens=rec["completion_tokens"],
reasoning_chars=rec["reasoning_chars"],
finish=rec["finish"]))
time.sleep(0.3)
if stop:
break
if pilot:
per = statistics.mean(r["cost"] for r in rows)
proj = per * N_PER_ARM * 2
print(f"\nПИЛОТ: средняя цена вызова ${per:.6f} ⇒ проекция полного блока "
f"({N_PER_ARM}×2 вызовов) = ${proj:.4f} при потолке ${CEILING_USD}")
print("ВЛЕЗАЕТ — можно гнать полный блок" if proj <= CEILING_USD
else "НЕ ВЛЕЗАЕТ — стоп и пинг, не резать n молча")
return
report(rows, spent)
def report(rows: list[dict], spent: float) -> None:
from scipy.stats import mannwhitneyu # noqa: PLC0415
a = [r for r in rows if r["arm"] == "default"]
b = [r for r in rows if r["arm"] == "low"]
print(f"\nn: дефолт {len(a)} · low {len(b)} · потрачено ${spent:.6f}")
if len(a) < 3 or len(b) < 3:
print("данных мало для вывода")
return
print(f"\n{'метрика':22s}{'дефолт (медиана)':>19s}{'low (медиана)':>16s}"
f"{'отношение':>11s}{'p (MW)':>9s}")
print("-" * 77)
for key in ("completion_tokens", "reasoning_chars"):
ma = statistics.median(r[key] for r in a)
mb = statistics.median(r[key] for r in b)
p = mannwhitneyu([r[key] for r in a], [r[key] for r in b], alternative="two-sided").pvalue
print(f"{key:22s}{ma:19.0f}{mb:16.0f}{(mb / ma if ma else 0):11.3f}{p:9.4f}")
pa = {r["prompt_tokens"] for r in a}
pb = {r["prompt_tokens"] for r in b}
print(f"\nprompt_tokens: дефолт {sorted(pa)} · low {sorted(pb)}")
print(" ⇒ серверная реакция на параметр" if pa != pb
else " ⇒ серверной реакции на параметр НЕТ (как у отозванного замера 05.08)")
(OUT / "effort-watch.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1),
encoding="utf-8")
print("\nЧтение: квирк §3а («low→high, ручка не работает») ОСТАЁТСЯ В СИЛЕ, если различий нет;\n"
"снимается только при значимом различии обеих метрик И серверной реакции prompt_tokens.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""Ф1 шаг 0 — ЖИВОЙ листинг моделей по каждому ключу. $0 (GET /models не биллится).
Зачем отдельным скриптом и почему первым. Гардрейл CLAUDE.md: «слаги моделей не менять без live-
фактчека `/models`», и quirks 00 несёт отдельный урок календаря «слаг живой модель та же»
(DeepSeek 31.07 сменил веса под неизменным слагом, D39.61). Пре-рег обязан назвать кандидатов
слагами, снятыми В ДЕНЬ ЗАПУСКА, иначе смета и вердикты Ф1 повисают на памяти сессии.
Ключи не печатаются никогда скрипт выводит только имена моделей и HTTP-код.
`.env` читается через python-dotenv; для Claude файл закрыт deny-правилом, и это правильно.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
import requests
from dotenv import load_dotenv
REPO = Path("/home/ubuntu/projects/textmachine")
load_dotenv(REPO / "eval" / ".env")
OUT = Path.home() / "books" / "role-topology"
OUT.mkdir(parents=True, exist_ok=True)
# (провайдер, base_url, env-ключ) — источник: quirks 00 «Эндпоинты и модели»; семь ключей,
# перечисленных промтом эксп-21 Ф1.
PROVIDERS = [
("deepseek", "https://api.deepseek.com/v1", "DEEPSEEK_API_KEY"),
("zai", "https://api.z.ai/api/paas/v4", "ZAI_API_KEY"),
("kimi", "https://api.moonshot.ai/v1", "KIMI_API_KEY"),
("openai", "https://api.openai.com/v1", "OPENAI_API_KEY"),
("gemini", "https://generativelanguage.googleapis.com/v1beta/openai", "GEMINI_API_KEY"),
("xai", "https://api.x.ai/v1", "XAI_API_KEY"),
("mistral", "https://api.mistral.ai/v1", "MISTRAL_API_KEY"),
]
def main() -> None:
snap = {}
for name, base, env in PROVIDERS:
key = os.getenv(env)
if not key:
print(f"{name:10s} КЛЮЧА НЕТ ({env}) — провайдер вне скрина, объявить в пре-реге")
snap[name] = {"error": "no key"}
continue
try:
r = requests.get(f"{base}/models", headers={"Authorization": f"Bearer {key}"},
timeout=30)
except Exception as e: # noqa: BLE001
print(f"{name:10s} СЕТЬ: {type(e).__name__}")
snap[name] = {"error": type(e).__name__}
continue
if r.status_code != 200:
print(f"{name:10s} HTTP {r.status_code}")
snap[name] = {"error": f"http {r.status_code}", "body": r.text[:200]}
continue
ids = sorted(m["id"] for m in r.json().get("data", []))
snap[name] = {"models": ids}
print(f"{name:10s} {len(ids):3d}: {', '.join(ids[:40])}")
if len(ids) > 40:
print(f"{'':10s} …ещё {len(ids) - 40}")
(OUT / "models-live.json").write_text(json.dumps(snap, ensure_ascii=False, indent=1),
encoding="utf-8")
print(f"\nснимок → {OUT / 'models-live.json'}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Ф0.4 детектор №2, ШАГ 1 — покрытие QE-ранкера на паре zh→ru. МИНИ-БЕНЧ ДО ОПОРЫ.
Промт эксп-21 предписывает дословно: «QE-ранкер локальными весами CometKiwi/xCOMET покрытие
zhru проверить мини-бенчем ДО опоры». Это не формальность: WMT-метрики обучены на языковых парах
шэрда, и zhru в них представлена слабо. Опереть на ранкер арм C и арм G, не проверив, что он
вообще РАЗЛИЧАЕТ порчу на этой паре, значит купить дорогой прогон вслепую.
Что за модель и почему не CometKiwi. `Unbabel/wmt22-cometkiwi-da`, `wmt23-cometkiwi-da-xl` и
`XCOMET-XL` на HuggingFace имеют `gated: auto` нужен принятый лицензионный клик и токен, которого
у сессии нет. Пакет `unbabel-comet` 2.2.7 вдобавок не импортируется на Python 3.14 (`functools.
_HashedSeq` удалён в 3.14). Взято НЕзагейченное: **`google/metricx-24-hybrid-large-v2p6`** та же
задача (обученный предсказывать MQM-ошибку), есть штатный reference-free (QE) режим, mT5-large
1.23B, поднимается на CPU за ~7 с. Отклонение от буквы промта объявлено здесь, а не в отчёте задним
числом.
ШКАЛА ПЕРЕВЁРНУТА: MetricX предсказывает ВЕЛИЧИНУ ОШИБКИ (0 = идеально, 25 = мусор). Больше
хуже. Все сравнения ниже написаны в этой ориентации; спутать её значит получить детектор, который
флагает ровно здоровые сегменты.
Земля бенча. Проба 18 посадила в шесть окон по одной СМЫСЛОВОЙ ИНВЕРСИИ (класс k2: сказано
обратное исходнику «не выдержат»«легко выдержат», «поднимался»«опускался»). Эталон известен
посимвольно, посадки валидированы ревью посылки экспа-19 (12/12 регексов корректны). Это ровно тот
класс, который детектор обязан ловить, и он построен ЧУЖОЙ сессией.
Читается так: если испорченное окно НЕ получает систематически худший балл, чем чистое, ранкер
на zhru слеп, и арм C с реальными детекторами надо либо строить иначе, либо объявить неизмеримым.
Запуск (офлайн, $0): ~/.venvs/tm-qe/bin/python eval/role_topology/qe_bench.py
"""
from __future__ import annotations
import os
import statistics
import sys
from pathlib import Path
os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python")
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, "/home/ubuntu/.venvs/metricx")
sys.path.insert(0, str(REPO / "eval" / "bank_arbitration"))
sys.path.insert(0, str(REPO / "eval" / "editor_contract"))
import torch # noqa: E402
from transformers import AutoTokenizer # noqa: E402
from metricx24 import models # noqa: E402
import os as _os
# Модель переключается переменной окружения, чтобы отрицательный результат можно было
# перепроверить на СТАРШЕЙ версии тем же кодом, а не второй копией скрипта.
MODEL = _os.environ.get("METRICX_MODEL", "google/metricx-24-hybrid-large-v2p6")
TOKENIZER = _os.environ.get("METRICX_TOKENIZER", "google/mt5-large")
MAX_LEN = 1536
class QE:
"""Reference-free обёртка MetricX-24. Балл = предсказанная величина ошибки, БОЛЬШЕ — ХУЖЕ."""
def __init__(self) -> None:
self.tok = AutoTokenizer.from_pretrained(TOKENIZER, legacy=False)
self.model = models.MT5ForRegression.from_pretrained(MODEL, dtype=torch.float32)
self.model.eval()
def score(self, source: str, candidate: str) -> float:
# Формат входа QE-режима задан авторами метрики (metricx24/predict.py): без reference.
text = f"source: {source} candidate: {candidate}"
enc = self.tok(text, max_length=MAX_LEN, truncation=True, padding=False,
return_tensors="pt")
ids = enc["input_ids"]
# mT5-токенайзер вешает </s>; MetricX обучен БЕЗ него — авторский препроцессинг снимает.
ids = ids[:, :-1]
with torch.no_grad():
# ⚠ `use_cache=False` ОБЯЗАТЕЛЕН и найден исполнением: с кэшем по умолчанию
# transformers 4.57 строит маску кросс-внимания на один токен короче выхода энкодера
# и падает («size of tensor a (N) must match b (N-1)») на ЛЮБОЙ длине входа. Веса
# MetricX старше этой версии transformers; апстрим её не пинит.
out = self.model(input_ids=ids, attention_mask=torch.ones_like(ids), use_cache=False)
return float(out.predictions[0])
def main() -> None:
import editor_wire_probe as P # noqa: PLC0415
from inject_probe import pick_windows # noqa: PLC0415
qe = QE()
wins = pick_windows()
print("МИНИ-БЕНЧ ПОКРЫТИЯ zh→ru: MetricX-24-hybrid-large, reference-free режим")
print("шкала перевёрнута — БОЛЬШЕ значит ХУЖЕ\n")
print(f"{'окно':6s}{'чистый':>9s}{'k1 слово':>10s}{'k2 инверсия':>13s}"
f"{'Δk1':>8s}{'Δk2':>8s}")
print("-" * 54)
d1, d2 = [], []
for k, (_, src, _) in enumerate(wins):
clean = P.draft_of(k)
s_clean = qe.score(src, clean)
row = [s_clean]
for cls in ("k1", "k2"):
one = [d for d in P.DEFECTS[k] if d[0] == cls]
bad = clean
for _c, old, new, _b, _f in one:
bad = bad.replace(old, new, 1)
row.append(qe.score(src, bad))
d1.append(row[1] - row[0])
d2.append(row[2] - row[0])
print(f"w{k:<5d}{row[0]:9.3f}{row[1]:10.3f}{row[2]:13.3f}"
f"{row[1] - row[0]:+8.3f}{row[2] - row[0]:+8.3f}")
print("-" * 54)
print(f"{'медиана Δ':6s}{'':9s}{'':10s}{'':13s}{statistics.median(d1):+8.3f}"
f"{statistics.median(d2):+8.3f}")
for name, d in (("k1 выдуманное слово", d1), ("k2 смысловая инверсия", d2)):
worse = sum(1 for x in d if x > 0)
print(f" {name:24s} испорченное хуже чистого в {worse}/{len(d)} окнах")
print("\nЧитать: положительная Δ = ранкер увидел порчу. Знак, а не величина, решает вопрос "
"покрытия;\nвеличина скажет, хватит ли разрешения на СЕГМЕНТНЫЙ флаг (шаг 2).")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Ф0.6 (часть) — КАЛИБРОВКА ВНЕШНЕГО ГЕЙТА АРМА G на реальных редакторских правках. $0.
Арм G принимает правку редактора, только если внешний гейт видит улучшение (guarded APE,
max(draft, edited)). Шаг 3 (`qe_power.py`) показал, что парный QE уверенно ловит ПОРЧУ: инверсия
полярности даёт медиану Δ +1.49 при направлении 66/72, p<1e-5. Но это половина вопроса. Вторая
половина цена ошибки в другую сторону: сколько ХОРОШИХ правок гейт отвергнет, потому что не
увидел в них улучшения. Арм, который режет половину полезной редактуры, бесполезен независимо от
того, как хорошо он ловит порчу.
Норма, по которой это делается ДО прогона, а не после: «Сначала померить ИНСТРУМЕНТ, потом им
мерить» (эксп-20 §7 п.0). Гейт арма G инструмент, и его поведение на реальных правках обязано
быть известно до того, как на нём построен вердикт о топологии.
Материал. `~/books/gu-zhenren/labels/raw/corpus.jsonl` несёт для одной единицы ВСЕ ТРИ текста:
`source` (zh), `draft` (черновик) и `final` (после редактора). То есть реальная редакторская правка
уже сделана и лежит её не надо покупать. Сегменты выравниваются тем же кодом, что в шаге 2.
Что печатается и как читается:
Δ = QE(final) QE(draft) при одном и том же источнике. Шкала перевёрнута Δ<0 значит «редактор
улучшил по мнению гейта», Δ>0 «ухудшил».
Гейт арма G принял бы правку при Δ < 0. Доля принятых и есть его пропускная способность.
Что этот замер НЕ говорит. Он не утверждает, что правки с Δ>0 плохи: QE оценка адекватности
перевода источнику, а редактор по ратифицированному мандату D32.1(г) покупает СТИЛЬ, а не верность
(«ось решения для черновика верность, не стиль; стиль редактор переписывает»). Стилевое улучшение
при неизменной верности даёт Δ0, и гейт его отвергнет это не ошибка гейта, а его конструктивная
слепота, и именно её величину надо знать заранее.
Запуск (офлайн, $0): ~/.venvs/tm-qe/bin/python eval/role_topology/qe_guard.py [n_units]
"""
from __future__ import annotations
import json
import os
import statistics
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
from align import align, split_ru, split_zh # noqa: E402
from qe_bench import QE, MODEL # noqa: E402
OUT = Path.home() / "books" / "role-topology"
CORPUS = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl"
def main(n_units: int) -> None:
units = [json.loads(l) for l in CORPUS.read_text(encoding="utf-8").splitlines() if l.strip()]
units = [u for u in units
if (u.get("source") or "").strip() and (u.get("draft") or "").strip()
and (u.get("final") or "").strip()]
# Берётся ПРЕФИКС, а не случайная выборка: `Math.random` в этом харнессе сделал бы прогон
# невоспроизводимым, а вопрос замера не требует случайности — требуется объявленное n.
units = units[:n_units]
qe = QE()
rows: list[dict] = []
unit_rows: list[dict] = []
skipped = 0
for idx, u in enumerate(units):
zs = split_zh(u["source"])
ds, fs = split_ru(u["draft"]), split_ru(u["final"])
# Черновик и финал выравниваются с источником НЕЗАВИСИМО: редактор меняет число
# предложений (эксп-20 §4: 13→6 абзацев), и общее выравнивание было бы подгонкой.
pd = [(a, b) for a, b in align(zs, ds) if a and b]
pf = [(a, b) for a, b in align(zs, fs) if a and b]
# ⚠ СОПОСТАВЛЕНИЕ ПО ПЕРЕСЕЧЕНИЮ, а не по точному совпадению группы zh-индексов.
# Первая редакция требовала совпадения кортежа и теряла 596 сегментов из 1143 — причём
# СИСТЕМАТИЧЕСКИ: группировка расходится ровно там, где редактор слил или разбил
# предложения, то есть там, где он и работал. Клейм на такой выборке был бы смещён в
# сторону консервативных правок. Здесь берётся объединение пересекающихся групп.
for si, di in pd:
sset = set(si)
hit = [(a, b) for a, b in pf if sset & set(a)]
if not hit:
skipped += 1
continue
s = " ".join(zs[i] for i in sorted(sset | {i for a, _ in hit for i in a}))
d = " ".join(ds[i] for i in di)
f = " ".join(fs[i] for i in sorted({i for _, b in hit for i in b}))
if d == f:
continue # редактор сегмент не тронул — решать нечего
rows.append(dict(unit=idx, draft=qe.score(s, d), final=qe.score(s, f),
dlen=len(d), flen=len(f)))
# ЕДИНИЦА ЦЕЛИКОМ — замер без выравнивания вовсе, поэтому без потерь по построению.
# Арм G может гейтить и на этой гранулярности; какая из двух работает, решает замер.
unit_rows.append(dict(unit=idx,
draft=qe.score(u["source"], u["draft"]),
final=qe.score(u["source"], u["final"])))
if not rows:
print("сравнимых сегментов не набралось")
return
deltas = [r["final"] - r["draft"] for r in rows]
better = sum(1 for x in deltas if x < 0)
same = sum(1 for x in deltas if abs(x) < 0.25)
print(f"КАЛИБРОВКА ГЕЙТА АРМА G — модель {MODEL}")
print(f"единиц {len(units)} · изменённых редактором сегментов {len(rows)} · "
f"без пары после выравнивания {skipped}\n")
print(f"медиана Δ (final draft) {statistics.median(deltas):+.3f}")
print(f"редактор УЛУЧШИЛ по гейту {better}/{len(rows)} = {better / len(rows):.0%}")
print(f"гейт видит паритет (|Δ|<0.25) {same}/{len(rows)} = {same / len(rows):.0%}")
print(f"\n{'решение гейта':>28s}{'сегментов':>11s}{'доля':>8s}")
print("-" * 47)
for label, cond in (("принять правку (Δ<0)", lambda x: x < 0),
("принять с запасом (Δ<0.5)", lambda x: x < -0.5),
("отвергнуть (Δ>0)", lambda x: x > 0),
("отвергнуть уверенно (Δ>0.5)", lambda x: x > 0.5)):
k = sum(1 for x in deltas if cond(x))
print(f"{label:>28s}{k:11d}{k / len(rows):8.0%}")
ud = [r["final"] - r["draft"] for r in unit_rows]
if ud:
ub = sum(1 for x in ud if x < 0)
print(f"\nЕДИНИЦА ЦЕЛИКОМ (без выравнивания, потерь нет по построению), n={len(ud)}:")
print(f" медиана Δ {statistics.median(ud):+.3f} · редактор улучшил {ub}/{len(ud)} "
f"= {ub / len(ud):.0%}")
(OUT / f"qe-guard-{'xl' if 'XL' in MODEL else 'large'}.json").write_text(
json.dumps({"segments": rows, "units": unit_rows}, ensure_ascii=False), encoding="utf-8")
print("\nЧитать вместе с шагом 3: гейт ловит порчу (Δ +1.49 на инверсии, 66/72), но его\n"
"решение по РЕАЛЬНОЙ правке — это доля выше, и она определяет, что арм G вообще купит.")
if __name__ == "__main__":
os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python")
main(int(sys.argv[1]) if len(sys.argv) > 1 else 25)

View file

@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Ф0.6 (часть) — калибровка гейта арма G НА ЦЕЛОЙ ЕДИНИЦЕ, без выравнивания. $0.
Зачем отдельно от `qe_guard.py`. Посегментный замер требует сопоставить черновиковый сегмент с
финальным, а редактор меняет разбиение и любое сопоставление вносит смещение:
точное совпадение групп теряет 596 сегментов из 1143, причём СИСТЕМАТИЧЕСКИ там, где редактор
перестраивал предложения, то есть где он и работал (смещение в сторону консервативных правок);
пересечение групп теряет ноль, но при слиянии предложений даёт финальной стороне более широкий
кусок источника, чем черновой (смещение В ПОЛЬЗУ финала).
Единица целиком не требует выравнивания вовсе у неё нет ни того, ни другого смещения. Это и есть
арбитр между двумя посегментными числами.
Что решается. Арм G принимает правку, только если внешний гейт видит улучшение. Здесь меряется,
что гейт скажет о РЕАЛЬНОЙ редакторской правке, которая уже сделана и лежит в корпусе пакета-6.
Шкала перевёрнута: Δ = QE(final) QE(draft); Δ<0 значит «редактор улучшил по мнению гейта».
Запуск (офлайн, $0, ~25 мин на CPU):
~/.venvs/tm-qe/bin/python eval/role_topology/qe_guard_units.py
"""
from __future__ import annotations
import json
import os
import statistics
import sys
from math import comb
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
from qe_bench import QE, MODEL # noqa: E402
CORPUS = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl"
OUT = Path.home() / "books" / "role-topology"
def two_sided_sign_p(better: int, n: int) -> float:
k = max(better, n - better)
return min(1.0, 2 * sum(comb(n, i) for i in range(k, n + 1)) / (2 ** n))
def main() -> None:
units = [json.loads(l) for l in CORPUS.read_text(encoding="utf-8").splitlines() if l.strip()]
units = [u for u in units if all((u.get(k) or "").strip()
for k in ("source", "draft", "final"))]
qe = QE()
rows = [dict(draft=qe.score(u["source"], u["draft"]), final=qe.score(u["source"], u["final"]))
for u in units]
d = [r["final"] - r["draft"] for r in rows]
n = len(d)
better = sum(1 for x in d if x < 0)
print(f"КАЛИБРОВКА ГЕЙТА АРМА G НА ЦЕЛОЙ ЕДИНИЦЕ — модель {MODEL}")
print(f"n = {n} единиц, выравнивание не применялось ⇒ потерь и смещения нет по построению\n")
print(f"медиана Δ (final draft) {statistics.median(d):+.4f}")
print(f"среднее Δ {statistics.mean(d):+.4f}")
print(f"редактор УЛУЧШИЛ по гейту {better}/{n} = {better / n:.0%}")
print(f"двусторонний знаковый тест p = {two_sided_sign_p(better, n):.4f}")
print(f"гейт видит паритет (|Δ|<0.25) {sum(1 for x in d if abs(x) < 0.25)}/{n}")
print(f"|Δ| < 1.0 {sum(1 for x in d if abs(x) < 1.0)}/{n}")
(OUT / "qe-guard-units-all.json").write_text(json.dumps(rows), encoding="utf-8")
if __name__ == "__main__":
os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python")
main()

View file

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Ф0.4 детектор №2, ШАГ 3 — МОЩНОСТЬ парного QE на классе «инверсия полярности».
Что осталось неотвеченным после шага 2. На XL парная Δ вышла положительной по медиане в обоих
классах (+0.981 и +1.147) с направлением 4/6 и 5/6. Соблазн прочитать это как «на сравнении версий
ранкер работает» но n=6, и знаковый тест при 5/6 даёт односторонний p0.11. Клейм НЕ УСТАНОВЛЕН,
и объявлять его установленным на шести точках ровно тот антипаттерн, который эксп-19 уже допустил
однажды (правило «диапазоны не пересекаются» при n=3, отозвано адверсариальным ревью).
Почему вопрос стоит того, чтобы добрать мощность. Он разводит два РАЗНЫХ применения ранкера:
арм C ищет дефект в ОДНОМ тексте, образца для сравнения нет нужен АБСОЛЮТНЫЙ порог.
Шаг 2 показал, что порога нет: на обеих моделях лучшая precision 0.17.
арм G сравнивает ДВЕ версии одного сегмента (guarded APE, «принимать правку, только если
внешний гейт видит улучшение») нужна ПАРНАЯ дельта, и только она.
Если парная дельта состоятельна, арм G питаем, а арм C нет. Это не оттенок, а разная судьба двух
армов фазы 2б, поэтому вопрос решается замером, а не осторожной формулировкой.
Как добирается n. Ручных посадок больше не станет их делала другая сессия под другую задачу.
Зато ИНВЕРСИЯ ПОЛЯРНОСТИ порождается механически и остаётся тем же классом дефекта: снятие или
вставка отрицания при глаголе меняет смысл на противоположный, сохраняя грамматичность. По 81
здоровому сегменту строится столько пар, сколько найдётся глаголов, и каждая судится парно.
ГРАНИЦА, объявленная заранее: механическая инверсия ЛЕГЧЕ ручной посадки. Голое «не» сильный
поверхностный сигнал, а посадки пробы 18 меняли смысл переписыванием оборота («не выдержат»
«легко выдержат»). Поэтому результат читается как ВЕРХНЯЯ ГРАНИЦА парного режима: если ранкер
не берёт даже это, вопрос закрыт отрицательно; если берёт это ещё не значит, что возьмёт тонкую
инверсию, и ручные 6 точек остаются единственной оценкой на реальном классе.
Запуск (офлайн, $0):
~/.venvs/tm-qe/bin/python eval/role_topology/qe_power.py # large
METRICX_MODEL=google/metricx-24-hybrid-XL-v2p6 METRICX_TOKENIZER=google/mt5-xl \
~/.venvs/tm-qe/bin/python eval/role_topology/qe_power.py # XL
"""
from __future__ import annotations
import json
import os
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"))
sys.path.insert(0, str(REPO / "eval" / "editor_contract"))
sys.path.insert(0, str(REPO / "eval" / "exp16"))
import pymorphy3 # noqa: E402
from align import align, split_ru, split_zh # noqa: E402
from qe_bench import QE, MODEL # noqa: E402
_MORPH = pymorphy3.MorphAnalyzer()
OUT = Path.home() / "books" / "role-topology"
def flip_polarity(sent: str) -> str | None:
"""Инвертирует полярность предложения: снимает «не» при глаголе либо вставляет его.
Работает только по ЛИЧНОМУ глаголу или глаголу прошедшего времени причастия и деепричастия
исключены сознательно: «не» при них меняет смысл иначе и часто даёт неграмматичный результат,
а неграмматичность ранкер поймает не как инверсию, а как порчу формы (другой класс).
"""
m = re.search(r"\е\s+([А-Яа-яЁё]+)", sent)
if m and _is_finite_verb(m.group(1)):
return sent[:m.start()] + m.group(1) + sent[m.end():]
for m in re.finditer(r"\b([А-Яа-яЁё]{3,})\b", sent):
w = m.group(1)
if sent[max(0, m.start() - 4):m.start()].strip().endswith("не"):
continue
# ⚠ Пропускаем глагол с ПРОПИСНОЙ: он открывает предложение или реплику, и вставка даёт
# «не Стой» — неграмматичный текст. Ранкер оценил бы его как порчу ФОРМЫ, то есть замер
# мерил бы другой класс дефекта. Поймано проверкой генератора до прогона.
if w[0].isupper():
continue
if _is_finite_verb(w):
return sent[:m.start()] + "не " + sent[m.start():]
return None
def _is_finite_verb(word: str) -> bool:
for p in _MORPH.parse(word.lower()):
if not p.is_known:
continue
if p.tag.POS == "VERB":
return True
return False
def sign_test_p(successes: int, n: int) -> float:
"""Односторонний p знакового теста при H0: направление случайно (q=0.5).
Считается точно через биномиальные коэффициенты scipy тут не нужен, а зависимость лишняя.
"""
from math import comb # noqa: PLC0415
return sum(comb(n, k) for k in range(successes, n + 1)) / (2 ** n)
def main() -> None:
import editor_wire_probe as P # noqa: PLC0415
from inject_probe import pick_windows # noqa: PLC0415
qe = QE()
pairs_scored: list[dict] = []
for k, (_, src, _) in enumerate(pick_windows()):
clean = P.draft_of(k)
zs, rs = split_zh(src), split_ru(clean)
for si, di in align(zs, rs):
if not si or not di:
continue
s = " ".join(zs[i] for i in si)
d = " ".join(rs[i] for i in di)
bad = flip_polarity(d)
if not bad or bad == d:
continue
base = qe.score(s, d)
pairs_scored.append(dict(window=k, base=base, flipped=qe.score(s, bad),
seg_len=len(d)))
n = len(pairs_scored)
if not n:
print("инверсий не построено — проверить flip_polarity")
return
deltas = [r["flipped"] - r["base"] for r in pairs_scored]
pos = sum(1 for x in deltas if x > 0)
print(f"МОЩНОСТЬ ПАРНОГО QE на инверсии полярности — модель {MODEL}")
print(f"пар построено: {n} (из 81 здорового сегмента шести окон)\n")
print(f"медиана Δ {statistics.median(deltas):+.3f}")
print(f"среднее Δ {statistics.mean(deltas):+.3f}")
print(f"направление {pos}/{n} = {pos / n:.0%}")
print(f"знаковый тест p = {sign_test_p(pos, n):.5f} (односторонний)")
print(f"Δ > 1.0 {sum(1 for x in deltas if x > 1.0)}/{n}")
print(f"Δ < 0 (ранкер счёл инверсию ЛУЧШЕ) {sum(1 for x in deltas if x < 0)}/{n}")
# Операционный вопрос арма G: с каким порогом на ПАРНУЮ дельту он принимал бы решение и
# сколько здоровых правок при этом отверг бы. Ложная тревога здесь = отказ от хорошей правки.
print(f"\n{'порог Δ':>9s}{'поймано инверсий':>19s}{'доля':>8s}")
print("-" * 36)
for thr in (0.0, 0.25, 0.5, 1.0, 2.0):
got = sum(1 for x in deltas if x > thr)
print(f"{thr:9.2f}{got:19d}{got / n:8.0%}")
(OUT / f"qe-power-{'xl' if 'XL' in MODEL else 'large'}.json").write_text(
json.dumps(pairs_scored, ensure_ascii=False), encoding="utf-8")
print("\nЧитать: это ВЕРХНЯЯ граница парного режима (механическая инверсия проще ручной).\n"
"Отрицательный исход закрывает вопрос; положительный — открывает только арм G, не арм C.")
if __name__ == "__main__":
os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python")
main()

View file

@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Ф0.4 детектор №2, ШАГ 2 — ПОСЕГМЕНТНЫЙ QE-флаг с выравниванием zh↔ru.
Почему шаг 2 существует. Шаг 1 (`qe_bench.py`) намерил, что на уровне ЦЕЛОГО ОКНА MetricX порчу
практически не видит: медиана Δ +0.035 при направлении 4/6 то есть неотличимо от нуля. На
одиночном предложении та же модель разделила отрицание и его инверсию на Δ +4.9. Вывод не «ранкер
слеп», а «единица не та»: одна испорченная фраза в окне на тысячу слов растворяется в усреднении.
Арму C нужен не балл окна, а СПАН для починки, поэтому единицей делается предложение.
Цена этого решения выравнивание. zh и ru дробят предложения по-разному (zhru исторически
расширяется ×2.22.7, exp07), поэтому пара «i-е zh i-е ru» неверна. Берётся монотонное
выравнивание по длине (схема ГейлаЧёрча: длины предложений в переводе пропорциональны, отклонения
штрафуются квадратично) с операциями 1-1, 1-2, 2-1, 1-0, 0-1. Это детерминированный $0-код без
моделей он не должен становиться вторым источником ошибок, поэтому качество выравнивания
печатается отдельно (доля сегментов, попавших в пару).
Как читается результат. Позитивы известны посимвольно: в каждом окне ровно одно предложение несёт
посадку k1 (выдуманное слово) и одно k2 (смысловая инверсия). Меряются две вещи, и они разные:
ПАРНАЯ Δ тот же сегмент с посадкой и без. Отвечает «видит ли ранкер порчу в принципе»,
контролируя трудность сегмента. Это верхняя граница.
АБСОЛЮТНЫЙ порог можно ли отделить испорченный сегмент от здоровых ОДНИМ числом, не зная,
где искать. Только это и есть детектор; парная Δ детектором быть не может.
Шкала MetricX перевёрнута: больше = хуже.
Запуск (офлайн, $0, ~10 мин на CPU):
~/.venvs/tm-qe/bin/python eval/role_topology/qe_segment.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"))
sys.path.insert(0, str(REPO / "eval" / "editor_contract"))
from align import align, split_ru, split_zh # noqa: E402
from qe_bench import QE # noqa: E402
OUT = Path.home() / "books" / "role-topology"
def main() -> None:
import editor_wire_probe as P # noqa: PLC0415
from inject_probe import pick_windows # noqa: PLC0415
qe = QE()
rows: list[dict] = []
print("ПОСЕГМЕНТНЫЙ QE: выравнивание zh↔ru + балл MetricX на пару (больше = хуже)\n")
for k, (_, src, _) in enumerate(pick_windows()):
clean = P.draft_of(k)
zs, rs = split_zh(src), split_ru(clean)
pairs = align(zs, rs)
paired = sum(1 for a, b in pairs if a and b)
print(f"w{k}: zh {len(zs):3d} предл. · ru {len(rs):3d} предл. · пар {len(pairs):3d}, "
f"из них связанных {paired} ({paired / max(1, len(pairs)):.0%})")
# Испорченные варианты того же окна — по одному на класс.
variants = {"clean": clean}
for cls in ("k1", "k2"):
bad = clean
for c, old, new, _b, _f in P.DEFECTS[k]:
if c == cls:
bad = bad.replace(old, new, 1)
variants[cls] = bad
for name, text in variants.items():
rsv = split_ru(text)
# Порча не меняет ЧИСЛО предложений (замена внутри фразы), поэтому выравнивание
# переиспользуется — иначе сравнивались бы разные разбиения, а не разные тексты.
if len(rsv) != len(rs):
print(f" ⚠ w{k}/{name}: число ru-предложений сдвинулось "
f"({len(rs)}{len(rsv)}) — окно вне парного сравнения")
continue
for si, di in pairs:
if not si or not di:
continue
s = " ".join(zs[i] for i in si)
d = " ".join(rsv[i] for i in di)
rows.append(dict(window=k, variant=name, di=tuple(di),
score=qe.score(s, d), text_len=len(d)))
# ДЕКОЙ (D39.46б). Без него отрицательный результат неинтерпретируем: «ранкер не увидел
# посадку» и «ранкер слеп на этом материале» — разные вердикты с разными следствиями.
# Декой — заведомая грубая порча: ru-сторона сегмента подменяется ru-стороной ДРУГОГО сегмента
# того же окна. Ранкер, который не ловит ЭТО, не годен ни для чего.
decoy: list[float] = []
for k, (_, src, _) in enumerate(pick_windows()):
clean = P.draft_of(k)
zs, rs = split_zh(src), split_ru(clean)
pairs = [(a, b) for a, b in align(zs, rs) if a and b]
for idx, (si, di) in enumerate(pairs):
if idx + 2 >= len(pairs):
break
s = " ".join(zs[i] for i in si)
own = " ".join(rs[i] for i in di)
other = " ".join(rs[i] for i in pairs[idx + 2][1])
decoy.append(qe.score(s, other) - qe.score(s, own))
if decoy:
print(f"\nДЕКОЙ (ru-сторона подменена чужим сегментом), n={len(decoy)}: "
f"медиана Δ {statistics.median(decoy):+.3f}, Δ>0 в "
f"{sum(1 for x in decoy if x > 0)}/{len(decoy)}, макс {max(decoy):+.3f}")
(OUT / "qe-decoy.json").write_text(json.dumps(decoy), encoding="utf-8")
(OUT / "qe-segments.json").write_text(
json.dumps([{**r, "di": list(r["di"])} for r in rows], ensure_ascii=False),
encoding="utf-8")
report(rows, P)
def report(rows: list[dict], P) -> None: # noqa: ANN001
by = {(r["window"], r["variant"], r["di"]): r["score"] for r in rows}
print("\nПАРНАЯ Δ (тот же сегмент с посадкой и без) — верхняя граница чувствительности")
print(f"{'класс':8s}{'сегментов':>11s}{'медиана Δ':>12s}{'Δ>0':>7s}{'макс Δ':>9s}")
print("-" * 47)
hits = {}
for cls in ("k1", "k2"):
deltas = []
for (w, v, di), sc in by.items():
if v != cls:
continue
base = by.get((w, "clean", di))
if base is None:
continue
d = sc - base
if abs(d) > 1e-9: # сегмент, которого посадка коснулась
deltas.append((d, w, di))
if not deltas:
print(f"{cls:8s}{'':>11s}")
continue
hits[cls] = deltas
vals = [d for d, _, _ in deltas]
print(f"{cls:8s}{len(vals):11d}{statistics.median(vals):+12.3f}"
f"{sum(1 for x in vals if x > 0):7d}{max(vals):+9.3f}")
print("\nАБСОЛЮТНЫЙ ПОРОГ — это и есть детектор: отделяем испорченный сегмент, не зная где он")
clean_scores = [r["score"] for r in rows if r["variant"] == "clean"]
if not clean_scores:
return
print(f"здоровых сегментов {len(clean_scores)}, их баллы: "
f"медиана {statistics.median(clean_scores):.2f}, "
f"p90 {sorted(clean_scores)[int(0.9 * len(clean_scores))]:.2f}, "
f"макс {max(clean_scores):.2f}")
print(f"{'порог':>7s}{'recall k1':>11s}{'recall k2':>11s}{'ложных/окно':>13s}{'precision':>11s}")
print("-" * 53)
n_windows = len({r["window"] for r in rows})
for thr in (5, 7, 9, 11, 13, 15):
fp = sum(1 for s in clean_scores if s >= thr)
line = [f"{thr:7d}"]
tp_all = 0
for cls in ("k1", "k2"):
got = sum(1 for d, w, di in hits.get(cls, []) if by[(w, cls, di)] >= thr)
tot = len(hits.get(cls, []))
tp_all += got
line.append(f"{got}/{tot}".rjust(11))
line.append(f"{fp / max(1, n_windows):13.1f}")
line.append(f"{tp_all / max(1, tp_all + fp):11.3f}")
print("".join(line))
print("\nЧитать: если ложных на окно много при recall<1, одним порогом класс не берётся —\n"
"тогда арм C в режиме «реальные детекторы» питается не QE, а связкой сигналов.")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""РЕВЬЮ ИСПОЛНЕНИЕМ для харнесса эксп-21 (мандат самопроверки, D39.46/промт §Мандат).
Зачем. Полигон регулярно ошибается, и ошибка рига неотличима от находки, пока её не поймали
отдельным кодом (D37: заявленные «0 ошибок» = 36 ошибок; эксп-20: FEWSHOT утёк в боевой арм и был
пойман только регрессией на длину промпта). Здесь пинится всё, на чём стоят выводы Ф0.4, включая
дефекты, найденные в САМОЙ ЗЕМЛЕ, иначе следующий ре-ран молча получит другие числа.
Проверки, которые НЕ требуют весов QE, идут всегда. Проверки MetricX идут только если модель уже
скачана (`--qe`), потому что 4.6 ГБ весов не должны быть условием прогона детектора слов.
Запуск: eval/.venv/bin/python eval/role_topology/selfcheck.py
~/.venvs/tm-qe/bin/python eval/role_topology/selfcheck.py --qe
"""
from __future__ import annotations
import json
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"))
sys.path.insert(0, str(REPO / "eval" / "editor_contract"))
FAILS = 0
def ck(name: str, ok: bool, got: str = "") -> None:
global FAILS
if not ok:
FAILS += 1
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {name}" + ("" if ok else f"{got}"))
def check_word_detector() -> None:
import detect_word as D # noqa: PLC0415
bank = D.load_bank()
ck("С1: вайтлист банка непуст и несёт канон", len(bank) > 100 and "юаня" in bank,
f"{len(bank)} форм")
# Сигналы поодиночке. Ловушка этого детектора — слой, который «улучшает» precision, гася
# настоящие дефекты; поэтому каждый слой проверяется И на пропуск, И на срабатывание.
ck("С0: обычное русское слово знакомо словарю", D.known("сокровище"))
ck("С0: выдуманное слово словарю незнакомо", not D.known("дерзновяжной"))
ck("С2: реальная транскрипция опознана как транскрипция", D.translit_shape("фанчжэнь"))
ck("С2: склонённая транскрипция опознана", D.translit_shape("цунях"))
ck("С2: выдуманное слово НЕ опознано как транскрипция",
not D.translit_shape("дерзновяжной"))
ck("С3: продуктивное сложение разбирается", D.decomposes("высокорангового"))
ck("С3: суффиксальная деривация разбирается", D.decomposes("женьшеневого"))
ck("С3 (регрессия снятой редакции): «силённый» НЕ разбирается",
not D.decomposes("силённый"),
"вернулся жадный цикл «известное начало + любой хвост», гасивший 6 дефектов из 10")
# Выделенная валидация. Пинится ЗНАЧЕНИЕ, а не факт запуска: если посадки перестанут ловиться,
# это регрессия детектора, и она обязана быть громкой.
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(f"holdout: посадки k1 пробы 18 ловятся {hit}/{len(plants)}",
(hit, len(plants)) == (6, 6), f"{hit}/{len(plants)}")
rows = [json.loads(l) for l in D.LABELS.read_text(encoding="utf-8").splitlines() if l.strip()]
s = D.score(rows, 3, bank)
ck("k3: полный детектор даёт tp=7 fp=2 fn=3",
(s["tp"], s["fp"], s["fn"]) == (7, 2, 3),
f"tp={s['tp']} fp={s['fp']} fn={s['fn']}")
ck("k3: оба выживших ложных срабатывания — известная пара U+0301",
sorted(s["fp_words"]) == ["льшим", "льшую"], str(sorted(s["fp_words"])))
# ⚠ ДЕФЕКТЫ ЗЕМЛИ, найденные при проверке посылки. Пинятся здесь, чтобы отчёт не пересчитал
# recall по номинальным 10 позитивам, когда часть из них не того класса и не находится.
corp = D.LABELS.parent.parent / "raw" / "corpus.jsonl"
big = "\n".join((json.loads(l).get("draft") or "") + "\n" + (json.loads(l).get("final") or "")
for l in corp.read_text(encoding="utf-8").splitlines() if l.strip())
ck("земля: «вперди» и «силённый» ОТСУТСТВУЮТ в corpus.jsonl (дефект набора пакета-6)",
"вперди" not in big and "силённый" not in big,
"они появились — набор пере-собран, числа recall пере-считать")
ck("земля: «ной» — валидное русское слово, а не выдуманное (класс метки неверен)",
D.known("ной"))
def check_alignment() -> None:
import align as Q # noqa: PLC0415
zh = ["天很黑。", "他走了。", "风停了,雨也停了。"]
ru = ["Было темно.", "Он ушёл.", "Ветер стих.", "Дождь тоже прекратился."]
pairs = Q.align(zh, ru)
ck("выравнивание: монотонно и без потерь по zh",
[i for a, _ in pairs for i in a] == list(range(len(zh))),
str([a for a, _ in pairs]))
ck("выравнивание: монотонно и без потерь по ru",
[i for _, b in pairs for i in b] == list(range(len(ru))),
str([b for _, b in pairs]))
ck("выравнивание: 1→2 распознано на последней паре",
any(len(a) == 1 and len(b) == 2 for a, b in pairs), str(pairs))
ck("разбиение zh: терминаторы не съедены", len(Q.split_zh("甲。乙!丙?")) == 3,
str(Q.split_zh("甲。乙!丙?")))
ck("разбиение ru: аббревиатуры не рвут строку по строчной букве",
len(Q.split_ru("Он сказал. она молчала.")) == 1,
str(Q.split_ru("Он сказал. она молчала.")))
def check_qe() -> None:
from qe_bench import QE # noqa: PLC0415
qe = QE()
good = qe.score("他没有来。", "Он не пришёл.")
bad = qe.score("他没有来。", "Он пришёл.")
# САМАЯ ОПАСНАЯ ОШИБКА этого детектора — перепутанная ориентация шкалы: он тогда флагает
# ровно здоровые сегменты, и это выглядит как работающий детектор.
ck(f"QE: шкала перевёрнута (хуже = больше); {good:.2f} < {bad:.2f}", bad > good,
f"верный {good:.3f}, инверсия {bad:.3f}")
ck("QE: детерминизм повтора", abs(qe.score("他没有来。", "Он не пришёл.") - good) < 1e-4)
ck("QE: пустой кандидат штрафуется", qe.score("他没有来。", "") > good)
def main() -> None:
check_word_detector()
check_alignment()
if "--qe" in sys.argv:
check_qe()
else:
print("[ПРОП ] проверки QE пропущены (запустить ~/.venvs/tm-qe/bin/python … --qe)")
print(f"\n{'ХАРНЕСС ЧИСТ' if not FAILS else f'ПРОВАЛОВ: {FAILS}'}")
sys.exit(1 if FAILS else 0)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Сверка НЕСУЩИХ ЧИСЕЛ отчёта `docs/experiments/21-role-topology.md` с сырьём.
Мандат промта: «Верификатор чисел отчёта отдельным скриптом; ненулевой код возврата = отчёт не
сдаётся». Смысл в том, что число попадает в текст через мою же аналитику, и опечатка, устаревшая
цифра после пере-съёмки и факт визуально неотличимы. Здесь каждое заявленное число выводится из
персистированных артефактов ЗАНОВО и сравнивается с тем, что написано в отчёте включая сам текст
отчёта, чтобы расхождение ловилось, даже если я забуду поправить таблицу.
Запуск: eval/.venv/bin/python eval/role_topology/verify_report.py
"""
from __future__ import annotations
import json
import re
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"
FAILS = 0
def ck(claim: str, ok: bool, got: str = "") -> None:
global FAILS
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 и не добавил бы проверки: артефакт и есть сырьё).
seg = RAW / "qe-segments.json"
dec = RAW / "qe-decoy.json"
if not seg.exists() or not dec.exists():
ck("§2.2 сырьё QE на месте", False, "нет qe-segments.json / qe-decoy.json")
else:
import statistics # noqa: PLC0415
d = json.loads(dec.read_text(encoding="utf-8"))
ck("§2.2 декой n=69, медиана +7.107, направление 65/69",
len(d) == 69 and abs(statistics.median(d) - 7.107) < 0.01
and sum(1 for x in d if x > 0) == 65,
f"n={len(d)} медиана={statistics.median(d):.3f} "
f"плюсовых={sum(1 for x in d if x > 0)}")
srows = json.loads(seg.read_text(encoding="utf-8"))
by = {(r["window"], r["variant"], tuple(r["di"])): r["score"] for r in srows}
for cls, want_n, want_pos in (("k1", 6, 4), ("k2", 6, 3)):
deltas = [sc - by[(w, "clean", di)] for (w, v, di), sc in by.items()
if v == cls and (w, "clean", di) in by
and abs(sc - by[(w, "clean", di)]) > 1e-9]
ck(f"§2.2 парная Δ класса {cls}: {want_n} сегментов, Δ>0 в {want_pos}",
len(deltas) == want_n and sum(1 for x in deltas if x > 0) == want_pos,
f"{len(deltas)} сегментов, Δ>0 в {sum(1 for x in deltas if x > 0)}")
clean = [r["score"] for r in srows if r["variant"] == "clean"]
ck("§2.2 здоровых сегментов 81", len(clean) == 81, str(len(clean)))
# §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.25", lost / n, 0.25),
("появилось величин/ед 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))
# Дисциплина отчёта: заявленный $0 обязан подтверждаться отсутствием платного сырья.
paid = [p for p in RAW.glob("*.json") if p.name.startswith(("rt-", "j-", "screen-"))]
ck("§0 заявлено «потрачено $0» и платных артефактов нет", not paid, str([p.name for p in paid]))
ck("статус-баннер отчёта соответствует", "ПЛАТНЫЕ ФАЗЫ НЕ ЗАПУСКАЛИСЬ" in text)
print(f"\n{'ВСЕ ЧИСЛА ОТЧЁТА СХОДЯТСЯ' if not FAILS else f'РАСХОЖДЕНИЙ: {FAILS}'}")
sys.exit(1 if FAILS else 0)
if __name__ == "__main__":
main()