From fb5e87315764b4274c2ac2a04620036ddb963c64 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 26 Jul 2026 20:07:19 +0300 Subject: [PATCH] Accept polygon package eight as D39.50: emission widening closed negatively, banknote discipline and answer-language hole become the design core --- docs/PROGRESS.md | 22 +- docs/architecture/05-decisions-log.md | 6 + .../reports/POLYGON_PREMEASURE_2026-07-26.md | 783 ++++++++++++++++++ eval/pkg7/README.md | 20 + eval/pkg7/bank_profile.py | 169 ++++ eval/pkg7/joint_recall.py | 245 ++++++ eval/pkg7/role_probe.py | 121 +++ eval/pkg7/score_grid.py | 168 ++++ eval/pkg7/score_probe2.py | 133 +++ eval/pkg7/termharness/build.sh | 43 + eval/pkg7/termharness/main.go | 325 ++++++++ 11 files changed, 2034 insertions(+), 1 deletion(-) create mode 100644 docs/archive/reports/POLYGON_PREMEASURE_2026-07-26.md create mode 100644 eval/pkg7/bank_profile.py create mode 100644 eval/pkg7/joint_recall.py create mode 100644 eval/pkg7/role_probe.py create mode 100644 eval/pkg7/score_grid.py create mode 100644 eval/pkg7/score_probe2.py create mode 100755 eval/pkg7/termharness/build.sh create mode 100644 eval/pkg7/termharness/main.go diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 8e447fc0..526ab87c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-25, пак-17 закрыт, право по ToS подписано, пак-18 принят — долгов контракта нет, D39.26–D39.31). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.49); этот файл — ЖУРНАЛ.** +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-25, пак-17 закрыт, право по ToS подписано, пак-18 принят — долгов контракта нет, D39.26–D39.31). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.50); этот файл — ЖУРНАЛ.** > - **Фазы/курс:** Ф0 ✅ · Ф1-инфра ✅ (D20–D28; golden = инвариант №8) · арка «качество-первым» D26→D38.5 закрыта · **арх-ресет D39 исполнен ЦЕЛИКОМ** (7 слоёв → программа D39.1–D39.10 → паки 11–16, D39.13–D39.24; статус слоёв — шапка-таблица `architecture/09-target-architecture.md`) · пере-прогон rerun2 прочитан (D39.20: операционка живьём, $0-резюм ×3 арма, $6.63<$15; планка ≤2 НЕ пройдена; анти-корреляция гладкость↔верность ×3 → mistral-редактор вон) · эксп ЗАКРЫТ (D39.22), итерация №2 отложена · **курс = разработка бэкенда: пак-17 «канал B вживую» ЗАКРЫТ ЦЕЛИКОМ (дизайн D39.26 · стройка D39.27 · дельта `allow` D39.28, 25.07); вокабуляр = ПАРА `violence`/`sexually-explicit`, остаток — ФАКТЫ ToS в зоне полигона (fail-closed до них)** (D39.25: в движке НЕТ понятия «18+» — generic content-labels × provider-capabilities; лейблы вне хешей · резолв до валидации · один хоп `chain[0]` · гранулярность = книга) → **КУРС АМЕНДИРОВАН ВЛАДЕЛЬЦЕМ 25.07 (D39.33): МАСШТАБ — ПОСЛЕДНИМ, после достройки алгоритмического идеала; доказательство эффективности крупных изменений — МИНИ-ПРОГОНЫ ~10 глав (детерминированные чекеры и скан остатка первыми, судья — только когда от него зависит решение).** **ОЧЕРЕДЬ ИСПРАВЛЕНА ЗАМЕРОМ (D39.35): $0-резюм драфта на стенде УЖЕ МЁРТВ** (снапшот разошёлся по трём осям — cheapgate v3→v4, renderfmt v2→v3, langpack 09974d6→a7d4be2), поэтому срочности «гнать, пока жив» НЕТ, а ниты перестали быть условными: **(1) микро-пак «предпрогонная гигиена» ($0-код): два нита общности + ОБЯЗАТЕЛЬНЫЙ флип `Gates.RegressionGuard.Enabled` (невыполненное обязательство D38.4 п.5) + опц. проекция пере-оплаты в `tmctl status`** · **(2) мини-прогон-замер ~10 глав** (драфт ≈$0.02, редактура ≈$0.08–0.15/арм, судья НЕ зовётся ⇒ один арм ≈$0.10–0.17, два ≈$0.20–0.32; перед прогоном бэкап `rerun2/*.db`, БД на схеме v9 против v10 бинаря) · **(3) параллельно $0 — сид/банк-дельта** (元 · 赤城 · 学堂家老 · 春秋蝉; два терма ждут подписи владельца) · **(4) пак-19 в урезанном скоупе** · (4) далее слой-2 извлечение из тел промтов · ru-target · native-Gemini судья. Затем — сид-дельта (元 · 赤城 · 学堂家老 · 春秋蝉), МАСШТАБ целой книги (7,78 млн символов, ~2284 раздела) и пилот Ф2.5. Хроника треков A/B, exp15/16 и стройки паков — архивы ниже + D-лог. > - **Стек:** draft deepseek-v4-flash (thinking ON) → **editor deepseek-v4-pro БИЛИНГВ ИНТЕРИМ (D39.22; glm-5 резерв, mistral вон D39.20)**, промпт v3-discourse (P1a+чэнъюй+few_shot-тумблер) → судья gemini-3.1-pro-preview; канал B Mistral+grok; 7 ключей; ~$0.85/ранобэ (D30.4). > - **ОТКРЫТЫЕ ПЕТЛИ (норма 25.07, перепись 180 инцидентов: класс NEVER_CLOSED больше не копим — каждая петля обязана получить диспозицию решено/отложено-с-записью/отклонено; ведёт оркестратор).** *Владелец:* ~~вокабуляр content-лейблов~~ **РЕШЕНО 25.07: ПАРА `violence`/`sexually-explicit`** (D39.27 п.5 — сохраняет D14 п.1 и dspro-редактора на violence) ⇒ следствие в работе: assert-only `action` (дельта пака-17) · ~~Q2 редактор под лейблом~~ **ОТЛОЖЕН С ЗАПИСЬЮ** (D39.27 п.7: при паре связывает только `sexually-explicit`-книги, которых нет; возвращается вместе с предусловием D22.7 перед первой explicit-книгой; кандидаты — grok-4.3 интерим, glm-5 только после сверки ToS Z.AI по первоисточнику, mistral отвергнут D39.20) · ~~ToS-факты + три решения по ним~~ **ЗАКРЫТЫ 25.07 (D39.29 факты + D39.30 подписи владельца):** лейбл `violence` НЕ заводится, интерпретация «нейтральное изображение ≠ пропаганда» зафиксирована письменно · риск Z.AI (плоский запрет «violent content»; экспозиция состоялась в rerun2 через glm-арм) ПРИНЯТ сознательно с условиями пересмотра · две строки `accepts_labels: [sexually-explicit]` (`xai`, `mistral`) ПОДПИСАНЫ и ПРИМЕНЕНЫ в `models.yaml`. **Остаток ToS-вопросов ЗАКРЫТ владельцем 25.07 (D39.32):** local = «политик нет, переводим всё» ⇒ строка применена · Gemini-оговорка читается СУЖЕНИЕМ ⇒ вердикт `FORBIDDEN`→`UNCLEAR`, но по доктрине это НЕ разрешение: строку Gemini не получает, **ждёт одного слова, если владелец хочет подписать интерпретацию как разрешение** · причину пустоты в конфиг НЕ выносим (живёт в quirks-доке) · **ре-чек политик стал ПРАВИЛОМ** (триггеры: Google ToS 30.07.2026 · квартал 25.10.2026 · любая правка `accepts_labels`) · дата аккаунта OpenAI — открыта, приоритет низкий (OpenAI вне цепочки). **Продуктовое (Ф3):** Gemini API Additional ToS запрещает клиентов, «likely to be accessed by individuals under 18» — обязательство на конечный продукт, независимо от лейблов · **промпт-слой под лейблом (НОВОЕ, вскрыто вопросом владельца): лейбл сегодня НЕ доезжает до промпта** (чистые данные маршрутизации + load-time гарантия); нужно ли, чтобы переводчик/редактор ЗНАЛИ о свойстве контента — отдельное решение промпт-слоя · ~~мини-каноны сид-дельты~~ ЗАКРЫТО 26.07: все четыре пункта решены, правок сида ноль; книжные канон-решения живут С КНИГОЙ (`books/gu-zhenren/CANON-NOTES.md`), НЕ в D-логе — урок владельца: движок не строится вокруг одной книги · ~~включение платной петли ремонта~~ **РЕШЕНО 26.07 (D39.38): НЕ включается** — замер 0 в её классах; вернуться при расширении классов и ненулевом остатке; **уточнение D39.39: «0» = ноль в охвате детекторов с измеренным теперь recall (латиница 0.50 боевой, битые формы 0.10) — решение в силе (петля видит теми же детекторами), но читать как «финал чист» нельзя** · **вопросы разметки Р1–Р4 (НОВОЕ, D39.39, пакет-6 §5, 21 спорная строка):** гипербола 千万 (довод: тот же автор в гл.2 даёт 数十万 о том же деянии) · что есть дефект в классе 成 — форма или значение (от ответа precision K5c 1.000 или 0.167) · сокращённая форма термина («гу» при dst «гу-тварь») · принципиально неразрешимая офлайн речь/мысль (если да — потолок recall K4b < 1.0 by construction, вписать в контракт правила) · ~~место фикс-пака «чекеры» в очереди~~ **РЕШЕНО 26.07 (D39.40): это ПАК-21, а перед ним мини-прогон ~10 глав на полном бэкенде (после лендинга 20 и 19)**; ответы Р1–Р4 нужны к дизайну пака-21 · ja→ru-реплика §B5 (тайминг) · старые Ф2.5-блокеры (билингв-якорь — D25 п.9 Q1 · контаминация корпуса — D27 п.4 · судья-дублёр D22.6 · планка запуска · publishable/waiver — D25 п.1 · FN-bound L3 — D25 п.4 · юр-пакет · провенанс 12-*-доков · xAI-ключ off перед продом D27 · residual-тачпойнты exp16, не влитые в сид-дельту: мини-голд алиасов · precision@30, `books/gu-zhenren/exp16/`). *Оркестратор:* ~~ратификация дизайна пака-16~~ РЕШЕНО D39.24 (25.07) · фантом-гард D-ссылок в чек-листе лендинга (действует) · ~~doc-sync pass~~ **ИСПОЛНЕН 25.07** (запись ниже: оба puml перерисованы под пост-пак-16 · баннеры на 01/02/03/04/06/07/08/09 · ресёрч-свип 18/18c/19/20/21/22 · README/POLYGON_PACKAGE4 освежены; residual — пинги полигону в его зоне) · ~~приёмка пака-17 фаза 1~~ **РЕШЕНО D39.26 (25.07: принято в редакции §14, три добора приёмки, фаза 2 размечена)** · **`escalation_model` = ОТВЕТИВШАЯ модель (НОВОЕ, вынесено из пака-17): вердикт-изменение** — golden пинит `esc_model` на ОТКАЗАВШЕМ хопе, маскировка не скрывает ⇒ отдельная ратификация с санкционированным пере-капчером · **`--accept-rebill[=usd]` с порогом `min($0.50, 5%×ProjectedBookUSD)` РАТИФИЦИРОВАН D20.2-Q2, но в коде/CLI ОТСУТСТВУЕТ (НОВОЕ)** — живёт только в спеке `backend/docs/D15.2-*.md`; `--resnapshot` пере-пиннит без суммы · **D22.7 (пер-чанковый L3-скрин) — предусловие первой erotica-книги в проде НЕ снято паком-17 (НОВОЕ, держать отдельным гейтом)** · фантом-гард D-ссылок в чек-листе лендинга (действует). *Бэкенд:* **ХВОСТЫ МИНИ-ПРОГОНА (D39.37, 26.07, по убыванию веса):** ~~(1) размеченный набор для чекеров~~ **ЗАКРЫТ 26.07 (полигон-пакет-6, D39.39)** ⇒ породил вход нового фикс-пака «чекеры»: 12 дефектов с file:line (супрессор K5c гасит юнит целиком · атрибуция K4b (23 безатрибутивных + 12 с `!?…`) · `inner_marker` на тире-строки · предохранитель K5a отсекает все реальные магнитуды · заглавные/гомоглифы K2 · 两 в `cheng_re` · U+0301 рвёт `TokenizeCyrillic` · мн.число в `decl.forms`-тулинге · однознаковые ключи пост-чека) — приёмка против набора за $0; **место РЕШЕНО (D39.40): ПАК-21, после мини-прогона на полном бэкенде; + переезд `translitInterjections` в данные (улов владельца)** · (2) **тест-пин джойна WHAT↔WHICH** (мутация оркестратора выжила — фикс-лист №1 следующего касания) · ~~(3) покрытие WHAT↔WHICH + флаговый майнинг-стоп~~ **ПАК-20 ЗАКРЫТ ЦЕЛИКОМ 26.07 (фаза 1 D39.41 · решения D39.42 · фаза 2 ПРИНЯТА И ЗАЛЕНДЕНА D39.45): терминолог + `--verify-bank` (дефолт=авто) + авто-провод с пометкой + точечная ре-редактура с $0-пере-пином + банкнота отдельным файлом; живьём на 10 главах, $0.0476, деньги сходятся тремя путями; заделы серии/импорта — контрактами.** Открытое по паку: `feed_cap` (рекомендация: малой дельтой) · жанровые метки словаря · подпись словаря после фазы B полигона · resnapshot стендовых книг с auto-строками; хвосты: `AttachKWIC` квадратичен · смета молчит о пере-покупке роли на подписи · S12 частичен · zh-worst-case в `bankTokenBudget` (утечка §0 признана) · gofmt `llm.go` (до-паковый) · **вариативность банкноты 37↔14 (n=2) → полигону в фазу B** · (4) `dialogue_dash` мерить только на невиданном тексте (подогнан под 蛊真人) · (5) генеральность нарезки: кана/ханьцзи один класс ⇒ ja `est_out` ~1.7× завышен МОЛЧА; `EstimateTokens` недосчитывает кириллицу (резервации занижены) · (6) golden второй фикстурой С langpack · (7) `prompt_override` — честная формулировка гарантии слоя-2 · (8) майнер/банк не-CJK (едет с ja→ru) · (9) `request_log` скоуп/ретеншен + `first_flag_reason` третье представление истории · (10) флор-16000 без модели · (11) `max_tokens`-упоры вернутся на en · ~~пак-17 (фазы 1–2 + дельта)~~ **ЗАКРЫТ ЦЕЛИКОМ 25.07 (D39.26/27/28)** · ~~ПАК-18 «долги контракта»~~ **ПРИНЯТ И ЗАЛЕНДЕН 25.07 (D39.31) — долгов контракта не осталось**; выдавался (`BACKEND_PACK18_DEBTS_SESSION_PROMPT.md`: `--accept-rebill` на снапшотной гранулярности — `guard_hash` в коде нет и не строим · `escalation_model` = авторитетная модель с САНКЦИОНИРОВАННЫМ пере-капчером golden под этот дифф · два нита общности условно-байт-нейтрально) · **ru-target долг (слой 7, `isRuTarget`×11 прод-сайтов · `TokenizeCyrillic`×6 · ключ «ru» в санитайзере) — ЖДЁТ СВОЕГО ДИЗАЙН-ПАКА** (связывает только не-русский таргет, по текущей карте zh/ja/en→ru не срочен) · **ПАК-19 «голос и состояние» ВЫДАН 25.07** (`BACKEND_PACK19_VOICE_STATE_SESSION_PROMPT.md`, двухфазный; ниты общности едут ЕГО лендингом — D39.31 п.5) · **проекция пере-оплаты в `tmctl status` (НОВОЕ, из отчёта пака-18): спека §9 предлагает заменить булев `ConfigDrift` числом «N чанков, ~$X» — дёшево, `omitempty`, не сделано сознательно** · **не-долги, следующие несущие:** масштаб целой книги (волны/каденс/потолки/ETA на сотнях глав — гоняли максимум 5) · Ф2-механизмы (голос/состояние D21 · native-Gemini судья) · извлечение дискурс-норм/few-shot из ТЕЛ промтов (слой 2, форсинг = ja→ru) · ~~пак-16 ф.2~~ ЗАЛЕНДЕНА 25.07 · per-class исходы ремонта — схемное решение (класс не durable), принимается ВМЕСТЕ с включением петли · ниты общности (владелец 25.07) — **ОТЛОЖЕНЫ ПО ЗАМЕРУ (D39.31 п.5): едут на ближайший ПОЛНЫЙ `--resnapshot`**; для Detail-строк байт-нейтральной формы не существует определительно (дефолт шаблона обязан рендерить текущую строку). Прежняя формулировка: только в байт-нейтральной форме с доказательством исполнением (иначе `CheapGateVersion`/`pack.Version()` двигают снапшот стендовых книг и убивают $0-резюм драфта под замер остатка пака-16): Detail-строки чекеров → шаблоны/цитаты из данных (статический 时辰-текст в generic-файле соврёт второй паре) · множитель ×2 `lintTimeUnits` (`checkers.go:174`) → ключ данных. *Полигон:* ~~пакет-6 (размеченный набор чекеров)~~ **ОТРАБОТАН И ПРИНЯТ 26.07 (D39.39): 3015 позиций, 6 прогонов, $0; metrics.json воспроизведён побайтно; набор на стенде `books/gu-zhenren/labels/` = постоянный измерительный стенд чекеров; ~~ПАКЕТ-7 (v2, ресёрч-программа)~~ **ЗАКРЫТ ЦЕЛИКОМ 26.07 (A: D39.43 · Z-решения: D39.44 · B+B′: D39.46; $0.45043 из коридора $2.60): ратифицировано «контексты — главный рычаг терминолога»; судья = только катастроф-экран с декоем; словарь инертен до расширения эмиссии на term; три процессных правила — в нормы промтов; ~~на владельце подписи Z-B1–B4~~ **АМЕНДИРОВАНО D39.47: жанровый словарь ОТМЕНЁН КАК КЛАСС (решение о переводе = подпись владельца на банк КНИГИ; Z-B1/B2/B6 сняты, Z-B4 сужен, Z-B3 остаётся — промпт уже в узкой форме); дельта размотки → бэкендерам, главный вопрос покрытия = расширение эмиссии на term + feed_cap (ждёт санкции)**; амендмент D39.42 п.1 (терминолог на полном банке = единицы долларов) и Q4→Р3 (сокращённая форма = поле записи, вход пака-21) — в силе; полигон-очередь ПУСТА, кандидаты: вариативность банкноты 37↔14 · ёфикатор/санитайзер-разметка · dialogue_dash на невиданном (оставшиеся кандидаты: ёфикатор/санитайзер-классы не размечены — §6.5 отчёта пакета-6; `dialogue_dash` на невиданном тексте) · ~~пакет 5 (факты ToS + 8 пингов)~~ **ОТРАБОТАН ЦЕЛИКОМ И ПРИНЯТ 25.07 (D39.29); все восемь пингов закрыты.** Исторический список закрытых пингов: (1) `00-provider-quirks.md:13,73` — катовер deepseek-chat 24.07 ПРОШЁЛ, пост-катовер факт (жив ли алиас, цены) не внесён — live-проба `/models` + вендор-дока; (2) `00-provider-quirks.md:69` — оговорка «mistral-editor-арм до guard» superseded D39.20 (mistral вон из редакторов; rate-limit-цифры оставить — нужны переводчику канала B пака-17); (3) `09-pilot-protocol.md:3` — ⚠-указатель застрял на D31/glm-5: продлить до пост-D39.22 (dspro-интерим · планка ≤2 не пройдена rerun2 · авто-QA слепыми метками D39.20 · стайл-каноны D39.21), тело не трогать; (4) `15-segmentation-empirics.md:6-7` — шапка до сих пор «НИ ОДНОГО платного вызова», а эксп залендён REV.2 (D39.7/D39.8, $12.79) — перевернуть статус-блок; (5) `16-bank-mining.md:3` — дописать финал «ИСПОЛНЕН И ЗАЛЕНДЁН (D39.10); gender-тачпойнт §7 закрыт (D39.19); продолжение — Go-майнер парити EXACT»; (6) `experiments/README.md` — индекс без строк exp15/16 (сверка застряла на D38.2); (7) `eval/README.md` — не тронут с 19.07 (пре-rerun2): освежить статус под пост-D39.22 (эксп закрыт · судейский риг-стандарт D39.7 · экстракция только `tmctl export`). @@ -133,6 +133,26 @@ ## Полигон (секция параллельной сессии — записи добавлять сюда) +### Пакет-8 «три пре-замера перед расширением эмиссии» ИСПОЛНЕН, СТОП на ратификацию ($0.11313 из $0.14), 26.07 + +Отчёт: [archive/reports/POLYGON_PREMEASURE_2026-07-26.md](archive/reports/POLYGON_PREMEASURE_2026-07-26.md). Пре-регистрация §0 написана до единого платного вызова; критик полноты — на каждую фазу; майнер и сборка запроса роли — из пин-копии `0a7fa5b` (эмиссия побайтно равна пину пакета-7 `996bade`, проверено `cmp`). + +**Главный итог — вопрос расширения переставлен.** Расширять эмиссию майнера незачем и недостаточно: 66 из 74 поверхностей банкноты лежат ВНЕ варианта C (потолок и фильтр типа сняты), потому что не входят в кандидатный алфавит по ЧАСТОТЕ, а не из-за снимаемых констант. Каналы почти не пересекаются — 2 общие поверхности из 78, и 5/6 объёма даёт банкнота, которая одна умеет класс `term` вместе с dst. + +**Замер 1 ($0) — пре-зарегистрированный стоп сработал.** Совместный recall против подписанного сида НЕИЗМЕРИМ на существующих артефактах: банкнота гонялась с непустым банком, 49 из 51 терма сида были инъектированы, а промт просит «новые, которых НЕТ в глоссарии»; свободных пар (терм × глава) 21 из 217, и все три их поверхности короткие. Цифру не подгонял — назвал цену измерения (холодный прогон с пустым банком). Страховочный выход дал главный результат выше. Побочно: банкнота невоспроизводима (Жаккар двух прогонов одних глав 0.077, после снятия `《》` 0.40), 48% её предложений разовые, `《咏梅》` и `咏梅` едут как две строки. Подписанные `蛊`/`转` не доезжают до модели НИ ОДНИМ каналом (`allow_short:false` при `SignificantLen=1`) — правка данных, не кода. Нашёл и дефект нарезки пакета-7: `--max-chapters` фильтрует по НОМЕРУ, а нумерация 节 перезапускается в каждом из 6 томов, поэтому «gu 25гл» был рассеянной выборкой; абсолютные recall занижены втрое (0.089 → 0.255 на непрерывном срезе), структурный вывод про класс `term` устоял. + +**Замер 2 ($0.0168, 3 повтора) — ВЕРДИКТ ПРОВАЛ по пре-зарегистрированному порогу.** На хвосте ранга варианта C роль выдумывает dst для 77 · 80 · 78 кандидатов из 100. Сентинел ⟦TM-NO-DST⟧ различает «переводимо/непереводимо», а не «терм/не терм» (огрызки отклоняет 7/7, `一天` переводит «один день»), и на своей же задаче нестабилен (размах 0.375). Контроль на подписанных термах: 8 из 9 ответов совпали с подписью — роль умеет отвечать верно и не умеет отказываться. Условие применения ратифицированного фолбэка (D39.46, судья-с-декоем) наступило; строить его не моё дело. + +**Замер 3 ($0.0963, 6 точек × 3 повтора + арм без якоря) — дефолт 3×40 достаточен, сетка неразличима.** Средние 0.533–0.633 при внутриточечном размахе 0.175; на расходящихся позициях то же (0.289–0.445 при размахе 0.273). Развилка владельца «широкая подача × узкий контекст vs узкая × широкий» НЕРАЗЛИЧИМА (8×40 = 0.533 против 3×120 = 0.567). 8×120 стоит в 4.9 раза дороже 0×0 и не точнее. **Напряжение с ратифицированным D39.46 («контексты — главный рычаг») называю прямо и за отмену не выдаю:** три различия постановки (лёгкая выборка по частоте вместо трудной S1, боевой якорь ⟦TM-CANON⟧, отсутствие строки `drafts:`), ни одно не устранял; закрывает вопрос пере-замер выборки пакета-7 на боевой сборке. + +**Самая тяжёлая находка — §3.4: якорь ⟦TM-CANON⟧ держит ЯЗЫК ответа.** Арм без якоря в одном прогоне из трёх вернул 22 строки из 40 по-английски («Rank 1», «Grade C», «cultivation», «Primordial Sea»); в 8×120 — 3 и 2 латинских ответа. Ни `ParseReply`, ни эхо-гейт этого не ловят, строка уезжает в карту подписи как `status:draft`. Пустой/короткий якорь — это ровно состояние книги сразу после расширения эмиссии. + +**Снял собственную рекомендацию замером (§5.1).** Предлагал дисциплинировать банкноту частотным порогом; посчитал частоты по всей книге и обнаружил, что разделяющего порога не существует: `少年` («юность») 1149 против `光阴之河` («Река времени») 15. Отбор здесь семантический — либо вызов модели, либо владелец. + +**Вопросов владельцу нет; в `backend/` не писал, не коммитил.** Открытым остаётся холодный старт с пустым банком — единственная цифра, на которой стоит цена расширения, и она стоит денег. + +**Добор §6 по вопросу владельца «хватает ли одной книги, движок ведь универсальный» ($0, после СТОПа).** Прогнал боевой майнер с того же пина по стенду. `en→ru` сегодня не запускается вовсе: пары `en`/`en-ru` в `configs/langpacks/` нет, загрузчик отказывает ЛОУДНО (D39.15 работает как задумано — недостающее это данные). Когда загрузчик удовлетворён зеркалом zh-пака, канал WHICH на латинице даёт **0 кандидатов на 814k знаков** обеих английских книг (Кристофф и Fifty Shades) — механизм в `hanRuns` (`miner_substrate.go:46-48`): кандидатный алфавит есть пробеги ханьских иероглифов, в латинице их нет. На японском (`異世界魔術師`, 134k) канал не молчит, а **ошибается: 10 строк, все типа `name`, все ложные** (`何故` «почему» 44×, `王国` «королевство» 35×, `马鹿` «дурак» 21× — китайские фамильные паттерны на японских composites); настоящий ja-пак ложняки уберёт и не добавит ничего, потому что имена этой книги в катакане, а `hanRuns` её не видит. **Вывод: второй КНИГИ мало — для латиницы нужен второй ДЕТЕКТОР** (прототип и замеры есть: `eval/pkg7/mine_nonhan.py`, каналы ORTHO/DISP/CONTRAST, достаёт `Ashdrinker`/`San Michon`/`Forever King`). Попутно найден дефект общности: **`kwic_width` меряется в РУНАХ** (`terminology.go:332-349`), дефолт 40 живёт в Go одной константой на все пары и означает 43–84 морфемы на zh против 13 слов с обрубленной фразой на en — место такой константы в пар-данных, а мой вердикт «3×40 достаточен» читается только как «43–84 морфемы достаточно». Ничего не строил и не запускал платного: §6.4 — предложение, санкции на него не было. + ### Пакет-6 «размеченный набор для чекеров» ОТРАБОТАН И ПРИНЯТ (D39.39), 26.07 Сессия полигона собрала набор из 91 юнита шести реальных прогонов (~149k знаков исходника, 3015 размеченных позиций, 70 дефектов, 21 спорная; синтетики ноль), разметила ДО просмотра вердиктов и прогнала НАСТОЯЩИЕ чекеры replace-харнессом. Первый честный замер линеек: где линейка стреляет — почти не врёт (K1/K2/K3/K5b precision 1.000), но recall почти везде низкий (K4b 0.109 · K3 0.100 · K5a 0.000), K5c ИНВЕРТИРОВАН на реальном материале (супрессор «процент» погасил единственный юнит с 10×-ошибками шкалы), K6 шумит 14:1 (мн.число вне `decl.forms` + однознаковый ключ 转). 12 дефектов чекеров с file:line, 4 вопроса владельцу (Р1–Р4). Приёмка оркестратора: `metrics.json` побайтно ре-раном, независимый пересчёт разметки, живой репро U+0301, сверка K5c по сырому корпусу. Отчёт: [archive/reports/POLYGON_CHECKER_LABELS_2026-07-26.md](archive/reports/POLYGON_CHECKER_LABELS_2026-07-26.md); набор — на стенде (вне git), ре-ран детерминирован. diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index f4f3c820..a0477b91 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -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 77–82% стабильно (сентинел ⟦TM-NO-DST⟧ различает «переводимо», не «терм», и нестабилен даже там — размах 0.375); (2) **носитель покрытия — банкнота** (5/6 кандидатов, класс term с dst), но она невоспроизводима (Жаккар 0.077–0.40 между прогонами одних глав) и эмитирует 《咏梅》/咏梅 как разные строки — лечения, выдержавшие проверку: нормализация книжных кавычек + агрегация по прогонам; **частотного экрана НЕ существует** (§5.1: 少年 1149 против 光阴之河 15 — порога нет; полигон сам снял свою рекомендацию); (3) **дыра языка ответа**: ⟦TM-CANON⟧ — де-факто якорь ЦЕЛЕВОГО ЯЗЫКА; без него 22/40 английских строк в одном прогоне из трёх (8×120 тоже течёт), и конвейер их банкует (`wellFormedLemma` пропускает латиницу) — закрыть ДО любого расширения канала; (4) **kwic-дефолт 3×40 ДОСТАТОЧЕН** (ни одна точка сетки не отличима, 0×0 — лучшая; развилка «подача×контекст» неразличима), НО `kwic_width` в РУНАХ = утечка пары в общий слой (43–84 морфемы 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. diff --git a/docs/archive/reports/POLYGON_PREMEASURE_2026-07-26.md b/docs/archive/reports/POLYGON_PREMEASURE_2026-07-26.md new file mode 100644 index 00000000..1b304117 --- /dev/null +++ b/docs/archive/reports/POLYGON_PREMEASURE_2026-07-26.md @@ -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 `, + а не из рабочего дерева. Коммит объявляется в теле замера. +- **Деньги.** Всякая цифра стоимости — из фактического `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%: сентинел несёт свою функцию, расширение можно проектировать без + дополнительного экрана. +- **Серая зона** 20–50% — вывод «нужен экран или нужна правка промпта», без выбора между ними. + +**Контроль (арм без фактора).** Та же батча с ДОБАВЛЕННЫМИ 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 | гл. 1–10 (**основной**) | +| `minirun-verify/guzhenren-verify.db` | 2 | 10 | гл. 1–2 | +| `minirun-verify2/guzhenren-verify2.db` | 2 | 6 | гл. 4–5 | + +**Парсер сверен с боевой телеметрией, а не объявлен верным:** моя реплика `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** | + +Почти половина предложений — разовые фразы (`三天三夜` «три дня и три ночи», `六块` «шесть штук`, +`白银盘子` «серебряная тарелка», `雨声` «звук дождя`, `中年`/`少年`/`老年`). Это не терминология +книги, это обычные слова, которые модель приняла за термины. + +**Второй дефект — повторяемость.** Главы 1–2 прогонялись ДВАЖДЫ, побайтно тем же промтом +(`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 节, а все 节 с номерами 1–10 из шести томов: рассеянная выборка по +всей книге (и с коллизией ключей `(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.31–0.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.077–0.40. +3. Значит вопрос дизайна — не «поднять ли `emitRankCap`», а **«чем дисциплинировать банкноту»**: + частотный порог по тексту книги (он у движка уже есть для майнера), нормализация книжных кавычек, + агрегация по прогонам. Всё три — дешёвые, детерминированные и $0 в проде. + +**Чего замер не покрывает.** Холодного старта (банк пуст) не мерил никто; сколько банкнота +предложит, когда её не глушит инъекция, неизвестно, и это ровно та цифра, на которой стоит цена +расширения. Одна книга, одна пара, одна модель, 10 глав. + +### §1.8 Критик полноты фазы 1 + +Что проверено дополнительно по итогам критика и что осталось непокрытым: + +| Проверка | Итог | +|---|---| +| Парность пинов `0a7fa5b` / `996bade` | эмиссия побайтно одна (`cmp`) | +| Мой парсер банкноты против боевой телеметрии | пер-чанково совпадает, 71 = 71, `parse_fail` тоже | +| Все ли источники банкноты найдены | просканирована 21 база стенда; блоки только в 3 | +| Одинаковы ли промты двух прогонов гл.1–2 | `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 с поверхностями банкноты на одном срезе (гл. 1–10, $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% расширенной эмиссии, роль +выдумывает перевод в 77–82% случаев в каждом прогоне.** + +Отдельная улика: `是为 → «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 терял 6–7 +совпадений из 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.077–0.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 77–80% на мусоре; сентинел различает «переводимо», а не «терм» | **вероятно пар-слепой** — свойство модели и промта, но популяция мусора на латинице другая (не 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 достаточен» надо читать как «43–84 морфемы +контекста достаточно», а не как «числа 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. **Чего второй книгой не купить:** цену расширения эмиссии. Она упирается в холодный старт с + пустым банком на ЛЮБОЙ паре, и это по-прежнему единственная неизмеренная цифра. \ No newline at end of file diff --git a/eval/pkg7/README.md b/eval/pkg7/README.md index c72ddf7e..31f1859d 100644 --- a/eval/pkg7/README.md +++ b/eval/pkg7/README.md @@ -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 вызовам — метрика точности её не показывала. diff --git a/eval/pkg7/bank_profile.py b/eval/pkg7/bank_profile.py new file mode 100644 index 00000000..d53f0db2 --- /dev/null +++ b/eval/pkg7/bank_profile.py @@ -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()) diff --git a/eval/pkg7/joint_recall.py b/eval/pkg7/joint_recall.py new file mode 100644 index 00000000..bff29ec8 --- /dev/null +++ b/eval/pkg7/joint_recall.py @@ -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+3400–U+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()) diff --git a/eval/pkg7/role_probe.py b/eval/pkg7/role_probe.py new file mode 100644 index 00000000..14464bd3 --- /dev/null +++ b/eval/pkg7/role_probe.py @@ -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()) diff --git a/eval/pkg7/score_grid.py b/eval/pkg7/score_grid.py new file mode 100644 index 00000000..90e99a53 --- /dev/null +++ b/eval/pkg7/score_grid.py @@ -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()) diff --git a/eval/pkg7/score_probe2.py b/eval/pkg7/score_probe2.py new file mode 100644 index 00000000..e55df785 --- /dev/null +++ b/eval/pkg7/score_probe2.py @@ -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.20–0.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()) diff --git a/eval/pkg7/termharness/build.sh b/eval/pkg7/termharness/build.sh new file mode 100755 index 00000000..d0cfcb7f --- /dev/null +++ b/eval/pkg7/termharness/build.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Собирает termharness КАК МОДУЛЬ ВНЕ РЕПО — та же схема, что у minerharness (см. его build.sh): +# исходник живёт в eval/ и потому ревьюируем, а go.mod + `replace` материализуются в рабочем каталоге +# вне git, так что второго go.mod в репозитории не появляется. +# +# ./build.sh [commit] +# +# Пин обязателен: `replace` смотрит в копию коммита (`git archive`), а не в рабочее дерево. +# Отличие от minerharness ровно одно — имя модуля и копируемый main.go; сознательно НЕ обобщал общий +# скрипт с параметром, чтобы не править файл, которым пинятся уже сданные цифры пакета-7. +set -euo pipefail + +WORK="${1:?usage: build.sh [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)" diff --git a/eval/pkg7/termharness/main.go b/eval/pkg7/termharness/main.go new file mode 100644 index 00000000..bba632ca --- /dev/null +++ b/eval/pkg7/termharness/main.go @@ -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 печатает `исходная_строкаключ` — единственный способ сшивать множества сида, майнера и +// банкноты ТЕМ ЖЕ ключом, что использует движок (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) +}