Accept polygon package eight as D39.50: emission widening closed negatively, banknote discipline and answer-language hole become the design core

This commit is contained in:
Claude (backend session) 2026-07-26 20:07:19 +03:00
parent 0a7fa5b7cc
commit fb5e873157
11 changed files with 2034 additions and 1 deletions

File diff suppressed because one or more lines are too long

View file

@ -1148,3 +1148,9 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу
## D39.49 — Владелец (26.07): три пре-замера D39.48 САНКЦИОНИРОВАНЫ (~$0.14, потолок $0.30), исполнитель — ПОЛИГОН («сначала идти в полигон, доделать замеры, потом продолжение в паке-20 бэкенда»). Выдан ПАКЕТ-8. ✅
Промт: `docs/POLYGON_PACKAGE8_PREMEASURE_SESSION_PROMPT.md` — (1) совместный recall майнер∪банкнота против сида ($0, из черновиков corpus.jsonl пакета-6); (2) доля отказов роли на мусоре (≈$0.01, боевой промпт HEAD, критерий до прогона); (3) сетка kwic × подача (≈$0.12, харнесс пакета-7). Нормы D39.46/47 вшиты. По итогам — дизайн-секция расширения эмиссии в продолжении пака-20 (СТОП → ратификация → стройка). Рекомендация оркестратора по параллельности: пак-19 (бэкенд-зона) может идти одновременно с пакетом-8 (полигон-зона); продолжение пака-20 — после обоих, перед мини-прогоном D39.40.
## D39.50 — Пакет-8 ПРИНЯТ (8/8 CONFIRMED, $0.11313): посылка «расширить эмиссию майнера» ЗАКРЫТА ОТРИЦАТЕЛЬНО — механизмом, не порогом; дизайн продолжения пака-20 переформулирован: ДИСЦИПЛИНА БАНКНОТЫ + ДЫРА ЯЗЫКА ОТВЕТА + allow_short + kwic_width в пар-данные; холодный старт — единственная неизмеренная цифра (26.07, оркестратор №8). ✅
**Ратифицировано фактами:** (1) **расширение `emitRankCap`/фильтра типа НЕ строится**: 66 из 74 поверхностей банкноты лежат вне варианта C по ЧАСТОТЕ (82% ниже emitMinFreq на срезе — вне кандидатного алфавита), а на хвосте расширенной эмиссии роль ВЫДУМЫВАЕТ dst 7782% стабильно (сентинел ⟦TM-NO-DST⟧ различает «переводимо», не «терм», и нестабилен даже там — размах 0.375); (2) **носитель покрытия — банкнота** (5/6 кандидатов, класс term с dst), но она невоспроизводима (Жаккар 0.0770.40 между прогонами одних глав) и эмитирует 《咏梅》/咏梅 как разные строки — лечения, выдержавшие проверку: нормализация книжных кавычек + агрегация по прогонам; **частотного экрана НЕ существует** (§5.1: 少年 1149 против 光阴之河 15 — порога нет; полигон сам снял свою рекомендацию); (3) **дыра языка ответа**: ⟦TM-CANON⟧ — де-факто якорь ЦЕЛЕВОГО ЯЗЫКА; без него 22/40 английских строк в одном прогоне из трёх (8×120 тоже течёт), и конвейер их банкует (`wellFormedLemma` пропускает латиницу) — закрыть ДО любого расширения канала; (4) **kwic-дефолт 3×40 ДОСТАТОЧЕН** (ни одна точка сетки не отличима, 0×0 — лучшая; развилка «подача×контекст» неразличима), НО `kwic_width` в РУНАХ = утечка пары в общий слой (4384 морфемы zh против обрубка фразы en) → пар-данные; (5) **蛊/转 подписаны и не доезжают ни одним каналом** (`SignificantLen=1`) — правка ДАННЫХ сида (`allow_short: true`); (6) **условие судьи-экрана (фолбэк D39.46) наступило**, но экран платный за кандидата — дизайн обязан посчитать, где ставить и что снимается бесплатно; (7) **холодный старт с пустым банком — не мерил никто**, и именно там цена любого утверждения о покрытии (кандидат — совместить с мини-прогоном D39.40); (8) §6: **en-детектора НЕ СУЩЕСТВУЕТ** (`hanRuns` — 0 эмиссии на латинице), ja с zh-таблицами активно неверен (0/10) — второй книге нужен второй ДЕТЕКТОР, эксперименты на Кристоффе преждевременны; прототип каналов — `mine_nonhan.py` пакета-7.
**Напряжение с D39.46 «контексты — главный рычаг» названо, НЕ отменено:** три различия постановки (лёгкая выборка · якорь в боевой сборке · нет строки drafts:); пере-замер на трудной выборке пакета-7 с боевой сборкой — кандидат, до него kwic не трогать. **Дефект нарезки пакета-7** (фильтр по номеру 节 при перезапуске нумерации в томах): абсолютные recall фазы A занижены втрое (0.089→0.255 на непрерывном срезе), структурный вывод (term кратно слабейший) УСТОЯЛ. Приёмка: 8/8 CONFIRMED, ревью-шапка с нитами на отчёте `POLYGON_PREMEASURE_2026-07-26.md`; деньги $0.11313 из потолка $0.30.

View file

@ -0,0 +1,783 @@
# Полигон, пакет-8: три пре-замера перед дизайном расширения эмиссии
**Санкция:** D39.49 (владелец, 26.07.2026), база D39.48. Бюджет ≈$0.14, потолок $0.30.
> **Ревью-шапка (оркестратор №8, 26.07): ПРИНЯТ, D39.50 — 8/8 CONFIRMED.** Приёмка исполнением из
> сохранённого сырья (147 вызовов): деньги $0.11313 пересчитаны по usage до токена и цента; множества
> каналов (13/67/2/78) пере-раном joint_recall на read-only копиях БД; цензура 49/51 и правомерность
> пре-рег стопа — пересчётом; разметка замера 2 зафиксирована ДО прогона (mtime-цепочка), выдумки
> 77/80/78 из 100 — пере-раном скорера; сетка §3.1 — все клетки, латиница 22/40 без якоря — боевым
> ParseReply; дефект нарезки пакета-7 — строкой кода (split_book.py:210-211) и пере-раном непрерывного
> среза (0.255); частоты §5.1 — grep по полной книге, все 23 числа. Ниты: (а) типы строк банкноты в
> §1.3 посчитаны по ТРЁМ прогонам при «67 поверхностях» основного — популяция сменена без оговорки
> (по основному: 57/9/3/2); (б) mtime-довод §2 доказывает «не правлено после», сильнее артефактного
> доказательства не существует. Пре-регистрационная дисциплина (сработавший стоп §1.2 вместо подгонки,
> снятая собственная рекомендация §5.1) — образцовая.
**Зона:** `eval/pkg7/` (инструменты) + этот отчёт. `backend/` — только чтение, сессия не коммитит.
---
## Эхо-блок (что я услышал, ДО работы)
1. Три пре-замера перед дизайном расширения эмиссии. Словарь отменён; ось — «что банкуется без подписи ≠ что кладётся на подпись».
2. **З1 ($0):** recall майнера · банкноты · их объединения против `guzhenren-seed-v2.yaml`, по классам, плюс доля сида, которую банкнота уже покрывает. Майнер — на объявленном коммит-пине через `minerharness`.
3. **З2 (~$0.01):** ~100 кандидатов из хвоста варианта C, боевой промпт роли с HEAD, дешёвая модель, один прогон; критерий «выдумала dst мусору» и порог провала — названы ДО трат. При провале фолбэк D39.46 констатирую, не строю.
4. **З3 (~$0.12):** сетка kwic×подача, минимум 3×40 · 8×120 · промежуточные, 3 повтора, мера против подписанного сида; прямой ответ на развилку «широкая подача × узкий контекст vs узкая × широкий».
5. Смета до вызовов, деньги из `usage` до цента; превышение потолка = стоп и пинг.
6. Нормы D39.46/47: арм без фактора · сравнение на расходящихся позициях · декой · пре-регистрация с критерием провала · критик полноты на КАЖДУЮ фазу · самопроверка исполнением.
7. Право сказать «этого делать не надо» сохраняю: замер, подрывающий посылку, = стоп и канал вопросов.
8. После отчёта — СТОП; итоги уходят в дизайн расширения (бэкенд, пак-20).
---
## §0 Пре-регистрация — написана ДО прогонов и до единого платного вызова
Разделы §1§3 заполняются ПОСЛЕ. Всё, что ниже этой черты, зафиксировано заранее; любое отклонение
от плана помечается в теле отчёта явно, с причиной.
### §0.1 Общие правила
- **Пин.** Майнер и сборка запроса роли гоняются из копии бэкенда, извлечённой `git archive <commit>`,
а не из рабочего дерева. Коммит объявляется в теле замера.
- **Деньги.** Всякая цифра стоимости — из фактического `usage` ответа провайдера, пересчитанного по
`backend/configs/models.yaml`. Смета публикуется ДО прогона; расхождение смета↔факт называется.
- **Сырьё.** Каждый платный вызов сохраняется целиком (запрос, ответ, `usage`, `finish_reason`)
в скретчпаде сессии; выводы пересчитываются ИЗ сырья отдельным скриптом, а не из памяти прогона.
- **Провал замера — тоже результат.** Если критерий не даёт разделения, это пишется как «неизмеримо
на этих данных», а не подгоняется под ожидание.
### §0.2 Замер 1 — совместный recall (майнер банкнота)
**Вопрос.** Какую долю ПОДПИСАННОГО банка книги два канала эмиссии находят вместе, и сколько из этого
даёт банкнота, которой в recall-программе пакета-7 не было вовсе.
**Знаменатель.** Термы `guzhenren-seed-v2.yaml`, физически встречающиеся в срезе (та же дисциплина,
что в `seed_recall.py`: иначе меряется длина среза, а не recall).
**Срез.** Тот, на котором реально гонялась банкнота, — `minirun-banknote/guzhenren-ch1-10.gb18030.txt`.
Оба канала меряются на ОДНОМ тексте, иначе объединение бессмысленно.
**Каналы.**
- Майнер: `minerharness`, сид ПУСТОЙ (иначе `miner_emit.go` исключает засеянное по построению и
recall выходит 0 по определению).
- Банкнота: блоки ⟦TM-BANK-v1⟧ из сырья прогонов, разобранные репликой `parseBanknote`.
**Пре-названная угроза достоверности (главная).** Прогон банкноты шёл с НЕПУСТЫМ банком: промпт роли
переводчика просит «НОВЫЕ … которых НЕТ в приложенном глоссарии». Значит терм сида, попавший в
инъекцию чанка, банкнота не могла предложить ПО ПОСТРОЕНИЮ — ровно та же ловушка, из-за которой
майнер обязан гоняться с пустым сидом. Поэтому заранее фиксирую разбиение знаменателя:
- **цензурированные** — термы сида, инъектированные хотя бы в один чанк (у банкноты не было шанса);
- **со свободным шансом** — термы сида, не инъектированные нигде.
Recall банкноты считается ОТДЕЛЬНО на второй группе; сырая цифра «по всему знаменателю» публикуется,
но помечается как нижняя граница, а не как оценка способности канала.
**Критерий «замер не состоялся»:** если группа «со свободным шансом» меньше 10 термов, совместный
recall против подписанного сида объявляется НЕИЗМЕРИМЫМ на существующих артефактах, и вместо
подгонки пишется, чего стоит его измерить (холодный прогон с пустым банком).
**Второй выход, не зависящий от цензуры** (страховка, названа заранее): пересечение и разности
множеств эмиссии двух каналов на одном срезе — сколько поверхностей даёт только майнер, только
банкнота, оба. Этот вопрос («что банкуется без подписи ≠ что кладётся на подпись») цензурой не
затрагивается, потому что не опирается на сид.
**Что считается основанием для расширения эмиссии:** если объединение по классу `term` остаётся
ниже 0.20 — расширение оправдано; если объединение ≥0.50 — выигрыш расширения кратно меньше того,
что показала абляция, и это надо сказать вслух.
### §0.3 Замер 2 — доля отказов роли на мусоре
**Вопрос.** Отвечает ли терминолог сентинелом ⟦TM-NO-DST⟧ на кандидатов, которым перевода не
существует, — или выдумывает dst грамматическим огрызкам. Правило проверяется впервые (0 пустых
консолидаций из 875 в живом прогоне).
**Материал.** ~100 кандидатов из ХВОСТА расширенной эмиссии варианта C (снят потолок `emitRankCap` +
снят фильтр типа) — ровно та популяция, которую расширение эмиссии впустит в вход роли.
**Промпт.** Боевой `backend/prompts/zh-ru/terminologist.md` на HEAD (без жанрового блока), запрос
собирается боевым кодом (`terminology.RenderBatch` + `RenderCanonAnchor` + `MessagesWithInjection`),
не моей репликой. Модель — дешёвая, один прогон.
**Разметка (названа ДО прогона).** Каждый кандидат раскладывается на три класса ПО ИСХОДНИКУ, до
того как я увижу ответ:
- **МУСОР** — строка не является языковой единицей (грамматический огрызок: `便是`, `的关`, `则是一`);
- **ГОДНЫЙ** — самостоятельная именуемая сущность или доменное понятие;
- **СЕРЫЙ** — обычное слово/словосочетание языка, не термин книги (`明日`, `议论声`).
Разметка одного разметчика, фиксируется файлом ДО отправки запроса и не правится после.
**Метрика.** Доля МУСОРНЫХ кандидатов, на которые роль вернула непустой dst («выдумала»).
**Пороги, названные заранее.**
- **Провал** — выдумывает >50% мусора: сентинел не работает как фильтр, расширение эмиссии без
внешнего экрана пустит выдумки в карту подписи. Фолбэк уже ратифицирован (D39.46, судья-с-декоем);
строить его не моя задача — констатировать.
- **Годно** — выдумывает <20%: сентинел несёт свою функцию, расширение можно проектировать без
дополнительного экрана.
- **Серая зона** 2050% — вывод «нужен экран или нужна правка промпта», без выбора между ними.
**Контроль (арм без фактора).** Та же батча с ДОБАВЛЕННЫМИ 10 заведомо ГОДНЫМИ кандидатами из
подписанного сида. Без него неизмеримо, отличает ли роль мусор от годного вообще: 100% отказов на
чистом мусоре и 100% отказов на всём — разные вещи, а по одной батче они неразличимы.
**Что НЕ измеряет.** Качество dst у годных кандидатов (это З3) и поведение на других парах.
### §0.4 Замер 3 — сетка kwic × подача
**Вопрос.** Боевой дефолт 3×40 не покрыт ни одним замером (весь пакет-7 мерил ~8×120). Где на кривой
цена/качество стоит дефолт и есть ли между точками разрыв, ради которого его стоит двигать.
**Точки сетки** (kwic_per × kwic_width): `3×40` (боевой дефолт) · `5×80` · `8×120` (точка пакета-7) ·
`3×120` и `8×40` — последние две есть ответ на развилку владельца «широкая подача × узкий контекст vs
узкая × широкий»: они несут сопоставимый объём контекста при противоположной форме подачи.
**Арм без фактора (обязателен по D39.46):** `0×0` — кандидаты вообще без строк `ctx:`. Без него
«контексты — главный рычаг» на боевом промпте не измерено, а перенесено из другого харнесса.
**Выборка.** Термы подписанного сида, физически встречающиеся в срезе, — мера верности есть
совпадение с подписью владельца. Выборка типа S1 пакета-7 (термы с расхождением, не вырожденные).
**Повторы.** 3 прогона каждой точки. Разрыв между точками засчитывается ТОЛЬКО если он больше
внутриточечного размаха — правило, купленное фазой B пакета-7 за $0.024.
**Метрика.** Доля термов выборки, где консолидированный dst совпадает с подписью владельца
(нормализация: регистр, ё/е, пробелы). Плюс стоимость точки из фактического `usage`.
**Пороги, названные заранее.**
- Дефолт 3×40 признаётся **достаточным**, если его точность не ниже точности 8×120 минус
внутриточечный размах.
- Дефолт признаётся **заниженным**, если существует точка с точностью выше 3×40 больше чем на
размах, и её цена на терм ниже удвоенной цены 3×40.
- Развилка решается ТОЛЬКО если `3×120` и `8×40` расходятся больше чем на размах; иначе вывод —
«форма подачи при равном объёме контекста не различима на этой выборке».
**Что НЕ измеряет.** Одна книга, одна пара, одна модель. Перенос на другие пары — гипотеза.
---
## §1 Замер 1 — совместный recall (майнер банкнота). $0
**Пин:** `0a7fa5b` (HEAD). Эмиссия майнера на этом пине **побайтно совпадает** с пином пакета-7
`996bade` (`cmp` чист) — проверено исполнением, а не принято по диффу: между коммитами в
`miner_emit.go` добавлены счётчики `EmissionStats`, поле `Term.Dst` и режим `draft` в `DeltaYAML`,
и надо было убедиться, что ни одно из этого не сдвинуло эмиссию. Цифры пакета-7 и пакета-8 сравнимы.
**Срез:** `minirun-banknote/guzhenren-ch1-10.gb18030.txt` — 10 节 тома 1, 27 322 знака, 20 чанков.
Это тот же файл, на котором гонялся прогон с банкнотой, поэтому оба канала меряются на одном тексте.
### §1.1 Отклонение от посылки промта — сказано вслух
Промт говорит: «блоки ⟦TM-BANK-v1⟧ уже лежат в черновиках `corpus.jsonl` пакета-6 (91 юнит, 6
прогонов)». **Это не так, и я проверил все артефакты стенда, а не только названный.** В
`corpus.jsonl` блок несут **2 юнита из 91** (оба`verify2`), и пакет-6 честно классифицировал их
как утечку служебного блока в текст черновика. Прогоны `acceptance`, `minirun`, `rerun2-*` шли с
каналом ВЫКЛЮЧЕННЫМ — блоков там нет вовсе.
Сырьё банкноты живёт не там: в трёх базах прогонов (сканированы все 21 база стенда).
| База | Блоков | Строк | Срез |
|---|---|---|---|
| `minirun-banknote/guzhenren-banknote.db` | 9 | 71 | гл. 110 (**основной**) |
| `minirun-verify/guzhenren-verify.db` | 2 | 10 | гл. 12 |
| `minirun-verify2/guzhenren-verify2.db` | 2 | 6 | гл. 45 |
**Парсер сверен с боевой телеметрией, а не объявлен верным:** моя реплика `parseBanknote` даёт
пер-чанково те же числа, что движок записал в `retrieval_state.n_banknote_lines`, включая
`banknote_parse_fail=1` на гл.3 — совпадение полное, 71 = 71.
### §1.2 Пре-зарегистрированный стоп СРАБОТАЛ: совместный recall против сида неизмерим
Критерий §0.2 («если группа со свободным шансом < 10 термов замер объявляется несостоявшимся»)
выполнился с запасом.
| | Термов | recall |
|---|---|---|
| Знаменатель: термы сида, встречающиеся в срезе | 51 | — |
| Майнер (как терм) | 12 | 0.235 |
| Майнер (терм алиас) | 14 | **0.275** |
| Банкнота (сырая) | 2 | 0.039 ← нижняя граница |
| **Объединение** | 14 | **0.275** |
**Цензура: 49 из 51 термов сида были инъектированы в промт черновика, а промт просит «новые,
которых НЕТ в приложенном глоссарии».** Свободных от инъекции — 2 терма. Уточнение до уровня
ГЛАВЫ (инъекция пер-чанковая, а не книжная, поэтому пар «терм × глава» больше) даёт 21 свободную
пару из 217 — и на всех 21 банкнота не предложила ничего.
И этот знаменатель ещё и **структурно смещён**: все свободные пары дают ровно три поверхности —
`蛊`, `转`, `古月`. Они не инъектировались не случайно, а потому что коротки (см. §1.5). То есть
«свободный шанс» и «короткая поверхность» на этих данных — одно и то же множество, и вывода о
способности канала из него не извлечь никакого.
**Вывод:** совместный recall против ПОДПИСАННОГО сида на существующих артефактах **не измеряется**.
Чтобы измерить, нужен холодный прогон черновой волны с ПУСТЫМ банком и включённой банкнотой — это
не $0. Подгонять цифру под ожидание я не стал.
### §1.3 Страховочный выход (пре-зарегистрирован, цензурой не затронут): множества каналов
| | Поверхностей |
|---|---|
| Майнер | 13 |
| Банкнота (основной прогон) | 67 |
| Пересечение | **2** |
| Только майнер | 11 |
| Только банкнота | **65** |
| Объединение | 78 |
**Каналы почти не пересекаются: 2 общие поверхности из 78.** Разъезд в 3%, названный в D39.42, на
уровне множеств выглядит как разъезд в 97%. И объём даёт банкнота: 67 против 13, то есть **пять
шестых кандидатов в карту подписи приходит не от майнера**.
По типам строк банкноты: `term` 67 строк · `title` 13 · `name` 4 · `place` 3. То есть банкнота
эмитирует ровно тот класс, который майнер не эмитирует НИКОГДА (фильтр типа `name|place|title`).
Среди её предложений — `光阴之河` (Река времени), `十大奇蛊`, `九转境界`, `力量蛊`, `智慧蛊`,
`青铜真元`, `紫府`, `秘法`, `遗藏`, `蛊室`, `性命交修`, `开窍大典`.
### §1.4 Но объём банкноты — не покрытие: два объективных дефекта канала
Разметку «годный/мусор» я здесь сознательно НЕ применяю (это был бы мой вкус). Беру пороги, которые
движок уже применяет к другому каналу, — `emitMinFreq=5` и `runeLen≥2` из `miner_emit.go`:
| Свойство предложений банкноты (67 поверхностей) | Доля |
|---|---|
| Прошли бы боевые пороги майнера (частота ≥5 и длина ≥2) | 11 = **0.164** |
| Встречаются в ≥2 главах среза | 18 = 0.269 |
| Встречаются в срезе **≤1 раза** | 32 = **0.478** |
Почти половина предложений — разовые фразы (`三天三夜` «три дня и три ночи», `六块` «шесть штук`,
`白银盘子` «серебряная тарелка», `雨声` «звук дождя`, `中年`/`少年`/`老年`). Это не терминология
книги, это обычные слова, которые модель приняла за термины.
**Второй дефект — повторяемость.** Главы 12 прогонялись ДВАЖДЫ, побайтно тем же промтом
(`prompt_sha256 = d1dc7a3a2675…`), той же моделью `deepseek-v4-flash`, при temperature 0.3:
| | Поверхностей | Пересечение | Жаккар |
|---|---|---|---|
| Прогон A vs прогон B, как есть | 5 vs 9 | 1 | **0.077** |
| То же, после снятия книжных кавычек `《》` | 5 vs 9 | 4 | **0.400** |
**Эмиссия банкноты — лотерея прогона.** Что попадёт в карту подписи, зависит от сэмплирования, а не
от книги. Для дизайна расширения это значит: канал, дающий 5/6 объёма, невоспроизводим, и опираться
на него как на измеритель покрытия нельзя без агрегации по нескольким прогонам.
**Третий дефект, мелкий, но он стоит строк владельцу.** Три поверхности приходят в книжных кавычках
(`《咏梅》`, `《将敬酒》`, `《江城子》`), и **тот же прогон** эмитирует их же без кавычек (`咏梅`,
`将敬酒`, `江城子`) с другим переводом. `NormalizeSourceKey` кавычки не снимает, значит это ЧЕТЫРЕ
разные строки в карте подписи вместо двух, и ни одна из них не сойдётся с ханьским n-граммным
пространством майнера.
### §1.5 Побочная находка: подписанный терм не доезжает до модели вообще
`蛊` — заглавное понятие книги, 188 вхождений в срезе, **владельцем подписан** (`蛊 → гу`,
`status: approved`). Он не доезжает до модели ни одним путём:
- **майнер не эмитирует**`runeLen(c.Src) < 2` (`miner_emit.go`);
- **инъекция не показывает**`SignificantLen=1 < minKeyLenHan=2` при `allow_short=0`
(`membank/memory.go:276`), то есть подписанная строка выбрасывается из индекса инъекции;
- **банкнота не предлагает** — 0 из 21 свободной пары.
То же у `转` («ранг», 61×). Это НЕ вопрос расширения эмиссии: терм уже подписан, его не надо
находить — его надо доставлять. Механизм отключения известен и у него есть штатный обход:
`allow_short: true` в строке сида. Правка — ДАННЫЕ, Go не трогается.
**Не раздуваю severity:** на этой книге промах компенсируется косвенно — «гу» приезжает в модель
внутри инъектированных `蛊师 → гу-мастер` и `蛊虫 → гу-червь`, и черновики действительно пишут «гу»
(видно в самих банкнотах: `力量蛊 → гу Силы`, `第三蛊 → третья гу`). То есть замер показывает дыру в
доставке, а не доказанный дефект перевода.
### §1.6 Методологическая находка про срезы пакета-7 (самопроверка исполнением)
Проверяя, почему мой срез «10 глав» в 7 раз короче одноимённого среза пакета-7, я нашёл дефект
нарезки в собственном инструменте пакета-7. В 蛊真人 нумерация 节 **перезапускается в каждом из 6
томов**, а `split_book.py --max-chapters N` фильтрует по НОМЕРУ (`c[0] <= max_ch`). Поэтому «gu 10
глав» пакета-7 — это не первые 10 节, а все 节 с номерами 110 из шести томов: рассеянная выборка по
всей книге (и с коллизией ключей `(chapter, chunk_idx)`).
Что это ломает и что нет — измерено, а не оценено:
| Срез | Знаков | Термов сида | recall (терм∪алиас) | term | name | title | place |
|---|---|---|---|---|---|---|---|
| пакет-7, «gu 25гл» (рассеянный) | 471 496 | 56 | 0.089 | 0.043 | 0.231 | 0.077 | 0.000 |
| пакет-8, 25 节 ПОДРЯД | 70 708 | 55 | **0.255** | 0.091 | 0.308 | 0.462 | 0.400 |
**Абсолютные recall пакета-7 занижены нарезкой втрое** (потолок `emitRankCap=200` на длинном тексте
режет сильнее). **Структурный вывод пакета-7 устоял целиком:** класс `term` и на непрерывном срезе
остаётся кратно ниже остальных (0.091 против 0.310.46), а оба «попадания» в классе `term`
`元海` и `元石` — попали не как термины, а случайностью паттерна (`元` читается как фамилия, `海` как
топо-суффикс). Ни одного доменного понятия майнер по-прежнему не эмитирует.
Влияние на `internal/miner` — нулевое: `ChunkIdx` майнер не читает вовсе, а `Chapter` использует
только для рассеяния (`miner_substrate.go:116`), так что склейка томов рассеяние занижала, а не
завышала.
**Чувствительность к нарезке пере-проверена и на этом срезе:** свип целевого размера чанка
800/2000/6000 даёт побайтно одну и ту же эмиссию (Жаккар 1.000). Цифры §1 не артефакт субстрата.
### §1.7 Ответ на вопрос, ради которого замер заказан
Пре-регистрация §0.2 назвала порог: объединение по классу `term` ниже 0.20 = расширение оправдано.
**Измеренное объединение по `term` = 0.105** (2 из 19, и оба — случайность паттерна).
Но главный итог замера не в этой цифре, а в переформулировке:
1. **Расширять эмиссию майнера — не единственная и не первая опция.** Канал, который уже сегодня
даёт 5/6 кандидатов и умеет класс `term` вместе с dst, — это банкнота, а не майнер.
2. **Банкнота при этом непригодна как измеритель покрытия в нынешнем виде:** 48% предложений
разовые, 16% прошли бы боевые пороги, Жаккар между двумя прогонами одних и тех же глав 0.0770.40.
3. Значит вопрос дизайна — не «поднять ли `emitRankCap`», а **«чем дисциплинировать банкноту»**:
частотный порог по тексту книги (он у движка уже есть для майнера), нормализация книжных кавычек,
агрегация по прогонам. Всё три — дешёвые, детерминированные и $0 в проде.
**Чего замер не покрывает.** Холодного старта (банк пуст) не мерил никто; сколько банкнота
предложит, когда её не глушит инъекция, неизвестно, и это ровно та цифра, на которой стоит цена
расширения. Одна книга, одна пара, одна модель, 10 глав.
### §1.8 Критик полноты фазы 1
Что проверено дополнительно по итогам критика и что осталось непокрытым:
| Проверка | Итог |
|---|---|
| Парность пинов `0a7fa5b` / `996bade` | эмиссия побайтно одна (`cmp`) |
| Мой парсер банкноты против боевой телеметрии | пер-чанково совпадает, 71 = 71, `parse_fail` тоже |
| Все ли источники банкноты найдены | просканирована 21 база стенда; блоки только в 3 |
| Одинаковы ли промты двух прогонов гл.12 | `prompt_sha256` совпадает — сравнение честно |
| Чувствительность к нарезке | Жаккар 1.000 на 800/2000/6000 |
| Молчание канала | 11 из 20 чанков не выдали блок вовсе, `truncated=0` — это молчание, не обрезка |
| Круговая порука сида | все 53 подписанные строки имеют `source='seed'` (не промоушен из майнинга); но собирал ли владелец сид, глядя на дельты прошлых паков, из артефакта не восстановить — **остаётся угрозой достоверности** |
| **НЕ покрыто** | холодный старт; вторая книга; вторая пара; вторая модель |
---
## §2 Замер 2 — доля отказов роли на мусоре. Факт $0.0064 (смета ≤$0.0183)
**Как собран запрос.** Не моей репликой: Go-харнесс `eval/pkg7/termharness` собирает батчи БОЕВЫМ
кодом — `terminology.Merge``AttachKWIC``ScoreVariants``Batch``RenderCanonAnchor`
`pipeline.MessagesWithInjection`, промт `backend/prompts/zh-ru/terminologist.md` с HEAD. Пин
`0a7fa5b`. Ответы разбираются боевым `terminology.ParseReply` (тот же харнесс, `-mode parse`) — он
и решает, что считается ответом, что отказом, а что «плохой строкой». Модель `deepseek-v4-flash`,
`max_tokens=8000` (пол `min_max_tokens` из `models.yaml`, к которому боевой `terminologyCallBudget`
батч такого размера и приводит), thinking не отключался.
**Материал.** 100 кандидатов из хвоста РАНГА варианта C (потолок `emitRankCap` снят + снят фильтр
типа; ранг записан самим движком в пин-копии, так что «хвост» определён порядком движка, а не моим
вкусом) + 10 контрольных заведомо годных строк подписанного сида. **Контрольные строки исключены из
якоря ⟦TM-CANON⟧** — иначе контроль выродился бы в списывание из якоря. 7 батчей.
**Разметка** зафиксирована файлом ДО отправки: МУСОР 16 · СЕРЫЙ 84 · ГОДНЫХ в хвосте 0.
### §2.1 Результат
| Класс | Кандидатов | Выдумала dst | ⟦TM-NO-DST⟧ | Промолчала |
|---|---|---|---|---|
| **МУСОР** (огрызки и «числительное+счётное») | 16 | 9 = **0.562** | 7 = 0.438 | 0 |
| **СЕРЫЙ** (обычные слова, не термины книги) | 84 | 68 = **0.810** | 0 | 16 |
| **КОНТРОЛЬ** (подписанные термы) | 10 | 9 = 0.900 | 0 | 1 |
Плохих строк парсера — 0.
**Вердикт по пре-зарегистрированному порогу §0.3: доля выдумок на МУСОРЕ 0.562 > 0.50 → ПРОВАЛ.**
### §2.2 Но провал не там, где я ждал — структура ответов её объясняет
Разбиение класса МУСОР по ответам оказалось идеальным и не случайным (это разбиение ПОСТ-ХОК, оно
не было пре-зарегистрировано, и я это помечаю):
| Подкласс МУСОРА | Строки | Ответ роли |
|---|---|---|
| Разрез поперёк границы слова | `了上` `了不` `人一` `人不` `人是` `他在` `在大` | ⟦TM-NO-DST⟧ — **7 из 7** |
| «Числительное + счётное слово» | `一人` `一句` `一天` `一年` `一座` `一番` `一面` `三年` | перевод — **8 из 8** («один день», «три года») |
| Прочее | `是为` | «являться» |
**Сентинел различает НЕ «терм / не терм», а «переводимо / непереводимо».** Это ровно то, о чём
промт роли и просит («если не можешь выбрать перевод уверенно»), — и значит на своей задаче правило
работает безупречно, а на задаче «отсеять не-терминологию» не работает вовсе: `一天` переводится
уверенно и правильно, термином книги от этого не становясь.
Подтверждение с другой стороны — контроль: на подписанных термах роль отвечает 9 из 10, и 8 из 9
ответов совпадают с подписью владельца буквально (`方源→Фан Юань`, `蛊→гу`, `蛊师→гу-мастер`,
`古月→Гуюэ`, `方正→Фан Чжэн`, `资质→талант`, `家老→старейшина`, `酒虫→винный червь` — регистр иной).
Расходится один: `转 → «поворот»` против подписанного «ранг». То есть роль **умеет** давать нужный
ответ на годном материале и **не умеет** отказываться от негодного.
### §2.3 Цена вопроса для дизайна расширения
Из 100 кандидатов хвоста роль выдумала перевод **77**. По §C2-7 консолидированный dst переводит
строку в режим `status: draft` — то есть в инъекцию редактора с пометкой ⟨проверить⟩. Значит
расширение эмиссии до варианта C без внешнего экрана кладёт в рабочий канон книги строки вида
«люди», «различный», «один день» — помеченные, неподписанные, но уже влияющие на редактуру.
Фолбэк на этот случай ратифицирован заранее (D39.46: слепой судья с декоем как катастроф-экран).
**Строить его не моя задача — констатирую, что условие его применения наступило.** И называю то,
чего в фолбэке нет: судья-экран стоит денег на каждый кандидат, а 82% лишних кандидатов отсекаются
БЕСПЛАТНО частотным порогом, который у движка уже есть (см. §1.4 и §2.4).
### §2.4 Побочный результат, который меняет постановку задачи
Я пересёк эмиссию варианта C с поверхностями банкноты на одном срезе (гл. 110, $0):
| | Поверхностей |
|---|---|
| Вариант C (потолок и фильтр типа сняты) | 559 |
| Банкнота, **все три прогона** (в §1.3 бралcя один — там 67) | 74 |
| **Пересечение** | **8** |
| Банкнота ВНЕ варианта C | **66** |
**Расширение эмиссии майнера НЕ достаёт того, что находит банкнота.** Причина измерена, а не
предположена: **82% поверхностей банкноты встречаются в срезе реже 5 раз** (55 из 67 в основном
прогоне) и потому лежат ниже порога `emitMinFreq`, то есть вне кандидатного алфавита майнера —
независимо от потолка ранга и фильтра типа. Из 11 поверхностей банкноты, проходящих боевые пороги, вариант C содержит 8; три
остальные срезаны другими правилами (`古月方源` поглощён алиас-кластером подписанного `古月`,
`力量蛊`/`智慧蛊` — субсумпцией).
Так что `光阴之河`, `十大奇蛊`, `九转境界`, `元气`, `力量蛊` снятием двух констант **не появятся**.
Их приносит только банкнота — тот канал, который §1.4 показал невоспроизводимым.
**Оговорка, которую надо держать рядом с этой цифрой.** «Ниже порога частоты» здесь — свойство
СРЕЗА в 10 глав, а не книги: во всём тексте `元气` встречается 101 раз, `遗藏` 143, `智慧蛊` 634
(§5.1). Боевой терминолог работает после полной черновой волны, то есть на полной книге, и там эти
поверхности в кандидатный алфавит майнера войдут. Вывода о расширении это не меняет: войдут они
вместе с `少年` (1149) и `中年` (244), а порога, разделяющего эти два множества, не существует (§5.1).
### §2.5 Критик полноты фазы 2
| Проверка | Итог |
|---|---|
| Сборка запроса — боевая, не реплика | да: `termharness` на пине, промт с HEAD |
| Разбор ответа — боевой `ParseReply` | да; плохих строк 0 |
| Контроль (арм с заведомо годным материалом) | есть, 10 строк, вне якоря канона |
| Повторы | 3 прогона, разброс — §2.6 |
| Есть ли ответы, которые парсер обязан был отсечь | нашлось: `是为 → «is»` (r2) — английское слово в zh→ru роли проходит `wellFormedLemma` и банкуется |
| Разметка зафиксирована до вызова | да, отдельным файлом |
| **НЕ покрыто** | одна модель; ГОДНЫХ строк в самом хвосте нет, поэтому precision роли на «расширенном, но осмысленном» материале не измерена; разметчик один |
### §2.6 Повторы (3 прогона, $0.0168 суммарно) — и они меняют формулировку вывода
| Класс | r0 | r1 | r2 | Размах |
|---|---|---|---|---|
| МУСОР | 0.562 | **0.938** | 0.562 | **0.375** |
| СЕРЫЙ | 0.810 | 0.774 | 0.821 | 0.048 |
| КОНТРОЛЬ | 0.900 | 0.900 | 1.000 | 0.100 |
Пер-строчная картина показывает, что именно скачет:
| Терм | r0 | r1 | r2 |
|---|---|---|---|
| `了上` | ⟦TM-NO-DST⟧ | «наверх» | ⟦TM-NO-DST⟧ |
| `人不` | ⟦TM-NO-DST⟧ | «люди» | ⟦TM-NO-DST⟧ |
| `他在` | ⟦TM-NO-DST⟧ | «он» | ⟦TM-NO-DST⟧ |
| `是为` | «являться» | «является» | **«is»** |
**Вывод правится по факту:** сентинел не просто «не фильтр не-терминологии» — он **нестабилен и на
своей собственной задаче**. В двух прогонах из трёх непереводимые огрызки отклоняются полностью, в
третьем переводятся все. Размах 0.375 при разрыве от порога 0.06 — то есть по одному прогону вывод
«роль отклоняет огрызки» был бы куплен случайностью сэмплирования. Устойчиво здесь только одно, и
это самое важное для дизайна: **на классе СЕРЫЙ, который и составляет 84% расширенной эмиссии, роль
выдумывает перевод в 7782% случаев в каждом прогоне.**
Отдельная улика: `是为 → «is»` — англоязычный ответ в zh→ru роли. Он проходит `wellFormedLemma`,
не является эхом исходника и потому банкуется как рабочий канон книги. Продолжение этой ниточки —
в §3.4, где она оказалась не случайностью.
---
## §3 Замер 3 — сетка kwic × подача. Факт $0.0963 (126 вызовов)
**Выборка:** 40 подписанных термов сида, встречающихся в непрерывном срезе 25 节 (отбор
детерминированный: вхождения вниз, порог ≥3). Эталон — подпись владельца. Все 40 **исключены из
якоря** ⟦TM-CANON⟧ (в якоре осталось 13 не пересекающихся строк), иначе замер мерил бы списывание.
Черновых вариантов (`drafts:`) у кандидатов нет — значит измеряемый фактор один: контексты.
**Сборка запроса — боевая** (`termharness` на пине `0a7fa5b`, промт с HEAD), разбор — боевой
`ParseReply`, модель `deepseek-v4-flash`, 3 повтора на точку.
### §3.1 Результат сетки
| Точка | Вызовов | $ | r0 | r1 | r2 | Средняя | Размах | $/1000 термов·прогон |
|---|---|---|---|---|---|---|---|---|
| **0×0** (арм без фактора) | 3 | 0.00530 | 0.625 | 0.650 | 0.625 | **0.633** | 0.025 | **0.044** |
| **3×40** (боевой дефолт) | 9 | 0.00935 | 0.575 | 0.600 | 0.675 | **0.617** | 0.100 | 0.078 |
| 3×120 | 18 | 0.01495 | 0.550 | 0.575 | 0.575 | 0.567 | 0.025 | 0.125 |
| 5×80 | 21 | 0.01682 | 0.550 | 0.625 | 0.625 | 0.600 | 0.075 | 0.140 |
| 8×40 | 18 | 0.01568 | 0.550 | 0.600 | 0.450 | 0.533 | 0.150 | 0.131 |
| **8×120** (точка пакета-7) | 48 | 0.02562 | 0.475 | 0.550 | 0.650 | 0.558 | 0.175 | **0.214** |
**Максимальный внутриточечный размах — 0.175. Разброс СРЕДНИХ по всем шести точкам — 0.100.**
То есть по пре-зарегистрированному правилу чтения **ни одна точка не отличима ни от одной другой.**
То же на расходящихся позициях (норма D39.46; подмножество считается ЗАНОВО в каждом повторе, иначе
подмножество, выбранное по одному прогону, само есть выбор по случайности):
| Точка | r0 | r1 | r2 | Средняя |
|---|---|---|---|---|
| 0×0 | 0.464 | 0.409 | 0.462 | **0.445** |
| 3×40 | 0.393 | 0.318 | 0.538 | 0.417 |
| 5×80 | 0.357 | 0.364 | 0.462 | 0.394 |
| 3×120 | 0.357 | 0.273 | 0.385 | 0.338 |
| 8×120 | 0.250 | 0.227 | 0.500 | 0.326 |
| 8×40 | 0.357 | 0.318 | 0.192 | 0.289 |
Расходящихся позиций 28/22/26 из 40. Внутриточечный размах здесь 0.273, разброс средних 0.156 —
**снова неотличимо.**
### §3.2 Вердикты по пре-зарегистрированным порогам §0.4
1. **Боевой дефолт 3×40 — ДОСТАТОЧЕН.** Его точность 0.617 против 8×120 = 0.558; порог «не ниже
8×120 минус размах» выполнен с большим запасом. Двигать дефолт вверх оснований НЕТ.
2. **Дефолт НЕ занижен.** Лучшая точка (0×0) выше него на +0.017 при размахе 0.175.
3. **Развилка владельца «широкая подача × узкий контекст vs узкая × широкий» — НЕРАЗЛИЧИМА
на этой выборке.** 8×40 = 0.533 против 3×120 = 0.567, разница 0.033 при размахе 0.175. Форма
подачи при сопоставимом объёме контекста ничего не решает; решает, по-видимому, вообще не объём.
4. **Кривая цена/качество монотонна ТОЛЬКО по цене.** От 0×0 к 8×120 стоимость растёт в 4.9 раза
($0.044 → $0.214 на 1000 термов за прогон), точность не растёт вовсе.
### §3.3 Напряжение с ратифицированным D39.46 — называю прямо, за отмену НЕ выдаю
D39.46 ратифицировал: «контексты — главный рычаг качества терминолога» (арм без KWIC терял 67
совпадений из 19 при внутриармовом размахе 1). **Мой замер этого не воспроизводит:** на боевом
промте арм без контекстов оказался не хуже всех остальных.
Я НЕ утверждаю, что D39.46 неверен. Три различия постановки, любое из которых объясняет расхождение,
и ни одно из которых я не устранял:
1. **Другая выборка.** Пакет-7 брал 19 термов С РАСХОЖДЕНИЕМ черновиков — то есть заведомо трудные.
Здесь — 40 подписанных термов по частоте вниз, среди них половина простых (`方源`, `方正`,
`古月赤城`), где модель права и без единого контекста.
2. **Другой промт.** Пакет-7 гонял собственную сборку без якоря ⟦TM-CANON⟧; здесь — боевая сборка
с якорем. Якорь может работать заменой контекстов (см. §3.4 — он оказался не безобиден).
3. **Нет строки `drafts:`.** В бою кандидат несёт варианты черновиков; здесь их нет ни у одной точки.
Контексты могли быть рычагом именно в связке «контексты + разноголосица черновиков».
**Что из этого следует практически, независимо от того, кто прав:** поднимать `kwic_per_term`
и `kwic_width` над боевым дефолтом 3×40 нечем обосновать. На лёгком материале контексты не помогают,
на трудном — вопрос открыт, и закрывает его пере-замер на выборке пакета-7 с боевой сборкой запроса,
чего я НЕ делал.
### §3.4 Побочный результат, самый тяжёлый в пакете: якорь держит ЯЗЫК ответа
Контрольный арм (не был в плане промта; добавлен как арм-без-фактора для якоря): `3×40` без блока
⟦TM-CANON⟧, 3 повтора, $0.0086.
| Арм | r0 | r1 | r2 | Средняя |
|---|---|---|---|---|
| 3×40 с якорем | 0.575 | 0.600 | 0.675 | 0.617 |
| 3×40 **без якоря** | 0.550 | 0.525 | **0.250** | 0.442 |
Провал в r2 не шум разметки. Вот что роль вернула:
```
一转 Rank 1 丙等 Grade C 修行 cultivation
元海 Primordial Sea 古月 Gu Yue 资质 talent
酒虫 Wine Bug 转 realm 青茅山 Qingmao Mountain
```
**Роль ответила по-английски** — 22 строки из 40 в одном прогоне из трёх. Счёт по всем 126 вызовам
замера (латиница без единой кириллицы в ответе):
| Арм | r0 | r1 | r2 |
|---|---|---|---|
| 0×0 · 3×40 · 3×120 · 5×80 · 8×40 | 0/40 | 0/40 | 0/40 |
| **8×120** | **3**/39 | **2**/39 | 0/40 |
| **без якоря** | 0/40 | 0/40 | **22**/40 |
Два вывода, и оба про дизайн расширения:
1. **Блок ⟦TM-CANON⟧ — де-факто якорь ЦЕЛЕВОГО ЯЗЫКА, а не только канона.** Его кириллические строки
удерживают модель в русском. Промт роли объявляет язык через `{{target_lang}}`, и одного этого
объявления не хватает.
2. **Это ровно сценарий расширения.** Якорь пуст или короток именно на книге, где подписано мало
строк, — то есть на холодном старте и сразу после расширения эмиссии, когда кандидатов много, а
подписей мало. Соотношение «много кандидатов / пустой якорь» — та самая точка, где замер показал
срыв в английский.
3. **Ничто в конвейере этого не ловит.** `ParseReply` пропускает латиницу (`wellFormedLemma` её
допускает — она нужна для латинских имён), эхо-гейт не срабатывает (ответ не равен исходнику),
и строка уезжает в карту подписи как `status: draft` — то есть в инъекцию редактора с
⟨проверить⟩. В §2.6 то же самое поймано отдельно (`是为 → «is»`), там я счёл это единичным.
### §3.5 Критик полноты фазы 3
| Проверка | Итог |
|---|---|
| Арм без фактора (0×0) | есть, и он оказался лучшей точкой |
| Повторы и правило «разрыв > размаха» | 3 повтора на точку; правило применено, разрывов нет |
| Сравнение на расходящихся позициях | посчитано ПОКАЖДОМУ повтору, не по одному |
| Батчи не роняли термы | «молчание» ≤2 термов на прогон; все ответы `finish=stop` |
| Утечка эталона через якорь | 40 термов выборки из якоря исключены; проверено по файлу канона |
| Язык ответа | посчитан по всем 126 вызовам — так и нашлась находка §3.4 |
| Ошибки вызовов | 0 |
| **НЕ покрыто** | выборка пакета-7 (трудные термы) на боевой сборке; строка `drafts:`; вторая модель; вторая пара; влияние `batch_runes` (фиксирован 6000 во всех точках) |
---
## §4 Деньги — из фактического `usage`, до цента
| Арм / проба | Вызовов | Вход (в т.ч. кэш) | Выход | $ |
|---|---|---|---|---|
| Замер 2, `probe2` ×3 | 21 | 90 015 (67 072) | 47 959 | 0.01683 |
| Замер 3, `0×0` | 3 | 7 581 (6 016) | 18 091 | 0.00530 |
| Замер 3, `3×40` | 9 | 35 307 (26 624) | 28 785 | 0.00935 |
| Замер 3, `3×120` | 18 | 81 954 (60 672) | 42 128 | 0.01495 |
| Замер 3, `5×80` | 21 | 93 684 (69 504) | 47 278 | 0.01682 |
| Замер 3, `8×40` | 18 | 80 571 (59 904) | 45 067 | 0.01568 |
| Замер 3, `8×120` | 48 | 210 399 (156 672) | 63 073 | 0.02562 |
| Замер 3, без якоря | 9 | 34 617 (27 008) | 26 601 | 0.00859 |
| **ИТОГО** | **147** | **634 128 (473 472)** | **318 982** | **$0.11313** |
Бюджет $0.14, потолок $0.30. **Факт $0.11313** — 81% плана. Замер 1 бесплатен целиком.
Цены — `deepseek-v4-flash` $0.14/$0.28 за 1M, кэш $0.0028 (`backend/configs/models.yaml`,
read-only). 75% входных токенов пришли из кэша — это и есть причина, по которой шесть точек сетки
поместились в смету одной.
---
## §5 Итог: что эти три замера говорят дизайну расширения эмиссии
1. **Вопрос «поднимать ли `emitRankCap`» закрыт отрицательно, и не порогом, а механизмом.**
Расширение майнера не достаёт того, ради чего затевалось: 66 из 74 поверхностей банкноты лежат
ВНЕ варианта C, и не из-за потолка ранга или фильтра типа, а потому что 82% из них не входят в
кандидатный алфавит по ЧАСТОТЕ. На полной книге частоты выше и часть этих поверхностей в алфавит
вернётся — вместе с `少年` (1149 вхождений) и `中年` (244), см. §5.1. Снятие двух констант ни в
одном из двух случаев не даёт того, ради чего его предлагали.
2. **Зато расширение дорого стоит в другую сторону.** На хвосте варианта C роль выдумывает перевод
для 77 кандидатов из 100 — и это устойчиво (77 · 80 · 78 из 100 в трёх прогонах). Сентинел
⟦TM-NO-DST⟧ различает «переводимо / непереводимо», а не «терм / не терм», и даже на своей задаче
нестабилен (размах 0.375).
3. **Настоящий носитель покрытия — банкнота, и её надо не расширять, а дисциплинировать.**
Она даёт 5/6 кандидатов и умеет класс `term` вместе с dst. Но 48% её предложений встречаются в
срезе один раз, Жаккар между двумя прогонами одних и тех же глав 0.0770.40, и она эмитирует
`《咏梅》` и `咏梅` как две разные строки. Из дешёвых детерминированных лечений выдерживают
проверку **два**: нормализация книжных кавычек и агрегация по прогонам. Третье — частотный порог —
**я предложил и сам же опроверг замером, см. §5.1.**
### §5.1 Рекомендация, которую я снял собственной проверкой
Первая редакция §5 предлагала дисциплинировать банкноту частотным порогом — тем самым
`emitMinFreq`, который у движка уже есть для майнера, «$0 в проде, снимает 82% лишнего». Прежде чем
подавать это в дизайн, я посчитал частоты обеих популяций **во всей книге** (7.78 млн знаков, $0):
| Настоящие термы книги | В книге | Не-термы из той же эмиссии | В книге |
|---|---|---|---|
| 智慧蛊 «гу Мудрости» | 634 | 少年 «юность» | **1149** |
| 遗藏 «забытое сокровище» | 143 | 中年 «средний возраст» | 244 |
| 元气 «первичная ци» | 101 | 三天三夜 «три дня и три ночи» | 72 |
| 家主 «глава клана» | 95 | 六成 «шесть десятых» | 69 |
| 力量蛊 «гу Силы» | 48 | 停息 «прекратиться» | 39 |
| 光阴之河 «Река времени» | 15 | 地下溶洞 «подземная пещера» | 25 |
| 九转境界 | 12 | 早智 | 11 |
| 紫府 «Пурпурный дворец» | 7 | 八分 | 9 |
| 十大奇蛊 | 5 | 雨声 «звук дождя» | 4 |
| 古月先祖 «предок Гуюэ» | **2** | 参茶 · 白银盘子 · 方格子 · 乏闷 | 1 |
**Порога, разделяющего эти два столбца, не существует.** Самое частое слово всей выборки — не термин
(`少年`, 1149), а настоящий центральный концепт `光阴之河` («Река времени», 15) лежит НИЖЕ пяти
не-терминов. Частота снимает объём, но не тот: единственное, что она отсекает надёжно, — строки с
одним вхождением, и вместе с `参茶` она убивает `古月先祖`.
Вывод для дизайна — отрицательный и от этого не менее полезный: **дешёвого частотного экрана для
банкноты не существует, и планировать расширение в расчёте на него нельзя.** Отбор здесь
семантический, а значит либо стоит вызова модели (судья-с-декоем D39.46), либо остаётся владельцу.
Отдельно: та же таблица объясняет, почему `emitMinFreq=5` не «настроен плохо». Он не разделяет
термы и не-термы ни при каком значении — он лишь бюджетирует объём, и именно так его и надо читать.
4. **kwic-дефолты трогать нечем.** 3×40 не отличим ни от одной точки сетки, включая 8×120 за
5-кратную цену; развилка «широкая подача vs широкий контекст» неразличима.
5. **Перед любым расширением надо закрыть язык ответа.** Пустой или короткий якорь ⟦TM-CANON⟧ —
состояние книги, у которой мало подписей, то есть состояние ровно после расширения — дал 22
английских ответа из 40 в одном прогоне из трёх, и конвейер их не ловит.
6. **Мелочь, которая чинится данными:** `蛊` и `转` подписаны владельцем, но не доезжают до модели
ни одним каналом (`allow_short: false` при `SignificantLen=1`). Правка — строка в сиде.
**Чего эти замеры не покрывают** (сведено в одно место): холодный старт с пустым банком — не мерил
никто, и именно там стоит цена расширения · вторая книга, вторая пара, вторая модель · выборка
трудных термов пакета-7 на боевой сборке запроса · строка `drafts:` в кандидате · `batch_runes` как
ось · разметчик в замере 2 один.
**СТОП.** Итоги уходят в дизайн расширения эмиссии (продолжение пака-20, бэкенд). Ни строки в
`backend/` эта сессия не написала и не коммитила.
---
## §6 Добор по вопросу владельца: «хватает ли экспов по одной книге?» ($0, после СТОПа)
Владелец спросил (26.07), достаточно ли одной книги, раз движок строится универсальным, и не надо ли
гонять на английском Кристоффе. Проверил на стенде боевым майнером из того же пина. Ответ — не
надо, и не потому что «и так ясно», а потому что **гонять пока нечего: на латинице канал WHICH не
слабый, а мёртвый, а на японском — активно неверный.**
### §6.1 Три измеренных факта
**1. `en→ru` сегодня не запускается вовсе.** Пары `en`/`en-ru` в `backend/configs/langpacks/` не
существует, и загрузчик отказывается работать ЛОУДНО, как и задумано (D39.15: «language data is
required, never silently empty»). Это гардрейл общности, работающий правильно: недостающее — ДАННЫЕ,
и движок об этом кричит, а не деградирует молча.
**2. Когда загрузчик удовлетворён, канал WHICH на латинице даёт НОЛЬ.** Зеркалю zh-пак в `en`
(только в пин-копии скретчпада — цель не оценить таблицы, а увидеть кандидатный алфавит):
| Книга | Знаков | Эмитировано |
|---|---|---|
| Kristoff, *Empire of the Dawn* (25 глав) | 814 674 | **0** |
| *Fifty Shades* (26 глав) | 814 756 | **0** |
Механизм — не тюнинг: кандидатный алфавит есть `hanRuns` (`miner_substrate.go:46-48`), максимальные
пробеги ханьских иероглифов, а паттерны (`miner_patterns.go`) строятся поверх них. В латинском
тексте ханьских пробегов нет, поэтому пусто не «почти», а точно.
**3. На японском канал не молчит, а ошибается.** Тот же приём (zh-пак зеркалом в `ja`),
`異世界魔術師`, 25 глав, 134 204 знака → эмитировано 10 строк, **все типа `name`, и все десять —
ложные срабатывания китайских фамильных паттернов на японских composites:**
```
何故 «почему» 44× ← surname:何 王国 «королевство» 35× ← surname:王
何人 «сколько человек» 22× 王女 «принцесса» 27× ← surname:王|formant_suffix:女
马鹿 «дурак» 21× ← surname:马 元々 «изначально» 14× ← surname:元
何処 «где» 13× 范囲 «диапазон» 14×
危険 «опасность» 6× 解呪 «снятие проклятия» 8×
```
Precision 0/10. **Оговорка, обязательная:** я подставил КИТАЙСКИЕ фамильные таблицы японской книге,
поэтому ложняки — свойство подставленных данных, а не приговор японской паре. Но вывод от этого
только жёстче: настоящий ja-пак убрал бы эти десять, и **не добавил бы ничего**, потому что имена
собственные этой книги живут в катакане и руби (замерено пакетом-7, §A4.5), а `hanRuns` катакану не
видит по построению.
### §6.2 Что из пакета-8 переносится на другие пары, а что нет
| Вывод | Статус переносимости |
|---|---|
| Роль выдумывает dst 7780% на мусоре; сентинел различает «переводимо», а не «терм» | **вероятно пар-слепой** — свойство модели и промта, но популяция мусора на латинице другая (не n-граммные огрызки, а словосочетания); не проверено |
| Якорь ⟦TM-CANON⟧ держит язык ответа | **вероятно пар-слепой и ХУЖЕ для en→ru**: срыв в английский на английском исходнике неотличим от нормальной работы глазом |
| Частотного порога, разделяющего термы и не-термы, не существует | **пар-слепой по механизму**, но конкретные числа — этой книги |
| Каналы почти не пересекаются; 5/6 объёма даёт банкнота | **усиливается**: на en/ja доля майнера не 1/6, а ноль |
| **kwic-дефолт 3×40 достаточен** | **ТОЛЬКО zh.** См. §6.3 |
| Все recall-цифры, профиль банкноты, `allow_short` | этой книги и этой пары |
### §6.3 Дефект общности, найденный этим добором: `kwic_width` меряется в РУНАХ
`window()` (`terminology.go:332-349`) режет окно в рунах, и дефолт `terminologyDefaultKWICWidth = 40`
живёт в Go как одна константа на все пары. Что она означает физически:
```
[zh] 方源: «“方源,乖乖地交出春秋蝉,我给你个痛快!”…» 43 знака ≈ 43 морфемы ≈ 2 реплики
[zh] 开窍大典: 84 знака ≈ два полных предложения с окружением
[en] Gabriel: «offers reminder of these, our players:\nGabriel de León—The Last Silversaint, the Black»
13 СЛОВ, обрезано с обеих сторон посреди фразы
```
Одна и та же цифра даёт две реплики диалога на китайском и обрубок фразы на английском. **Это ровно
тот класс, который CLAUDE.md называет утечкой: Go не ветвится по паре (и правильно), но смысл
константы по паре различается, значит её место — пар-данные (`internal/lang`/langpack), а не
инженерный дефолт.** И мой собственный вердикт §3.2 «3×40 достаточен» надо читать как «4384 морфемы
контекста достаточно», а не как «числа 3 и 40 достаточно».
### §6.4 Что делать (предложение, НЕ исполнение — санкции не было)
1. **Гонять эксперименты на Кристоффе сейчас преждевременно.** Мерить нечего: WHICH-канала для
латиницы не существует, а терминолог без кандидатов не запускается. Второй КНИГИ мало — нужен
второй ДЕТЕКТОР.
2. **Дешёвый и честный первый шаг — не эксперимент, а решение:** признать, что `internal/miner`
детектор ханьской пары, и назвать, чем ловятся кандидаты на латинице. Прототип уже есть и
замерен пакетом-7: `eval/pkg7/mine_nonhan.py`, каналы ORTHO/DISP/CONTRAST; на Кристоффе он достал
`Ashdrinker`, `Silversaint`, `San Michon`, `Forever King` — то, что боевой канал не производит
в принципе.
3. **Что стоит перепроверить на второй паре ДО расширения** (когда детектор будет): срыв языка
ответа без якоря (§3.4) — на en→ru он опаснее всего; и `kwic_width` как пар-данные (§6.3).
4. **Чего второй книгой не купить:** цену расширения эмиссии. Она упирается в холодный старт с
пустым банком на ЛЮБОЙ паре, и это по-прежнему единственная неизмеренная цифра.

View file

@ -50,3 +50,23 @@
**Итог пакета (26.07): жанровый словарь ОТМЕНЁН владельцем как класс** — решение о переводе принадлежит подписи на банк памяти, а пар-словарь навязывал бы одно видение всем книгам пары. Замеры силы не теряют и перепрогонов не требуют (главный результат `P0b``P1` идёт по армам без словаря; `P5``P2` несут одинаковые инертные строки), беспредметен только арм `P0`. Отсюда последний и самый дорогой урок:
13. **Замер может подорвать ПОСЫЛКУ задачи, а не только её исполнение — и тогда деливерабл надо оспорить, а не исполнить.** Мы намерили ровно основание для отмены (V0 на центральном терме, ложная сходимость русских глоссариев, покрытие 0/80, четыре строки из шести отсутствуют в книге) — и всё равно принесли «таблицу на подпись», потому что она стояла в промте. Право сессии сказать «этого делать не надо» существует, канал вопросов для этого и заведён; не воспользоваться им дороже, чем не измерить вовсе, потому что так наш вкус едва не стал контрактом пары.
## Пакет-8 (пре-замеры перед расширением эмиссии, 26.07)
Отчёт — `docs/archive/reports/POLYGON_PREMEASURE_2026-07-26.md`. Инструменты живут здесь же по указанию промта пакета-8 (зона записи `eval/pkg7/`), хотя относятся к следующему пакету.
| Скрипт | Что делает |
|---|---|
| `termharness/` | собирает запрос БОЕВОЙ роли терминолога вне репо (`Merge``AttachKWIC``ScoreVariants``Batch``RenderCanonAnchor``MessagesWithInjection`); режимы `build` · `norm` (боевой `NormalizeSourceKey`) · `parse` (боевой `ParseReply`) |
| `joint_recall.py` | recall майнера · банкноты · объединения против подписанного сида; парсер ⟦TM-BANK-v1⟧ — реплика `parseBanknote`, сверенная с боевой телеметрией |
| `bank_profile.py` | цензура инъекцией по парам (терм × глава) + объективный профиль эмиссии банкноты боевыми порогами майнера |
| `role_probe.py` | драйвер платных вызовов: смета до трат, $0-резюм по уже оплаченным батчам, сырьё с `usage`/`finish_reason`, деньги из `models.yaml` |
| `score_probe2.py` | доля выдуманных dst по классам разметки, разброс по повторам |
| `score_grid.py` | сетка kwic × подача: точность против подписи, внутриточечный размах, расходящиеся позиции ПОКАЖДОМУ повтору |
Уроки, купленные пакетом-8:
14. **«Данные лежат там-то» — проверяй, прежде чем строить на этом замер.** Промт сказал, что блоки банкноты лежат в `corpus.jsonl` пакета-6. Их там 2 из 91; сырьё жило в трёх базах прогонов, и нашлось только сканированием всех 21 базы стенда. Полминуты проверки против замера, построенного не на том корпусе.
15. **Реплика формата запроса — третья копия и первый кандидат на расхождение.** Всё, что можно собрать боевым кодом, надо собирать боевым кодом: `termharness` вызывает те же функции, что рантайм, и вопрос «а точно ли модель видела то же самое» просто не возникает.
16. **Свою же рекомендацию проверяй замером ДО того, как подать её в дизайн.** Я предложил дисциплинировать банкноту частотным порогом («$0, снимает 82% лишнего») — и, посчитав частоты по всей книге, обнаружил, что самое частое слово выборки не термин (`少年`, 1149), а центральный концепт `光阴之河` — 15. Порога не существует; рекомендация снята собственной проверкой, а не ревью.
17. **Считай язык ответа, а не только его правильность.** Арм без якоря ⟦TM-CANON⟧ в одном прогоне из трёх ответил по-английски (22 строки из 40), и ни парсер, ни эхо-гейт этого не ловят. Находка появилась только потому, что я посчитал скрипт письма по всем 126 вызовам — метрика точности её не показывала.

169
eval/pkg7/bank_profile.py Normal file
View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Полигон, пакет-8 (замер 1, часть 2): профиль канала БАНКНОТЫ — то, чего recall против сида не видит.
Первая часть замера (`joint_recall.py`) упёрлась в пре-зарегистрированный стоп: банкнота гонялась с
непустым банком, свободных от инъекции термов сида осталось 2 из 51, и совместный recall против
подписи объявлен неизмеримым на существующих артефактах. Этот скрипт добывает то, что ИЗМЕРИМО и на
что цензура не действует:
1. ЦЕНЗУРА ПО ГЛАВАМ. Инъекция глоссария пер-чанковая, а не книжная: терм, инъектированный в главе 2,
в главе 7 модели не показывался. Значит пара (терм, глава) даёт куда больший знаменатель со
свободным шансом, чем терм целиком. Считаем именно пары.
2. ОБЪЕКТИВНЫЙ ПРОФИЛЬ эмиссии банкноты: частота и рассеяние по главам каждой предложенной
поверхности. Это заменяет разметку «годный/мусор» там, где разметка была бы моим вкусом:
пороги берём НЕ свои, а боевые (`emitMinFreq`=5, `runeLen`2 из miner_emit.go) вопрос
ставится как «сколько предложений банкноты прошло бы фильтры, которые движок уже применяет к
майнеру», и ответ на него проверяем кем угодно.
$0. Читает только КОПИИ баз и срез.
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import subprocess
import sys
from collections import defaultdict
from pathlib import Path
import yaml
sys.path.insert(0, str(Path(__file__).parent))
from joint_recall import parse_banknote, split_banknote # noqa: E402 (одна реализация парсера, не две)
EMIT_MIN_FREQ = 5 # miner_emit.go:31 — боевой порог частоты эмиссии
EMIT_MIN_RUNES = 2 # miner_emit.go — боевой порог длины
def norm_keys(harness: Path, strings: list[str]) -> dict[str, str]:
uniq = sorted({s for s in strings if s.strip()})
p = subprocess.run([str(harness), "-mode", "norm"], input="\n".join(uniq),
capture_output=True, text=True, check=True)
out = {}
for line in p.stdout.split("\n"):
if line:
src, _, key = line.partition("\t")
out[src] = key
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--seed", required=True, type=Path)
ap.add_argument("--chunks", required=True, type=Path)
ap.add_argument("--db", required=True, type=Path, help="КОПИЯ базы прогона банкноты (основной)")
ap.add_argument("--harness", required=True, type=Path)
ap.add_argument("--out", type=Path)
a = ap.parse_args()
rows = [json.loads(l) for l in a.chunks.open(encoding="utf-8") if l.strip()]
by_chapter: dict[int, str] = defaultdict(str)
for r in rows:
by_chapter[r["chapter"]] += "\n" + r["source"]
text = "\n".join(by_chapter[c] for c in sorted(by_chapter))
con = sqlite3.connect(f"file:{a.db}?mode=ro", uri=True)
# инъекция по главам
injected_by_ch: dict[int, set[str]] = defaultdict(set)
for ch, blob in con.execute("select chapter, injected_ids from retrieval_state where injected_ids<>''"):
for ident in json.loads(blob):
injected_by_ch[ch].add(ident.split("\x1f")[0])
# банкнота по главам
bank_by_ch: dict[int, list[dict]] = defaultdict(list)
q = ("select j.chapter, cp.response_text, cp.finish_reason from checkpoints cp "
"join jobs j on j.id=cp.job_id where cp.role='translator' and cp.response_text like '%TM-BANK%'")
for ch, resp, finish in con.execute(q):
_, block, malformed = split_banknote(resp or "")
if malformed or not block or finish != "stop":
continue
ents, _ = parse_banknote(block)
bank_by_ch[ch].extend(ents)
con.close()
doc = yaml.safe_load(a.seed.read_text(encoding="utf-8")) or {}
terms = doc.get("terms") or []
strings = [t.get("src") or "" for t in terms]
for t in terms:
strings.extend(al.get("alias", "") for al in (t.get("aliases") or []))
for ents in bank_by_ch.values():
strings.extend(e["src"] for e in ents)
for s in injected_by_ch.values():
strings.extend(s)
nk = norm_keys(a.harness, strings)
bank_keys_by_ch = {ch: {nk.get(e["src"], e["src"]) for e in ents} for ch, ents in bank_by_ch.items()}
inj_keys_by_ch = {ch: {nk.get(s, s) for s in ss} for ch, ss in injected_by_ch.items()}
# --- 1. пары (терм, глава) --------------------------------------------------------------------
pairs_total = pairs_free = pairs_free_hit = pairs_cens = pairs_cens_hit = 0
free_rows = []
for t in terms:
src = t.get("src") or ""
surfaces = [src] + [al.get("alias", "") for al in (t.get("aliases") or []) if al.get("alias")]
keys = {nk.get(s, s) for s in surfaces if s}
for ch in sorted(by_chapter):
body = by_chapter[ch]
if not any(s and s in body for s in surfaces):
continue
pairs_total += 1
hit = bool(keys & bank_keys_by_ch.get(ch, set()))
if keys & inj_keys_by_ch.get(ch, set()):
pairs_cens += 1
pairs_cens_hit += int(hit)
else:
pairs_free += 1
pairs_free_hit += int(hit)
free_rows.append({"src": src, "type": t.get("type", "") or "term", "chapter": ch,
"hit": hit, "occ": sum(body.count(s) for s in surfaces if s)})
print("=== 1. Цензура по парам (терм × глава) ===")
print(f"пар «терм сида встречается в главе»: {pairs_total}")
print(f" инъектирован в этой главе (шанса не было): {pairs_cens}"
f" из них банкнота всё равно предложила: {pairs_cens_hit}")
print(f" НЕ инъектирован (свободный шанс): {pairs_free}"
f" банкнота предложила: {pairs_free_hit}"
+ (f" = {pairs_free_hit / pairs_free:.3f}" if pairs_free else ""))
if free_rows:
print(" свободные пары (топ-15 по вхождениям в главе):")
for r in sorted(free_rows, key=lambda r: -r["occ"])[:15]:
print(f" гл.{r['chapter']:<3d} {r['src']}\t{r['type']}\t{r['occ']}×\t{'НАШЛА' if r['hit'] else ''}")
# --- 2. объективный профиль эмиссии банкноты --------------------------------------------------
all_ents = [e for ents in bank_by_ch.values() for e in ents]
prof = {}
for e in all_ents:
s = e["src"]
if s in prof:
continue
freq = text.count(s)
chs = sum(1 for ch in by_chapter if s in by_chapter[ch])
prof[s] = {"type": e["type"], "freq": freq, "chapters": chs, "runes": len(s)}
n = len(prof)
passes = [s for s, p in prof.items() if p["freq"] >= EMIT_MIN_FREQ and p["runes"] >= EMIT_MIN_RUNES]
multi = [s for s, p in prof.items() if p["chapters"] >= 2]
once = [s for s, p in prof.items() if p["freq"] <= 1]
print("\n=== 2. Объективный профиль эмиссии банкноты (боевые пороги майнера) ===")
print(f"уникальных поверхностей: {n}")
print(f" прошли бы emitMinFreq≥{EMIT_MIN_FREQ} и runeLen≥{EMIT_MIN_RUNES}: {len(passes)} = {len(passes)/n:.3f}")
print(f" встречаются в ≥2 главах: {len(multi)} = {len(multi)/n:.3f}")
print(f" встречаются в срезе ≤1 раза (разовые): {len(once)} = {len(once)/n:.3f}")
print("\n прошедшие оба боевых порога:")
for s in sorted(passes, key=lambda s: -prof[s]["freq"]):
p = prof[s]
print(f" {s}\t{p['type']}\t{p['freq']}×\tглав:{p['chapters']}")
if a.out:
a.out.write_text(json.dumps({
"pairs": {"total": pairs_total, "censored": pairs_cens, "censored_hit": pairs_cens_hit,
"free": pairs_free, "free_hit": pairs_free_hit},
"free_rows": free_rows, "profile": prof,
}, ensure_ascii=False, indent=1), encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())

245
eval/pkg7/joint_recall.py Normal file
View file

@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""Полигон, пакет-8 (замер 1): СОВМЕСТНЫЙ recall двух каналов эмиссии против подписанного банка.
Дыра, которую замер закрывает (признана D39.48): вся recall-программа пакета-7 мерила ОДИН канал
офлайн-майнер. Живьём кандидатов в карту подписи кладут ДВА: майнер (WHICH, поверхности без dst) и
банкнота черновика (WHAT, поверхность + предложенный перевод). Цена расширения эмиссии зависит от их
ОБЪЕДИНЕНИЯ, а не от майнера в одиночку.
Дисциплина (иначе цифра врёт в свою пользу):
- оба канала меряются на ОДНОМ срезе том, на котором реально гонялась банкнота;
- знаменатель только термы сида, физически встречающиеся в срезе;
- майнер гоняется с ПУСТЫМ сидом (`miner_emit.go` исключает засеянное по построению);
- ключи нормализуются БОЕВЫМ `text.NormalizeSourceKey` через `termharness -mode norm`, а не
приблизительной репликой на Python (пакет-7 честно помечал это расхождение здесь его нет);
- ГЛАВНОЕ: банкнота гонялась с НЕПУСТЫМ банком, а промт роли просит «новые, которых НЕТ в
приложенном глоссарии». Значит инъектированный терм сида банкнота не могла предложить ПО
ПОСТРОЕНИЮ ровно та же ловушка, из-за которой майнеру нужен пустой сид. Поэтому знаменатель
разбивается на цензурированную часть (инъектировался хотя бы раз) и часть со свободным шансом,
и recall банкноты считается на второй. Пре-регистрировано в §0.2 отчёта ДО прогона.
$0: ни одного сетевого вызова, ни одной модели. Читает только КОПИИ баз в скретчпаде.
"""
from __future__ import annotations
import argparse
import json
import re
import sqlite3
import subprocess
import sys
from pathlib import Path
import yaml
BANK_SEP = "⟦TM-BANK-v1⟧"
BANK_FRAGMENT = "TM-BANK"
# Тот же терпимый разделитель полей, что в banknote.go:64 (таб | ≥2 пробела | пайп с окружением).
FIELD_SPLIT = re.compile(r"\t| {2,}|\s*\|\s*")
TYPE_OK = {"name", "place", "title", "term", "nickname"}
def has_han(s: str) -> bool:
"""Точный диапазон banknote.go:127-134 (U+3400U+9FFF), НЕ более широкий unicode.Han."""
return any(0x3400 <= ord(c) <= 0x9FFF for c in s)
def split_banknote(output: str) -> tuple[str, str, bool]:
"""Реплика splitBanknote (banknote.go:86-105) вместе с распознаванием СЛОМАННОГО разделителя."""
idx = output.find(BANK_SEP)
if idx < 0:
i = output.find(BANK_FRAGMENT)
if i < 0:
return output.rstrip(), "", False
nl = output.rfind("\n", 0, i)
return output[: max(nl, 0)].rstrip(), "", True
return output[:idx].rstrip(), output[idx + len(BANK_SEP) :].strip("\n"), False
def parse_banknote(block: str) -> tuple[list[dict], int]:
"""Реплика parseBanknote (banknote.go:140-187) при truncatedGeneration=false."""
entries, bad = [], 0
for ln in block.split("\n"):
if not ln.strip():
continue
parts = [p.strip() for p in FIELD_SPLIT.split(ln.strip()) if p.strip()]
if len(parts) < 2:
bad += 1
continue
src, dst = parts[0], parts[1]
typ = parts[2].lower() if len(parts) >= 3 else "term"
if typ not in TYPE_OK:
typ = "term"
if not has_han(src):
bad += 1
continue
entries.append({"src": src, "dst": dst, "type": typ})
return entries, bad
def norm_keys(harness: Path, strings: list[str]) -> dict[str, str]:
"""Боевая нормализация ключей одним вызовом Go-харнесса. Пустые/дублирующиеся строки безопасны."""
uniq = sorted({s for s in strings if s.strip()})
if not uniq:
return {}
p = subprocess.run([str(harness), "-mode", "norm"], input="\n".join(uniq),
capture_output=True, text=True, check=True)
out = {}
for line in p.stdout.split("\n"):
if not line:
continue
src, _, key = line.partition("\t")
out[src] = key
missing = [s for s in uniq if s not in out]
if missing:
raise SystemExit(f"нормализация не покрыла {len(missing)} строк, напр. {missing[:3]}")
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--seed", required=True, type=Path)
ap.add_argument("--chunks", required=True, type=Path, help="JSONL среза (тот же, что у майнера)")
ap.add_argument("--mined-tsv", required=True, type=Path)
ap.add_argument("--db", action="append", required=True, type=Path,
help="КОПИЯ базы прогона с банкнотой; можно несколько")
ap.add_argument("--primary-db", type=Path, help="какая из баз — основной прогон (для цензуры)")
ap.add_argument("--harness", required=True, type=Path, help="termharness (для -mode norm)")
ap.add_argument("--out", type=Path)
a = ap.parse_args()
text = "\n".join(json.loads(l)["source"] for l in a.chunks.open(encoding="utf-8") if l.strip())
# --- канал 1: майнер -------------------------------------------------------------------------
mined_primary, mined_alias = [], []
for i, line in enumerate(a.mined_tsv.open(encoding="utf-8")):
if i == 0:
continue
f = line.rstrip("\n").split("\t")
if not f or not f[0]:
continue
mined_primary.append(f[0])
if len(f) > 4 and f[4]:
mined_alias.extend(x for x in f[4].split("|") if x)
# --- канал 2: банкнота -----------------------------------------------------------------------
bank_rows, per_db = [], {}
for db in a.db:
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
n_blocks, n_bad = 0, 0
rows = []
q = ("select response_text, finish_reason from checkpoints "
"where role='translator' and response_text like '%TM-BANK%'")
for resp, finish in con.execute(q):
_, block, malformed = split_banknote(resp or "")
if malformed or not block:
n_bad += 1
continue
# finish=stop-only gate (banknote.go:261): прод принимает кандидатов ТОЛЬКО из полной
# генерации. Повторяем, иначе замер учтёт то, чего движок бы не взял.
if finish != "stop":
continue
ents, bad = parse_banknote(block)
n_blocks += 1
n_bad += bad
rows.extend(ents)
per_db[db.name] = {"blocks": n_blocks, "lines": len(rows), "bad": n_bad}
bank_rows.extend(rows)
con.close()
# --- цензура: что банкнота НЕ МОГЛА предложить по построению ---------------------------------
injected = set()
if a.primary_db:
con = sqlite3.connect(f"file:{a.primary_db}?mode=ro", uri=True)
for (blob,) in con.execute("select injected_ids from retrieval_state where injected_ids<>''"):
for ident in json.loads(blob):
injected.add(ident.split("\x1f")[0])
con.close()
# --- сид -------------------------------------------------------------------------------------
doc = yaml.safe_load(a.seed.read_text(encoding="utf-8")) or {}
terms = doc.get("terms") or []
all_strings = list(injected) + mined_primary + mined_alias + [r["src"] for r in bank_rows]
for t in terms:
all_strings.append(t.get("src") or "")
all_strings.extend(al.get("alias", "") for al in (t.get("aliases") or []))
nk = norm_keys(a.harness, all_strings)
mined_keys = {nk[s] for s in mined_primary if s in nk}
mined_any = mined_keys | {nk[s] for s in mined_alias if s in nk}
bank_keys = {nk[r["src"]] for r in bank_rows if r["src"] in nk}
injected_keys = {nk[s] for s in injected if s in nk}
rows = []
for t in terms:
src = t.get("src") or ""
surfaces = [src] + [al.get("alias", "") for al in (t.get("aliases") or [])]
present = [s for s in surfaces if s and s in text]
if not present:
continue
keys = {nk[s] for s in surfaces if s in nk}
rows.append({
"src": src, "type": t.get("type", "") or "term", "dst": t.get("dst", ""),
"occurrences": sum(text.count(s) for s in present),
"miner": bool(keys & mined_any),
"miner_primary": bool(keys & mined_keys),
"bank": bool(keys & bank_keys),
"censored": bool(keys & injected_keys),
})
n = len(rows)
free = [r for r in rows if not r["censored"]]
def rate(sel, field):
return (sum(r[field] for r in sel) / len(sel)) if sel else float("nan")
print(f"срез: {a.chunks} термов сида в срезе: {n}")
print(f"майнер (терм): {sum(r['miner_primary'] for r in rows):3d} recall={rate(rows,'miner_primary'):.3f}")
print(f"майнер (терм∪алиас): {sum(r['miner'] for r in rows):3d} recall={rate(rows,'miner'):.3f}")
print(f"банкнота (сырая): {sum(r['bank'] for r in rows):3d} recall={rate(rows,'bank'):.3f} ← НИЖНЯЯ ГРАНИЦА (цензура)")
union = sum(r["miner"] or r["bank"] for r in rows)
print(f"объединение: {union:3d} recall={union / n:.3f}" if n else "")
print()
print(f"цензура: инъектировано хотя бы раз — {sum(r['censored'] for r in rows)}/{n}; "
f"со свободным шансом — {len(free)}")
if free:
print(f" банкнота на свободных: {sum(r['bank'] for r in free)}/{len(free)} = {rate(free,'bank'):.3f}")
print(f" майнер на свободных: {sum(r['miner'] for r in free)}/{len(free)} = {rate(free,'miner'):.3f}")
print("\nпо классам (знаменатель / майнер / банкнота / объединение):")
bytype: dict[str, list[int]] = {}
for r in rows:
b = bytype.setdefault(r["type"], [0, 0, 0, 0])
b[0] += 1
b[1] += int(r["miner"])
b[2] += int(r["bank"])
b[3] += int(r["miner"] or r["bank"])
for k in sorted(bytype):
c, m, bk, u = bytype[k]
print(f" {k:9s} {c:3d} {m:3d}={m/c:.3f} {bk:3d}={bk/c:.3f} {u:3d}={u/c:.3f}")
# --- множества каналов (вопрос, цензурой НЕ затронутый) --------------------------------------
print(f"\nэмиссия на срезе: майнер {len(mined_keys)} поверхностей · банкнота {len(bank_keys)}")
print(f" пересечение: {len(mined_keys & bank_keys)}")
print(f" только майнер: {len(mined_keys - bank_keys)}")
print(f" только банкнота: {len(bank_keys - mined_keys)}")
print(f" объединение: {len(mined_keys | bank_keys)}")
print("\nсырьё банкноты по базам:", json.dumps(per_db, ensure_ascii=False))
print("\nНЕ найдено НИ ОДНИМ каналом (топ-20 по частоте):")
for r in sorted((r for r in rows if not (r["miner"] or r["bank"])), key=lambda r: -r["occurrences"])[:20]:
mark = "цензура" if r["censored"] else "свободный"
print(f" {r['src']}\t{r['type']}\t{r['occurrences']}×\t{r['dst']}\t[{mark}]")
if a.out:
a.out.write_text(json.dumps({
"rows": rows, "per_db": per_db,
"mined_keys": sorted(mined_keys), "bank_keys": sorted(bank_keys),
"bank_rows": bank_rows, "injected": sorted(injected_keys),
}, ensure_ascii=False, indent=1), encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())

121
eval/pkg7/role_probe.py Normal file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Полигон, пакет-8: драйвер БОЕВОГО запроса роли терминолога.
Отправляет ровно то, что собрал `termharness -mode build` (системный промт роли + якорь TM-CANON +
блок кандидатов) ни одного своего слова в промте нет. Полигон отвечает только за сетку параметров,
повторы и деньги.
Дисциплина (нормы D39.46/47):
- СМЕТА печатается ДО первого вызова; `--dry-run` даёт её без единой траты;
- сырьё каждого вызова сохраняется целиком (сообщения, ответ, usage, finish_reason) выводы потом
пересчитываются ИЗ сырья, а не из памяти прогона;
- деньги считаются из фактического usage по `backend/configs/models.yaml` (read-only), с учётом
кэш-цены prompt_tokens_details.cached_tokens;
- max_tokens = 8000 не круглое число, а пол `min_max_tokens` deepseek-v4-flash из models.yaml,
к которому боевой `terminologyCallBudget` и приводит батч такого размера (est/2+256 < 8000);
- thinking у DeepSeek НЕ отключается (гардрейл проекта: эхо-мина).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
import yaml
from openai import OpenAI
REPO = Path(__file__).resolve().parents[2]
def load_prices(models_yaml: Path) -> dict:
doc = yaml.safe_load(models_yaml.read_text(encoding="utf-8")) or {}
out = {}
for name, m in (doc.get("models") or {}).items():
p = (m or {}).get("price") or {}
out[name] = (p.get("input_per_m", 0.0), p.get("output_per_m", 0.0), p.get("cached_per_m", 0.0))
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--req", required=True, type=Path, help="выход termharness -mode build")
ap.add_argument("--raw-dir", required=True, type=Path)
ap.add_argument("--tag", required=True, help="метка точки сетки (идёт в имена файлов сырья)")
ap.add_argument("--repeats", type=int, default=1)
ap.add_argument("--model", default="deepseek-v4-flash")
ap.add_argument("--base-url", default="https://api.deepseek.com/v1")
ap.add_argument("--key-env", default="DEEPSEEK_API_KEY")
ap.add_argument("--max-tokens", type=int, default=8000)
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args()
req = json.loads(a.req.read_text(encoding="utf-8"))
batches = req["batches"]
prices = load_prices(REPO / "backend/configs/models.yaml")
pin, pout, pcached = prices.get(a.model, (0.0, 0.0, 0.0))
# Оценка входа: тот же грубый делитель, что в пакете-7 (симв./3.2). Смета — верхняя граница по
# выходу (max_tokens), как и боевая terminologyEstimateUSD, поэтому факт всегда ниже.
chars = sum(len(m["content"]) for b in batches for m in b["messages"])
est_in = chars / 3.2 * a.repeats
est_out = len(batches) * a.max_tokens * a.repeats
print(f"ПЛАН [{a.tag}]: батчей {len(batches)} × повторов {a.repeats} = {len(batches)*a.repeats} вызовов; "
f"термов {req['candidates']}; kwic {req['kwic_per']}×{req['kwic_width']}")
print(f"СМЕТА ДО ВЫЗОВОВ: вход ≈{est_in/1000:.1f}k ток → ${est_in/1e6*pin:.4f}; "
f"выход ≤{est_out/1000:.1f}k ток → ≤${est_out/1e6*pout:.4f}; "
f"ИТОГО ≤ ${est_in/1e6*pin + est_out/1e6*pout:.4f}")
if a.dry_run:
return 0
key = os.environ.get(a.key_env)
if not key:
print(f"нет ключа {a.key_env}", file=sys.stderr)
return 2
client = OpenAI(api_key=key, base_url=a.base_url)
a.raw_dir.mkdir(parents=True, exist_ok=True)
total = 0.0
for rep in range(a.repeats):
for b in batches:
# $0-резюм по образцу боевого чекпойнта: уже оплаченный батч не перепокупается. Без этого
# любой таймаут харнесса означал бы повторную оплату всей точки сетки.
done = a.raw_dir / f"{a.tag}_r{rep}_b{b['index']}.json"
if done.exists() and "error" not in json.loads(done.read_text(encoding="utf-8")):
continue
msgs = [{"role": m["role"], "content": m["content"]} for m in b["messages"]]
t0 = time.time()
try:
resp = client.chat.completions.create(model=a.model, messages=msgs, max_tokens=a.max_tokens)
except Exception as e: # noqa: BLE001 — ошибка вызова тоже улика, не повод падать молча
print(f"[{a.tag} r{rep} b{b['index']}] ОШИБКА: {e}", file=sys.stderr)
(a.raw_dir / f"{a.tag}_r{rep}_b{b['index']}.json").write_text(
json.dumps({"tag": a.tag, "rep": rep, "batch": b["index"], "error": str(e)},
ensure_ascii=False, indent=1), encoding="utf-8")
continue
dt = time.time() - t0
txt = resp.choices[0].message.content or ""
u = resp.usage
cin = getattr(u, "prompt_tokens", 0)
cout = getattr(u, "completion_tokens", 0)
cached = getattr(getattr(u, "prompt_tokens_details", None), "cached_tokens", 0) or 0
usd = (cin - cached) / 1e6 * pin + cached / 1e6 * pcached + cout / 1e6 * pout
total += usd
(a.raw_dir / f"{a.tag}_r{rep}_b{b['index']}.json").write_text(
json.dumps({"tag": a.tag, "rep": rep, "batch": b["index"], "keys": b["keys"],
"messages": msgs, "response": txt,
"finish": resp.choices[0].finish_reason,
"usage": {"in": cin, "out": cout, "cached": cached},
"usd": usd, "seconds": round(dt, 1),
"kwic_per": req["kwic_per"], "kwic_width": req["kwic_width"]},
ensure_ascii=False, indent=1), encoding="utf-8")
print(f"[{a.tag} r{rep} b{b['index']}] in={cin} (cached {cached}) out={cout} "
f"finish={resp.choices[0].finish_reason} ${usd:.5f} {dt:.0f}s")
print(f"ИТОГО ФАКТ [{a.tag}]: ${total:.5f}")
return 0
if __name__ == "__main__":
sys.exit(main())

168
eval/pkg7/score_grid.py Normal file
View file

@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Полигон, пакет-8 (замер 3): сетка kwic × подача — счёт ИЗ СЫРЬЯ.
Мера совпадение консолидированного dst с ПОДПИСЬЮ владельца. Нормализация обеих сторон
симметричная и объявлена в §0.4: регистр, ё/е, пробелы, дефисы. Разбор ответа боевым
`terminology.ParseReply` через `termharness -mode parse`.
Правило чтения разрыва (куплено фазой B пакета-7 за $0.024): разрыв между точками засчитывается,
только если он больше ВНУТРИТОЧЕЧНОГО размаха по повторам. Поэтому печатаются обе величины, и ни
один вывод не строится на одной точке.
"""
from __future__ import annotations
import argparse
import glob
import json
import re
import subprocess
import sys
import tempfile
from collections import defaultdict
from pathlib import Path
def norm_dst(s: str) -> str:
s = (s or "").strip().lower().replace("ё", "е")
s = re.sub(r"[-―−]", "-", s)
s = re.sub(r"\s+", " ", s)
return s.strip(" .,:;!?«»\"'()")
def parse_reply(harness: Path, reply: str, keys: list[str]) -> dict:
with tempfile.TemporaryDirectory() as td:
rp, kp, op = Path(td) / "r", Path(td) / "k", Path(td) / "o"
rp.write_text(reply, encoding="utf-8")
kp.write_text(json.dumps(keys, ensure_ascii=False), encoding="utf-8")
subprocess.run([str(harness), "-mode", "parse", "-reply", str(rp), "-keys", str(kp),
"-out", str(op)], check=True, capture_output=True)
return json.loads(op.read_text(encoding="utf-8"))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--raw-dir", required=True, type=Path)
ap.add_argument("--sample", required=True, type=Path, help="JSON выборки [{src,dst,type,occ}]")
ap.add_argument("--harness", required=True, type=Path)
ap.add_argument("--out", type=Path)
a = ap.parse_args()
sample = json.loads(a.sample.read_text(encoding="utf-8"))
gold = {s["src"]: s["dst"] for s in sample}
# tag → rep → {key: dst|""|None}
got: dict[str, dict[int, dict]] = defaultdict(lambda: defaultdict(dict))
money: dict[str, dict] = defaultdict(lambda: {"usd": 0.0, "in": 0, "out": 0, "cached": 0, "calls": 0})
errors = []
for f in sorted(glob.glob(str(a.raw_dir / "*.json"))):
d = json.loads(Path(f).read_text(encoding="utf-8"))
if "error" in d:
errors.append((Path(f).name, d["error"]))
continue
tag, rep = d["tag"], d["rep"]
g = parse_reply(a.harness, d["response"], d["keys"])
for k, v in g["answered"].items():
got[tag][rep][k] = v
for k in g["declined"]:
got[tag][rep][k] = ""
m = money[tag]
m["usd"] += d.get("usd", 0.0)
u = d.get("usage") or {}
m["in"] += u.get("in", 0); m["out"] += u.get("out", 0); m["cached"] += u.get("cached", 0)
m["calls"] += 1
if errors:
print(f"ВЫЗОВОВ С ОШИБКОЙ: {len(errors)}{errors[:3]}", file=sys.stderr)
# ключи = нормализованные src; сид даёт src, ключ строим тем же харнессом
p = subprocess.run([str(a.harness), "-mode", "norm"], input="\n".join(sorted(gold)),
capture_output=True, text=True, check=True)
key_of = {l.split("\t")[0]: l.split("\t")[1] for l in p.stdout.split("\n") if l}
gold_by_key = {key_of[s]: d for s, d in gold.items()}
src_by_key = {key_of[s]: s for s in gold}
def sort_key(t: str):
m = re.fullmatch(r"g(\d+)x(\d+)", t)
return (0, int(m.group(1)), int(m.group(2))) if m else (1, 0, 0) # именные армы — в конец
order = sorted(got, key=sort_key)
print(f"выборка: {len(gold)} подписанных термов; точек сетки: {len(order)}\n")
header = f"{'точка':8s} {'вызовов':>7s} {'$':>9s} {'точн r0':>8s} {'r1':>6s} {'r2':>6s} {'размах':>7s} {'отказ':>6s} {'молч':>5s}"
print(header)
print("-" * len(header))
summary = {}
for tag in order:
accs, decl, sil = [], [], []
for rep in sorted(got[tag]):
g = got[tag][rep]
hit = sum(1 for k, d in gold_by_key.items() if k in g and g[k] and norm_dst(g[k]) == norm_dst(d))
accs.append(hit / len(gold_by_key))
decl.append(sum(1 for k in gold_by_key if g.get(k) == ""))
sil.append(sum(1 for k in gold_by_key if k not in g))
m = money[tag]
span = max(accs) - min(accs)
cells = [f"{x:.3f}" for x in accs] + [""] * (3 - len(accs))
print(f"{tag:8s} {m['calls']:7d} {m['usd']:9.5f} {cells[0]:>8s} {cells[1]:>6s} {cells[2]:>6s} "
f"{span:7.3f} {sum(decl)/len(decl):6.1f} {sum(sil)/len(sil):5.1f}")
summary[tag] = {"acc": accs, "span": span, "usd": m["usd"], "calls": m["calls"],
"in": m["in"], "out": m["out"], "cached": m["cached"],
"declined": decl, "silent": sil}
if summary:
spans = [v["span"] for v in summary.values()]
within = max(spans)
print(f"\nмаксимальный ВНУТРИТОЧЕЧНЫЙ размах: {within:.3f} — разрыв между точками "
f"засчитывается только если он БОЛЬШЕ этой величины")
means = {t: sum(v["acc"]) / len(v["acc"]) for t, v in summary.items()}
best = max(means, key=lambda t: means[t])
base = "g3x40"
if base in means:
print(f"боевой дефолт {base}: средняя точность {means[base]:.3f}; лучшая точка {best}: {means[best]:.3f}; "
f"разница {means[best]-means[base]:+.3f}")
print("\nсредняя точность по точкам:", ", ".join(f"{t}={means[t]:.3f}" for t in order))
# развилка владельца: широкая подача × узкий контекст vs узкая × широкий
if "g8x40" in means and "g3x120" in means:
d = means["g8x40"] - means["g3x120"]
verdict = "РАЗЛИЧИМЫ" if abs(d) > within else "НЕРАЗЛИЧИМЫ на этой выборке"
print(f"\nразвилка 8×40 (много узких) vs 3×120 (мало широких): {means['g8x40']:.3f} vs "
f"{means['g3x120']:.3f}, разница {d:+.3f} при размахе {within:.3f}{verdict}")
# Позиции, на которых точки РАСХОДЯТСЯ (норма D39.46). Считается ПОКАЖДОМУ повтору отдельно:
# подмножество, определённое по одному прогону, само есть выбор по случайности сэмплирования,
# и точность на нём тогда завышается у той точки, чей ответ в этот прогон был удачным.
reps = sorted(set.intersection(*[set(got[t]) for t in order]))
div_acc: dict[str, list[float]] = {t: [] for t in order}
div_sizes = []
for rep in reps:
div = [k for k in gold_by_key
if len({norm_dst(got[t][rep].get(k) or "") for t in order}) > 1]
div_sizes.append(len(div))
for t in order:
g = got[t][rep]
hit = sum(1 for k in div if g.get(k) and norm_dst(g[k]) == norm_dst(gold_by_key[k]))
div_acc[t].append(hit / len(div) if div else float("nan"))
print(f"\nрасходящихся позиций по повторам: {div_sizes} из {len(gold_by_key)}")
for t in order:
v = div_acc[t]
print(f" {t:9s} " + " ".join(f"r{r}={x:.3f}" for r, x in zip(reps, v))
+ f" среднее {sum(v)/len(v):.3f} размах {max(v)-min(v):.3f}")
within_div = max(max(v) - min(v) for v in div_acc.values())
print(f" максимальный внутриточечный размах на расходящихся: {within_div:.3f}")
summary["_divergent"] = {t: div_acc[t] for t in order} | {"sizes": div_sizes, "within": within_div}
rep0 = reps[0]
div0 = [k for k in gold_by_key if len({norm_dst(got[t][rep0].get(k) or "") for t in order}) > 1]
print("\n сами расходящиеся позиции (прогон 0):")
for k in div0:
row = " | ".join(f"{t[1:]}:{(got[t][rep0].get(k) or '')}" for t in order)
print(f" {src_by_key[k]} (подпись: {gold_by_key[k]}) → {row}")
if a.out:
a.out.write_text(json.dumps({"summary": summary, "errors": errors}, ensure_ascii=False, indent=1),
encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())

133
eval/pkg7/score_probe2.py Normal file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Полигон, пакет-8 (замер 2): доля выдуманных dst на мусоре — счёт ИЗ СЫРЬЯ.
Разбор ответов делает БОЕВОЙ `terminology.ParseReply` через `termharness -mode parse`, а не реплика:
именно парсер решает, что считается ответом, что отказом TM-NO-DST, а что «плохой строкой»
(эхо исходника, кривая лемма). Реплика тут расходилась бы с продом ровно там, где это важнее всего.
Пороги названы в §0.3 отчёта ДО прогона: >50% выдумок на МУСОРЕ = провал; <20% = годно.
"""
from __future__ import annotations
import argparse
import glob
import json
import subprocess
import sys
import tempfile
from collections import Counter
from pathlib import Path
def parse_with_engine(harness: Path, reply: str, keys: list[str]) -> dict:
with tempfile.TemporaryDirectory() as td:
rp, kp, op = Path(td) / "r.txt", Path(td) / "k.json", Path(td) / "o.json"
rp.write_text(reply, encoding="utf-8")
kp.write_text(json.dumps(keys, ensure_ascii=False), encoding="utf-8")
subprocess.run([str(harness), "-mode", "parse", "-reply", str(rp), "-keys", str(kp),
"-out", str(op)], check=True, capture_output=True)
return json.loads(op.read_text(encoding="utf-8"))
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--raw-dir", required=True, type=Path)
ap.add_argument("--labels", required=True, type=Path)
ap.add_argument("--control", required=True, type=Path, help="JSON кандидатов; хвост + контроль")
ap.add_argument("--harness", required=True, type=Path)
ap.add_argument("--out", type=Path)
a = ap.parse_args()
labels = json.loads(a.labels.read_text(encoding="utf-8"))
cands = json.loads(a.control.read_text(encoding="utf-8"))
ctrl_srcs = [c["src"] for c in cands if c["src"] not in labels]
# Повторы разбираются РАЗДЕЛЬНО: слить их в одну карту значило бы выдать разброс прогона за
# согласие (урок фазы B пакета-7). Основной вывод считается по прогону 0, размах — по всем.
per_rep: dict[int, dict] = {}
usd, cin, cout, cached = 0.0, 0, 0, 0
files = sorted(glob.glob(str(a.raw_dir / "*.json")))
for f in files:
d = json.loads(Path(f).read_text(encoding="utf-8"))
if "error" in d:
print(f"ВЫЗОВ С ОШИБКОЙ: {f}: {d['error']}", file=sys.stderr)
continue
rep = d.get("rep", 0)
st = per_rep.setdefault(rep, {"answered": {}, "declined": set(), "unanswered": set(), "bad": 0})
got = parse_with_engine(a.harness, d["response"], d["keys"])
st["answered"].update(got["answered"])
st["declined"].update(got["declined"])
st["unanswered"].update(got["unanswered"])
st["bad"] += got["bad_lines"]
usd += d.get("usd", 0.0)
u = d.get("usage") or {}
cin += u.get("in", 0); cout += u.get("out", 0); cached += u.get("cached", 0)
base = per_rep[min(per_rep)]
answered, declined, unanswered, bad = base["answered"], base["declined"], base["unanswered"], base["bad"]
def bucket(srcs: list[str]) -> Counter:
c = Counter()
for s in srcs:
if s in answered:
c["выдумала dst"] += 1
elif s in declined:
c["⟦TM-NO-DST⟧"] += 1
elif s in unanswered:
c["промолчала"] += 1
else:
c["не в ответе"] += 1
return c
junk = [s for s, v in labels.items() if v == "МУСОР"]
grey = [s for s, v in labels.items() if v == "СЕРЫЙ"]
print(f"вызовов: {len(files)} ${usd:.5f} in={cin} (cached {cached}) out={cout} плохих строк парсера: {bad}")
rows = []
for name, srcs in (("МУСОР", junk), ("СЕРЫЙ", grey), ("КОНТРОЛЬ (подписанные)", ctrl_srcs)):
c = bucket(srcs)
n = len(srcs)
inv = c["выдумала dst"]
print(f"\n{name}: {n} кандидатов")
for k in ("выдумала dst", "⟦TM-NO-DST⟧", "промолчала", "не в ответе"):
if c[k]:
print(f" {k:16s} {c[k]:3d} = {c[k]/n:.3f}")
rows.append({"class": name, "n": n, "invented": inv, "rate": inv / n if n else None})
jr = rows[0]["rate"]
verdict = ("ПРОВАЛ (>0.50)" if jr > 0.50 else "ГОДНО (<0.20)" if jr < 0.20 else "СЕРАЯ ЗОНА (0.200.50)")
print(f"\nВЕРДИКТ по §0.3 на классе МУСОР (прогон {min(per_rep)}): доля выдуманных {jr:.3f}{verdict}")
# Размах по повторам: вывод «провал» держится, только если он больше разброса прогона.
if len(per_rep) > 1:
print("\nразброс по повторам (доля выдуманных dst):")
spans = {}
for name, srcs in (("МУСОР", junk), ("СЕРЫЙ", grey), ("КОНТРОЛЬ", ctrl_srcs)):
vals = []
for rep in sorted(per_rep):
st = per_rep[rep]
vals.append(sum(1 for s in srcs if s in st["answered"]) / len(srcs))
spans[name] = (min(vals), max(vals))
print(f" {name:10s} " + " ".join(f"r{r}={v:.3f}" for r, v in zip(sorted(per_rep), vals))
+ f" размах {max(vals)-min(vals):.3f}")
rows.append({"spans": {k: list(v) for k, v in spans.items()}})
print("\nчто именно роль ответила на МУСОР (все строки):")
for s in sorted(junk):
v = answered.get(s)
print(f" {s}\t{v if v is not None else ('⟦TM-NO-DST⟧' if s in declined else 'промолчала')}")
print("\nКОНТРОЛЬ — ответ против подписи владельца:")
ctrl_dst = {c["src"]: c for c in cands}
for s in ctrl_srcs:
print(f" {s}\t{answered.get(s, '⟦TM-NO-DST⟧' if s in declined else 'промолчала')}")
if a.out:
a.out.write_text(json.dumps({"rows": rows, "answered": answered,
"declined": sorted(declined), "unanswered": sorted(unanswered),
"bad_lines": bad, "usd": usd,
"usage": {"in": cin, "out": cout, "cached": cached}},
ensure_ascii=False, indent=1), encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())

43
eval/pkg7/termharness/build.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Собирает termharness КАК МОДУЛЬ ВНЕ РЕПО — та же схема, что у minerharness (см. его build.sh):
# исходник живёт в eval/ и потому ревьюируем, а go.mod + `replace` материализуются в рабочем каталоге
# вне git, так что второго go.mod в репозитории не появляется.
#
# ./build.sh <work-dir> [commit]
#
# Пин обязателен: `replace` смотрит в копию коммита (`git archive`), а не в рабочее дерево.
# Отличие от minerharness ровно одно — имя модуля и копируемый main.go; сознательно НЕ обобщал общий
# скрипт с параметром, чтобы не править файл, которым пинятся уже сданные цифры пакета-7.
set -euo pipefail
WORK="${1:?usage: build.sh <work-dir> [commit]}"
COMMIT="${2:-HEAD}"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
SHA="$(git -C "$REPO" rev-parse --short "$COMMIT")"
PIN="$WORK/pinned-$SHA"
MOD="$WORK/termharness"
mkdir -p "$PIN" "$MOD"
if [ ! -d "$PIN/backend" ]; then
git -C "$REPO" archive "$COMMIT" backend | tar -x -C "$PIN"
fi
cp "$(dirname "${BASH_SOURCE[0]}")/main.go" "$MOD/main.go"
cp "$PIN/backend/go.sum" "$MOD/go.sum"
{
echo "module textmachine/backend/termharness"
echo
sed -n '/^go /p' "$PIN/backend/go.mod"
echo
echo "require textmachine/backend v0.0.0"
echo
sed -n '/^require (/,/^)/p' "$PIN/backend/go.mod"
echo
echo "replace textmachine/backend => $PIN/backend"
} > "$MOD/go.mod"
cd "$MOD"
GOFLAGS=-mod=mod GOPROXY=off go build -o "$MOD/termharness" .
echo "built: $MOD/termharness (backend pinned at $SHA in $PIN/backend)"

View file

@ -0,0 +1,325 @@
// Command termharness собирает запрос БОЕВОЙ роли терминолога (`internal/terminology` +
// `pipeline.MessagesWithInjection`) вне репозитория — как это делает `minerharness` для майнера.
//
// ЗАЧЕМ ИМЕННО ТАК (пакет-8, замеры 2 и 3). Промт роли — не единственное, что видит модель: между
// системным промтом и списком термов боевой код кладёт якорь ⟦TM-CANON⟧, а сам список рендерит
// `terminology.RenderBatch` с фиксированным набором полей (`key/type/origin/freq/since_ch/aliases/
// related/evidence/drafts/ctx`). Реплика этого на Python в пакете-7 была бы третьей копией формата и
// первым же кандидатом на расхождение с продом — а именно расхождение сборки запроса и было ошибкой
// фазы B («фактор, который есть во всех армах, не измеряется ни одним»). Здесь запрос собирает тот же
// код, что в проде; полигон отвечает только за сетку параметров и за отправку.
//
// Пин обязателен по той же причине, что и в minerharness: `replace` смотрит в копию коммита
// (`git archive`), а не в рабочее дерево.
//
// Режимы:
//
// -mode build — собрать батчи запросов (JSON: system/canon/user + ключи батча)
// -mode norm — нормализовать ключи построчно тем же `text.NormalizeSourceKey`, что и прод
// -mode parse — разобрать ответ модели боевым `terminology.ParseReply`
//
// Ноль сетевых вызовов: харнесс НИЧЕГО не отправляет. Отправка и деньги — на стороне Python-драйвера.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"strings"
"textmachine/backend/internal/config"
"textmachine/backend/internal/pipeline"
"textmachine/backend/internal/terminology"
"textmachine/backend/internal/text"
)
// chunkRow — субстрат `eval/pkg7/split_book.py`, тот же, что ест minerharness.
type chunkRow struct {
Chapter int `json:"chapter"`
ChunkIdx int `json:"chunk_idx"`
Source string `json:"source"`
}
// candRow — вход WHICH-канала (майнер или произвольный список кандидатов для пробы отказов).
type candRow struct {
Src string `json:"src"`
Type string `json:"type"`
Freq int `json:"freq"`
SinceCh int `json:"since_ch"`
Aliases []string `json:"aliases"`
Evidence []string `json:"evidence"`
}
// obsRow — вход WHAT-канала (банкнота), уже свёрнутый по (key, dst), как это делает bankObservedByKey.
type obsRow struct {
Key string `json:"key"`
Src string `json:"src"`
Type string `json:"type"`
Proposals []struct {
Dst string `json:"dst"`
Type string `json:"type"`
Chunks int `json:"chunks"`
} `json:"proposals"`
}
type batchOut struct {
Index int `json:"index"`
Keys []string `json:"keys"`
Srcs []string `json:"srcs"`
Messages []map[string]any `json:"messages"`
Cands []map[string]any `json:"cands"`
Sizes map[string]int `json:"sizes"`
Variants map[string][]string `json:"variants"`
}
func main() {
var (
mode = flag.String("mode", "build", "build | norm | parse")
chunksP = flag.String("chunks", "", "JSONL чанков (chapter, chunk_idx, source)")
candsP = flag.String("cands", "", "JSON кандидатов WHICH-канала")
obsP = flag.String("observed", "", "JSON наблюдений банкноты (опционально)")
canonP = flag.String("canon", "", "JSON подписанных пар [[src,dst],…] (опционально)")
bookP = flag.String("book", "", "book.yaml")
promptP = flag.String("prompt", "", "промт роли (terminologist.md)")
kwicPer = flag.Int("kwic-per", 3, "контекстов на терм")
kwicWidth = flag.Int("kwic-width", 40, "ширина контекста")
batchRunes = flag.Int("batch-runes", 6000, "рун в одном батче")
canonCap = flag.Int("canon-cap", 40, "потолок строк якоря ⟦TM-CANON⟧")
replyP = flag.String("reply", "", "файл ответа модели (для -mode parse)")
keysP = flag.String("keys", "", "JSON ожидаемых ключей (для -mode parse)")
outP = flag.String("out", "", "куда писать JSON (пусто = stdout)")
)
flag.Parse()
switch *mode {
case "norm":
runNorm()
case "parse":
runParse(*replyP, *keysP, *outP)
case "build":
runBuild(*chunksP, *candsP, *obsP, *canonP, *bookP, *promptP, *kwicPer, *kwicWidth, *batchRunes, *canonCap, *outP)
default:
fatal(fmt.Errorf("неизвестный режим %q", *mode))
}
}
// runNorm печатает `исходная_строка<TAB>ключ` — единственный способ сшивать множества сида, майнера и
// банкноты ТЕМ ЖЕ ключом, что использует движок (trad→simp + NFKC + katakana→hiragana + lower).
func runNorm() {
w := bufio.NewWriter(os.Stdout)
defer w.Flush()
sc := bufio.NewScanner(os.Stdin)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
s := strings.TrimRight(sc.Text(), "\r\n")
if s == "" {
continue
}
fmt.Fprintf(w, "%s\t%s\n", s, text.NormalizeSourceKey(s))
}
if err := sc.Err(); err != nil {
fatal(err)
}
}
func runParse(replyPath, keysPath, outPath string) {
if replyPath == "" || keysPath == "" {
fatal(fmt.Errorf("-mode parse требует -reply и -keys"))
}
reply, err := os.ReadFile(replyPath)
if err != nil {
fatal(err)
}
var keys []string
readJSON(keysPath, &keys)
got, bad := terminology.ParseReply(string(reply), keys, text.NormalizeSourceKey)
// Разделяем ТРИ исхода, которые прод считает по-разному (terminologist.go:269-279): ответ с
// переводом, явный отказ ⟦TM-NO-DST⟧ (пустая строка в карте) и молчание (ключа в карте нет).
declined, answered := []string{}, map[string]string{}
for k, v := range got {
if v == "" {
declined = append(declined, k)
continue
}
answered[k] = v
}
unanswered := []string{}
for _, k := range keys {
if _, ok := got[k]; !ok {
unanswered = append(unanswered, k)
}
}
sort.Strings(declined)
sort.Strings(unanswered)
writeJSON(outPath, map[string]any{
"answered": answered, "declined": declined, "unanswered": unanswered, "bad_lines": bad,
})
}
func runBuild(chunksPath, candsPath, obsPath, canonPath, bookPath, promptPath string,
kwicPer, kwicWidth, batchRunes, canonCap int, outPath string) {
if chunksPath == "" || candsPath == "" || bookPath == "" || promptPath == "" {
fatal(fmt.Errorf("-mode build требует -chunks -cands -book -prompt"))
}
book, err := config.LoadBook(bookPath)
if err != nil {
fatal(err)
}
tpl, err := pipeline.LoadPromptTemplate(promptPath)
if err != nil {
fatal(err)
}
// Субстрат KWIC — тот же, что у майнера: нормализованный текст чанка.
var chunks []terminology.Chunk
f, err := os.Open(chunksPath)
if err != nil {
fatal(err)
}
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 1<<22), 1<<22)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" {
continue
}
var r chunkRow
if err := json.Unmarshal([]byte(line), &r); err != nil {
fatal(err)
}
chunks = append(chunks, terminology.Chunk{
Chapter: r.Chapter, ChunkIdx: r.ChunkIdx, NSource: text.NormalizeSourceKey(r.Source),
})
}
f.Close()
if err := sc.Err(); err != nil {
fatal(err)
}
var cr []candRow
readJSON(candsPath, &cr)
mined := make([]terminology.Mined, 0, len(cr))
for _, c := range cr {
mined = append(mined, terminology.Mined{
Key: text.NormalizeSourceKey(c.Src), Src: c.Src, Type: c.Type,
Freq: c.Freq, SinceCh: c.SinceCh, Aliases: c.Aliases, Evidence: c.Evidence,
})
}
var observed []terminology.Observed
if obsPath != "" {
var or []obsRow
readJSON(obsPath, &or)
for _, o := range or {
key := o.Key
if key == "" {
key = text.NormalizeSourceKey(o.Src)
}
ps := make([]terminology.Proposal, 0, len(o.Proposals))
for _, p := range o.Proposals {
ps = append(ps, terminology.Proposal{Dst: p.Dst, Type: p.Type, Chunks: p.Chunks})
}
observed = append(observed, terminology.Observed{Key: key, Src: o.Src, Type: o.Type, Proposals: ps})
}
}
var canon []terminology.Neighbour
if canonPath != "" {
var pairs [][2]string
readJSON(canonPath, &pairs)
for _, p := range pairs {
canon = append(canon, terminology.Neighbour{Src: text.NormalizeSourceKey(p[0]), Dst: p[1]})
}
sort.Slice(canon, func(i, j int) bool { return canon[i].Src < canon[j].Src })
}
cands := terminology.Merge(mined, observed)
// kwic-per 0 = арм БЕЗ контекстов (обязательный арм-без-фактора D39.46). Прод такой конфиг
// подменил бы дефолтом (terminologyOpts), поэтому здесь AttachKWIC просто не вызывается — это
// ЕДИНСТВЕННОЕ сознательное отклонение от боевой сборки, и оно помечено в отчёте.
if kwicPer > 0 && kwicWidth > 0 {
cands = terminology.AttachKWIC(cands, chunks, kwicPer, kwicWidth)
} else {
cands = terminology.AttachKWIC(cands, chunks, 0, 0) // проставит Freq/Occurrences, контексты пустые
}
opts := terminology.ScoreOpts{Neighbours: canon}
for i := range cands {
terminology.ScoreVariants(&cands[i], opts)
}
batches := terminology.Batch(cands, batchRunes)
out := make([]batchOut, 0, len(batches))
for i, b := range batches {
msgs, err := pipeline.MessagesWithInjection(tpl,
pipeline.RenderVars{Book: book, Text: terminology.RenderBatch(b)},
terminology.RenderCanonAnchor(terminology.CanonFor(b, canon, canonCap)))
if err != nil {
fatal(err)
}
mm := make([]map[string]any, 0, len(msgs))
for _, m := range msgs {
mm = append(mm, map[string]any{"role": m.Role, "content": m.Content})
}
keys, srcs := make([]string, 0, len(b)), make([]string, 0, len(b))
cinfo := make([]map[string]any, 0, len(b))
vars := map[string][]string{}
for _, c := range b {
keys = append(keys, c.Key)
srcs = append(srcs, c.Src)
cinfo = append(cinfo, map[string]any{
"key": c.Key, "src": c.Src, "type": c.Type, "origin": string(c.Origin),
"freq": c.Freq, "kwic": len(c.KWIC), "spread": c.Spread(), "best": c.Best(),
})
for _, v := range c.Variants {
vars[c.Key] = append(vars[c.Key], v.Dst)
}
}
nchars := 0
for _, m := range msgs {
nchars += len([]rune(m.Content))
}
out = append(out, batchOut{
Index: i, Keys: keys, Srcs: srcs, Messages: mm, Cands: cinfo,
Sizes: map[string]int{"runes": nchars, "terms": len(b)},
Variants: vars,
})
}
writeJSON(outPath, map[string]any{
"batches": out, "candidates": len(cands), "kwic_per": kwicPer, "kwic_width": kwicWidth,
"batch_runes": batchRunes, "version": "terminology-v1-merge+kwic+c2-3",
})
}
func readJSON(path string, v any) {
b, err := os.ReadFile(path)
if err != nil {
fatal(err)
}
if err := json.Unmarshal(b, v); err != nil {
fatal(fmt.Errorf("%s: %w", path, err))
}
}
func writeJSON(path string, v any) {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
fatal(err)
}
if path == "" {
os.Stdout.Write(append(b, '\n'))
return
}
if err := os.WriteFile(path, append(b, '\n'), 0o644); err != nil {
fatal(err)
}
fmt.Fprintf(os.Stderr, "написано: %s\n", path)
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "termharness:", err)
os.Exit(1)
}