From 864038a41401590a3e7e8597165a5eef751a898c Mon Sep 17 00:00:00 2001 From: heaven Date: Thu, 6 Aug 2026 18:40:37 +0300 Subject: [PATCH] Freeze experiment 21 pre-registration and the role-topology harness before any paid call, with detector, battery and verifier results measured at zero cost --- docs/experiments/21-role-topology.md | 324 ++++++++++++++++ eval/role_topology/align.py | 78 ++++ eval/role_topology/battery.py | 537 +++++++++++++++++++++++++++ eval/role_topology/detect_word.py | 254 +++++++++++++ eval/role_topology/effort_watch.py | 169 +++++++++ eval/role_topology/list_models.py | 69 ++++ eval/role_topology/qe_bench.py | 118 ++++++ eval/role_topology/qe_guard.py | 128 +++++++ eval/role_topology/qe_guard_units.py | 66 ++++ eval/role_topology/qe_power.py | 150 ++++++++ eval/role_topology/qe_segment.py | 165 ++++++++ eval/role_topology/selfcheck.py | 132 +++++++ eval/role_topology/verify_report.py | 128 +++++++ 13 files changed, 2318 insertions(+) create mode 100644 docs/experiments/21-role-topology.md create mode 100644 eval/role_topology/align.py create mode 100644 eval/role_topology/battery.py create mode 100644 eval/role_topology/detect_word.py create mode 100644 eval/role_topology/effort_watch.py create mode 100644 eval/role_topology/list_models.py create mode 100644 eval/role_topology/qe_bench.py create mode 100644 eval/role_topology/qe_guard.py create mode 100644 eval/role_topology/qe_guard_units.py create mode 100644 eval/role_topology/qe_power.py create mode 100644 eval/role_topology/qe_segment.py create mode 100644 eval/role_topology/selfcheck.py create mode 100644 eval/role_topology/verify_report.py diff --git a/docs/experiments/21-role-topology.md b/docs/experiments/21-role-topology.md new file mode 100644 index 00000000..b34e6dcd --- /dev/null +++ b/docs/experiments/21-role-topology.md @@ -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-перевода, рассеянные +окна для дисперсии + связный отрезок 8–10 глав для дрейф-метрик. + +**Такого текста в дереве нет.** Инвентарь `~/books` с провенансом по докам: `gu-zhenren` (蛊真人) — +претрейн-канон, ровно тот случай, который строка 55 бэклога объявляет негодным дословно; +`jinpingmei` — минский байхуа, PD; `isekai_majutsushi_jp` — ja, не zh (exp11 §41); +`fifty_shades_1_en`, `Empire_of_the_Dawn` — en. Каталог `eval/data/samples/webnovel/zh/`, куда +харнесс `webnovel_slice.py:14` ждёт корпус, никогда не создавался — строка 55 висит именно поэтому. +То, что exp07 называл «невиданным вебновелл-текстом», — это само 蛊真人 (`07-coverage-precision.md:85`), +и его же хвост предписывает «перепроверить **на корпусе владельца**». + +**Норма при этом однозначна:** корпус кладёт владелец (`webnovel_slice.py:1-3` — «запускается по +появлению корпуса владельца»). Полигон текст не добывает. + +**Развилка передана владельцу 06.08** (варианты: владелец кладёт файлы · санкционирует добычу +полигоном · Ф2 идёт на 蛊真人 с объявленной границей). До ответа платные фазы не стартуют. +**Оговорка о свежести, которую важно не потерять:** дата публикации контаминацию не снимает — +катофы моделей разные. Чище всего берутся последние главы ИДУЩЕГО сериала (июнь–август 2026), и +контаминация всё равно меряется (continuation/cloze + поиск fan-переводов), а не предполагается. + +### 0.3. Армы + +**Ф2а — «переписывание как класс».** Черновики фризятся и подаются идентичными всем армам-редакторам +(парный дизайн, McNemar). + +| Арм | Что | +|---|---| +| F | do-nothing пол: черновик как есть через гейты. Все редакторские армы мерятся против него | +| A | байт-боевой бейзлайн flash→dspro (без банкноты — объявленная девиация бейзлайна, не чинить) | +| B | связка flash→glm-5-OFF full-regen | +| D | однопроходка сильной модели С мандатом перевёрстки | +| D′ | она же БЕЗ мандата — деконфаунд «модель против мандата» | + +**Ф2б — ремонт и маршрутизация.** Гейт входа: детекторы Ф0.4 валидированы. + +| Арм | Что | +|---|---| +| C | черновик (с мандатом перевёрстки) → детекторы+гейты → точечный ремонт дешёвым фиксером; два режима: oracle-флаги (потолок) и реальные детекторы (пол); после каждого фикса полный ре-гейт, кап 2 итерации | +| E | обратная связка: сильный черновик → дешёвый фиксер по флагу | +| G | guarded+routed: QE/гейты скорят все окна → стиль-пасс ТОЛЬКО флагнутым, правка принимается только если внешний гейт видит улучшение (max(draft, edited)) | + +**⚠ ПОПРАВКА К АРМАМ C и G ПО РЕЗУЛЬТАТУ Ф0.4 (§2.2).** QE-ранкер локальную смысловую инверсию не +детектирует. Следствия, объявленные ДО запуска: +- арм **C** гонится в oracle-режиме и в режиме «реальные детекторы», но реальный режим питается + ТОЛЬКО детектором выдуманных слов + существующими $0-гейтами; класс «инверсия» в реальном + режиме остаётся ненайденным, и разрыв oracle↔реальность печатается как главный результат арма; +- арм **G** маршрутизирует не по QE-баллу инверсии, а по батарее Ф0.5 + QE как ГРУБОМУ фильтру + (декой доказал: рассогласование сегмента ранкер видит, Δ +7.1) — то есть G ловит «сегмент поехал + целиком», а не «сегмент поехал в одном слове». Это сужение арма, и оно объявлено здесь. + +**Reflow-план кодом (Q4c, research/19 §C1.3) — ДИСПОЗИЦИЯ ОТКАЗА.** Не гоню. Основание: эксп-20 §5.2 +уже проверил гипотезу «мандат в промпте заменяет второй проход» слепым судом и ОПРОВЕРГ её (русский +3:12, вёрстка 5:10 в пользу связки), а деконфаунд мандата в этом паке несёт арм D′ — отдельный +кодовый reflow-план добавил бы третий способ спросить то же самое при бюджете, которого нет. + +### 0.4. Метрики и пороги + +Порядок первичности задан не вкусом, а измеренным шумом инструментов. + +1. **Детерминированная батарея Ф0.5 — первична.** Шума не имеет: повтор даёт то же число. + Состав и границы каждой проверки — §2.3. +2. **MQM-lite двух судей** — счёт типизированных ошибок (спан+тип+severity на 1000 слов). + Pairwise-преференс — только для стиль-осей. Судья не судит армы своего семейства-жильца; + вердикты раскладываются по семействам судей. Агрегация Bradley–Terry с bootstrap-CI. +3. **Цена за принятую 1000 слов** — p50/p95, с кэшем/батчем/ретраями; перезаписанные вызовы тоже + деньги (леджер = нижняя граница). +4. **Дрейф на связном отрезке** — швы, ты/вы-стабильность, рост банка. + +**Порог различимости берётся из Ф0.6 и записывается ДО Ф2.** Правило вердикта: «арм X бьёт Y, если +перевес по оси больше порога Ф0.6»; средние исходы = «неопределённо»; при неразличимости побеждает +самый дешёвый и детерминированный арм. + +### 0.5. Прогнозы (сверка с фактом идёт в отчёт) + +Записаны до запуска, чтобы отчёт мог показать, где я ошибся. + +| # | Прогноз | На чём основан | +|---|---|---| +| П1 | F (do-nothing) НЕ будет последним; по общему вердикту он в пределах порога от A | эксп-20: на dspro второй проход отрицателен (общий 5:1 в пользу одного прохода) | +| П2 | B (glm-5) выиграет русскую прозу и проиграет верности/термам | эксп-20 §5: у glm свип вёрстки 6:0, у dspro верность 5:1 | +| П3 | D (однопроходка сильной) выиграет качеством и проиграет ценой ≥2× | цена сильного тира против flash | +| П4 | G даст лучшее отношение цена/качество среди всех армов | он единственный не платит за неизменённое | +| П5 | Разрыв oracle↔реальные детекторы в арме C будет БОЛЬШЕ, чем разрыв между армами Ф2а | recall детектора слов 0.70, детектора инверсий нет вовсе | +| П6 | Батарея разведёт армы там, где судья скажет «неразличимо» | у батареи шум ноль, у судьи 56% самосогласия | + +### 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, макс 16–18) + полностью перекрывают баллы испорченных. **Арм C по классу «инверсия» не питаем.** +- **арм G** сравнивает ДВЕ версии одного сегмента ⇒ ему нужна парная дельта. Она состоятельна: + на механической инверсии полярности (72 пары, построены снятием/вставкой отрицания при личном + глаголе) медиана Δ **+1.490**, направление **66/72 = 92%**, знаковый тест **p < 1e-5**. + **Арм G питаем.** + +⚠ Граница парного замера объявлена заранее и остаётся в силе: механическая инверсия ЛЕГЧЕ ручной +посадки (голое «не» — сильный поверхностный сигнал, а посадки пробы 18 меняли смысл переписыванием +оборота). Поэтому 92% читается как ВЕРХНЯЯ граница парного режима; на реальном классе оценка — те +самые 5/6 при p≈0.11, то есть не установлено. + +**Следствия для пака** (внесены в §0.3 ДО запуска): арм C в реальном режиме не покрывает класс +инверсий и печатает разрыв oracle↔реальность как главный результат; арм G сохраняет свой QE-гейт, +но его пропускная способность обязана быть откалибрована на РЕАЛЬНЫХ правках — §2.5. + +### 2.3. Детерминированная батарея: построена, и три её метрики сняты собственным замером + +Девять проверок по спецификациям `docs/research/12-failure-modes-ru-target.md` (у каждого из 12 +режимов отказа русской цели там есть раздел «Детекция» — правила взяты оттуда, а не придуманы). +16 пинов правил на синтетике с известным ответом + прогон по 91 реальной единице. + +**Главное, что дал прогон по чистому тексту: правила, написанные по букве спецификации, в трёх +случаях из шести мерили шум.** Числа до и после — на 91 единице, на единицу: + +| Метрика | было | стало | что было не так | +|---|---|---|---| +| нарушений типографики | 3.87 | **0.18** | ёлочка в начале строки считалась нарушением (228 срабатываний) — это легитимная цитата/мысль; прописная после атрибуции (119) — в основном имена собственные, которым прописная положена | +| единиц с транслит-междометиями | 0.64 | **0.00** | подстрочный матчинг: 77 «ара» внутри «барабан», 13 «ауч» внутри «паучьей». Чинится границами слова | +| потеряно / появилось величин | 0.70 / 5.56 | **0.25 / 0.27** | не разбирались русские числительные словами («пятьсот»); малые числительные в русском живут местоимениями («один из них»), которым в 一个 нет величины. Пол величины 100 выбран свипом 8 конфигураций | +| не-палладиевских имён | 1.05 | **0.07** | склонённые транскрипции («Чжэна» — 24 срабатывания) и ПЕРЕВЕДЁННЫЕ имена («Первопредок», «Кровавокрылая»), которым палладиевская форма не положена | +| омографов без ё | 1.67 | **снята** | 130 из 152 — обычное множественное «все», 22 — «небо» в прямом значении. Различить «все/всё» без контекста нельзя; вместо счёта меряется политика ё и её единообразие | + +Живые остатки на том же корпусе: ханьцзы в финале 4 знака на 91 единицу (1 единица); не-палладиевских +имён **6 срабатываний / 5 различных слов** на 2184 кандидата, из них 2 — настоящие сигналы; смешение +ты/вы в 0.31 единицы (нижняя граница: смешение между разными собеседниками легитимно); медиана доли +слов черновика, доживших до финала, 0.942. + +*(Расхождение 5↔6 поймано верификатором `verify_report.py`, а не глазами: в первую редакцию отчёта +попало число РАЗЛИЧНЫХ слов, посчитанное дедуплицирующим путём, при 6 фактических срабатываниях. +Ровно тот класс ошибки, ради которого верификатор и написан.)* + +**Объявленные границы батареи** (в отчёт, не в примечание): величины 1–99 вне охвата; «длины +предложений против нативного ру-корпуса» не считаются абсолютно — нативного русского референс-корпуса +в дереве нет, и подставлять чужую константу нельзя (тот же класс ошибки, что снятый жанровый словарь +D39.47), поэтому распределение сравнивается МЕЖДУ АРМАМИ; ты/вы без speaker-ID — сигнал к просмотру, +не счёт ошибок; род прош. времени работает только при явной близости имени и глагола. + +### 2.4. Живой листинг моделей + +Снят по всем семи ключам, все отвечают. Против `00-provider-quirks.md` появились новые жильцы, и +они пойдут в кандидаты Ф1 слагами дня запуска: **grok-4.5** · **kimi-k3** · **glm-5-turbo**, +**glm-5.2** · **gemini-3.5-flash**, **gemini-3.6-flash**. У DeepSeek листинг прежний (два слага) — +но урок календаря D39.61 в силе: «слаг живой ≠ модель та же», и вахта реестра 108 (пере-проба +маппинга эффорта `deepseek-v4-pro`) остаётся первым платным шагом Ф1. + +--- + +## §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б — не запускались. diff --git a/eval/role_topology/align.py b/eval/role_topology/align.py new file mode 100644 index 00000000..e1821171 --- /dev/null +++ b/eval/role_topology/align.py @@ -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] + + diff --git a/eval/role_topology/battery.py b/eval/role_topology/battery.py new file mode 100644 index 00000000..26cce4d8 --- /dev/null +++ b/eval/role_topology/battery.py @@ -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"(? 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-детектор 成/万»). + # Цена объявлена: потеря величины 1–9 этой проверкой НЕ ловится. + 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% спикер, адресат + на 10–15 пп хуже) и здесь честно НЕ делается. Меряется наблюдаемое детерминированно: смешение + обеих форм в ОДНОЙ единице. Это нижняя граница дефекта — смешение между разными собеседниками + легитимно, поэтому число читается как сигнал к просмотру, а не как счёт ошибок. + """ + 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()) diff --git a/eval/role_topology/detect_word.py b/eval/role_topology/detect_word.py new file mode 100644 index 00000000..64a15b6f --- /dev/null +++ b/eval/role_topology/detect_word.py @@ -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.90→0.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: + """Форма палладиевской транскрипции — с учётом РУССКОГО СКЛОНЕНИЯ поверх неё. + + Голая проверка на слоги ломается на «цунях»/«чжэнем»: русское окончание не является + палладиевским слогом. Поэтому пробуем и сам токен, и его усечения на 1–3 знака с конца — + ровно длина русской флексии. Усечённый стем обязан остаться ≥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() diff --git a/eval/role_topology/effort_watch.py b/eval/role_topology/effort_watch.py new file mode 100644 index 00000000..f898fcfb --- /dev/null +++ b/eval/role_topology/effort_watch.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""ВАХТА РЕЕСТРА 108 — пере-проба маппинга `reasoning_effort` у `deepseek-v4-pro`. + +Почему это первый платный шаг пака. Вся эмпирика экспов 18–20 стоит на квирке §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 дефолтных розыгрышей одного запроса +уехали 1952→15720 знаков размышления строго монотонно) и n≥10 на арм. + +⚠ ОБЪЯВЛЕННОЕ ОТКЛОНЕНИЕ ОТ ПРОШЛОЙ ПРОБЫ: вход КОМПАКТНЕЕ (~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() diff --git a/eval/role_topology/list_models.py b/eval/role_topology/list_models.py new file mode 100644 index 00000000..c81b567c --- /dev/null +++ b/eval/role_topology/list_models.py @@ -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() diff --git a/eval/role_topology/qe_bench.py b/eval/role_topology/qe_bench.py new file mode 100644 index 00000000..587797e8 --- /dev/null +++ b/eval/role_topology/qe_bench.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Ф0.4 детектор №2, ШАГ 1 — покрытие QE-ранкера на паре zh→ru. МИНИ-БЕНЧ ДО ОПОРЫ. + +Промт эксп-21 предписывает дословно: «QE-ранкер локальными весами CometKiwi/xCOMET — покрытие +zh→ru проверить мини-бенчем ДО опоры». Это не формальность: WMT-метрики обучены на языковых парах +шэрда, и zh→ru в них представлена слабо. Опереть на ранкер арм 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 регексов корректны). Это ровно тот +класс, который детектор обязан ловить, и он построен ЧУЖОЙ сессией. + +Читается так: если испорченное окно НЕ получает систематически худший балл, чем чистое, — ранкер +на zh→ru слеп, и арм 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-токенайзер вешает ; 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() diff --git a/eval/role_topology/qe_guard.py b/eval/role_topology/qe_guard.py new file mode 100644 index 00000000..e55e1e16 --- /dev/null +++ b/eval/role_topology/qe_guard.py @@ -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) diff --git a/eval/role_topology/qe_guard_units.py b/eval/role_topology/qe_guard_units.py new file mode 100644 index 00000000..79381a49 --- /dev/null +++ b/eval/role_topology/qe_guard_units.py @@ -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() diff --git a/eval/role_topology/qe_power.py b/eval/role_topology/qe_power.py new file mode 100644 index 00000000..40d22e46 --- /dev/null +++ b/eval/role_topology/qe_power.py @@ -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 даёт односторонний p≈0.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"\bне\s+([А-Яа-яЁё]+)", sent) + if m and _is_finite_verb(m.group(1)): + return sent[:m.start()] + m.group(1) + sent[m.end():] + for m in re.finditer(r"\b([А-Яа-яЁё]{3,})\b", sent): + w = m.group(1) + if sent[max(0, m.start() - 4):m.start()].strip().endswith("не"): + continue + # ⚠ Пропускаем глагол с ПРОПИСНОЙ: он открывает предложение или реплику, и вставка даёт + # «не Стой» — неграмматичный текст. Ранкер оценил бы его как порчу ФОРМЫ, то есть замер + # мерил бы другой класс дефекта. Поймано проверкой генератора до прогона. + if w[0].isupper(): + continue + if _is_finite_verb(w): + return sent[:m.start()] + "не " + sent[m.start():] + return None + + +def _is_finite_verb(word: str) -> bool: + for p in _MORPH.parse(word.lower()): + if not p.is_known: + continue + if p.tag.POS == "VERB": + return True + return False + + +def sign_test_p(successes: int, n: int) -> float: + """Односторонний p знакового теста при H0: направление случайно (q=0.5). + + Считается точно через биномиальные коэффициенты — 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() diff --git a/eval/role_topology/qe_segment.py b/eval/role_topology/qe_segment.py new file mode 100644 index 00000000..8f9449f2 --- /dev/null +++ b/eval/role_topology/qe_segment.py @@ -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 дробят предложения по-разному (zh→ru исторически +расширяется ×2.2–2.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() diff --git a/eval/role_topology/selfcheck.py b/eval/role_topology/selfcheck.py new file mode 100644 index 00000000..f8669f71 --- /dev/null +++ b/eval/role_topology/selfcheck.py @@ -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() diff --git a/eval/role_topology/verify_report.py b/eval/role_topology/verify_report.py new file mode 100644 index 00000000..e0e41184 --- /dev/null +++ b/eval/role_topology/verify_report.py @@ -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()