Freeze experiment 21 pre-registration and the role-topology harness before any paid call, with detector, battery and verifier results measured at zero cost
This commit is contained in:
parent
73a7e7a59f
commit
864038a414
13 changed files with 2318 additions and 0 deletions
324
docs/experiments/21-role-topology.md
Normal file
324
docs/experiments/21-role-topology.md
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
# 21. Топология ролей перевода: чем платим за второй проход и есть ли ему замена
|
||||
|
||||
**Полигон-сессия 06.08.2026.** Исполнение `docs/POLYGON_ROLE_TOPOLOGY_SESSION_PROMPT.md`
|
||||
(оркестратор №15, санкция владельца 06.08, D39.108). Зона: `eval/role_topology/` + этот файл +
|
||||
пинг в PROGRESS «Полигон». Потолки предварительные: Ф0 ≤$0.70 · Ф1 ≤$1.00 · Ф2а ≤$2.50 · Ф2б ≤$3.00.
|
||||
|
||||
> **СТАТУС: Ф0 ИСПОЛНЕНА ЧАСТИЧНО, ПЛАТНЫЕ ФАЗЫ НЕ ЗАПУСКАЛИСЬ. Потрачено $0.**
|
||||
> Разделы §0–§2 — пре-регистрация и результаты бесплатной метрологии. Блокер запуска назван в §0.2.
|
||||
|
||||
---
|
||||
|
||||
## ИТОГ (для оркестратора)
|
||||
|
||||
*Заполняется после Ф2. Сейчас закрыты только вопросы метрологии — см. §2.*
|
||||
|
||||
**Что уже решено данными и меняет план пака:**
|
||||
|
||||
1. **Детектор «выдуманное слово» построен и работает** (§2.1): precision 0.78 / recall 0.70 на
|
||||
2177 размеченных словотипах против **1.00 / 0.10** у боевых чекеров, вне выборки 6/6. Это не
|
||||
починка бага, а закрытие дыры, которую движок объявил сам: `sanitizer.go:429` дословно —
|
||||
«detection needs a morphology pass (Ф2), so it stays silent here».
|
||||
2. **Детектор «смысловая инверсия» через QE-ранкер — ОТРИЦАТЕЛЬНЫЙ РЕЗУЛЬТАТ** (§2.2), проверенный
|
||||
контрольным декоем. Следствие для плана: арм C фазы 2б в режиме «реальные детекторы» по классу
|
||||
инверсий питать нечем; развилка вынесена владельцу в §0.3.
|
||||
3. **Три метрики из шести, которые я построил по букве промта, мерили шум и были сняты замером**
|
||||
(§2.3). Это цена, которую платит любая батарея без прогонки на чистом тексте, и она названа
|
||||
числами, а не общими словами.
|
||||
|
||||
---
|
||||
|
||||
## §0. ПРЕ-РЕГИСТРАЦИЯ
|
||||
|
||||
Фриз коммитом ДО первого платного вызова. Изменения после фриза = новый experiment-ID; девиации —
|
||||
явным списком в §5. Amend фриза запрещён.
|
||||
|
||||
### 0.1. Вопрос и что считается ответом
|
||||
|
||||
Какая топология целевая — «черновик + переписыватель», «черновик + гейты + точечный ремонт»,
|
||||
«однопроходка сильной моделью», гибрид с маршрутизацией — и кто жильцы ролей.
|
||||
|
||||
**Ответом считается** таблица «арм → детерминированная батарея · MQM-lite двух судей · цена за
|
||||
принятую 1000 слов (p50/p95) · дрейф на связном отрезке», где различия названы ОТНОСИТЕЛЬНО порога
|
||||
различимости Ф0.6, а не абсолютно. **Ответом НЕ считается** ранжирование армов по среднему баллу
|
||||
судьи: эксп-20 §5.4 намерил, что судья согласен сам с собой на идентичном входе в 56% клеток и
|
||||
разниц меньше ~2:1 не разрешает.
|
||||
|
||||
Армы равноправны. «Правильного ответа», которого ждут, не существует; при неразличимости дефолт —
|
||||
самый дешёвый и детерминированный арм.
|
||||
|
||||
### 0.2. Материал — ОТКРЫТЫЙ БЛОКЕР
|
||||
|
||||
Промт заказывает невиданный срез: свежая zh-вебновелла без опубликованного ru-перевода, рассеянные
|
||||
окна для дисперсии + связный отрезок 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б — не запускались.
|
||||
78
eval/role_topology/align.py
Normal file
78
eval/role_topology/align.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Детерминированное выравнивание zh↔ru предложениями — $0, БЕЗ моделей и без torch.
|
||||
|
||||
Вынесено из `qe_segment.py` по находке самопроверки: выравнивание нужно и батарее Ф0.5, и
|
||||
QE-детектору, но батарея не должна тянуть за собой 4.6 ГБ весов и отдельный venv. Модуль
|
||||
импортируется обоими и проверяется `selfcheck.py` в базовом окружении полигона.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import re
|
||||
|
||||
# Терминаторы предложений: китайские полноширинные и латинские. Многоточие 。。。/…… не разрывается.
|
||||
_ZH_SPLIT = re.compile(r"(?<=[。!?;…])(?![。!?;…])")
|
||||
_RU_SPLIT = re.compile(r"(?<=[.!?…])\s+(?=[«\"(\[А-ЯЁA-Z—-])")
|
||||
|
||||
|
||||
def split_zh(t: str) -> list[str]:
|
||||
return [s.strip() for s in _ZH_SPLIT.split(t) if s.strip()]
|
||||
|
||||
|
||||
def split_ru(t: str) -> list[str]:
|
||||
out = []
|
||||
for line in t.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
out.extend(s.strip() for s in _RU_SPLIT.split(line) if s.strip())
|
||||
return out
|
||||
|
||||
|
||||
def align(src: list[str], dst: list[str]) -> list[tuple[list[int], list[int]]]:
|
||||
"""Монотонное выравнивание по длине (Гейл–Чёрч). Возвращает пары индексов.
|
||||
|
||||
Стоимость пары — квадрат нормированного отклонения фактического отношения длин от среднего по
|
||||
паре текстов. Среднее берётся ИЗ САМИХ ТЕКСТОВ, а не константой 2.2 из exp07: константа снята
|
||||
на другом материале, и подставлять её сюда значит тащить чужую калибровку в новый замер.
|
||||
"""
|
||||
if not src or not dst:
|
||||
return []
|
||||
ratio = sum(len(d) for d in dst) / max(1, sum(len(s) for s in src))
|
||||
INF = float("inf")
|
||||
n, m = len(src), len(dst)
|
||||
cost = [[INF] * (m + 1) for _ in range(n + 1)]
|
||||
back: dict[tuple[int, int], tuple[int, int]] = {}
|
||||
cost[0][0] = 0.0
|
||||
|
||||
def pair_cost(si: int, sj: int, di: int, dj: int) -> float:
|
||||
ls = sum(len(x) for x in src[si:sj]) * ratio
|
||||
ld = sum(len(x) for x in dst[di:dj])
|
||||
if ls + ld == 0:
|
||||
return 0.0
|
||||
# Пропуск (1-0 / 0-1) штрафуется как полное расхождение — иначе выравнивание охотно
|
||||
# «теряет» предложения, и потерянный сегмент никогда не будет пред'явлен детектору.
|
||||
base = ((ls - ld) ** 2) / max(ls + ld, 1.0)
|
||||
return base + (6.0 if (sj == si or dj == di) else 0.0)
|
||||
|
||||
for i in range(n + 1):
|
||||
for j in range(m + 1):
|
||||
if cost[i][j] == INF:
|
||||
continue
|
||||
for ds, dd in ((1, 1), (1, 2), (2, 1), (1, 0), (0, 1), (2, 2)):
|
||||
ni, nj = i + ds, j + dd
|
||||
if ni > n or nj > m or (ds == 0 and dd == 0):
|
||||
continue
|
||||
c = cost[i][j] + pair_cost(i, ni, j, nj)
|
||||
if c < cost[ni][nj]:
|
||||
cost[ni][nj] = c
|
||||
back[(ni, nj)] = (i, j)
|
||||
out: list[tuple[list[int], list[int]]] = []
|
||||
cur = (n, m)
|
||||
while cur != (0, 0):
|
||||
prev = back.get(cur)
|
||||
if prev is None:
|
||||
return []
|
||||
out.append((list(range(prev[0], cur[0])), list(range(prev[1], cur[1]))))
|
||||
cur = prev
|
||||
return out[::-1]
|
||||
|
||||
|
||||
537
eval/role_topology/battery.py
Normal file
537
eval/role_topology/battery.py
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ф0.5 — ДЕТЕРМИНИРОВАННАЯ БАТАРЕЯ. Первичные метрики бейк-оффа там, где судья не нужен.
|
||||
|
||||
Зачем она первична. Эксп-20 §5.4 намерил, что судья-LLM согласен сам с собой на ИДЕНТИЧНОМ входе
|
||||
в 56% клеток (33% на несущих осях) и разниц меньше ~2:1 не разрешает. Значит любой вывод бейк-оффа,
|
||||
опирающийся только на судью, обречён быть «неопределённо». Детерминированные метрики шума не имеют
|
||||
вовсе: их повтор даёт то же число. Поэтому топология сравнивается сначала ими, а судья добирает
|
||||
только те оси, которые кодом не берутся (художественность прозы).
|
||||
|
||||
Что меряется и откуда взяты правила. Каждая проверка ниже — реализация уже написанной проектом
|
||||
спецификации, а не моё изобретение: `docs/research/12-failure-modes-ru-target.md` перечисляет
|
||||
12 режимов отказа русской цели с разделом «Детекция» у каждого, и оттуда взяты и признаки, и
|
||||
границы применимости. Ссылки стоят у каждой функции.
|
||||
|
||||
Генеральность. Русско-специфичные факты (междометия-транслит, кавычки-ёлочки, ты/вы, приставки)
|
||||
собраны в СЛОВАРЬ `RU` внизу как ДАННЫЕ ЦЕЛИ. Код по паре не ветвится: для →de/→en меняется таблица,
|
||||
а не функции. Пар-специфика источника (нормализация 万/亿, палладиевские слоги) живёт отдельно и
|
||||
берётся из langpack движка. Ревью-вопрос §0.1 «заработает ли пара, которой в репо нет, без правки
|
||||
Go» здесь беспредметен (Go нет вовсе), но структурная аналогия соблюдена сознательно.
|
||||
|
||||
⚠ НОРМАЛИЗАЦИЯ ДО ТОКЕНИЗАЦИИ обязательна и найдена исполнением. Комбинирующий знак ударения
|
||||
(U+0301) в «бо́льшим» разрезает слово для наивного токенизатора — именно так в размеченном наборе
|
||||
пакета-6 появились фантомные словотипы «льшим»/«льшую». Боевой путь от этого НЕ страдает (проверено
|
||||
Go-оверлеем: `ExportNormalize` знак снимает, а `SanitizeOutput` на тексте с ударением и без даёт
|
||||
одинаковый вердикт), но полигонный код обязан снимать его сам.
|
||||
|
||||
Запуск (офлайн, $0):
|
||||
eval/.venv/bin/python eval/role_topology/battery.py --selftest # проверка правил
|
||||
eval/.venv/bin/python eval/role_topology/battery.py --corpus # прогон по corpus.jsonl
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import statistics
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path("/home/ubuntu/projects/textmachine")
|
||||
sys.path.insert(0, str(REPO / "eval" / "exp16"))
|
||||
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
|
||||
|
||||
import pymorphy3 # noqa: E402
|
||||
from detect_word import decomposes as _decomposes # noqa: E402
|
||||
from detect_word import translit_shape as _translit_shape # noqa: E402
|
||||
from palladius import is_palladius_token, palladius_conformant # noqa: E402
|
||||
|
||||
_MORPH = pymorphy3.MorphAnalyzer()
|
||||
|
||||
# ─────────────────────────── ДАННЫЕ ЦЕЛИ (ru). Не код — таблица. ────────────────────────────
|
||||
RU = {
|
||||
# research/12 §9: транслит английских/японских междометий — маркер машинного перевода.
|
||||
# Единичное вхождение легитимно (для YA «вау» нормален), меряется ЧАСТОТА, не факт.
|
||||
"interjection_calques": ["ауч", "упс", "воу", "вау", "угх", "хах", "ара", "ох ты боже",
|
||||
"оу", "йес", "хмпф", "аргх", "эйч", "уупс"],
|
||||
# research/12 §10: русская норма — реплика с абзаца вводится ТИРЕ, кавычки для реплик не
|
||||
# используются. Дефис вместо тире и прописная после атрибуции — английская схема.
|
||||
"dialogue_dash": "—",
|
||||
"quote_open": "«",
|
||||
"quote_close": "»",
|
||||
# research/12 §11: омографы, где ё обязательна при ЛЮБОЙ политике проекта.
|
||||
"yo_homographs": [("все", "всё"), ("узнаем", "узнаём"), ("совершенный", "совершённый"),
|
||||
("небо", "нёбо"), ("осел", "осёл"), ("падеж", "падёж")],
|
||||
# research/12 §12: маркированные неофеминитивы — регистровый сбой вне современной прозы.
|
||||
"marked_feminitives": ["авторка", "докторка", "блогерка", "редакторка", "директорка",
|
||||
"профессорка", "лекторка"],
|
||||
}
|
||||
# Источник zh: множители величин. Ошибка здесь = ошибка в фактуре, самый дорогой класс.
|
||||
ZH_MAGNITUDE = {"万": 10_000, "亿": 100_000_000, "千": 1_000, "百": 100}
|
||||
_ZH_DIGITS = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
|
||||
"六": 6, "七": 7, "八": 8, "九": 9, "十": 10}
|
||||
|
||||
# Русские числительные (леммы) — данные ЦЕЛИ. Нужны, чтобы «пятьсот» не читалось как потеря
|
||||
# величины 500: художественный русский пишет числа словами, а исходник — цифрами и иероглифами.
|
||||
_RU_NUM = {"ноль": 0, "один": 1, "два": 2, "две": 2, "три": 3, "четыре": 4, "пять": 5,
|
||||
"шесть": 6, "семь": 7, "восемь": 8, "девять": 9, "десять": 10,
|
||||
"одиннадцать": 11, "двенадцать": 12, "тринадцать": 13, "четырнадцать": 14,
|
||||
"пятнадцать": 15, "шестнадцать": 16, "семнадцать": 17, "восемнадцать": 18,
|
||||
"девятнадцать": 19, "двадцать": 20, "тридцать": 30, "сорок": 40, "пятьдесят": 50,
|
||||
"шестьдесят": 60, "семьдесят": 70, "восемьдесят": 80, "девяносто": 90,
|
||||
"сто": 100, "двести": 200, "триста": 300, "четыреста": 400, "пятьсот": 500,
|
||||
"шестьсот": 600, "семьсот": 700, "восемьсот": 800, "девятьсот": 900}
|
||||
_RU_MULT = {"тысяча": 1_000, "миллион": 1_000_000, "миллиард": 1_000_000_000}
|
||||
# Пол фактонесущей величины. ВЫБРАН ЗАМЕРОМ (свип 8 конфигураций на 91 единице): при поле 10
|
||||
# ложных «появившихся» 1.64 на единицу, при 100 — 0.27 при тех же 0.25 потерянных. Малые
|
||||
# числительные в русском живут местоимениями («один из них»), которым в 一个 нет величины.
|
||||
MIN_VALUE = 100
|
||||
|
||||
_RE_CYR_WORD = re.compile(r"[А-Яа-яЁё]+")
|
||||
_RE_LAT_WORD = re.compile(r"[A-Za-z]+")
|
||||
_RE_HAN = re.compile(r"[㐀-鿿]")
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""NFC + снятие комбинирующих знаков НА КИРИЛЛИЧЕСКОЙ базе (см. баннер модуля).
|
||||
|
||||
Зеркалит боевой `stripCombiningOnCyrillic` (`checks/sanitizer.go:746`): знак на латинской
|
||||
базе (é во французском имени) сохраняется, на кириллической — снимается.
|
||||
"""
|
||||
out, prev_cyr = [], False
|
||||
for ch in unicodedata.normalize("NFC", text):
|
||||
if unicodedata.category(ch) == "Mn":
|
||||
if prev_cyr:
|
||||
continue
|
||||
out.append(ch)
|
||||
continue
|
||||
out.append(ch)
|
||||
prev_cyr = bool(_RE_CYR_WORD.match(ch))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def words(text: str) -> list[str]:
|
||||
return _RE_CYR_WORD.findall(normalize(text))
|
||||
|
||||
|
||||
# ──────────────────────────────── проверки батареи ────────────────────────────────
|
||||
|
||||
def check_palladius(final: str, bank_dst: set[str]) -> dict:
|
||||
"""1. Палладий-линт. research/12 не покрывает транскрипцию — источник D39.46 Z-B3 и exp16.
|
||||
|
||||
Кандидат в имена: слово с ПРОПИСНОЙ в середине предложения, незнакомое морфологии. Если оно
|
||||
не сегментируется палладиевскими слогами — это либо не-палладиевская транскрипция (нарушение
|
||||
конвенции), либо порча. Строки банка проверяются отдельно: `palladius_conformant` требует
|
||||
конформности КАЖДОГО кириллического слова строки (Фан Юань — обе части).
|
||||
"""
|
||||
txt = normalize(final)
|
||||
bad_text, checked = [], 0
|
||||
for m in re.finditer(r"(?<![.!?…]\s)(?<!^)\b([А-ЯЁ][а-яё]{2,})", txt, re.M):
|
||||
w = m.group(1)
|
||||
if any(p.is_known for p in _MORPH.parse(w.lower())):
|
||||
continue
|
||||
# ⚠ ПЕРЕВЕДЁННОЕ имя — не нарушение конвенции, а другой класс. «Первопредок», «Виноцвет»,
|
||||
# «Кровавокрылая» палладиевскими быть не обязаны: их перевели, а не транскрибировали.
|
||||
# Разложимость на русские части и есть признак перевода — этот фильтр снял 6 из 13
|
||||
# выживших срабатываний, и все шесть были ложными.
|
||||
if _decomposes(w.lower()):
|
||||
continue
|
||||
checked += 1
|
||||
# ⚠ Голая проверка на слоги давала 96 ложных из 2192 — все склонённые транскрипции
|
||||
# («Чжэна», «Мочэнем»): русское окончание слогом Палладия не является. Порог стема здесь
|
||||
# 1 слог, а не 2: кандидат УЖЕ опознан как имя, а односложные транскрипции нормальны
|
||||
# (Чжэн · Фан · Сун) — при пороге 2 одно «Чжэна» давало 24 ложных.
|
||||
if not _translit_shape(w, stem_min_syllables=1):
|
||||
bad_text.append(w)
|
||||
bad_bank = [d for d in bank_dst if _RE_CYR_WORD.search(d) and not palladius_conformant(d)]
|
||||
return dict(name_candidates=checked, nonconformant_text=len(bad_text),
|
||||
nonconformant_text_words=sorted(set(bad_text))[:12],
|
||||
nonconformant_bank=len(bad_bank))
|
||||
|
||||
|
||||
def check_cjk_leak(final: str, threshold: float = 0.02) -> dict:
|
||||
"""7. Утечка ханьцзы. Порог НИЖЕ боевого 0.15 — это прямое требование промта Ф0.5.
|
||||
|
||||
Боевой `classify` флагает эхо при доле исходной письменности >0.15 (`disposition.go:224`), а
|
||||
эксп-20 §5.4 нашёл живую МИКРО-утечку 19 знаков на окно (доля ~0.005), которую этот порог не
|
||||
видит. Здесь считается и доля, и АБСОЛЮТНОЕ число — второе и есть чувствительная метрика.
|
||||
"""
|
||||
t = normalize(final)
|
||||
han = _RE_HAN.findall(t)
|
||||
letters = [c for c in t if c.isalpha()]
|
||||
share = len(han) / max(1, len(letters))
|
||||
return dict(han_chars=len(han), han_share=round(share, 5),
|
||||
over_probe_threshold=share > threshold,
|
||||
over_prod_threshold=share > 0.15)
|
||||
|
||||
|
||||
def check_typography(final: str) -> dict:
|
||||
"""8. Русская издательская типографика. research/12 §10 (диалоги) + §11 (ё).
|
||||
|
||||
Считаются НАРУШЕНИЯ, а не наличие: реплика, введённая кавычкой или дефисом вместо тире;
|
||||
прямые ASCII-кавычки; дефис на месте тире между пробелами. Признаки взяты дословно из
|
||||
раздела «Детекция» §10 («запрет " и “ в диалогах, em-dash + пробел в начале реплики»).
|
||||
"""
|
||||
t = normalize(final)
|
||||
lines = [l.strip() for l in t.split("\n") if l.strip()]
|
||||
ascii_quotes = t.count('"') + t.count("“") + t.count("”")
|
||||
hyphen_as_dash = len(re.findall(r"(?<= )-(?= )", t))
|
||||
dash_openers = sum(1 for l in lines if l.startswith(RU["dialogue_dash"]))
|
||||
# ⚠ Ёлочка в начале строки НЕ нарушение и из счёта убрана: 228 «нарушений» на 91 единице
|
||||
# оказались легитимными цитатами и внутренней речью, для которых «…» — русская норма.
|
||||
# Нарушение — только АНГЛИЙСКАЯ кавычка как ввод реплики (research/12 §10 «запрет " и “»).
|
||||
ascii_openers = sum(1 for l in lines if l[:1] in ('"', "“"))
|
||||
guillemet_openers = sum(1 for l in lines if l[:1] == RU["quote_open"])
|
||||
# Схема Розенталя: авторские слова после реплики идут со СТРОЧНОЙ («— …, — сказал он»).
|
||||
# ⚠ Прописная сама по себе нарушением не является — имя собственное обязано быть с прописной
|
||||
# (119 срабатываний на корпусе были в основном именами). Флагается только случай, когда с
|
||||
# прописной стоит ИЗВЕСТНЫЙ ГЛАГОЛ РЕЧИ/ДЕЙСТВИЯ, то есть заведомо не имя.
|
||||
upper_after_attr = 0
|
||||
for m in re.finditer(r"[,!?…]\s+—\s+([А-ЯЁ][а-яё]+)", t):
|
||||
w = m.group(1).lower()
|
||||
if any(p.tag.POS == "VERB" for p in _MORPH.parse(w) if p.is_known):
|
||||
upper_after_attr += 1
|
||||
return dict(lines=len(lines), dash_openers=dash_openers,
|
||||
guillemet_openers=guillemet_openers, ascii_openers=ascii_openers,
|
||||
ascii_quotes=ascii_quotes, hyphen_as_dash=hyphen_as_dash,
|
||||
upper_after_attribution=upper_after_attr,
|
||||
violations=ascii_openers + ascii_quotes + hyphen_as_dash + upper_after_attr)
|
||||
|
||||
|
||||
def check_yo(final: str) -> dict:
|
||||
"""11. Консистентность ё. research/12 §11: смесь хуже любой из двух политик.
|
||||
|
||||
Меряются две разные вещи: (а) СМЕСЬ — доля ё-содержащих слов, где ё вообще возможна;
|
||||
(б) ОМОГРАФЫ без ё, где она обязательна при любой политике (все/всё, узнаем/узнаём).
|
||||
"""
|
||||
ws = [w.lower() for w in words(final)]
|
||||
with_yo = sum(1 for w in ws if "ё" in w)
|
||||
# ⚠ Сырой счёт омографов без ё был ложной метрикой (152 на 91 единице): «все» без ё чаще
|
||||
# всего ПРАВИЛЬНО — это множественное «все», а не «всё». Различить их без разбора контекста
|
||||
# нельзя, поэтому меряется не омограф, а ПОЛИТИКА: текст, который использует ё где-то, но не
|
||||
# везде, непоследователен (research/12 §11: «смесь хуже любой из двух политик»). Омограф
|
||||
# считается нарушением ТОЛЬКО в таком тексте — там ё уже объявлена автором как употребляемая.
|
||||
# ⚠ СЧЁТ ОМОГРАФОВ СНЯТ С РОЛИ НАРУШЕНИЯ — замером, а не рассуждением: из 152 срабатываний на
|
||||
# 91 единице 130 дало обычное множественное «все» и 22 — «небо» в прямом значении. То есть
|
||||
# правило почти целиком ложное, и различить «все/всё» без разбора контекста нельзя.
|
||||
# Остаётся то, что детерминированно ИЗМЕРИМО: политика ё и её единообразие между единицами
|
||||
# (research/12 §11: «смесь хуже любой из двух политик»). Омографы печатаются справочно.
|
||||
homograph_seen = sum(1 for w in ws if any(w == bare for bare, _ in RU["yo_homographs"]))
|
||||
return dict(words=len(ws), words_with_yo=with_yo,
|
||||
yo_policy="есть" if with_yo else "нет", homographs_seen=homograph_seen)
|
||||
|
||||
|
||||
def check_translationese(final: str) -> dict:
|
||||
"""9. Translationese-прокси. research/12 §9 (междометия-транслит) + §12 (неофеминитивы).
|
||||
|
||||
⚠ ГРАНИЦА, объявленная заранее: «длины предложений против НАТИВНОГО ру-корпуса» промт просит,
|
||||
но нативного русского референс-корпуса в дереве нет, и подставлять чужую константу нельзя
|
||||
(тот же класс ошибки, что снятый жанровый словарь D39.47). Поэтому распределение длин
|
||||
печатается КАК ЕСТЬ и сравнивается МЕЖДУ АРМАМИ — для бейк-оффа этого достаточно, для
|
||||
абсолютного вердикта «это translationese» — нет, и так и читается.
|
||||
"""
|
||||
# ⚠ Матчинг ТОЛЬКО по границам слова. Подстрочный `in` давал 77 «ара» (внутри «барабан»,
|
||||
# «характер») и 13 «ауч» (внутри «паучьей») на 91 единице — то есть метрика мерила бы шум.
|
||||
toks = Counter(w.lower() for w in words(final))
|
||||
calques = {c: toks[c] for c in RU["interjection_calques"] if toks[c]}
|
||||
fem = {f: toks[f] for f in RU["marked_feminitives"] if toks[f]}
|
||||
sents = [s for s in re.split(r"(?<=[.!?…])\s+", normalize(final)) if s.strip()]
|
||||
lens = [len(s.split()) for s in sents] or [0]
|
||||
return dict(sentences=len(sents), sent_len_median=statistics.median(lens),
|
||||
sent_len_p90=sorted(lens)[int(0.9 * (len(lens) - 1))],
|
||||
calque_interjections=calques, marked_feminitives=fem)
|
||||
|
||||
|
||||
def check_numbers(source: str, final: str) -> dict:
|
||||
"""6. Перенос чисел с нормализацией 万/亿. research/12 не покрывает; источник — бэклог 12
|
||||
(«HARD-value-детектор 成/万») и D39.79.
|
||||
|
||||
Из источника извлекаются величины в двух записях — арабской и китайской иероглифической
|
||||
(三万 = 30000). Из перевода — арабские и русские числительные-множители. Сравниваются
|
||||
МНОЖЕСТВА величин: пропавшая или появившаяся величина есть дефект фактуры.
|
||||
⚠ Проверка НЕ ловит переставленные величины при совпадающем множестве — объявлено.
|
||||
"""
|
||||
src_vals = set(int(x) for x in re.findall(r"\d+", source))
|
||||
# Разбор источника требует ЯВНОЙ ЦИФРЫ перед множителем. Голое 十 намеренно НЕ берётся:
|
||||
# в китайском оно живёт в неколичественных идиомах (十分 «весьма», 十足 «полный»), и его
|
||||
# добор поднял ложные потери 0.25→0.93 на единицу при выигрыше 0.27→0.10 по выдуманным —
|
||||
# то есть сделал метрику хуже. Замер вариантов — в отчёте, не на глаз.
|
||||
for m in re.finditer(r"([零一二两三四五六七八九十]+)\s*([万亿千百十])", source):
|
||||
digits, mag = m.group(1), m.group(2)
|
||||
cur = 0
|
||||
for ch in digits:
|
||||
v = _ZH_DIGITS.get(ch, 0)
|
||||
cur = cur * 10 if v == 10 else cur + v
|
||||
src_vals.add((cur or 1) * (10 if mag == "十" else ZH_MAGNITUDE[mag]))
|
||||
dst_vals = set(int(x) for x in re.findall(r"\d+", normalize(final)))
|
||||
for m in re.finditer(r"(\d+)\s*(тысяч\w*|миллион\w*)", normalize(final), re.I):
|
||||
mult = 1_000 if m.group(2).lower().startswith("тысяч") else 1_000_000
|
||||
dst_vals.add(int(m.group(1)) * mult)
|
||||
# ⚠ РУССКИЕ ЧИСЛИТЕЛЬНЫЕ СЛОВАМИ. Без них проверка объявляла потерянными 64 величины на 91
|
||||
# единице — почти все ложно: русский художественный текст пишет «пятьсот», а не «500».
|
||||
# Разбор идёт по ЛЕММЕ (склонение снимает морфология), накопление — как в русском счёте:
|
||||
# сотни+десятки+единицы складываются, множитель умножает накопленное.
|
||||
acc, cur = 0, 0
|
||||
for w in words(final):
|
||||
lemma = _MORPH.parse(w.lower())[0].normal_form
|
||||
if lemma in _RU_NUM:
|
||||
cur += _RU_NUM[lemma]
|
||||
continue
|
||||
if lemma in _RU_MULT:
|
||||
acc += max(cur, 1) * _RU_MULT[lemma]
|
||||
cur = 0
|
||||
continue
|
||||
if cur or acc:
|
||||
dst_vals.add(acc + cur)
|
||||
acc = cur = 0
|
||||
if cur or acc:
|
||||
dst_vals.add(acc + cur)
|
||||
# ⚠ ПОЛ ВЕЛИЧИНЫ. Без него метрика мерила шум: «выдуманными» выходили 1·2·3·4·5 — это русские
|
||||
# числительные в местоименной роли («один из них»), которым в 一个 источника не соответствует
|
||||
# никакая величина. Замер на 91 единице: 82 случая «1», 65 «2», 51 «3». Порог 10 оставляет
|
||||
# ФАКТОНЕСУЩИЕ величины (ранги, расстояния, счёт камней) — ровно тот класс, ради которого
|
||||
# заведена строка 12 бэклога («HARD-value-детектор 成/万»).
|
||||
# Цена объявлена: потеря величины 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())
|
||||
254
eval/role_topology/detect_word.py
Normal file
254
eval/role_topology/detect_word.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ф0.4 детектор №1 — «ВЫДУМАННОЕ СЛОВО» (класс k3 размеченного набора пакета-6).
|
||||
|
||||
Зачем он существует. Эксп-20 §3.3 намерил, что точечная починка по флагу снимает дефект за
|
||||
$0.0002 — но мерил при ИДЕАЛЬНОЙ детекции: флаги брались из посадок, а не от детектора. Промт
|
||||
эксп-21 (строка 13) это и называет главной оговоркой: «детекторов „выдуманное слово“ и „смысловая
|
||||
инверсия“ не существует». Арм C фазы 2б без такого детектора неизмерим — его «реальный» режим
|
||||
некому питать. Здесь строится прототип ПОЛИГОНА (не движковое решение: пин строки 46 «Hunspell в
|
||||
движок не строить» не задет — это Python-зонд, как весь eval/).
|
||||
|
||||
Земля. `~/books/gu-zhenren/labels/labels/k3.jsonl` — 2177 размеченных словотипов из шести реальных
|
||||
прогонов, 10 из них помечены `defect`. Разметка сделана ЧУЖОЙ сессией (пакет-6, 26.07) до и вне
|
||||
этого экспа ⇒ второй контур по отношению ко мне выполнен построением, а не моей рукой.
|
||||
БАЗЛАЙН, который надо бить, лежит там же в `metrics.json`: боевые чекеры дают на k3
|
||||
**precision 1.0, recall 0.1** (ловят 1 дефект из 10).
|
||||
|
||||
Устройство. Слои проверяются НАКОПИТЕЛЬНО, и каждый печатается отдельной строкой — норма D39.46(а)
|
||||
(«у каждого измеряемого фактора обязан быть арм БЕЗ него») применена к слоям детектора: вклад
|
||||
каждого виден, а не заявлен.
|
||||
|
||||
С0 нет в словаре pymorphy3 — сырой сигнал
|
||||
С1 + банк/канон книги OK — подписанное имя и его склонённые формы не выдуманы
|
||||
С2 + палладиевская транскрипция OK — транскрипция ВНЕ банка тоже не выдуманное слово
|
||||
С3 + декомпозиция OK — продуктивная деривация/сложение (высоко+рангового)
|
||||
|
||||
Разделяющий принцип, на котором стоит С3. Выдуманные слова этого класса — ПОРЧА одного реального
|
||||
слова (вперди←впереди, подамлю←подавлю): они не разбираются на известные части. Легитимные
|
||||
незнакомые словарю слова — наоборот, СЛОЖЕНИЯ и приставочные производные известных частей. То есть
|
||||
разделяет не редкость слова, а разложимость. Этот принцип пар-нейтрален: приставки и соединительные
|
||||
гласные — данные русской ЦЕЛИ (`internal/lang/data/target-ru.txt` держит ту же категорию фактов),
|
||||
а не факт о китайской книге ⇒ ревью-вопрос §0.1 («заработает ли на паре, которой в репо нет»)
|
||||
отвечается «да»: сменится файл цели, не код. Источники С1/С2 тоже данные: банк книги и
|
||||
пар-таблица Палладия (`configs/langpacks/zh-ru/palladius.txt`).
|
||||
|
||||
⚠ Первая редакция С3 была ЧИСТЫМ УБЫТКОМ и это поймано исполнением, а не рассуждением: жадный
|
||||
цикл «известное начало + любой хвост» гасил 6 настоящих дефектов из 10 (силённый → «сил»+«ённый»),
|
||||
уводя recall 0.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()
|
||||
169
eval/role_topology/effort_watch.py
Normal file
169
eval/role_topology/effort_watch.py
Normal file
|
|
@ -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()
|
||||
69
eval/role_topology/list_models.py
Normal file
69
eval/role_topology/list_models.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ф1 шаг 0 — ЖИВОЙ листинг моделей по каждому ключу. $0 (GET /models не биллится).
|
||||
|
||||
Зачем отдельным скриптом и почему первым. Гардрейл CLAUDE.md: «слаги моделей не менять без live-
|
||||
фактчека `/models`», и quirks 00 несёт отдельный урок календаря — «слаг живой ≠ модель та же»
|
||||
(DeepSeek 31.07 сменил веса под неизменным слагом, D39.61). Пре-рег обязан назвать кандидатов
|
||||
слагами, снятыми В ДЕНЬ ЗАПУСКА, иначе смета и вердикты Ф1 повисают на памяти сессии.
|
||||
|
||||
Ключи не печатаются никогда — скрипт выводит только имена моделей и HTTP-код.
|
||||
`.env` читается через python-dotenv; для Claude файл закрыт deny-правилом, и это правильно.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
REPO = Path("/home/ubuntu/projects/textmachine")
|
||||
load_dotenv(REPO / "eval" / ".env")
|
||||
OUT = Path.home() / "books" / "role-topology"
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# (провайдер, base_url, env-ключ) — источник: quirks 00 «Эндпоинты и модели»; семь ключей,
|
||||
# перечисленных промтом эксп-21 Ф1.
|
||||
PROVIDERS = [
|
||||
("deepseek", "https://api.deepseek.com/v1", "DEEPSEEK_API_KEY"),
|
||||
("zai", "https://api.z.ai/api/paas/v4", "ZAI_API_KEY"),
|
||||
("kimi", "https://api.moonshot.ai/v1", "KIMI_API_KEY"),
|
||||
("openai", "https://api.openai.com/v1", "OPENAI_API_KEY"),
|
||||
("gemini", "https://generativelanguage.googleapis.com/v1beta/openai", "GEMINI_API_KEY"),
|
||||
("xai", "https://api.x.ai/v1", "XAI_API_KEY"),
|
||||
("mistral", "https://api.mistral.ai/v1", "MISTRAL_API_KEY"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
snap = {}
|
||||
for name, base, env in PROVIDERS:
|
||||
key = os.getenv(env)
|
||||
if not key:
|
||||
print(f"{name:10s} КЛЮЧА НЕТ ({env}) — провайдер вне скрина, объявить в пре-реге")
|
||||
snap[name] = {"error": "no key"}
|
||||
continue
|
||||
try:
|
||||
r = requests.get(f"{base}/models", headers={"Authorization": f"Bearer {key}"},
|
||||
timeout=30)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"{name:10s} СЕТЬ: {type(e).__name__}")
|
||||
snap[name] = {"error": type(e).__name__}
|
||||
continue
|
||||
if r.status_code != 200:
|
||||
print(f"{name:10s} HTTP {r.status_code}")
|
||||
snap[name] = {"error": f"http {r.status_code}", "body": r.text[:200]}
|
||||
continue
|
||||
ids = sorted(m["id"] for m in r.json().get("data", []))
|
||||
snap[name] = {"models": ids}
|
||||
print(f"{name:10s} {len(ids):3d}: {', '.join(ids[:40])}")
|
||||
if len(ids) > 40:
|
||||
print(f"{'':10s} …ещё {len(ids) - 40}")
|
||||
(OUT / "models-live.json").write_text(json.dumps(snap, ensure_ascii=False, indent=1),
|
||||
encoding="utf-8")
|
||||
print(f"\nснимок → {OUT / 'models-live.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
118
eval/role_topology/qe_bench.py
Normal file
118
eval/role_topology/qe_bench.py
Normal file
|
|
@ -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-токенайзер вешает </s>; MetricX обучен БЕЗ него — авторский препроцессинг снимает.
|
||||
ids = ids[:, :-1]
|
||||
with torch.no_grad():
|
||||
# ⚠ `use_cache=False` ОБЯЗАТЕЛЕН и найден исполнением: с кэшем по умолчанию
|
||||
# transformers 4.57 строит маску кросс-внимания на один токен короче выхода энкодера
|
||||
# и падает («size of tensor a (N) must match b (N-1)») на ЛЮБОЙ длине входа. Веса
|
||||
# MetricX старше этой версии transformers; апстрим её не пинит.
|
||||
out = self.model(input_ids=ids, attention_mask=torch.ones_like(ids), use_cache=False)
|
||||
return float(out.predictions[0])
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import editor_wire_probe as P # noqa: PLC0415
|
||||
from inject_probe import pick_windows # noqa: PLC0415
|
||||
|
||||
qe = QE()
|
||||
wins = pick_windows()
|
||||
print("МИНИ-БЕНЧ ПОКРЫТИЯ zh→ru: MetricX-24-hybrid-large, reference-free режим")
|
||||
print("шкала перевёрнута — БОЛЬШЕ значит ХУЖЕ\n")
|
||||
print(f"{'окно':6s}{'чистый':>9s}{'k1 слово':>10s}{'k2 инверсия':>13s}"
|
||||
f"{'Δk1':>8s}{'Δk2':>8s}")
|
||||
print("-" * 54)
|
||||
d1, d2 = [], []
|
||||
for k, (_, src, _) in enumerate(wins):
|
||||
clean = P.draft_of(k)
|
||||
s_clean = qe.score(src, clean)
|
||||
row = [s_clean]
|
||||
for cls in ("k1", "k2"):
|
||||
one = [d for d in P.DEFECTS[k] if d[0] == cls]
|
||||
bad = clean
|
||||
for _c, old, new, _b, _f in one:
|
||||
bad = bad.replace(old, new, 1)
|
||||
row.append(qe.score(src, bad))
|
||||
d1.append(row[1] - row[0])
|
||||
d2.append(row[2] - row[0])
|
||||
print(f"w{k:<5d}{row[0]:9.3f}{row[1]:10.3f}{row[2]:13.3f}"
|
||||
f"{row[1] - row[0]:+8.3f}{row[2] - row[0]:+8.3f}")
|
||||
|
||||
print("-" * 54)
|
||||
print(f"{'медиана Δ':6s}{'':9s}{'':10s}{'':13s}{statistics.median(d1):+8.3f}"
|
||||
f"{statistics.median(d2):+8.3f}")
|
||||
for name, d in (("k1 выдуманное слово", d1), ("k2 смысловая инверсия", d2)):
|
||||
worse = sum(1 for x in d if x > 0)
|
||||
print(f" {name:24s} испорченное хуже чистого в {worse}/{len(d)} окнах")
|
||||
print("\nЧитать: положительная Δ = ранкер увидел порчу. Знак, а не величина, решает вопрос "
|
||||
"покрытия;\nвеличина скажет, хватит ли разрешения на СЕГМЕНТНЫЙ флаг (шаг 2).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
128
eval/role_topology/qe_guard.py
Normal file
128
eval/role_topology/qe_guard.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ф0.6 (часть) — КАЛИБРОВКА ВНЕШНЕГО ГЕЙТА АРМА G на реальных редакторских правках. $0.
|
||||
|
||||
Арм G принимает правку редактора, только если внешний гейт видит улучшение (guarded APE,
|
||||
max(draft, edited)). Шаг 3 (`qe_power.py`) показал, что парный QE уверенно ловит ПОРЧУ: инверсия
|
||||
полярности даёт медиану Δ +1.49 при направлении 66/72, p<1e-5. Но это половина вопроса. Вторая
|
||||
половина — цена ошибки в другую сторону: сколько ХОРОШИХ правок гейт отвергнет, потому что не
|
||||
увидел в них улучшения. Арм, который режет половину полезной редактуры, бесполезен независимо от
|
||||
того, как хорошо он ловит порчу.
|
||||
|
||||
Норма, по которой это делается ДО прогона, а не после: «Сначала померить ИНСТРУМЕНТ, потом им
|
||||
мерить» (эксп-20 §7 п.0). Гейт арма G — инструмент, и его поведение на реальных правках обязано
|
||||
быть известно до того, как на нём построен вердикт о топологии.
|
||||
|
||||
Материал. `~/books/gu-zhenren/labels/raw/corpus.jsonl` несёт для одной единицы ВСЕ ТРИ текста:
|
||||
`source` (zh), `draft` (черновик) и `final` (после редактора). То есть реальная редакторская правка
|
||||
уже сделана и лежит — её не надо покупать. Сегменты выравниваются тем же кодом, что в шаге 2.
|
||||
|
||||
Что печатается и как читается:
|
||||
Δ = QE(final) − QE(draft) при одном и том же источнике. Шкала перевёрнута ⇒ Δ<0 значит «редактор
|
||||
улучшил по мнению гейта», Δ>0 — «ухудшил».
|
||||
Гейт арма G принял бы правку при Δ < 0. Доля принятых и есть его пропускная способность.
|
||||
|
||||
⚠ Что этот замер НЕ говорит. Он не утверждает, что правки с Δ>0 плохи: QE — оценка адекватности
|
||||
перевода источнику, а редактор по ратифицированному мандату D32.1(г) покупает СТИЛЬ, а не верность
|
||||
(«ось решения для черновика — верность, не стиль; стиль редактор переписывает»). Стилевое улучшение
|
||||
при неизменной верности даёт Δ≈0, и гейт его отвергнет — это не ошибка гейта, а его конструктивная
|
||||
слепота, и именно её величину надо знать заранее.
|
||||
|
||||
Запуск (офлайн, $0): ~/.venvs/tm-qe/bin/python eval/role_topology/qe_guard.py [n_units]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path("/home/ubuntu/projects/textmachine")
|
||||
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
|
||||
|
||||
from align import align, split_ru, split_zh # noqa: E402
|
||||
from qe_bench import QE, MODEL # noqa: E402
|
||||
|
||||
OUT = Path.home() / "books" / "role-topology"
|
||||
CORPUS = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl"
|
||||
|
||||
|
||||
def main(n_units: int) -> None:
|
||||
units = [json.loads(l) for l in CORPUS.read_text(encoding="utf-8").splitlines() if l.strip()]
|
||||
units = [u for u in units
|
||||
if (u.get("source") or "").strip() and (u.get("draft") or "").strip()
|
||||
and (u.get("final") or "").strip()]
|
||||
# Берётся ПРЕФИКС, а не случайная выборка: `Math.random` в этом харнессе сделал бы прогон
|
||||
# невоспроизводимым, а вопрос замера не требует случайности — требуется объявленное n.
|
||||
units = units[:n_units]
|
||||
|
||||
qe = QE()
|
||||
rows: list[dict] = []
|
||||
unit_rows: list[dict] = []
|
||||
skipped = 0
|
||||
for idx, u in enumerate(units):
|
||||
zs = split_zh(u["source"])
|
||||
ds, fs = split_ru(u["draft"]), split_ru(u["final"])
|
||||
# Черновик и финал выравниваются с источником НЕЗАВИСИМО: редактор меняет число
|
||||
# предложений (эксп-20 §4: 13→6 абзацев), и общее выравнивание было бы подгонкой.
|
||||
pd = [(a, b) for a, b in align(zs, ds) if a and b]
|
||||
pf = [(a, b) for a, b in align(zs, fs) if a and b]
|
||||
# ⚠ СОПОСТАВЛЕНИЕ ПО ПЕРЕСЕЧЕНИЮ, а не по точному совпадению группы zh-индексов.
|
||||
# Первая редакция требовала совпадения кортежа и теряла 596 сегментов из 1143 — причём
|
||||
# СИСТЕМАТИЧЕСКИ: группировка расходится ровно там, где редактор слил или разбил
|
||||
# предложения, то есть там, где он и работал. Клейм на такой выборке был бы смещён в
|
||||
# сторону консервативных правок. Здесь берётся объединение пересекающихся групп.
|
||||
for si, di in pd:
|
||||
sset = set(si)
|
||||
hit = [(a, b) for a, b in pf if sset & set(a)]
|
||||
if not hit:
|
||||
skipped += 1
|
||||
continue
|
||||
s = " ".join(zs[i] for i in sorted(sset | {i for a, _ in hit for i in a}))
|
||||
d = " ".join(ds[i] for i in di)
|
||||
f = " ".join(fs[i] for i in sorted({i for _, b in hit for i in b}))
|
||||
if d == f:
|
||||
continue # редактор сегмент не тронул — решать нечего
|
||||
rows.append(dict(unit=idx, draft=qe.score(s, d), final=qe.score(s, f),
|
||||
dlen=len(d), flen=len(f)))
|
||||
|
||||
# ЕДИНИЦА ЦЕЛИКОМ — замер без выравнивания вовсе, поэтому без потерь по построению.
|
||||
# Арм G может гейтить и на этой гранулярности; какая из двух работает, решает замер.
|
||||
unit_rows.append(dict(unit=idx,
|
||||
draft=qe.score(u["source"], u["draft"]),
|
||||
final=qe.score(u["source"], u["final"])))
|
||||
|
||||
if not rows:
|
||||
print("сравнимых сегментов не набралось")
|
||||
return
|
||||
deltas = [r["final"] - r["draft"] for r in rows]
|
||||
better = sum(1 for x in deltas if x < 0)
|
||||
same = sum(1 for x in deltas if abs(x) < 0.25)
|
||||
print(f"КАЛИБРОВКА ГЕЙТА АРМА G — модель {MODEL}")
|
||||
print(f"единиц {len(units)} · изменённых редактором сегментов {len(rows)} · "
|
||||
f"без пары после выравнивания {skipped}\n")
|
||||
print(f"медиана Δ (final − draft) {statistics.median(deltas):+.3f}")
|
||||
print(f"редактор УЛУЧШИЛ по гейту {better}/{len(rows)} = {better / len(rows):.0%}")
|
||||
print(f"гейт видит паритет (|Δ|<0.25) {same}/{len(rows)} = {same / len(rows):.0%}")
|
||||
print(f"\n{'решение гейта':>28s}{'сегментов':>11s}{'доля':>8s}")
|
||||
print("-" * 47)
|
||||
for label, cond in (("принять правку (Δ<0)", lambda x: x < 0),
|
||||
("принять с запасом (Δ<−0.5)", lambda x: x < -0.5),
|
||||
("отвергнуть (Δ>0)", lambda x: x > 0),
|
||||
("отвергнуть уверенно (Δ>0.5)", lambda x: x > 0.5)):
|
||||
k = sum(1 for x in deltas if cond(x))
|
||||
print(f"{label:>28s}{k:11d}{k / len(rows):8.0%}")
|
||||
ud = [r["final"] - r["draft"] for r in unit_rows]
|
||||
if ud:
|
||||
ub = sum(1 for x in ud if x < 0)
|
||||
print(f"\nЕДИНИЦА ЦЕЛИКОМ (без выравнивания, потерь нет по построению), n={len(ud)}:")
|
||||
print(f" медиана Δ {statistics.median(ud):+.3f} · редактор улучшил {ub}/{len(ud)} "
|
||||
f"= {ub / len(ud):.0%}")
|
||||
(OUT / f"qe-guard-{'xl' if 'XL' in MODEL else 'large'}.json").write_text(
|
||||
json.dumps({"segments": rows, "units": unit_rows}, ensure_ascii=False), encoding="utf-8")
|
||||
print("\nЧитать вместе с шагом 3: гейт ловит порчу (Δ +1.49 на инверсии, 66/72), но его\n"
|
||||
"решение по РЕАЛЬНОЙ правке — это доля выше, и она определяет, что арм G вообще купит.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python")
|
||||
main(int(sys.argv[1]) if len(sys.argv) > 1 else 25)
|
||||
66
eval/role_topology/qe_guard_units.py
Normal file
66
eval/role_topology/qe_guard_units.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ф0.6 (часть) — калибровка гейта арма G НА ЦЕЛОЙ ЕДИНИЦЕ, без выравнивания. $0.
|
||||
|
||||
Зачем отдельно от `qe_guard.py`. Посегментный замер требует сопоставить черновиковый сегмент с
|
||||
финальным, а редактор меняет разбиение — и любое сопоставление вносит смещение:
|
||||
• точное совпадение групп теряет 596 сегментов из 1143, причём СИСТЕМАТИЧЕСКИ там, где редактор
|
||||
перестраивал предложения, то есть где он и работал (смещение в сторону консервативных правок);
|
||||
• пересечение групп теряет ноль, но при слиянии предложений даёт финальной стороне более широкий
|
||||
кусок источника, чем черновой (смещение В ПОЛЬЗУ финала).
|
||||
Единица целиком не требует выравнивания вовсе ⇒ у неё нет ни того, ни другого смещения. Это и есть
|
||||
арбитр между двумя посегментными числами.
|
||||
|
||||
Что решается. Арм G принимает правку, только если внешний гейт видит улучшение. Здесь меряется,
|
||||
что гейт скажет о РЕАЛЬНОЙ редакторской правке, которая уже сделана и лежит в корпусе пакета-6.
|
||||
|
||||
⚠ Шкала перевёрнута: Δ = QE(final) − QE(draft); Δ<0 значит «редактор улучшил по мнению гейта».
|
||||
|
||||
Запуск (офлайн, $0, ~25 мин на CPU):
|
||||
~/.venvs/tm-qe/bin/python eval/role_topology/qe_guard_units.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
from math import comb
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path("/home/ubuntu/projects/textmachine")
|
||||
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
|
||||
|
||||
from qe_bench import QE, MODEL # noqa: E402
|
||||
|
||||
CORPUS = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl"
|
||||
OUT = Path.home() / "books" / "role-topology"
|
||||
|
||||
|
||||
def two_sided_sign_p(better: int, n: int) -> float:
|
||||
k = max(better, n - better)
|
||||
return min(1.0, 2 * sum(comb(n, i) for i in range(k, n + 1)) / (2 ** n))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
units = [json.loads(l) for l in CORPUS.read_text(encoding="utf-8").splitlines() if l.strip()]
|
||||
units = [u for u in units if all((u.get(k) or "").strip()
|
||||
for k in ("source", "draft", "final"))]
|
||||
qe = QE()
|
||||
rows = [dict(draft=qe.score(u["source"], u["draft"]), final=qe.score(u["source"], u["final"]))
|
||||
for u in units]
|
||||
d = [r["final"] - r["draft"] for r in rows]
|
||||
n = len(d)
|
||||
better = sum(1 for x in d if x < 0)
|
||||
print(f"КАЛИБРОВКА ГЕЙТА АРМА G НА ЦЕЛОЙ ЕДИНИЦЕ — модель {MODEL}")
|
||||
print(f"n = {n} единиц, выравнивание не применялось ⇒ потерь и смещения нет по построению\n")
|
||||
print(f"медиана Δ (final − draft) {statistics.median(d):+.4f}")
|
||||
print(f"среднее Δ {statistics.mean(d):+.4f}")
|
||||
print(f"редактор УЛУЧШИЛ по гейту {better}/{n} = {better / n:.0%}")
|
||||
print(f"двусторонний знаковый тест p = {two_sided_sign_p(better, n):.4f}")
|
||||
print(f"гейт видит паритет (|Δ|<0.25) {sum(1 for x in d if abs(x) < 0.25)}/{n}")
|
||||
print(f"|Δ| < 1.0 {sum(1 for x in d if abs(x) < 1.0)}/{n}")
|
||||
(OUT / "qe-guard-units-all.json").write_text(json.dumps(rows), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python")
|
||||
main()
|
||||
150
eval/role_topology/qe_power.py
Normal file
150
eval/role_topology/qe_power.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ф0.4 детектор №2, ШАГ 3 — МОЩНОСТЬ парного QE на классе «инверсия полярности».
|
||||
|
||||
Что осталось неотвеченным после шага 2. На XL парная Δ вышла положительной по медиане в обоих
|
||||
классах (+0.981 и +1.147) с направлением 4/6 и 5/6. Соблазн прочитать это как «на сравнении версий
|
||||
ранкер работает» — но n=6, и знаковый тест при 5/6 даёт односторонний 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()
|
||||
165
eval/role_topology/qe_segment.py
Normal file
165
eval/role_topology/qe_segment.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Ф0.4 детектор №2, ШАГ 2 — ПОСЕГМЕНТНЫЙ QE-флаг с выравниванием zh↔ru.
|
||||
|
||||
Почему шаг 2 существует. Шаг 1 (`qe_bench.py`) намерил, что на уровне ЦЕЛОГО ОКНА MetricX порчу
|
||||
практически не видит: медиана Δ +0.035 при направлении 4/6 — то есть неотличимо от нуля. На
|
||||
одиночном предложении та же модель разделила отрицание и его инверсию на Δ +4.9. Вывод не «ранкер
|
||||
слеп», а «единица не та»: одна испорченная фраза в окне на тысячу слов растворяется в усреднении.
|
||||
Арму C нужен не балл окна, а СПАН для починки, поэтому единицей делается предложение.
|
||||
|
||||
Цена этого решения — выравнивание. zh и ru дробят предложения по-разному (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()
|
||||
132
eval/role_topology/selfcheck.py
Normal file
132
eval/role_topology/selfcheck.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
#!/usr/bin/env python3
|
||||
"""РЕВЬЮ ИСПОЛНЕНИЕМ для харнесса эксп-21 (мандат самопроверки, D39.46/промт §Мандат).
|
||||
|
||||
Зачем. Полигон регулярно ошибается, и ошибка рига неотличима от находки, пока её не поймали
|
||||
отдельным кодом (D37: заявленные «0 ошибок» = 36 ошибок; эксп-20: FEWSHOT утёк в боевой арм и был
|
||||
пойман только регрессией на длину промпта). Здесь пинится всё, на чём стоят выводы Ф0.4, включая
|
||||
дефекты, найденные в САМОЙ ЗЕМЛЕ, — иначе следующий ре-ран молча получит другие числа.
|
||||
|
||||
Проверки, которые НЕ требуют весов QE, идут всегда. Проверки MetricX идут только если модель уже
|
||||
скачана (`--qe`), потому что 4.6 ГБ весов не должны быть условием прогона детектора слов.
|
||||
|
||||
Запуск: eval/.venv/bin/python eval/role_topology/selfcheck.py
|
||||
~/.venvs/tm-qe/bin/python eval/role_topology/selfcheck.py --qe
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path("/home/ubuntu/projects/textmachine")
|
||||
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
|
||||
sys.path.insert(0, str(REPO / "eval" / "bank_arbitration"))
|
||||
sys.path.insert(0, str(REPO / "eval" / "editor_contract"))
|
||||
|
||||
FAILS = 0
|
||||
|
||||
|
||||
def ck(name: str, ok: bool, got: str = "") -> None:
|
||||
global FAILS
|
||||
if not ok:
|
||||
FAILS += 1
|
||||
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {name}" + ("" if ok else f" — {got}"))
|
||||
|
||||
|
||||
def check_word_detector() -> None:
|
||||
import detect_word as D # noqa: PLC0415
|
||||
|
||||
bank = D.load_bank()
|
||||
ck("С1: вайтлист банка непуст и несёт канон", len(bank) > 100 and "юаня" in bank,
|
||||
f"{len(bank)} форм")
|
||||
|
||||
# Сигналы поодиночке. Ловушка этого детектора — слой, который «улучшает» precision, гася
|
||||
# настоящие дефекты; поэтому каждый слой проверяется И на пропуск, И на срабатывание.
|
||||
ck("С0: обычное русское слово знакомо словарю", D.known("сокровище"))
|
||||
ck("С0: выдуманное слово словарю незнакомо", not D.known("дерзновяжной"))
|
||||
ck("С2: реальная транскрипция опознана как транскрипция", D.translit_shape("фанчжэнь"))
|
||||
ck("С2: склонённая транскрипция опознана", D.translit_shape("цунях"))
|
||||
ck("С2: выдуманное слово НЕ опознано как транскрипция",
|
||||
not D.translit_shape("дерзновяжной"))
|
||||
ck("С3: продуктивное сложение разбирается", D.decomposes("высокорангового"))
|
||||
ck("С3: суффиксальная деривация разбирается", D.decomposes("женьшеневого"))
|
||||
ck("С3 (регрессия снятой редакции): «силённый» НЕ разбирается",
|
||||
not D.decomposes("силённый"),
|
||||
"вернулся жадный цикл «известное начало + любой хвост», гасивший 6 дефектов из 10")
|
||||
|
||||
# Выделенная валидация. Пинится ЗНАЧЕНИЕ, а не факт запуска: если посадки перестанут ловиться,
|
||||
# это регрессия детектора, и она обязана быть громкой.
|
||||
import editor_wire_probe as WP # noqa: PLC0415
|
||||
plants = [w for rs in WP.DEFECTS.values() for cls, old, new, _, _ in rs if cls == "k1"
|
||||
for w in new.split() if w not in old.split()]
|
||||
hit = sum(1 for w in plants if D.verdict(w, bank)[0])
|
||||
ck(f"holdout: посадки k1 пробы 18 ловятся {hit}/{len(plants)}",
|
||||
(hit, len(plants)) == (6, 6), f"{hit}/{len(plants)}")
|
||||
|
||||
rows = [json.loads(l) for l in D.LABELS.read_text(encoding="utf-8").splitlines() if l.strip()]
|
||||
s = D.score(rows, 3, bank)
|
||||
ck("k3: полный детектор даёт tp=7 fp=2 fn=3",
|
||||
(s["tp"], s["fp"], s["fn"]) == (7, 2, 3),
|
||||
f"tp={s['tp']} fp={s['fp']} fn={s['fn']}")
|
||||
ck("k3: оба выживших ложных срабатывания — известная пара U+0301",
|
||||
sorted(s["fp_words"]) == ["льшим", "льшую"], str(sorted(s["fp_words"])))
|
||||
|
||||
# ⚠ ДЕФЕКТЫ ЗЕМЛИ, найденные при проверке посылки. Пинятся здесь, чтобы отчёт не пересчитал
|
||||
# recall по номинальным 10 позитивам, когда часть из них не того класса и не находится.
|
||||
corp = D.LABELS.parent.parent / "raw" / "corpus.jsonl"
|
||||
big = "\n".join((json.loads(l).get("draft") or "") + "\n" + (json.loads(l).get("final") or "")
|
||||
for l in corp.read_text(encoding="utf-8").splitlines() if l.strip())
|
||||
ck("земля: «вперди» и «силённый» ОТСУТСТВУЮТ в corpus.jsonl (дефект набора пакета-6)",
|
||||
"вперди" not in big and "силённый" not in big,
|
||||
"они появились — набор пере-собран, числа recall пере-считать")
|
||||
ck("земля: «ной» — валидное русское слово, а не выдуманное (класс метки неверен)",
|
||||
D.known("ной"))
|
||||
|
||||
|
||||
def check_alignment() -> None:
|
||||
import align as Q # noqa: PLC0415
|
||||
|
||||
zh = ["天很黑。", "他走了。", "风停了,雨也停了。"]
|
||||
ru = ["Было темно.", "Он ушёл.", "Ветер стих.", "Дождь тоже прекратился."]
|
||||
pairs = Q.align(zh, ru)
|
||||
ck("выравнивание: монотонно и без потерь по zh",
|
||||
[i for a, _ in pairs for i in a] == list(range(len(zh))),
|
||||
str([a for a, _ in pairs]))
|
||||
ck("выравнивание: монотонно и без потерь по ru",
|
||||
[i for _, b in pairs for i in b] == list(range(len(ru))),
|
||||
str([b for _, b in pairs]))
|
||||
ck("выравнивание: 1→2 распознано на последней паре",
|
||||
any(len(a) == 1 and len(b) == 2 for a, b in pairs), str(pairs))
|
||||
ck("разбиение zh: терминаторы не съедены", len(Q.split_zh("甲。乙!丙?")) == 3,
|
||||
str(Q.split_zh("甲。乙!丙?")))
|
||||
ck("разбиение ru: аббревиатуры не рвут строку по строчной букве",
|
||||
len(Q.split_ru("Он сказал. она молчала.")) == 1,
|
||||
str(Q.split_ru("Он сказал. она молчала.")))
|
||||
|
||||
|
||||
def check_qe() -> None:
|
||||
from qe_bench import QE # noqa: PLC0415
|
||||
|
||||
qe = QE()
|
||||
good = qe.score("他没有来。", "Он не пришёл.")
|
||||
bad = qe.score("他没有来。", "Он пришёл.")
|
||||
# САМАЯ ОПАСНАЯ ОШИБКА этого детектора — перепутанная ориентация шкалы: он тогда флагает
|
||||
# ровно здоровые сегменты, и это выглядит как работающий детектор.
|
||||
ck(f"QE: шкала перевёрнута (хуже = больше); {good:.2f} < {bad:.2f}", bad > good,
|
||||
f"верный {good:.3f}, инверсия {bad:.3f}")
|
||||
ck("QE: детерминизм повтора", abs(qe.score("他没有来。", "Он не пришёл.") - good) < 1e-4)
|
||||
ck("QE: пустой кандидат штрафуется", qe.score("他没有来。", "") > good)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_word_detector()
|
||||
check_alignment()
|
||||
if "--qe" in sys.argv:
|
||||
check_qe()
|
||||
else:
|
||||
print("[ПРОП ] проверки QE пропущены (запустить ~/.venvs/tm-qe/bin/python … --qe)")
|
||||
print(f"\n{'ХАРНЕСС ЧИСТ' if not FAILS else f'ПРОВАЛОВ: {FAILS}'}")
|
||||
sys.exit(1 if FAILS else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
128
eval/role_topology/verify_report.py
Normal file
128
eval/role_topology/verify_report.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Сверка НЕСУЩИХ ЧИСЕЛ отчёта `docs/experiments/21-role-topology.md` с сырьём.
|
||||
|
||||
Мандат промта: «Верификатор чисел отчёта отдельным скриптом; ненулевой код возврата = отчёт не
|
||||
сдаётся». Смысл в том, что число попадает в текст через мою же аналитику, и опечатка, устаревшая
|
||||
цифра после пере-съёмки и факт визуально неотличимы. Здесь каждое заявленное число выводится из
|
||||
персистированных артефактов ЗАНОВО и сравнивается с тем, что написано в отчёте — включая сам текст
|
||||
отчёта, чтобы расхождение ловилось, даже если я забуду поправить таблицу.
|
||||
|
||||
Запуск: eval/.venv/bin/python eval/role_topology/verify_report.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path("/home/ubuntu/projects/textmachine")
|
||||
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
|
||||
sys.path.insert(0, str(REPO / "eval" / "bank_arbitration"))
|
||||
RAW = Path.home() / "books" / "role-topology"
|
||||
REPORT = REPO / "docs" / "experiments" / "21-role-topology.md"
|
||||
|
||||
FAILS = 0
|
||||
|
||||
|
||||
def ck(claim: str, ok: bool, got: str = "") -> None:
|
||||
global FAILS
|
||||
if not ok:
|
||||
FAILS += 1
|
||||
print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {claim}" + ("" if ok else f" — сырьё даёт {got}"))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
text = REPORT.read_text(encoding="utf-8")
|
||||
import detect_word as D # noqa: PLC0415
|
||||
|
||||
# §2.1 — таблица слоёв детектора выводится заново из земли, а не читается из json.
|
||||
rows = [json.loads(l) for l in D.LABELS.read_text(encoding="utf-8").splitlines() if l.strip()]
|
||||
bank = D.load_bank()
|
||||
expect = {0: (9, 34, 1), 1: (9, 28, 1), 2: (8, 11, 2), 3: (7, 2, 3)}
|
||||
for layer, want in expect.items():
|
||||
s = D.score(rows, layer, bank)
|
||||
got = (s["tp"], s["fp"], s["fn"])
|
||||
ck(f"§2.1 слой С{layer} = tp/fp/fn {want}", got == want, str(got))
|
||||
s3 = D.score(rows, 3, bank)
|
||||
ck("§2.1 итоговые precision 0.778 / recall 0.700",
|
||||
abs(s3["precision"] - 0.778) < 0.001 and abs(s3["recall"] - 0.700) < 0.001,
|
||||
f"{s3['precision']:.3f}/{s3['recall']:.3f}")
|
||||
ck("§2.1 таблица отчёта несёт те же 0.778 / 0.700",
|
||||
"0.778" in text and "0.700" in text)
|
||||
|
||||
import editor_wire_probe as WP # noqa: PLC0415
|
||||
plants = [w for rs in WP.DEFECTS.values() for cls, old, new, _, _ in rs if cls == "k1"
|
||||
for w in new.split() if w not in old.split()]
|
||||
hit = sum(1 for w in plants if D.verdict(w, bank)[0])
|
||||
ck("§2.1 выделенная валидация 6/6", (hit, len(plants)) == (6, 6), f"{hit}/{len(plants)}")
|
||||
|
||||
ck("§2.1 ущерб: ложных 2 из 9 флагов (22%)",
|
||||
s3["fp"] == 2 and s3["tp"] + s3["fp"] == 9, f"{s3['fp']}/{s3['tp'] + s3['fp']}")
|
||||
|
||||
# §2.2 — QE. Числа берутся из персистированного сырья прогона, а не пере-считываются моделью
|
||||
# (пере-счёт стоил бы 20 минут CPU и не добавил бы проверки: артефакт и есть сырьё).
|
||||
seg = RAW / "qe-segments.json"
|
||||
dec = RAW / "qe-decoy.json"
|
||||
if not seg.exists() or not dec.exists():
|
||||
ck("§2.2 сырьё QE на месте", False, "нет qe-segments.json / qe-decoy.json")
|
||||
else:
|
||||
import statistics # noqa: PLC0415
|
||||
d = json.loads(dec.read_text(encoding="utf-8"))
|
||||
ck("§2.2 декой n=69, медиана +7.107, направление 65/69",
|
||||
len(d) == 69 and abs(statistics.median(d) - 7.107) < 0.01
|
||||
and sum(1 for x in d if x > 0) == 65,
|
||||
f"n={len(d)} медиана={statistics.median(d):.3f} "
|
||||
f"плюсовых={sum(1 for x in d if x > 0)}")
|
||||
srows = json.loads(seg.read_text(encoding="utf-8"))
|
||||
by = {(r["window"], r["variant"], tuple(r["di"])): r["score"] for r in srows}
|
||||
for cls, want_n, want_pos in (("k1", 6, 4), ("k2", 6, 3)):
|
||||
deltas = [sc - by[(w, "clean", di)] for (w, v, di), sc in by.items()
|
||||
if v == cls and (w, "clean", di) in by
|
||||
and abs(sc - by[(w, "clean", di)]) > 1e-9]
|
||||
ck(f"§2.2 парная Δ класса {cls}: {want_n} сегментов, Δ>0 в {want_pos}",
|
||||
len(deltas) == want_n and sum(1 for x in deltas if x > 0) == want_pos,
|
||||
f"{len(deltas)} сегментов, Δ>0 в {sum(1 for x in deltas if x > 0)}")
|
||||
clean = [r["score"] for r in srows if r["variant"] == "clean"]
|
||||
ck("§2.2 здоровых сегментов 81", len(clean) == 81, str(len(clean)))
|
||||
|
||||
# §2.3 — батарея. Пере-прогоняется по тому же корпусу целиком: это единственный способ
|
||||
# заметить, что правило поехало после правки.
|
||||
import battery as B # noqa: PLC0415
|
||||
corp = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl"
|
||||
units = [json.loads(l) for l in corp.read_text(encoding="utf-8").splitlines() if l.strip()]
|
||||
units = [u for u in units if (u.get("final") or "").strip()]
|
||||
ck("§2.3 единиц с финалом 91", len(units) == 91, str(len(units)))
|
||||
typo = han = lost = inv = cand = nonconf = calq = 0
|
||||
for u in units:
|
||||
f = u["final"]
|
||||
typo += B.check_typography(f)["violations"]
|
||||
han += B.check_cjk_leak(f)["han_chars"]
|
||||
nm = B.check_numbers(u.get("source") or "", f)
|
||||
lost += nm["lost_n"]
|
||||
inv += nm["invented_n"]
|
||||
p = B.check_palladius(f, set())
|
||||
cand += p["name_candidates"]
|
||||
nonconf += p["nonconformant_text"]
|
||||
calq += bool(B.check_translationese(f)["calque_interjections"])
|
||||
n = len(units)
|
||||
for label, got, want in (("нарушений типографики/ед 0.18", typo / n, 0.18),
|
||||
("ханьцзы всего 4", han, 4),
|
||||
("потеряно величин/ед 0.25", lost / n, 0.25),
|
||||
("появилось величин/ед 0.27", inv / n, 0.27),
|
||||
("не-палладиевских срабатываний 6", nonconf, 6),
|
||||
("единиц с кальками 0", calq, 0)):
|
||||
ck(f"§2.3 {label}", abs(got - want) < 0.005, f"{got:.3f}" if isinstance(got, float)
|
||||
else str(got))
|
||||
ck("§2.3 кандидатов в имена 2184", cand == 2184, str(cand))
|
||||
|
||||
# Дисциплина отчёта: заявленный $0 обязан подтверждаться отсутствием платного сырья.
|
||||
paid = [p for p in RAW.glob("*.json") if p.name.startswith(("rt-", "j-", "screen-"))]
|
||||
ck("§0 заявлено «потрачено $0» и платных артефактов нет", not paid, str([p.name for p in paid]))
|
||||
ck("статус-баннер отчёта соответствует", "ПЛАТНЫЕ ФАЗЫ НЕ ЗАПУСКАЛИСЬ" in text)
|
||||
|
||||
print(f"\n{'ВСЕ ЧИСЛА ОТЧЁТА СХОДЯТСЯ' if not FAILS else f'РАСХОЖДЕНИЙ: {FAILS}'}")
|
||||
sys.exit(1 if FAILS else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Reference in a new issue