Land pilot-harness package: memory_eval echo control and fidelity axis, M0-M3 rename, artifact blinding, exp09 v2 with D13, kana precision, Mistral probe, webnovel slice; journal entries for both sessions

This commit is contained in:
Claude (backend session) 2026-07-09 19:05:28 +03:00
parent 153277e3bb
commit 9afea37bee
13 changed files with 1904 additions and 232 deletions

View file

@ -1,7 +1,7 @@
# Журнал прогресса # Журнал прогресса
> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (обновляемый блок; на 2026-07-09). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1D17); этот файл — ЖУРНАЛ, не спека.** > **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (обновляемый блок; на 2026-07-09). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1D17); этот файл — ЖУРНАЛ, не спека.**
> - **Фаза:** 0 (каркас) завершена; машинерия Фазы 1 в основном построена — банк памяти v2, чанкер + txt/epub импорт, disposition/coverage-гейт/эскалация, durable resume, глоссарий→редактор. **Проведено стратегическое ревью (`architecture/07-strategic-review.md`, 09.07): архитектура end-to-end цела; ратифицирован пакет пост-ревью решений D13D17.** Следующее: пакет фиксов D15/D16 (бэкенд) + починка пилот-харнесса D13 (полигон) → реальный ja→ru прогон end-to-end → пилот Ф2.5. > - **Фаза:** 0 (каркас) завершена; машинерия Фазы 1 в основном построена — банк памяти v2, чанкер + txt/epub импорт, disposition/coverage-гейт/эскалация, durable resume, глоссарий→редактор. **Проведено стратегическое ревью (`architecture/07-strategic-review.md`, 09.07): архитектура end-to-end цела; ратифицирован пакет пост-ревью решений D13D17.** Следующее: пакет фиксов D15/D16 (бэкенд, **D16 лендится 09.07 — `memmatch-v3`**) → реальный ja→ru прогон end-to-end → пилот Ф2.5. **Пилот-харнесс D13 ПОЧИНЕН (полигон, 09.07): memory_eval эхо-контроль+fidelity-ось+M0M3+Go-синк, индикатив пере-прогнан (M1≈M2 банк оправдан; M3 роняет верность 45.6 → страх владельца подтверждён); exp09 v2; Mistral заведён (D14.2, refusal 11/11 ok); kana-precision (MIN=3 подтверждён); вебновелл-срез готов.**
> - **Стек (2026-07-05, подтверждён ревью):** черновик DeepSeek V4 Flash · редактор **grok-4.3** (thinking-OFF через ЯВНЫЙ `reasoning_effort:none` — дефолт grok = `low`; D1-монолингв оспорен внешним замером — решает пилот-арм D13.1/D17) · судья/апекс Gemini 3.1 Pro (mandatory thinking) · эскалация A: DeepSeek V4 Pro → GLM-5.1 → Gemini · канал B (18+): Grok + локальная abliterated (**DeepSeek для explicit ЗАПРЕЩЁН его ToS — D14.1; Mistral — кандидат-фолбэк после пробы, D14.2**). Anthropic выброшен, OpenAI оставлен. 6 ключей. > - **Стек (2026-07-05, подтверждён ревью):** черновик DeepSeek V4 Flash · редактор **grok-4.3** (thinking-OFF через ЯВНЫЙ `reasoning_effort:none` — дефолт grok = `low`; D1-монолингв оспорен внешним замером — решает пилот-арм D13.1/D17) · судья/апекс Gemini 3.1 Pro (mandatory thinking) · эскалация A: DeepSeek V4 Pro → GLM-5.1 → Gemini · канал B (18+): Grok + локальная abliterated (**DeepSeek для explicit ЗАПРЕЩЁН его ToS — D14.1; Mistral — кандидат-фолбэк после пробы, D14.2**). Anthropic выброшен, OpenAI оставлен. 6 ключей.
> - **Экономика:** `experiments/08-cost-model-v2.md` (~$0.69 ранобэ / ~$10.94 вебновелла-500) — заменяет старые пороги $0.6 / $5 / $30. ⚠ Resume: любой `--resnapshot` = переоплата книги — онгоинг гейтится D15.2. > - **Экономика:** `experiments/08-cost-model-v2.md` (~$0.69 ранобэ / ~$10.94 вебновелла-500) — заменяет старые пороги $0.6 / $5 / $30. ⚠ Resume: любой `--resnapshot` = переоплата книги — онгоинг гейтится D15.2.
> - **Ждём от владельца:** 35 глав реальных вебновелл (вся текущая эмпирика — классика из претрейна) + explicit-фрагменты для 18+ руки (единственный critical ревью) + провенанс двух внешних 12-*-доков. > - **Ждём от владельца:** 35 глав реальных вебновелл (вся текущая эмпирика — классика из претрейна) + explicit-фрагменты для 18+ руки (единственный critical ревью) + провенанс двух внешних 12-*-доков.
@ -527,9 +527,64 @@ keep-alive (Ф12), инъекция глоссария (`selective`), пор
Тесты: `capability_test` кейс assert'ит **присутствие** `reasoning_effort:"none"` (закрыл eval-указанную дыру — ложный «off шлёт nothing» тут падает); `TestBoevoyConfigGrokEditorExplicitNone` (config end-to-end: grok→explicit-none, не echo-flagged, edit=grok, c2-select=glm-5). Полный `-race` зелёный, `tmctl report` $0. Eval подтвердил: прогоны переигрывать не надо, баг был латентным (grok не был в стадии) — пойман до флипа. Тесты: `capability_test` кейс assert'ит **присутствие** `reasoning_effort:"none"` (закрыл eval-указанную дыру — ложный «off шлёт nothing» тут падает); `TestBoevoyConfigGrokEditorExplicitNone` (config end-to-end: grok→explicit-none, не echo-flagged, edit=grok, c2-select=glm-5). Полный `-race` зелёный, `tmctl report` $0. Eval подтвердил: прогоны переигрывать не надо, баг был латентным (grok не был в стадии) — пойман до флипа.
### 2026-07-09 — Пакет фиксов стратегического ревью (D15/D16, промт `BACKEND_SESSION_PROMPT_REVIEW_FIXES`) ЗАВЕРШЁН
6 задач + 3 самопойманных дефекта + 7 находок финального адверсариального селфревью — всё исправлено, каждый фикс **мутационно-проверен**, `go build/vet/test ./... -race` зелёный, `tmctl report` $0.
- **Задача 1 (гигиена).** Лгущие resume-комментарии (`runner.go` coverageSnap, `coverage.go` coverageGateVersion) переписаны честно: `--resnapshot` переоплачивает ВСЮ книгу (snapshotID в RequestHash), никакого «free re-classify» — только внутри неизменного снапшота. Edit `prompt_version` `v0-draft``v1-monolingual` в обоих `pipeline-c*.yaml` (D1-уточнение №3: один лейбл на два SHA закрыт). `capability.go:3` ссылка 03→05.
- **Задача 2 (D16 память).** 2a полисемия fail-loud (same-src/разные sense/dst/пересекающиеся окна, scoped к matchable src — `道` инертен, не регресс); 2b sticky **наследует** disposition (`activeIDs``map[string]injectionDisposition`, `Select`/`unionSticky`/`stickyWin` обновлены) — collision-prone AMBIGUOUS больше не апгрейдится в CONFIRMED мимо post-check/editor; 2c source word-boundary для **латиница/кириллица** (`suppressUnboundedPhonetic`); 2d ruby-reading→alias ручного термина (`attachRubyAliasesToManual`) + чек-лист ja-книги в `backend/README.md`. `memoryMatchVersion` v2→v3, `memoryNormAlgoVersion` v2→v3.
- **⚠ [→ оркестратору] 2c: kana/Han НЕ boundary-checked (осознанное расхождение с буквой промта «латиница, кана»).** Обоснование: у каны нет сегментации (как у Han) — letter-boundary ломает частый кейс «имя+частица» (すずきは) и противоречит контракту «длинный кана-ключ = CONFIRMED». ≥4-кана-компаунд-precision отложен на kana-precision-замер полигона + токенизатор B6 (согласуется с D16 «minKeyLenPhonetic=3 — консервативный дефолт до замера»). Прошу ратифицировать это сужение.
- **Задача 3 (`tmctl status`+`redrive`, D15.3).** `status.go`: read-only проекция (0 LLM, 0 replay) — book-manifest N/M (честный знаменатель через ре-чанкинг $0), unit-счёт done/in_progress/flagged/pending, паспорта глав (total/ok/flagged/skipped-stages, worst_flag, вердикт pass|attention|fail по exp07, $), деньги committed/reserved/ceiling%/projected, ETA от throughput (вторично), `--json` со стабильными enum. Колонка `escalated`/`escalation_model` в `chunk_status` (миграция v6) — телеметрия эскалации на flagged-resume (отложенная находка №6 Вехи 2.5). `redrive`: точечная переатака флагнутых (`--chapter/--chunk/--reason/--dry-run`), сброс терминального флага (`ResetChunkStages` удаляет chunk_status+чекпоинты сброшенных стадий, DispOK не тронут), свежий retry/escalation-бюджет, ре-ран durable-цикла. Продемонстрировано на example-книге ($0). **Деньги-семантика (задокументирована):** redrive НЕ возвращает committed (реально потраченное остаётся) → после redrive `committed ≥ SUM(checkpoints)`, безопасное направление; инвариант №1 в `README.md` дополнен исключением.
- **Задача 4 (спека D15.2, документ).** `backend/docs/D15.2-content-addressed-resume-spec.md` (в моей зоне). Суть: `snapshotID` в ключе вызова смешивает wire- и вердикт-входы; расщепить (`wireSnapshotID`/`verdictSnapshotID`), ключ чекпоинта держать на wire-идентичности (по факту `msgsContentHash` + не-msgs wire-параметры), вердикт-версии убрать из ключа и re-classify-ить на resume бесплатно. Снимает онгоинг-мину D15 (append-термин re-bill-ит только реально затронутые чанки). **[→ оркестратору] на утверждение; при ок — свернуть в `03-implementation-notes.md`.**
- **Задача 5 (reasoning-буфер, D13.6).** `EstimateUSD(+reasoningBudgetTokens)` резервирует reasoning как output для additive-провайдеров (xAI); fail-fast в `LoadPipeline`: reasoning-стадия на additive-провайдере без `reasoning_max_tokens` (+ escalate_to). Снимает D6.2-блок think-ON grok.
- **Задача 6 (перепроверка отсевов).** Артефакты 44-агентки эфемерны (PROGRESS называет лишь sticky-отсев — уже закрыт 2b). Адверсариальные пробы с исполнением по границам доверия → **2 подтверждённых + пофикшено:** (F) shared-alias livelock (общий firing-key между разными approved-терминами → `approvedSharedKeyCollisions` fail-loud) и (A) apostrophe-fold тихо-пусто (типографский апостроф → ASCII в обоих нормализаторах). Пробы удалены, `git status backend/` чист.
**Самопойманный при селфревью (сверх задач):** 2d+Задача6 могли hard-блокировать легитимную ja-книгу с омофонами (鈴木/鈴樹 → оба すずき) — `attachRubyAliasesToManual` теперь graceful skip+log коллизии (best-effort, не hard-block), ручные коллизии сида по-прежнему fail-loud.
**Финальное адверсариальное селфревью — воркфлоу 5 дименсий × верификация (15 агентов, refute-by-default): 7 CONFIRMED / 3 refuted, все 7 исправлены:**
1. [major] Полисемия same-src через общий matchable ALIAS проскакивала оба гарда D16.1 (sub-floor src `道` + общий алиас `大道`) → снял same-src-skip в `approvedSharedKeyCollisions` (ловит через общий alias-ключ; в обычном eligible-src кейсе `loadGlossarySeed` короткозамыкает — двойного репорта нет).
2. [major] `glossary_miss` (post-check-гейт, флаг на уровне ЧАНКА после стадий) был невидим `status`/`redrive` (все stage-строки DispOK) → status показывал done/pass против exit-2 translate. Фикс: при gate ON чанк с post-check-промахом промотится в flagged/glossary_miss (не re-drivable — правка глоссария = --resnapshot).
3. [major] additive-гейт ложно-негативил на `reasoning:""` (омиссия у grok = дефолт low = думает additive) → гейт и буфер теперь на `!= "off"` (не только low|med|high).
4. [minor] `status` ловил только intra-store multi-snapshot, не store-vs-current-config → добавлен ConfigDrift (read-only расчёт текущего снапшота, сравнение).
5. [minor] инвариант №1 README (`committed==SUM(checkpoints)`) — дополнен redrive-исключением.
6. [minor] `unionSticky` newest-wins не тестировался → тест на конфликтующие disposition.
7. [nit] `ProjectedBookUSD` пере-считывал при in-progress-спенде → база проекции = processedCost (только done+flagged).
**Техдолг / вопросы оркестратору:**
- Спека D15.2 и сужение 2c (kana-boundary) — на ратификацию (см. выше).
- **⚠ Рабочее дерево несёт незакоммиченные правки ПАРАЛЛЕЛЬНОЙ полигон-сессии** (`eval/`, `docs/experiments/`) — не мои, не трогал; коммитить пакет надо аккуратно (только `backend/` + `backend/docs/` + эта запись).
- Известная граница `status`/`redrive`: glossary_miss re-деривится из неизменного глоссария → не re-drivable (фикс = правка глоссария = --resnapshot); status теперь честно это показывает.
- F3 at-most-once — следующий D12-хвост (status/redrive закрыты).
## Полигон ## Полигон
(секция параллельной сессии — записи добавлять сюда) (секция параллельной сессии — записи добавлять сюда)
### 2026-07-09 — Сессия «Починка пилот-харнесса + пробы» (D13/D14.2/D16-хвост) ЗАВЕРШЕНА
Мандат `POLYGON_SESSION_PROMPT_PILOT_HARNESS.md` (6 задач) выполнен. Всё с провенансом (model id, UTC-дата, usage, полные сырые выходы в `data/pilot/raw_*`).
**1. `memory_eval.py` + `declmetric.py` починены (D13.5) и валидированы.** Эхо-контроль (детект/стрип глоссарий-преамбулы+source-эха, флаг+исключение, `--regen-on-echo`); полные сырые выходы (не `out[:160]`); **ось верности РЕАЛИЗОВАНА** (cross-family LLM-судья vs источник, family-guard D13.3); C0C3→**M0M3** (D13.4); зеркало синхронизировано с `memory.go` по 7 расхождениям (sticky depth-2+наследование disposition D16.2, until_ch, спойлер-гейт containment, токен-бюджет/eviction, полная trad2simp 508 hash-синк, ignorables Cf+VS, Han по всем плоскостям). Self-test 12 инвариантов зелёный; адверсариально верифицирован (faithful; supplementary-plane дельты закрыты). `declmetric` — зеркало normalizeTargetForm/containsWholeWord (NFC/dash/ignorable/letter-boundary).
- **Индикатив пере-прогнан на Ah-Q (grok-non-reasoning, судья gemini-2.5-flash, 5 чанков):** M0 verность 94.6/cons 0.567 · **M1 93.4/1.0** · **M2 93.4/1.0** · **M3 49.0/0.467** · эхо 0/5. Правила решения разрешились: **M1≈M2 (банк оправдан, M1 дешевле по input); M3 роняет ВЕРНОСТЬ 45.6 vs M0 → страх владельца подтверждён**; M3 consistency≈M0 → вред ловит ТОЛЬКО ось верности (судья: 阿Q→«Вэйчжуан», модель послушалась неверной инъекции). Таблица+чтение — exp09 §6-результаты.
- **⚠ Старые числа контаминированы** («C1 1.0=C2 1.0, C3 0.60»): C3 надут эхом, оси верности не было. Помечено в exp09.
**2. Гигиена оценочных артефактов (exp04):** `build_editor_artifact.py` — рандомизация меток ПО ФРАГМЕНТУ (соль в ключе), ключ+цена в ОТДЕЛЬНОМ `--key` файле (не в артефакте). Проверено: артефакт без утечки имён/цены, маппинг разный на каждом фрагменте. Честная сноска в exp04 §Оговорки: слепота LLM-судейского плеча была структурно сломана (ключ+цена в `<details>` того же артефакта) → grok-4.3 ПРОВИЗОРЕН, страхуют объективная цена + пилот (7-bis + D13.1).
**3. `09-pilot-protocol.md` v2 (D13):** армы D13.1 (моно-vs-билингв на одной модели, §3a) и гетерогенный C2 (D13.2); панель судей D13.3 (§5: 23 семейства, 11+ повторов+свап, Bradley-Terry/медиана, family-guard, κ+tie-rate/top-1); пре-регистрационный каркас §12 (MDE/мощность, N≥3/безκ, правила по руке). **Pearmut оценён (§10): НЕ брать как BWS-движок** (BWS отсутствует; протоколы DA/ESA/cESA/MQM) → строить тонкий свой BWS + красть attention-checks; Potato как опц. фронт (лицензию проверить). Pearmut = arXiv:2601.02933, MIT, self-host — verified GitHub/PyPI.
**4. Mistral канал B (D14.2):** заведён в `providers.json` (`mistral-large-2512` — сверен вживую `/models`; research-агент угадал `mistral-large-3-25-12` НЕВЕРНО, потому и сверяем; `safe_prompt:false`). **Refusal SFW/L1/L2-violence: 11/11 ok, 0 отказов/вырезаний/эха.** Базовое качество (индикатив, судья gemini): fidelity 85/95/90 zh/ja/en → **годен как переводчик канала B**. Explicit-часть гейтится фрагментами владельца. Детали — exp02 §Mistral.
**5. Kana-precision (D16-хвост, `kana_precision.py`+`kana_traps.json`):** MIN=3 подтверждён как дефолт (гасит len-2 substring-шум, сохраняет 3-kana имена メロス/おさん; MIN=4 роняет recall 13→6). Потолок precision = **homograph-класс** (имя==частое слово ひまわり/あさひ), ДЛИНО-ИНВАРИАНТЕН → MIN не гасит, нужен POS/known-word/ruby-якорь (вход B6). **D16.3 source-граница на кане КАТАСТРОФИЧНА** (TP 15→1 — имена+kana-частицы) → бэкенд правильно скоупнул на Latin/Cyrillic, на кану НЕ распространять. Таблица — exp09 §6-bis.
**6. Вебновелл-срез (`webnovel_slice.py`):** одна команда по появлению корпуса в `data/samples/webnovel/<lang>/*.txt` → r-коэффициенты (token_calc) + частота DeepSeek-эха вне претрейна + coverage-precision на терсных репликах (по плотности диалога). Блокируется корпусом graceful (exit 0 + инструкция куда класть файлы).
### [ПИНГИ ОРКЕСТРАТОРУ / БЭКЕНДУ / ВЛАДЕЛЬЦУ] (09.07)
- **[БЭКЕНД, координация] D16 лендится ПРЯМО СЕЙЧАС параллельно.** На момент моей работы `memory.go` показывал `M` с бампом `memoryMatchVersion``memmatch-v3` (D16.2 sticky-inherit + D16.3 phonetic-srcboundary), НО изменилась ПОКА только строка-константа/коммент — логика (`dispositionFor` sticky-ветка, `suppressContained` word-boundary) ещё в полёте. Моё зеркало уже целит контракт v3 (наследование disposition). **Пере-сверить зеркало с финальным Go после лендинга логики** (я на это оставил TODO в `memory_eval.py`).
- **[БЭКЕНД/ОРКЕСТРАТОР] D16.3 на кану НЕ распространять** — эмпирически подтверждено (kana TP 15→1 из-за kana-частиц; в японском нет пробелов). Текущее скоупирование на Latin/Cyrillic верно. Для homograph-класса каны (длино-инвариантен) — отдельная митигация (POS/known-word denylist или AMBIGUOUS для kana-only name-ключей вне ruby), вход в B6.
- **[ВЛАДЕЛЕЦ] `MISTRAL_API_KEY` уже в наборе** (в промте значился отсутствующим — ты добавил; спасибо, разблокировало D14.2 полностью). Осталось: explicit-фрагменты 18+ (gitignored) для L2-erotica/danmei/L3 руки exp02 — единственный critical D14.4.
- **[ОРКЕСТРАТОР] Внешняя перезапись git-истории** (reset→cherry-pick «Add MISTRAL_API_KEY») в ходе сессии стёрла мои незакоммиченные правки 3 tracked-файлов (declmetric/memory_eval/providers.json) — переприменил; результаты прогонов (gitignored `data/`) уцелели. Если это была твоя операция — учти, что рабочее дерево полигона было «грязным».
- **[ОРКЕСТРАТОР] Pearmut-вердикт (§10 exp09): своё тонкое BWS**, не Pearmut (у него нет BWS). Решение по инструменту закрыто, реализация — с корпусом+аннотаторами.
> **Сообщение от основной сессии (04.07, после ревью твоих экспериментов 0102).** Работа принята, качество высокое. Важные вводные: > **Сообщение от основной сессии (04.07, после ревью твоих экспериментов 0102).** Работа принята, качество высокое. Важные вводные:
> 1. **Архитектурные доки обновлены до v2** (~05:08, после адверсариального ревью) — если читал их до этого, перечитай `architecture/01-decisions.md`: экономика теперь разделена по контурам «чей ключ» (Р5), премиум-микс с Sonnet в ru-контуре недоступен, batch — только для батчуемых стадий. Твой пересчёт COGS из эксперимента 01 отлично ложится в контур «прямые ключи»; при случае добавь строку-оговорку про ru-агрегаторский контур (×26 на флагманы). > 1. **Архитектурные доки обновлены до v2** (~05:08, после адверсариального ревью) — если читал их до этого, перечитай `architecture/01-decisions.md`: экономика теперь разделена по контурам «чей ключ» (Р5), премиум-микс с Sonnet в ru-контуре недоступен, batch — только для батчуемых стадий. Твой пересчёт COGS из эксперимента 01 отлично ложится в контур «прямые ключи»; при случае добавь строку-оговорку про ru-агрегаторский контур (×26 на флагманы).
> 2. Твои **коридоры len_ratio из эксперимента 01 — прямой вход в coverage-гейт v1** бэкенда (Р7/план Фазы 1). Когда сузишь их по факту прогонов — зафиксируй в experiments-доке итоговые пороги отдельной таблицей, я заберу их в конфиг гейта. > 2. Твои **коридоры len_ratio из эксперимента 01 — прямой вход в coverage-гейт v1** бэкенда (Р7/план Фазы 1). Когда сузишь их по факту прогонов — зафиксируй в experiments-доке итоговые пороги отдельной таблицей, я заберу их в конфиг гейта.
@ -537,6 +592,18 @@ keep-alive (Ф12), инъекция глоссария (`selective`), пор
> 4. Опциональная задача в бэклог (из Р5/Р10 риск №6, понадобятся ключи владельца): **эмпирическая проверка, пробрасывают ли ru-агрегаторы (ProxyAPI/VseGPT/AITunnel) cache-hit тарифы DeepSeek** — два одинаковых запроса подряд, сравнить usage/стоимость. Это разблокирует экономику ru-контура. > 4. Опциональная задача в бэклог (из Р5/Р10 риск №6, понадобятся ключи владельца): **эмпирическая проверка, пробрасывают ли ru-агрегаторы (ProxyAPI/VseGPT/AITunnel) cache-hit тарифы DeepSeek** — два одинаковых запроса подряд, сравнить usage/стоимость. Это разблокирует экономику ru-контура.
> 5. Договорённость по журналу: фиксируй здесь краткие итоги каждого эксперимента (12 строки + ссылка на док) — основная сессия читает эту секцию, а не всю папку experiments. > 5. Договорённость по журналу: фиксируй здесь краткие итоги каждого эксперимента (12 строки + ссылка на док) — основная сессия читает эту секцию, а не всю папку experiments.
### 2026-07-09 (доп.) — ПЕРВЫЕ измерения на РЕАЛЬНОЙ вебновелле + en→ru проба (владелец дал корпус)
Владелец дал 蛊真人 (*Reverend Insanity*, снята с Qidian) в чистой вычитанной .txt (GB18030) и *Empire of the Dawn* Кристоффа (en, книга 3 — подлинность подтверждена файлом+сетью, St. Martin's, релиз 04.11.2025). Активирована Задача 6 на реальном тексте (не претрейн-классике — закрытие методдыры §V1.6/07-review):
- **Вебновелл-срез `webnovel_slice.py` (7 глав 蛊真人, 16 чанков, deepseek):**
- **B — DeepSeek-эхо: 4/16 = 25% РЕАЛЬНОГО эха** (выход 8083% CJK — исходник вместо перевода), **при thinking-ON**. Vs претрейн-классика 13/24 (54%): вне претрейна эхо вдвое реже, но **25% — живой продовый риск**; thinking-ON её НЕ снимает полностью → downstream эхо/coverage-гейт load-bearing. Верифицировано вручную (не артефакт классификатора).
- **C — coverage-гейт: 0 FP/16** (precision держится, как exp07 0/57). ⚠ Но терсных-диалоговых чанков не поймалось (в .txt реплики склеены в абзацы   , не строками) → precision гейта на ТЕРСНЫХ репликах этим срезом НЕ закрыт; нужен диалог-разбитый вход.
- **A — r-коэффициенты:** DeepSeek V3.2 ~0.75 ток/CJK-симв, GPT-o200k ~0.90 — тот же порядок, что классик-калибровка exp01 → COGS exp08 индикативно держится (`token_calc_webnovel.json` — точный diff).
- **en→ru проба Кристоффа (короткий отрывок, приватная калибровка — текст книги в проект НЕ сохранён):** deepseek fidelity **90**, grok **85**; оба `ok`, живой литературный русский. Судья поймал ровно то, что чинит банк памяти: **серийные термины** («coldblood» = обращение в мире серии, оба перевели как «хладнокровный») + имена (Dyvok/Lachie). ⇒ **банк памяти нужен и на en→ru**, не только zh/ja.
Пинги: **[БЭКЕНД] (1)** DeepSeek-эхо 25% на вебновелле при thinking-ON — эхо-мина живее, чем «13/24» на классике; downstream-детект эха обязателен. **(2)** реальные zh .txt часто **GB18030**, не UTF-8 — ingest должен детектить/конвертить кодировку. **[ОРКЕСТРАТОР]** en→ru тоже выигрывает от глоссария (серийные коины) — учесть в дизайне банка для en-книг.
### 2026-07-04 — Сессия 1 полигона (ответ на вводные выше — приняты) ### 2026-07-04 — Сессия 1 полигона (ответ на вводные выше — приняты)
Инфраструктура: `eval/` (venv, токенизаторы DeepSeek V3.2/Qwen3/Qwen2.5/GLM-4.6 + tiktoken), PD-корпус 16 текстов zh/ja/en/ru с 3 параллельными парами. Ключи — `eval/.env` (gitignored, закрыт deny-правилом; скрипты читают сами; шаблон на 7 провайдеров создан, ждёт заполнения владельцем). Инфраструктура: `eval/` (venv, токенизаторы DeepSeek V3.2/Qwen3/Qwen2.5/GLM-4.6 + tiktoken), PD-корпус 16 текстов zh/ja/en/ru с 3 параллельными парами. Ключи — `eval/.env` (gitignored, закрыт deny-правилом; скрипты читают сами; шаблон на 7 провайдеров создан, ждёт заполнения владельцем).

View file

@ -27,6 +27,22 @@
Плюс порог покрытия предложений sent_cov < 0.75 (не сработал ложно ни разу). Оговорки: выборка 11 литературных фрагментов × 6 провайдеров; нижняя граница enru задана локальной моделью (0.79 она переводит суше); на вебновелльном корпусе пороги перепроверить. Коридоры уже зашиты в `EXPECT_LEN_RATIO` (`eval/refusal_bench.py`) основная сессия может забирать таблицу в конфиг гейта как v1. Плюс порог покрытия предложений sent_cov < 0.75 (не сработал ложно ни разу). Оговорки: выборка 11 литературных фрагментов × 6 провайдеров; нижняя граница enru задана локальной моделью (0.79 она переводит суше); на вебновелльном корпусе пороги перепроверить. Коридоры уже зашиты в `EXPECT_LEN_RATIO` (`eval/refusal_bench.py`) основная сессия может забирать таблицу в конфиг гейта как v1.
## Mistral — проба канала B (D14.2, 2026-07-09)
DeepSeek выведен из explicit-части канала B (ToS §3.4(5), D14.1) → Mistral — кандидат-фолбэк. Заведён в `providers.json` (`mistral-large-2512`, слаг сверен вживую `/models`; `safe_prompt:false` явно; OpenAI-совместимый). `MISTRAL_API_KEY` **появился в наборе** (владелец добавил — в промте значился как отсутствующий).
**Refusal-срез SFW/L1/L2-violence (11 фрагментов, populated levels):**
| Уровень | ok | content_refusal | excision | echo |
|---|---|---|---|---|
| L0 clean | 6/6 | 0 | 0 | 0 |
| L1 warm | 4/4 | 0 | 0 | 0 |
| L2 violence | 1/1 | 0 | 0 | 0 |
**ZERO отказов/вырезаний/эха на всём доступном срезе.** Explicit-часть (L2-erotica/danmei, L3) — пусто в корпусе (гейтится фрагментами владельца; та же 18+ рука, единственный critical D14.4).
**Базовое качество ru-перевода (индикатив, 23 PD-фрагмента, судья gemini чужого семейства):** zh(А-Кью) fidelity 85 / ja(Мелос) 95 / en(Уэллс) 90; все `ok`, `cjk_share=0` (эхо-мины НЕТ). Имена-мистрансы на zh — БЕЗ глоссария (ровно то, что чинит банк). **Вывод: Mistral годен как переводчик канала B** (не только «не отказывает»). Граббля: на en выдал преамбулу «Вот перевод фрагмента:» — при боевом использовании усилить промпт/стрип. Цена ~$0.50/$1.50 за 1M (API-pricing 07-09).
## Что сделано ## Что сделано
1. **Прогонный скрипт `eval/refusal_bench.py`** — конфигурируемые OpenAI-совместимые провайдеры (`eval/providers.json`: DeepSeek, Qwen intl, Grok, GLM z.ai, Gemini OpenAI-слой c safety OFF через extra_body, OpenAI, OpenRouter, локальная ollama), ключи только из env, резюмируемость (пере-запуск дописывает недостающее). Промпт — режим перевода с явным transformation-фреймингом («перевод предоставленного правообладателем текста, не генерация»), т.е. меряем именно то, что будет делать продукт. 1. **Прогонный скрипт `eval/refusal_bench.py`** — конфигурируемые OpenAI-совместимые провайдеры (`eval/providers.json`: DeepSeek, Qwen intl, Grok, GLM z.ai, Gemini OpenAI-слой c safety OFF через extra_body, OpenAI, OpenRouter, локальная ollama), ключи только из env, резюмируемость (пере-запуск дописывает недостающее). Промпт — режим перевода с явным transformation-фреймингом («перевод предоставленного правообладателем текста, не генерация»), т.е. меряем именно то, что будет делать продукт.

View file

@ -62,6 +62,7 @@
- **Короткие фрагменты** (по одному отрывку) — срез, не приговор моделям в целом; полный выбор ядра пайплайна — пилот Фазы 2.5 (задача 4). - **Короткие фрагменты** (по одному отрывку) — срез, не приговор моделям в целом; полный выбор ядра пайплайна — пилот Фазы 2.5 (задача 4).
- Судьи поймали реальный дефект данных: версия C (Gemini) на «Мелосе» была **обрезана** (бюджет 8k при обязательном thinking) — **исправлено** после оценки (перегенерирована с 16k, артефакт обновлён); вывод по «Мелосу» (лидер A) от этого не меняется. - Судьи поймали реальный дефект данных: версия C (Gemini) на «Мелосе» была **обрезана** (бюджет 8k при обязательном thinking) — **исправлено** после оценки (перегенерирована с 16k, артефакт обновлён); вывод по «Мелосу» (лидер A) от этого не меняется.
- «Якорь ≠ эталон»: оба судьи отметили места, где английский якорь сам упрощает оригинал (напр. оттенок «жалостливо усмехнуться» у Дадзая) — подтверждает методическое следствие ниже. - «Якорь ≠ эталон»: оба судьи отметили места, где английский якорь сам упрощает оригинал (напр. оттенок «жалостливо усмехнуться» у Дадзая) — подтверждает методическое следствие ниже.
- **⚠ Слепота задним числом сломана (сноска D13.5, 2026-07-09; историю не переписываем).** Оценку вынесли LLM-судьи (GPT-фронтир + Opus), фетчащие артефакт целиком. А в том же артефакте лежали (а) **ключ «метка→модель»** (строка «Ключ слепой разметки» выше) и (б) **цена/латентность** — за `<details>`, что для DOM/markdown-фетча НЕ скрытие; плюс маппинг A/B/C/D был **зафиксирован одинаково на всех 4 фрагментах**. Значит слепота LLM-судейского плеча **структурно нарушена** (07-review §4.5, verified). ⇒ **Выбор `grok-4.3` дефолтным редактором остаётся ПРОВИЗОРНЫМ** — из этого плеча его нельзя считать чисто-слепым результатом. Что страхует выбор независимо от слепоты: **объективные латентность/цена** (grok — самый быстрый/дешёвый; модель-независимые факты, не подверженные предвзятости судьи) и **руки пилота Ф2.5** — мономодельный бейк-офф редактора (exp09 §7-bis, реальная моно-задача без исходника) + арм «моно-vs-билингв на одной модели» (D13.1). Инструмент починен: `build_editor_artifact.py` теперь рандомизирует метки ПО ФРАГМЕНТУ (соль в ключ-файле) и пишет ключ+цену в ОТДЕЛЬНЫЙ (непубликуемый до оценки) файл `--key` — будущие оценки слепы структурно.
## Методическое следствие (важно для пилота, задача 4) ## Методическое следствие (важно для пилота, задача 4)

View file

@ -1,10 +1,21 @@
# Эксперимент 09. Протокол пилота Фазы 2.5 — выбор ядра, memory-bet, судья, think-режим # Эксперимент 09. Протокол пилота Фазы 2.5 — выбор ядра, memory-bet, судья, think-режим (v2, D13)
**Дата:** 2026-07-05 · **Зона:** полигон (флагман) · **Статус:** протокол + харнесс-скелет; решающий прогон ждёт корпус владельца + человеческих аннотаторов. **Дата:** 2026-07-05 · **v2:** 2026-07-09 (поправки D13, починка харнесса, Pearmut-вердикт) · **Зона:** полигон (флагман) · **Статус:** протокол + харнесс (memory_eval починен и валидирован индикативом); решающий прогон ждёт корпус владельца + человеческих аннотаторов.
**Закрывает решения:** выбор ядра C0C3 (Р2), go/no-go на банк памяти (research/13 §Вердикт), валидность LLM-судьи (research/11-gap-3), think-ON/OFF (D6), ценность пре-пасса Annotator (04-unhappy). **Закрывает решения:** выбор ядра C0C3 (Р2), go/no-go на банк памяти (research/13 §Вердикт), валидность LLM-судьи (research/11-gap-3), think-ON/OFF (D6), ценность пре-пасса Annotator (04-unhappy).
Пилот — единственная точка, где сходятся все отложенные «решаемые только человеком» вопросы качества. Он же — **дециждающий эксперимент банка памяти** (ставка не морозится до него, вердикт GO-на-схему был условным). Пилот — единственная точка, где сходятся все отложенные «решаемые только человеком» вопросы качества. Он же — **дециждающий эксперимент банка памяти** (ставка не морозится до него, вердикт GO-на-схему был условным).
> **⚠ Разведение имён (D13.4, устраняет коллизию exp09):** **C0C3 = конфигурации ЯДРА** (§3, baseline/draft→editor/generate-select/select-refine). **M0M3 = режимы ТЕРМИНОЛОГИИ memory-eval** (§6, no-gloss/selective-bank/full-context/wrong-gloss). Раньше оба назывались C0C3 — риск путаницы в пре-регистрации; исправлено в доке, харнессе (`memory_eval.py`), данных и pre-registration §12.
## Сводка поправок v2 (D13, 2026-07-09)
- **§3a Новый арм D13.1** — моно-vs-билингв редактор на ОДНОЙ модели, простой general-промпт (прямой тест D1; отдельно от 7-bis бейк-оффа моделей).
- **§3b C2 только на ГЕТЕРОГЕННЫХ кандидатах (D13.2)** — draft'ы от разных моделей, не N сэмплов одной (иначе селекция ≈ монета, плечо предрешено).
- **§5 Панель судей (D13.3)** — 23 семейства; 11+ повторов со свапом позиций; Bradley-Terry/медиана; grok не судит grok-выходы; валидация судьи — κ vs человеческий IAA + tie-rate/top-1.
- **§6 memory-eval починен (D13.5)** — эхо-контроль, полные выходы, **ось верности реализована**, C0C3→M0M3, зеркало синхронизировано с Go по 7 расхождениям; индикатив пере-прогнан (§6-результаты).
- **§9/§12 Пре-регистрация расширена (D13.4)** — MDE/мощность, N аннотаторов ≥3 (иначе «безκ-режим»), правила решения по каждой руке — неизменяемый каркас §12.
- **§10 Инструмент — Pearmut оценён (D13.5): НЕ берём как BWS-движок** (у него нет BWS), строим тонкий свой + крадём его attention-checks. Вердикт §10.
## 1. Что пилот решает (и почему только он) ## 1. Что пилот решает (и почему только он)
| Решение | Почему не решено раньше | Рука пилота | | Решение | Почему не решено раньше | Рука пилота |
@ -22,7 +33,7 @@
- **Рекуррентные имена/термины** обязательны (≥8 сущностей, повторяющихся через главы) — иначе memory-eval нечего мерить. - **Рекуррентные имена/термины** обязательны (≥8 сущностей, повторяющихся через главы) — иначе memory-eval нечего мерить.
- **⚠ Методическое ограничение (эксп.04): владелец читает только en/ru.** → человеческая оценка **ВЕРНОСТИ** возможна лишь на **en→ru** (и русская сторона стиля любого перевода). Для zh/ja верность оценивается: (а) **английским якорь-подстрочником** (профессиональный en-перевод как мост), (б) LLM-судьёй чужого семейства против исходника, (в) сверкой с приватным русским эталоном. Заложено в дизайн (§45). - **⚠ Методическое ограничение (эксп.04): владелец читает только en/ru.** → человеческая оценка **ВЕРНОСТИ** возможна лишь на **en→ru** (и русская сторона стиля любого перевода). Для zh/ja верность оценивается: (а) **английским якорь-подстрочником** (профессиональный en-перевод как мост), (б) LLM-судьёй чужого семейства против исходника, (в) сверкой с приватным русским эталоном. Заложено в дизайн (§45).
Пока корпуса нет — индикативный прогон на PD-Ah-Q (`eval/pilot/memory_eval.py`) валидирует харнесс и decl-метрику. Пока корпуса нет — индикативный прогон на PD-Ah-Q (`eval/pilot/memory_eval.py`) валидирует харнесс и метрики; **вебновелл-срез добора** (r-коэффициенты/эхо/coverage-precision вне претрейна) — `eval/webnovel_slice.py`, одна команда по появлению файлов в `eval/data/samples/webnovel/`.
## 3. Дизайн ядра (C0C3, Р2) ## 3. Дизайн ядра (C0C3, Р2)
@ -30,50 +41,93 @@
- **C0 baseline** — один проход переводчика (+ детерминированный глоссарий = уровень VseGPT; baseline метрики, PROGRESS §синтез). - **C0 baseline** — один проход переводчика (+ детерминированный глоссарий = уровень VseGPT; baseline метрики, PROGRESS §синтез).
- **C1 draft→editor** — черновик → моноязычный редактор (D1). Дефолт Ф1. - **C1 draft→editor** — черновик → моноязычный редактор (D1). Дефолт Ф1.
- **C2 generate-then-select** — N черновиков → судья-селектор выбирает лучший (arXiv:2603.20324). Висит на калибровке s\* и валидности судьи (§5). - **C2 generate-then-select** — N черновиков → судья-селектор выбирает лучший (arXiv:2603.20324). Висит на калибровке s\* и валидности судьи (§5). **D13.2: кандидаты ГЕТЕРОГЕННЫ** — draft'ы от РАЗНЫХ моделей (deepseek/glm/kimi/mistral), НЕ N сэмплов одной. Основание: на гомогенных селекция ≈ монета (2603.20324 WR 0.512; LitMT 2606.05924 best-of-8+судья — последнее место). На гомогенных C2 предрешён и жжёт бюджет впустую.
- **C3 select-then-refine** — селект + точечный re-ask/refine на выбранном (verifier-gated, research/14 M2; рычаг L3 эксп.adaptive_reask). - **C3 select-then-refine** — селект + точечный re-ask/refine на выбранном (verifier-gated, research/14 M2; рычаг L3 эксп.adaptive_reask).
Каждая конфигурация × {think-OFF, think-ON} (§7) × {Annotator-pre-pass OFF/ON} (§8) — факторный, но приоритезированный (полный крест дорог; ядро × think — первично, Annotator — вторичный A/B на подвыборке). Каждая конфигурация × {think-OFF, think-ON} (§7) × {Annotator-pre-pass OFF/ON} (§8) — факторный, но приоритезированный (полный крест дорог; ядро × think — первично, Annotator — вторичный A/B на подвыборке).
### 3a. Арм D13.1 — моно-vs-билингв редактор на ОДНОЙ модели (прямой тест D1)
Отдельная рука, **не путать с 7-bis** (тот — бейк-офф РАЗНЫХ моделей на моно-редактуре). Здесь одна модель (grok-4.3) правит черновик в двух режимах:
- **моно**: вход = только русский черновик + глоссарий-констрейнты (боевой D1, без исходника);
- **билингв general**: вход = исходник + черновик + ПРОСТОЙ general-промпт «улучши, не переври».
**Основание (верифицировано по PDF arXiv:2605.13368, вкл. en→ru):** monolingual-рефайнмент теряет accuracy с ПЕРВОГО прохода (2.2…5.6 MQM у всех 6 моделей); general-рефайнмент С исходником даёт тот же fluency при accuracy ~flat и лучшем overall. **Вход в решение (D17):** если моно даёт паритет верности — D1 подтверждён дёшево; если налог на верность воспроизводится — флип на «редактор с исходником, простой промпт» (дешевле любой альтернативной митигации калек). Метрики те же (§4 BWS-стиль + §6 decl-консистентность + верность через сверку с эталоном/якорем).
## 4. Человеческая оценка — Best-Worst Scaling (BWS) ## 4. Человеческая оценка — Best-Worst Scaling (BWS)
**Почему BWS, не шкала Ликерта:** BWS (выбери ЛУЧШИЙ и ХУДШИЙ из набора) даёт более надёжные, менее шумные ранги, чем абсолютные оценки (устойчив к разнице калибровки аннотаторов) — стандарт для сравнения близких систем. LAIT показал: абсолютные оценки судей плывут, ранги — надёжнее. **Почему BWS, не шкала Ликерта:** BWS (выбери ЛУЧШИЙ и ХУДШИЙ из набора) даёт более надёжные, менее шумные ранги, чем абсолютные оценки (устойчив к разнице калибровки аннотаторов) — стандарт для сравнения близких систем. LAIT показал: абсолютные оценки судей плывут, ранги — надёжнее.
- **Единица:** абзац/короткий чанк (не вся глава — усталость аннотатора). Наборы из 34 систем (C0C3, или think-ON vs OFF пары). - **Единица:** абзац/короткий чанк (не вся глава — усталость аннотатора). Наборы из 34 систем (C0C3, или think-ON vs OFF пары).
- **Слепота:** аннотатор не видит, какая система какой выход дал (рандомизация порядка + меток). - **Слепота:** аннотатор не видит, какая система какой выход дал (рандомизация порядка + меток)**и рандомизация ПО НАБОРУ, ключ вне артефакта** (урок exp04, D13.5: за `<details>` для фетча не скрытие).
- **Оси оценки (раздельно, НЕ смешивать):** - **Оси оценки (раздельно, НЕ смешивать):**
- **Стиль/естественность** (русская сторона) — владелец/русскоязычный аннотатор, любой языковой парой (не нужен исходник). - **Стиль/естественность** (русская сторона) — владелец/русскоязычный аннотатор, любой языковой парой (не нужен исходник).
- **Верность/полнота** — только en→ru напрямую; для zh/ja — через en-якорь-подстрочник (§2). Отдельная BWS-задача с исходником/якорем на виду. - **Верность/полнота** — только en→ru напрямую; для zh/ja — через en-якорь-подстрочник (§2). Отдельная BWS-задача с исходником/якорем на виду.
- **N:** ≥3 аннотатора на набор для inter-annotator agreement (κ); при низком κ — расширять, не усреднять шум. - **N и κ (D13.4):** ≥3 аннотатора на набор для inter-annotator agreement (κ); при низком κ — расширять, не усреднять шум. **Если аннотатор реально один (владелец) — κ неисчислим → это ЯВНЫЙ «безκ-режим»** (пишем честно, не выдаём одиночную оценку за согласованную).
- **Мощность/MDE (D13.4):** число BWS-наборов пред-регистрируется под целевой MDE (минимально-детектируемую разницу рангов) — иначе «нет разницы» неотличимо от «мало мощности». См. §12.
- **Стилевая ось для M1** (in-context демонстрации, research/14): бьёт ли демо-рука стиль БЕЗ потери консистентности/fidelity (индикатив: по консистентности не бьёт; стиль не мерен). - **Стилевая ось для M1** (in-context демонстрации, research/14): бьёт ли демо-рука стиль БЕЗ потери консистентности/fidelity (индикатив: по консистентности не бьёт; стиль не мерен).
## 5. LLM-судейская панель (валидация + калибровка) ## 5. LLM-судейская панель (валидация + калибровка, D13.3)
Судья держит две вещи: (а) **выбор в C2/C3** (селектор), (б) **валидацию качества** offline. Обе непроверены на домене → пилот их валидирует, не принимает на веру. Судья держит две вещи: (а) **выбор в C2/C3** (селектор), (б) **валидацию качества** offline. Обе непроверены на домене → пилот их валидирует, не принимает на веру.
- **Панель чужих семейств** (анти-self-preference): Gemini-3.1-Pro (нативный, safety-off для 18+), grok-4.3, gpt-5-mini — судят выходы deepseek/grok. Никогда не судить моделью своего семейства свой выход. - **Панель — 23 семейства МАКСИМУМ (D13.3а).** Не 9 судей: 9 ≈ 2 эффективных голоса (2605.29800), лучший ОДИНОЧНЫЙ судья ≥ панели. Кандидаты чужих семейств (анти-self-preference): Gemini-3.1-Pro (нативный, safety-off для 18+), grok-4.3, gpt-5-mini.
- **11+ повторов на вердикт со свапом позиций A/B (D13.3б).** Одиночный прогон нестабилен (13.6% флипов; парафраз меняет исход в 25%; 2606.13685). Позиции A/B свапаются, вердикт агрегируется по повторам.
- **Агрегация — медиана / Bradley-Terry (D13.3в)**, НЕ среднее (JP-TL-Bench-паттерн).
- **grok НЕ судит grok-редактированные выходы (D13.3г)** — нарушение собственного анти-self-preference правила. Реализуется на уровне харнесса (family-guard; тот же принцип, что cross-family fidelity-судья в memory_eval — `family_of(judge) != family_of(translator)`).
- **Валидация судьи — κ vs человеческий IAA + tie-rate/top-1 within-prompt (D13.3д)**, НЕ средняя корреляция (2603.12520). Метрики: доля совпадений top-1 судья↔человек ВНУТРИ набора, tie-rate; Kendall τ судья↔человек на BWS-ранге как вторичная.
- **Шкала Комиссарова** (анкета MQM-весов, 04-unhappy): mistranslation / omission / addition / терминология / согласование рода / стиль-канцелярит. - **Шкала Комиссарова** (анкета MQM-весов, 04-unhappy): mistranslation / omission / addition / терминология / согласование рода / стиль-канцелярит.
- **Валидация #1 — корреляция судья↔человек** на BWS-ранге (LAIT: расходятся). Если ранговая корреляция (Kendall τ) судья↔человек низка → судья не годен как прокси, C2/C3 под вопросом. - **Калибровка порога s\*** (на нём вся ставка generate-select, arXiv:2603.20324): при каком судейском скоре «выбрать/переспросить» максимизируется человеческий win-rate. **Пред-регистрировать s\* ДО финального прогона** (§12).
- **Валидация #2 — калибровка порога s\*** (на нём вся ставка generate-select, arXiv:2603.20324): при каком судейском скоре «выбрать/переспросить» максимизируется человеческий win-rate. Пред-регистрировать s\* ДО финального прогона.
- **Cross-family fidelity-судья vs источника** — для memory-eval (§6, ось б).
## 6. Memory-eval — дециждающий эксперимент банка (WMT25 три-режимный) ## 6. Memory-eval — дециждающий эксперимент банка (WMT25 три-режимный; M0M3)
**Вопрос (страх владельца + go/no-go):** оправдана ли ДЕТЕРМИНИРОВАННАЯ селективная инъекция (наш банк) vs просто весь глоссарий в длинном контексте, и **вредит ли ошибочная инъекция** (тихая деградация). **Вопрос (страх владельца + go/no-go):** оправдана ли ДЕТЕРМИНИРОВАННАЯ селективная инъекция (наш банк) vs просто весь глоссарий в длинном контексте, и **вредит ли ошибочная инъекция** (тихая деградация).
**Три режима терминологии (WMT25) × baseline:** **Три режима терминологии (WMT25) × baseline — переименованы M0M3 (D13.4):**
- **C0** без глоссария; **C1** селективная инъекция (наш банк, §ниже); **C2** полный глоссарий + скользящее резюме (длинный контекст); **C3** случайный/неверный глоссарий (dst перемешан) — **адверсариальная рука, прямой замер тихой деградации**. - **M0** без глоссария; **M1** селективная инъекция (наш банк); **M2** полный глоссарий + скользящее резюме (длинный контекст); **M3** случайный/неверный глоссарий (dst перемешан) — **адверсариальная рука, прямой замер тихой деградации**.
**Инъекция C1 = ЗЕРКАЛО Go-горячего-пути** (`backend/internal/pipeline/memory.go` — ИСТОЧНИК ИСТИНЫ): нормализованный точный матч, per-язык min-key (Han≥2/фонетич.≥3), collision-prone короткий фонетический ключ → AMBIGUOUS, longest-match containment, спойлер-окно since/until (hard-reject), sticky scene-inertia. Сверено с Go-юнит-тестами; при расхождении — **верить Go**. `eval/memory_hotpath.py` — прототип ДО `cc57c7b` (глобальный MIN_KEY=2, без collision-downgrade) — **не переиспользовать**; актуальный контракт — в `eval/pilot/memory_eval.py`. **Инъекция M1 = ЗЕРКАЛО Go-горячего-пути** (`backend/internal/pipeline/memory.go` — ИСТОЧНИК ИСТИНЫ). Синхронизировано D13.5 по 7 расхождениям (07-review §4.5): sticky depth=2 union+reset; until_ch-гейт; span-containment со спойлер-гейтом суппрессора; токен-бюджет+eviction; **sticky-диспозиция НАСЛЕДУЕТСЯ** (D16.2 — collision-prone AMBIGUOUS не апгрейдится sticky'ем в CONFIRMED; Go бампнул `memmatch-v3`, лендится 09.07); полная вендоренная trad2simp-таблица (508, hash-синк); ignorables=Cf+VS-диапазоны. Han-скрипт по ВСЕМ плоскостям, significantLen по Unicode-letter (закрыты дельты адверсариального ревью — supplementary-plane имена). D16.3 source-граница Go скоупнута на **Latin/Cyrillic** и НЕ применяется к кане (см. kana-precision §6-bis). **При расхождении — верить Go.**
**Метрики (три оси раздельно):** **Метрики (три оси раздельно):**
- **(а) консистентность** — decl-осознанная (`eval/pilot/declmetric.py`, pymorphy3): одинаково ли рендерится approved-dst во всех чанках, где встречается src. **ОБЯЗАНА быть decl-осознанной** — наивный матч даёт 1867% ложных флагов (research/14 §2; backend memory_e1_test.go full-decl 0% / base-only 67%). Заодно — **валидация E1-надёжности post-check на РЕАЛЬНОМ корпусе** (жёсткий гейт `postcheck_gate` флипается только после этого замера precision на живых книгах — mirror D12/Q4). - **(а) консистентность** — decl-осознанная (`eval/pilot/declmetric.py`, pymorphy3 + Go-style stored-decl): одинаково ли рендерится approved-dst во всех чанках, где встречается src. **ОБЯЗАНА быть decl-осознанной** — наивный матч даёт 1867% ложных флагов (research/14 §2; backend memory_e1_test.go full-decl 0% / base-only 67%). Заодно — **валидация E1-надёжности post-check на РЕАЛЬНОМ корпусе** (жёсткий гейт `postcheck_gate` флипается только после этого замера precision на живых книгах).
- **(б) верность/пропуски** — CometKiwi (валидировать на ru-литературе — не подтверждён, research/11-gap-2) + LLM-судья чужого семейства против источника + сверка с эталоном. ru-специфика: согласование рода (вкл. глагол прош.вр.), склонение dst. - **(б) верность/пропуски — РЕАЛИЗОВАНА (D13.5, была дырой):** LLM-судья ЧУЖОГО семейства против источника (family-guard `family_of(judge)!=family_of(translator)`; mistranslation/omission/addition/wrong_names → JSON). Без неё пред-регистрированное правило «M3 роняет vs M0 по верности» непроверяемо. CometKiwi — опционально и только с оговоркой «на ru-литературе не валидирован» (research/11-gap-2), НЕ подключён.
- **(в) стоимость** — input/output токены по условиям (C2 заметно дороже — часть решения; эксп.08 цены). - **(в) стоимость** — input/output токены по режимам.
**Пред-регистрированное правило решения:** C1≈C2 по качеству но C1 дешевле → банк оправдан; C2≫C1 → нужен полнее контекст; **C3 роняет vs C0 по верности → страх владельца подтверждён, post-check/disposition обоснованы** (и мы их конвертируем в громкое, а не устраняем). **Эхо-контроль (D13.5):** модель эхает глоссарий-преамбулу («ГЛОССАРИЙ … 阿Q → А-кью …») перед переводом; скоринг всего выхода тогда считает эхнутые dst «отрендеренными» → M-режимы завышены, вред M3 занижен (ревью проследил chunk1-M3 надут 0.667→1.0). Теперь: детект преамбулы/source-эха (переиспользован CJK-классификатор refusal_bench) → стрип до тела перевода → **флаг echo_contaminated + исключение из чистого агрегата** (опц. `--regen-on-echo` переспрашивает с усиленным промптом) — не тихий стрип.
**Спойлер-рука:** отдельный замер — инъектится ли спойлер-запись (since_ch) до её главы (должна hard-reject) — валидировано в харнессе (глава 6 отвергает 革命党, глава 7 инъектит). **Пред-регистрированное правило решения:** M1≈M2 по качеству но M1 дешевле → банк оправдан; M2≫M1 → нужен полнее контекст; **M3 роняет vs M0 по ВЕРНОСТИ → страх владельца подтверждён, post-check/disposition обоснованы** (и мы их конвертируем в громкое, а не устраняем).
### 6-результаты. Индикатив на Ah-Q (пере-прогнан 2026-07-09 ПОСЛЕ починки; grok-4.20-non-reasoning, судья gemini-2.5-flash, 5 чанков)
| Режим | consistency (decl) | **fidelity (судья)** | in_tok (ср.) | echo |
|---|---|---|---|---|
| **M0** без глоссария | 0.567 | 94.6 | 1155 | 0/5 |
| **M1** селективный банк | **1.0** | 93.4 | 1237 | 0/5 |
| **M2** полный глоссарий | **1.0** | 93.4 | 1260 | 0/5 |
| **M3** неверный глоссарий | 0.467 | **49.0** | 1218 | 0/5 |
**Чтение (все пред-регистрированные правила разрешились чисто):**
1. **M1≈M2** (consistency 1.0=1.0, fidelity 93.4=93.4), M1 дешевле по input (1237<1260; разрыв растёт с полной книжной глоссарией) **банк оправдан** vs длинный контекст.
2. **M3 роняет ВЕРНОСТЬ: 49.0 vs M0 94.6 (45.6)** → страх владельца ПОДТВЕРЖДЁН количественно. Судья ловит: 阿Q (А-Кью) отрендерен как «Вэйчжуан» (имя человека → топоним) — модель ПОСЛУШАЛАСЬ неверной инъекции.
3. **Ключевое: M3 consistency (0.467) ≈ M0 (0.567)** — по консистентности M3 выглядит как baseline, вред НЕВИДЕН. Его ловит ТОЛЬКО ось верности → почему её реализация (D13.5) была решающей, а старый харнесс давал ложный «вред меньше».
> **⚠ Старые числа КОНТАМИНИРОВАНЫ, не использовать:** до починки харнесс показывал «C1 1.0 = C2 1.0, C3 0.60» (только консистентность; C3 chunk1 надут эхом до 1.0; оси верности НЕ было). Индикатив на PD-классике из претрейна — предварительный; решающий замер — на вебновелл-корпусе владельца + человеческий BWS.
### 6-bis. Kana-precision (D16-хвост, `eval/pilot/kana_precision.py`, 2026-07-09)
Замер precision матчера на ja-kana ключах при MIN=2/3/4 (вход в прод-ja решение: minKeyLenPhonetic=3 был «консервативным дефолтом до замера»). Trap-корпус (`kana_traps.json`): имена из PD-ja сэмплов (メロス/おさん/…) + коллизии, размеченные по типу (substring vs homograph).
| MIN (substring-матчер = текущий Go) | TP_fire | FP всего | FP substring | FP homograph | **FP CONFIRMED** | precision | conf-precision |
|---|---|---|---|---|---|---|---|
| 2 | 15 | 40 | 26 | 14 | 22 | 0.273 | 0.371 |
| **3 (дефолт)** | 13 | 22 | 8 | 14 | **8** (все len-4, 7 homograph) | 0.371 | 0.429 |
| 4 | 6 | 8 | 1 | 7 | 0 | 0.429 | 1.0 |
**Выводы:**
1. **Подтвердить MIN=3 как дефолт каны.** Он гасит len-2 substring-шум (substring-FP 26→8, precision 0.273→0.371), СОХРАНЯЯ 3-kana имена (メロス/ゼウス/おさん — реальные корпус-имена). MIN=4 даёт conf-precision 1.0, НО роняет recall (TP 13→6 — банит легитимные 3-kana имена, большой класс в ja).
2. **collision-downgrade (≤3→AMBIGUOUS) реально работает:** при MIN=3 len-3 фаерятся AMBIGUOUS (софт, ⟨проверить⟩+forced post-check), CONFIRMED только len≥4 → опасные авторитетные FP = 8 штук len-4.
3. **Потолок precision — класс HOMOGRAPH** (имя == целое частое слово: ひまわり=подсолнух, あさひ=утреннее солнце, ひかる=光る), ДЛИНО-ИНВАРИАНТЕН — MIN-порог его НЕ гасит (это полный substring на любой длине). Митигация ортогональна: POS/known-word гейтинг или требование ruby/kanji-якоря, или даунгрейд kana-only name-ключей в AMBIGUOUS вне зависимости от длины. Вход для B6 — оркестратору/бэкенду.
4. **D16.3 source-граница на кане КАТАСТРОФИЧНА (эмуляция):** TP 15→1 (14 реальных имён подавлены — за именем идёт kana-частица は/が, нет kana-run границы). Бэкенд правильно скоупнул D16.3 на Latin/Cyrillic; **на кану НЕ распространять** (в японском нет пробелов между словами). Замер это подтверждает.
## 7. Think-ON vs OFF (D6, встроено в ядро) ## 7. Think-ON vs OFF (D6, встроено в ядро)
@ -84,11 +138,11 @@
## 7-bis. Моно-редактор бейк-офф (F8 — доп-рука, из caveat эксп.08:92) ## 7-bis. Моно-редактор бейк-офф (F8 — доп-рука, из caveat эксп.08:92)
**Зачем:** эксп.04 выбрал grok-4.3 редактором, кормя его **билингвально** (исходник+черновик), но боевой редактор D1 — **моноязычный** (только черновик, без исходника) → рейтинг grok на моноредактуре **строго не перенесён** (оркестратор свернул caveat в D3/D1). Отдельная рука пилота — валидировать выбор редактора на РЕАЛЬНОЙ моноязычной задаче: **Зачем:** эксп.04 выбрал grok-4.3 редактором, кормя его **билингвально** (исходник+черновик), но боевой редактор D1 — **моноязычный**; рейтинг grok на моноредактуре **строго не перенесён**, а слепота exp04 была структурно сломана (D13.5-сноска exp04) → выбор ПРОВИЗОРЕН. Отдельная рука — валидировать выбор редактора на РЕАЛЬНОЙ моноязычной задаче:
- **grok-4.3 моно-редактура** (вход = только русский черновик + глоссарий-констрейнты, БЕЗ исходника) vs кандидаты (gemini-3.1-pro, gpt-5-mini, glm-5) на той же моно-задаче; - **grok-4.3 моно-редактура** (вход = только русский черновик + глоссарий-констрейнты, БЕЗ исходника) vs кандидаты (gemini-3.1-pro, gpt-5-mini, glm-5) на той же моно-задаче;
- крест с think-ON/OFF (§7): reasoning может значить для моноредактуры иначе, чем для билингвальной; - крест с think-ON/OFF (§7); метрика — BWS-стиль (§4) + консистентность (§6) + верность через сверку с эталоном;
- метрика — BWS-стиль (§4, русская сторона, исходник не нужен) + консистентность глоссаря (§6, decl-метрика) + верность через сверку с эталоном (моноредактор не должен «улучшать» смысл прочь от исходника — риск калек убран D1, но проверить, что моноредактор не дрейфит factual); - **вход в решение:** держится ли ранг grok-редактора при переходе билингв→моно; если нет — перевыбрать редактора Ф1.
- **вход в решение:** держится ли ранг grok-редактора при переходе билингв→моно; если нет — перевыбрать редактора Ф1 на моно-режиме. - **Инструмент слепоты починен:** `eval/build_editor_artifact.py` теперь рандомизирует метки ПО ФРАГМЕНТУ (соль в ключе) и пишет ключ+цену в ОТДЕЛЬНЫЙ файл (`--key`), не публикуемый до оценки.
## 8. Пре-пасс Annotator (A/B) ## 8. Пре-пасс Annotator (A/B)
@ -96,31 +150,53 @@
## 9. Пред-регистрация (до финального прогона — против p-hacking) ## 9. Пред-регистрация (до финального прогона — против p-hacking)
Зафиксировать ДО прогона: пороги s\*, допустимый flag-volume на главу (эксп.07: ≥2 excision-флага = fail), decl-метрику и её матчер, состав судейской панели, правило решения memory-eval, N аннотаторов и κ-порог. Изменения после — отдельным разделом с обоснованием. Зафиксировать ДО прогона **неизменяемым разделом §12**: пороги s\*, допустимый flag-volume на главу (эксп.07: ≥2 excision-флага = fail), decl-метрику и её матчер, состав судейской панели (§5), правило решения memory-eval (§6), N аннотаторов и κ-порог (§4), **MDE/мощность и число BWS-наборов**, правила решения по КАЖДОЙ руке (C0C3, D13.1 моно-vs-билингв, think, Annotator). Изменения после — отдельным разделом с обоснованием.
## 10. Харнесс `eval/pilot/` (построено / нужно) ## 10. Инструмент человеческой оценки — Pearmut vs своё (вердикт D13.5)
**Построено (runnable):** **Оценён Pearmut (arXiv:2601.02933, Zouhar & Kocmi, ETH; MIT, `pip install pearmut`, self-host файловый, verified по GitHub/PyPI 2026-07-09).**
- `declmetric.py` — decl-осознанная консистентность (pymorphy3 lemma + hyphen-translit + Go-style stored-decl whole-word). Smoke: склонённые формы (Вэйчжуане/Вана/Дэна/сюцая) → 0 ложных флагов.
- `memory_eval.py` — WMT25 3-режим + bank-vs-long-context, инъекция = зеркало Go-контракта (спойлер/disposition/sticky/containment валидированы dry-run). Индикативный прогон на PD-Ah-Q; масштабируется на корпус владельца (`--src`).
**Нужно достроить (когда есть корпус):** **Вердикт: НЕ брать Pearmut как BWS-движок; строить тонкий свой BWS-тул + красть attention-checks Pearmut.** Три решающих факта:
- BWS item-generator + агрегатор (наборы, рандомизация, κ, ранги) → `bws.py`. 1. **BWS в Pearmut ОТСУТСТВУЕТ (дилбрейкер).** Его протоколы — DA/ESA/cESA/MQM (скоринг + error-span), НЕ best-worst-из-набора. Наш дизайн (§4) целиком на BWS с рандомизацией меток и агрегацией в ранги — под data-model Pearmut это ломать.
- CometKiwi-обёртка (+ её валидация на ru-литературе, research/11-gap-2) → `cometkiwi.py`. 2. **Self-host НЕ различает кандидатов** (все self-host'ятся: Pearmut MIT, Potato, Label-Studio Apache-2.0, Appraise BSD) → приватность/18+ проходят все, решает BWS-fit, где Pearmut проигрывает.
- Судейская панель обёртка (cross-family, Комиссаров-анкета) → `judge_panel.py`. 3. **Наша логика тонкая и это то, что важно:** генерация наборов, слепая рандомизация меток, best-minus-worst (Orme) агрегация, κ, **безκ-режим** — ни один тул не даёт их turnkey; несколько сотен строк. Владение = пришпилить пре-регистрацию (§12) в тот же репо + держать две-строгие-оси (стиль/верность) как инвариант.
**Практический путь:** нужен нулевой UI-труд → поднять **Potato** (native BWS-схема; ⚠ лицензия MIT-vs-GPLv3 в источниках расходится — проверить `github.com/davidjurgens/potato/LICENSE` перед принятием) как фронт, свой export→агрегация→κ поверх. Нужен полный контроль слепоты/двух-осей/безκ — писать тонкий харнесс напрямую. В обоих — **украсть attention-checks Pearmut** (Loud/Silent/Tutorial — его самая переиспользуемая идея) + doc-level-context.
## 11. Харнесс `eval/pilot/` (состояние)
**Построено и валидировано:**
- `declmetric.py` — decl-осознанная консистентность (pymorphy3 + Go-style stored-decl); зеркало memnorm.go normalizeTargetForm/containsWholeWord (NFC/dash-fold/ignorable-strip/letter-boundary). Parity-asserts зелёные.
- `memory_eval.py` — WMT25 M0M3 + **fidelity-ось (cross-family судья) + эхо-контроль + полные выходы + провенанс**; инъекция = зеркало Go D13.5 (self-test 12 инвариантов зелёный; адверсариально верифицирован — faithful, дельты supplementary-plane закрыты). Индикатив пере-прогнан (§6-результаты).
- `kana_precision.py` (+ `kana_traps.json`) — kana-precision MIN=2/3/4 (§6-bis).
- `trad2simp.txt` — вендоренная таблица (508, hash-синк с Go-embed).
- `webnovel_slice.py` — одна команда добора вне претрейна (§2; блокируется корпусом, не падает).
**Нужно достроить (когда есть корпус + аннотаторы):**
- BWS item-generator + агрегатор (наборы, рандомизация, best-minus-worst, κ, безκ-режим) → `bws.py` (или Potato-фронт; §10). attention-checks по Pearmut.
- Судейская панель обёртка (23 семейства, 11+ повторов+свап, Bradley-Terry, family-guard, Комиссаров-анкета) → `judge_panel.py`.
- en-якорь-подстрочник pipeline для zh/ja верности. - en-якорь-подстрочник pipeline для zh/ja верности.
## 11. Ограничения и что нужно от владельца ## 12. Пред-регистрационный каркас (НЕИЗМЕНЯЕМЫЙ; заполнить ДО решающего прогона)
- **Корпус** (§2) — блокер решающего прогона: 2535 глав с приватными эталонами, рекуррентные имена, диалог/нарратив баланс. > Заполняется ОДИН раз перед решающим прогоном и не меняется; правки — отдельным датированным подразделом с обоснованием (D13.4).
- **Человеческие аннотаторы** BWS (≥3) — минимум владелец (en/ru); для zh/ja верности — en-якорь.
- **18+ рука** (канал B, судья 18+) — explicit-фрагменты только от владельца, gitignored; на выделенном xAI-промо-аккаунте (не клиентские книги). - **Корпус:** ____ глав, языки ____, ≥8 рекуррентных сущностей: да/нет.
- CometKiwi на ru-литературе не валидирован (research/11-gap-2) — пилот его и валидирует; до того — не доверять как единственной оси верности. - **Аннотаторы:** N = ____ (≥3 иначе «безκ-режим: да»), κ-порог = ____ (низкий → расширять, не усреднять).
- **Мощность:** число BWS-наборов = ____ под MDE рангов = ____.
- **Судья:** семейства = ____ (23), повторов/вердикт = ____ (≥11) со свапом A/B, агрегация = медиана/Bradley-Terry, family-guard: судья ∉ семейства выхода. Валидация = κ vs IAA + tie-rate/top-1.
- **s\*:** порог селектора C2/C3 = ____ (калибр §5).
- **memory-eval:** правило = M1≈M2&дешевле→банк; M3<M0 верностьстрах подтверждён; матчер = decl-осознанный (stored-decl primary, pymorphy cross-check); fidelity-судья = ____ (чужого семейства).
- **coverage-гейт:** flag-volume/глава fail-порог = ____ (exp07: ≥2 excision = fail).
- **Правила решения по руке:** C0C3 = ____; D13.1 моно-vs-билингв = ____ (паритет верности→D1; налог→флип); think-ON/OFF = ____ (+25% COGS оправдан?); Annotator A/B = ____.
## Воспроизведение (индикатив, без корпуса владельца) ## Воспроизведение (индикатив, без корпуса владельца)
```bash ```bash
eval/.venv/bin/python eval/pilot/declmetric.py # decl-метрика smoke eval/.venv/bin/python eval/pilot/declmetric.py # decl-метрика + parity-asserts
eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run --chapter 6 # инъекция-зеркало + спойлер-гейт eval/.venv/bin/python eval/pilot/memory_eval.py --self-test # 12 инвариантов зеркала (без API)
eval/.venv/bin/python eval/pilot/memory_eval.py --chapter 6 --max-chunks 5 # индикативный C0C3 на Ah-Q eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run --chapter 6 # инъекция-зеркало + спойлер/until/eviction
eval/.venv/bin/python eval/pilot/memory_eval.py --chapter 6 --max-chunks 5 --regen-on-echo # индикатив M0M3 + fidelity
eval/.venv/bin/python eval/pilot/kana_precision.py # kana-precision MIN=2/3/4
eval/.venv/bin/python eval/webnovel_slice.py # вебновелл-срез (по появлению корпуса)
``` ```

View file

@ -8,12 +8,13 @@
|---|---|---| |---|---|---|
| `refusal_bench.py` | exp02; его `split_sentences`/`classify_output` = **приёмочный оракул** coverage-гейта Go (паритет закреплён Go-тестом; любое изменение — Python-first, лок-степ) | `run_refusal.sh` — однокоманда | | `refusal_bench.py` | exp02; его `split_sentences`/`classify_output` = **приёмочный оракул** coverage-гейта Go (паритет закреплён Go-тестом; любое изменение — Python-first, лок-степ) | `run_refusal.sh` — однокоманда |
| `token_calc.py` | exp01 токен-калибровка (r-множители) | токенизаторы в `tokenizers/` | | `token_calc.py` | exp01 токен-калибровка (r-множители) | токенизаторы в `tokenizers/` |
| `editor_bench.py` + `build_editor_artifact.py` | exp04 бейк-офф редакторов | ⚠ слепота артефакта была сломана (ключ внутри) — при новых оценках ключ ВНЕ артефакта, рандомизация меток по фрагментам (D13.5) | | `editor_bench.py` + `build_editor_artifact.py` | exp04 бейк-офф редакторов | ✅ слепота починена (D13.5): рандомизация меток ПО ФРАГМЕНТУ (соль), ключ+цена в отдельном `--key` файле. Выбор grok-4.3 провизорен — exp04 §Оговорки |
| `coverage_precision.py`, `content_filter_probe.py` | exp07 precision гейта (0 FP/57) | | | `coverage_precision.py`, `content_filter_probe.py` | exp07 precision гейта (0 FP/57) | |
| `cost_model_v2.py` | exp08 COGS (~$0.69/ранобэ) | цены датировать и перепроверять | | `cost_model_v2.py` | exp08 COGS (~$0.69/ранобэ) | цены датировать и перепроверять |
| `extract_bench.py` | exp06 экстракция терминов (спот=локаль/рендер=облако) | health-верификация каждого ответа — образец | | `extract_bench.py` | exp06 экстракция терминов (спот=локаль/рендер=облако) | health-верификация каждого ответа — образец |
| `retrieval_bench.py` | эмбеддинг-бенч памяти (bge-m3 дефолт) | | | `retrieval_bench.py` | эмбеддинг-бенч памяти (bge-m3 дефолт) | |
| `pilot/declmetric.py`, `pilot/memory_eval.py` | харнесс пилота Ф2.5 (exp09) | ⚠ чинится ДО решающего прогона: стрип эха глоссария, ПОЛНЫЕ выходы (не [:160]), fidelity-ось, имена M0M3 (D13.5); инъекция = зеркало `backend/internal/pipeline/memory.go` — при расхождении верить Go | | `pilot/declmetric.py`, `pilot/memory_eval.py`, `pilot/kana_precision.py` | харнесс пилота Ф2.5 (exp09) | ✅ починен (D13.5): эхо-контроль, ПОЛНЫЕ выходы, **fidelity-ось** (cross-family судья), M0M3, Go-синк 7 расхождений (self-test+адверсариально verified). `--self-test`/`--dry-run` без API. Инъекция = зеркало `memory.go` — при расхождении верить Go (D16 лендится — пере-сверить). `kana_traps.json`+`trad2simp.txt` (508, hash-синк) |
| `webnovel_slice.py` | добор вне претрейна (r-коэф/эхо/coverage-precision) | одна команда по появлению `data/samples/webnovel/<lang>/*.txt`; блокируется корпусом graceful |
| `adaptive_*.py` | research/14 пробы (гибрид отклонён) | индикативные, не решения | | `adaptive_*.py` | research/14 пробы (гибрид отклонён) | индикативные, не решения |
| `memory_hotpath.py` | УСТАРЕЛ (контракт до cc57c7b) — не переиспользовать | актуальное зеркало — `pilot/memory_eval.py` | | `memory_hotpath.py` | УСТАРЕЛ (контракт до cc57c7b) — не переиспользовать | актуальное зеркало — `pilot/memory_eval.py` |
| `providers.json` | базовые URL/слаги для зондов | квирки — `docs/experiments/00-provider-quirks.md`, читать ПЕРЕД любым вызовом | | `providers.json` | базовые URL/слаги для зондов | квирки — `docs/experiments/00-provider-quirks.md`, читать ПЕРЕД любым вызовом |

View file

@ -1,20 +1,40 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Генератор страницы слепого сравнения редакторов (эксперимент 04). """Генератор страницы слепого сравнения редакторов (эксперимент 04).
Читает eval/data/editor_bench/<fragment>/*.txt, применяет слепую разметку A/B/C/D,
эмитит самодостаточный HTML (контент для Artifact-обёртки). Запуск: ГИГИЕНА СЛЕПОТЫ (D13.5 / 07-review §4.5 / POLYGON handoff Task 2): предыдущая версия была
eval/.venv/bin/python eval/build_editor_artifact.py <out.html> структурно НЕ-слепой при LLM-оценке (1) ключ «меткамодель» и цена лежали В ТОМ ЖЕ артефакте
(за <details> для LLM-фетча это НЕ скрытие, весь DOM виден), (2) маппинг A/B/C/D был
ЗАФИКСИРОВАН одинаково на всех фрагментах (узнав раз знаешь везде). Исправлено:
- метки рандомизируются ПО ФРАГМЕНТУ (детерминированная перестановка на соли, разная у каждого
фрагмента) узнать порядок на одном фрагменте не даёт его на других;
- ключ (меткамодель) + цена/латентность пишутся в ОТДЕЛЬНЫЙ файл (--key), КОТОРЫЙ НЕ
ПУБЛИКУЕТСЯ до вынесения оценки; в самом артефакте НЕТ ни имён моделей, ни цены;
- соль генерится случайно на сборку и хранится ТОЛЬКО в ключ-файле даже код-осведомлённый
оценщик не инвертирует перестановку без ключа.
Читает eval/data/editor_bench/<fragment>/*.txt, эмитит слепой HTML (контент для Artifact-обёртки)
и рядом приватный ключ-файл. Запуск:
eval/.venv/bin/python eval/build_editor_artifact.py <out.html> [--key <out.key.json>]
""" """
import argparse
import hashlib
import html import html
import json import json
import re import re
import sys import secrets
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
BENCH = ROOT / "data" / "editor_bench" BENCH = ROOT / "data" / "editor_bench"
# Слепая разметка: фиксированный перемешанный порядок (одинаков на всех фрагментах) MODELS = ["grok-4.3", "glm-4.6", "gemini-3.1-pro", "kimi-k2.6"]
BLIND = [("A", "grok-4.3"), ("B", "glm-4.6"), ("C", "gemini-3.1-pro"), ("D", "kimi-k2.6")] LABELS = ["A", "B", "C", "D"]
# стоимость/латентность — В КЛЮЧ-ФАЙЛ, НЕ в артефакт (цена коррелирует с моделью → утечка слепоты)
COST = {"grok-4.3": "~13 с, без «размышлений» — самый быстрый",
"glm-4.6": "~105 с (thinking off)",
"gemini-3.1-pro": "~6086 с, обязательный thinking",
"kimi-k2.6": "~150 с, ~11 000 токенов «размышлений» на абзац — самый дорогой"}
FRAGMENTS = [ FRAGMENTS = [
("rashomon", "японский → русский", "Акутагава Рюноскэ, «Расёмон»", False), ("rashomon", "японский → русский", "Акутагава Рюноскэ, «Расёмон»", False),
@ -23,11 +43,13 @@ FRAGMENTS = [
("wells", "английский → русский", "Г. Уэллс, «Машина времени»", True), ("wells", "английский → русский", "Г. Уэллс, «Машина времени»", True),
] ]
# стоимость/латентность из results.json (последний прогон) — для секции-расшифровки
COST = {"grok-4.3": "~13 с, без «размышлений» — самый быстрый", def label_map(salt: str, frag: str) -> list[tuple[str, str]]:
"glm-4.6": "~105 с (thinking off)", """Детерминированная перестановка меток ПО ФРАГМЕНТУ на секретной соли: сортируем модели по
"gemini-3.1-pro": "~6086 с, обязательный thinking", sha256(salt|frag|model) и присваиваем A,B,C,D. Разная у каждого фрагмента; без соли (в ключе)
"kimi-k2.6": "~150 с, ~11 000 токенов «размышлений» на абзац — самый дорогой"} не инвертируется. Возвращает [(label, model), ] в порядке меток."""
order = sorted(MODELS, key=lambda m: hashlib.sha256(f"{salt}|{frag}|{m}".encode()).hexdigest())
return list(zip(LABELS, order))
def paras(text: str) -> str: def paras(text: str) -> str:
@ -44,11 +66,18 @@ def read(frag: str, name: str) -> str:
def main() -> None: def main() -> None:
out = Path(sys.argv[1]) ap = argparse.ArgumentParser()
ap.add_argument("out")
ap.add_argument("--key", default="", help="приватный ключ-файл (по умолчанию <out>.key.json)")
ap.add_argument("--salt", default="", help="фиксировать соль (для воспроизводимости; иначе случайная)")
args = ap.parse_args()
out = Path(args.out)
key_path = Path(args.key) if args.key else out.with_suffix(".key.json")
salt = args.salt or secrets.token_hex(8)
S = [] S = []
S.append("<title>Сравнение редакторов ru-стиля · Эксперимент 04</title>") S.append("<title>Сравнение редакторов ru-стиля · Эксперимент 04</title>")
S.append(f"<style>{CSS}</style>") S.append(f"<style>{CSS}</style>")
S.append('<div class="wrap">') S.append('<div class="wrap">')
S.append('<header class="intro">') S.append('<header class="intro">')
S.append('<p class="eyebrow">TextMachine · полигон · эксперимент 04</p>') S.append('<p class="eyebrow">TextMachine · полигон · эксперимент 04</p>')
@ -60,11 +89,19 @@ def main() -> None:
'<li><span class="k k-style">стиль</span> — какая русская проза читается живее, без канцелярита и калек;</li>' '<li><span class="k k-style">стиль</span> — какая русская проза читается живее, без канцелярита и калек;</li>'
'<li><span class="k k-fidel">верность</span> — кто не потерял смысл и не добавил отсебятины (сверяй со «смысловым якорем»).</li>' '<li><span class="k k-fidel">верность</span> — кто не потерял смысл и не добавил отсебятины (сверяй со «смысловым якорем»).</li>'
'</ul>') '</ul>')
S.append('<p class="note">Названия моделей и цена — внизу, под ключом-расшифровкой, чтобы не влияли на оценку. ' S.append('<p class="note">⚠ Метки AD перемешаны СВОИМ порядком в каждом фрагменте — узнав, кто есть кто '
'Фрагмент Уэллса — англ.→рус.: оригинал перед тобой, судишь и стиль, и верность напрямую.</p>') 'в одном, ты НЕ знаешь этого в другом. Имена моделей и цена в артефакте НЕ приводятся '
'(ключ — в отдельном файле, вскрывается только ПОСЛЕ оценки). Фрагмент Уэллса — англ.→рус.: '
'оригинал перед тобой, судишь и стиль, и верность напрямую.</p>')
S.append("</header>") S.append("</header>")
key = {"salt": salt, "note": "ПРИВАТНЫЙ КЛЮЧ эксп.04 — НЕ публиковать вместе с артефактом; "
"вскрывать только ПОСЛЕ вынесения слепой оценки (урок 07-review §4.5). "
"Метки рандомизированы по фрагменту.", "cost": COST, "fragments": {}}
for i, (frag, direction, work, is_en) in enumerate(FRAGMENTS, 1): for i, (frag, direction, work, is_en) in enumerate(FRAGMENTS, 1):
lm = label_map(salt, frag)
key["fragments"][frag] = {lab: model for lab, model in lm}
anchor = read(frag, "source") if is_en else read(frag, "english_ref") anchor = read(frag, "source") if is_en else read(frag, "english_ref")
anchor_label = "Оригинал (английский)" if is_en else "Смысловой якорь — дословный английский перевод" anchor_label = "Оригинал (английский)" if is_en else "Смысловой якорь — дословный английский перевод"
draft = read(frag, "draft") draft = read(frag, "draft")
@ -74,7 +111,6 @@ def main() -> None:
f'<h2>{html.escape(work)}</h2></div>' f'<h2>{html.escape(work)}</h2></div>'
+ ('<span class="badge">твой полный контроль</span>' if is_en else '') + + ('<span class="badge">твой полный контроль</span>' if is_en else '') +
'</div>') '</div>')
S.append('<details class="context"><summary>Смысловой якорь и черновик</summary>' S.append('<details class="context"><summary>Смысловой якорь и черновик</summary>'
'<div class="context-grid">') '<div class="context-grid">')
S.append(f'<div class="ctx"><p class="ctx-label">{html.escape(anchor_label)}</p>' S.append(f'<div class="ctx"><p class="ctx-label">{html.escape(anchor_label)}</p>'
@ -82,32 +118,23 @@ def main() -> None:
S.append(f'<div class="ctx"><p class="ctx-label">Черновой перевод (вход редактора)</p>' S.append(f'<div class="ctx"><p class="ctx-label">Черновой перевод (вход редактора)</p>'
f'<div class="prose muted">{paras(draft)}</div></div>') f'<div class="prose muted">{paras(draft)}</div></div>')
S.append("</div></details>") S.append("</div></details>")
S.append('<div class="versions">') S.append('<div class="versions">')
for letter, model in BLIND: for letter, model in lm: # per-fragment shuffled order
txt = read(frag, model) txt = read(frag, model)
S.append(f'<article class="ver"><header class="ver-head"><span class="chip">{letter}</span></header>' S.append(f'<article class="ver"><header class="ver-head"><span class="chip">{letter}</span></header>'
f'<div class="prose">{paras(txt)}</div></article>') f'<div class="prose">{paras(txt)}</div></article>')
S.append("</div>") S.append("</div>")
S.append("</section>") S.append("</section>")
# ключ-расшифровка + стоимость (свёрнуто) # NO reveal/key section in the artifact (the exp04 blindness break). Key → separate file.
S.append('<details class="reveal"><summary>Показать расшифровку и стоимость</summary>')
S.append('<table><thead><tr><th>Метка</th><th>Модель</th><th>Латентность / цена редактуры</th></tr></thead><tbody>')
for letter, model in BLIND:
S.append(f'<tr><td class="chip-cell"><span class="chip sm">{letter}</span></td>'
f'<td class="mono">{html.escape(model)}</td><td>{html.escape(COST.get(model, ""))}</td></tr>')
S.append("</tbody></table>")
S.append('<p class="note">Черновик — DeepSeek (на китайском А-Кью DeepSeek «эхал» оригинал, '
'поэтому там черновик от GLM — деталь для бэкенда, на сравнение редакторов не влияет). '
'Латентность — на коротком фрагменте; в продакшене всё идёт батчем.</p>')
S.append("</details>")
S.append('<footer class="foot"><p>Выбор дефолтного редактора для Фазы 1. ' S.append('<footer class="foot"><p>Выбор дефолтного редактора для Фазы 1. '
'Твоя оценка — золотой стандарт: LLM-судьи на художественном качестве расходятся с людьми.</p></footer>') 'Твоя оценка — золотой стандарт: LLM-судьи на художественном качестве расходятся с людьми. '
'Ключ «метка→модель» и цена — в приватном файле, вскрываются после оценки.</p></footer>')
S.append("</div>") S.append("</div>")
out.write_text("\n".join(S), encoding="utf-8") out.write_text("\n".join(S), encoding="utf-8")
key_path.write_text(json.dumps(key, ensure_ascii=False, indent=2), encoding="utf-8")
print("written", out, out.stat().st_size, "bytes") print("written", out, out.stat().st_size, "bytes")
print("KEY (do NOT publish until scored):", key_path)
CSS = r""" CSS = r"""
@ -176,23 +203,12 @@ h1{font-family:var(--serif);font-weight:600;font-size:clamp(1.9rem,5vw,3rem);lin
.chip{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem; .chip{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;
font-family:var(--serif);font-weight:700;font-size:1.05rem;color:#fff;background:var(--accent); font-family:var(--serif);font-weight:700;font-size:1.05rem;color:#fff;background:var(--accent);
border-radius:50%;} border-radius:50%;}
.chip.sm{width:1.6rem;height:1.6rem;font-size:.85rem;}
.prose{font-family:var(--serif);font-size:1.02rem;line-height:1.62;max-width:var(--measure);} .prose{font-family:var(--serif);font-size:1.02rem;line-height:1.62;max-width:var(--measure);}
.prose p{margin:0 0 .75em;} .prose p{margin:0 0 .75em;}
.prose p:last-child{margin-bottom:0;} .prose p:last-child{margin-bottom:0;}
.prose.muted{color:var(--muted);font-size:.94rem;line-height:1.55;} .prose.muted{color:var(--muted);font-size:.94rem;line-height:1.55;}
.reveal{margin:2rem 0 0;border:1px solid var(--hair);border-radius:10px;background:var(--surface);}
.reveal>summary{cursor:pointer;padding:.9rem 1.1rem;font-weight:600;color:var(--accent);list-style:none;}
.reveal>summary::-webkit-details-marker{display:none;}
.reveal>summary::before{content:"🔑 ";}
.reveal table{width:100%;border-collapse:collapse;margin:0 0 .5rem;}
.reveal th,.reveal td{text-align:left;padding:.55rem 1.1rem;border-top:1px solid var(--hair);font-size:.92rem;}
.reveal th{font-size:.72rem;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600;}
.chip-cell{width:3rem;} .mono{font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:.88rem;}
.reveal .note{padding:0 1.1rem 1rem;}
.foot{margin-top:2.5rem;padding-top:1.5rem;border-top:1px solid var(--hair);color:var(--muted);font-size:.9rem;} .foot{margin-top:2.5rem;padding-top:1.5rem;border-top:1px solid var(--hair);color:var(--muted);font-size:.9rem;}
a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:4px;} a:focus-visible,summary:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:4px;}
@media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important;}} @media(prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important;}}

View file

@ -19,19 +19,66 @@ Two matchers, both reusable:
Also exposes GO-STYLE decl-form whole-word matching (given stored decl forms), so the eval Also exposes GO-STYLE decl-form whole-word matching (given stored decl forms), so the eval
can compare "stored-decl completeness" (Go's real mechanism) against pymorphy fallback — can compare "stored-decl completeness" (Go's real mechanism) against pymorphy fallback —
the E1 measurement the handoff asks the polygon to validate on real books. the E1 measurement the handoff asks the polygon to validate on real books.
D13.5 SYNC WITH GO (07-strategic-review §4.5; POLYGON handoff Task 1e-tail): normalize_target
and containsWholeWord are now a FAITHFUL mirror of memnorm.go normalizeTargetForm /
mempostcheck.go containsWholeWord NFC dash/hyphen-variant fold default-ignorable strip
(Cf + variation selectors) whitespace-collapse lower ёе, and a boundary test on the
Unicode IsLetter equivalent (category L*) rather than Cyrillic-only. Before this, a translit
name adjacent to a Latin letter or split by a zero-width joiner false-missed/false-hit vs Go.
""" """
from __future__ import annotations from __future__ import annotations
import re import re
import unicodedata
import pymorphy3 import pymorphy3
_MORPH = pymorphy3.MorphAnalyzer() _MORPH = pymorphy3.MorphAnalyzer()
_WORD_RE = re.compile(r"[А-Яа-яЁё]+(?:-[А-Яа-яЁё]+)?") _WORD_RE = re.compile(r"[А-Яа-яЁё]+(?:-[А-Яа-яЁё]+)?")
_CYR = re.compile(r"[А-Яа-яЁё]")
# Dash/hyphen/minus variants that fold to ASCII '-' (mirror of Go isDashVariant): hyphen,
# non-breaking hyphen, figure dash, en/em dash, horizontal bar, math minus, soft hyphen.
_DASH_VARIANTS = frozenset("‐‑‒–—―−­")
def _is_ignorable(c: str) -> bool:
"""Mirror of Go isIgnorableForMatch: default-ignorable / format code points (unicode.Cf:
ZWSP/ZWNJ/ZWJ/WJ/BOM/bidi) plus the variation selectors (VS1-16 U+FE00..FE0F and the
ideographic supplement U+E0100..E01EF). Dropped before matching so a stored «вана» is not
a false MISS against «ва<WJ>на». Soft hyphen (also Cf) is folded to '-' BEFORE this."""
o = ord(c)
return unicodedata.category(c) == "Cf" or (0xFE00 <= o <= 0xFE0F) or (0xE0100 <= o <= 0xE01EF)
def _is_letter(c: str) -> bool:
"""unicode.IsLetter equivalent: Unicode general category L* (Lu/Ll/Lt/Lm/Lo). Used for
the whole-word boundary so a Latin-letter neighbour bounds a translit name too (Go parity;
the old Cyrillic-only [А-Яа-яЁё] test missed Latin boundaries)."""
return unicodedata.category(c)[0] == "L"
def normalize_target(s: str) -> str: def normalize_target(s: str) -> str:
"""ё→е fold + lower + whitespace collapse (mirror of Go normalizeTargetForm, target side).""" """NFC → dash/hyphen-variant fold → drop default-ignorables → whitespace-collapse → lower →
return re.sub(r"\s+", " ", s).lower().replace("ё", "е").strip() ёе. Symmetric mirror of Go normalizeTargetForm (memnorm.go), applied to BOTH the stored
decl forms AND the model output so a correctly-rendered term is NEVER a false MISS."""
s = unicodedata.normalize("NFC", s)
out: list[str] = []
prev_space = False
for c in s:
if c in _DASH_VARIANTS:
c = "-" # soft hyphen folds here, so it is kept (not dropped below)
elif _is_ignorable(c):
continue
if c.isspace():
if not prev_space:
out.append(" ")
prev_space = True
continue
prev_space = False
c = c.lower()
if c == "ё":
c = "е"
out.append(c)
return "".join(out).strip()
def lemmas(text: str) -> set[str]: def lemmas(text: str) -> set[str]:
@ -56,14 +103,15 @@ def rendered_pymorphy(output: str, lemma_keys: list[str], hyphen_pat: str | None
def contains_whole_word(text_norm: str, form_norm: str) -> bool: def contains_whole_word(text_norm: str, form_norm: str) -> bool:
"""Go-style whole-word (letter-boundary) containment (mirror of Go containsWholeWord). """Go-style whole-word (letter-boundary) containment (mirror of Go containsWholeWord).
Both args must be normalize_target()'d.""" Boundary = Unicode IsLetter (category L*) on both ends. Both args must be normalize_target()'d."""
if not form_norm: if not form_norm:
return False return False
n = len(form_norm)
i = text_norm.find(form_norm) i = text_norm.find(form_norm)
while i != -1: while i != -1:
left_ok = i == 0 or not _CYR.match(text_norm[i - 1]) left_ok = i == 0 or not _is_letter(text_norm[i - 1])
j = i + len(form_norm) j = i + n
right_ok = j == len(text_norm) or not _CYR.match(text_norm[j]) right_ok = j == len(text_norm) or not _is_letter(text_norm[j])
if left_ok and right_ok: if left_ok and right_ok:
return True return True
i = text_norm.find(form_norm, i + 1) i = text_norm.find(form_norm, i + 1)
@ -99,7 +147,7 @@ def _score_stored(output: str, e: dict) -> bool:
return rendered_stored_decl(output, e.get("decl_forms", []), e["dst"]) return rendered_stored_decl(output, e.get("decl_forms", []), e["dst"])
if __name__ == "__main__": # smoke: inflected forms must NOT false-flag if __name__ == "__main__": # smoke: inflected forms must NOT false-flag; Latin-boundary parity
out = "Бородатого Вана встретили в Вэйчжуане; Дэна и сюцая тоже." out = "Бородатого Вана встретили в Вэйчжуане; Дэна и сюцая тоже."
ents = { ents = {
"王胡": {"dst": "Бородатый Ван", "lemma_keys": ["ван"], "decl_forms": ["Бородатого Вана", "Бородатый Ван"]}, "王胡": {"dst": "Бородатый Ван", "lemma_keys": ["ван"], "decl_forms": ["Бородатого Вана", "Бородатый Ван"]},
@ -109,3 +157,10 @@ if __name__ == "__main__": # smoke: inflected forms must NOT false-flag
} }
import json import json
print(json.dumps(consistency(out, ents), ensure_ascii=False, indent=2)) print(json.dumps(consistency(out, ents), ensure_ascii=False, indent=2))
# boundary + normalization parity asserts (Go containsWholeWord / normalizeTargetForm)
assert contains_whole_word(normalize_target("Ван ушёл"), normalize_target("Ван"))
assert not contains_whole_word(normalize_target("караВАН ушёл"), normalize_target("Ван"))
assert contains_whole_word(normalize_target("А‑кью"), normalize_target("А-кью")) # NB-hyphen folds
assert contains_whole_word(normalize_target("Ва​на"), normalize_target("вана")) # ZWSP stripped
assert not contains_whole_word(normalize_target("Ivan"), normalize_target("van")) # Latin boundary
print("declmetric parity asserts: OK")

View file

@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""kana-precision measurement (D16-tail / E1-protocol extension; POLYGON handoff Task 5).
QUESTION (memory.go:37-48): minKeyLenPhonetic=3 is a "conservative default pending a ja-kana
precision measurement". This quantifies matcher PRECISION on kana name-keys at MIN ∈ {2,3,4},
so the prod-ja decision (confirm 3 / raise / split by kana type) is grounded, not asserted.
The failure mode: a short kana name-key (glossary key) firing as a FALSE POSITIVE inside an
unrelated common Japanese word the kana analog of a Han homograph trap. Two distinct causes,
tagged in kana_traps.json (the agent partitioned them, and they behave DIFFERENTLY under a floor):
- substring: the name hides inside a LONGER unrelated word (リン リンゴ apple). Killed by a
high-enough MIN floor.
- homograph: the name IS a whole common noun (ひかる=光る shine, あさひ=morning sun, ひまわり=
sunflower). LENGTH-INVARIANT a MIN floor cannot filter it; it is a full substring at every
length. Needs POS / known-word gating, not a longer key.
Matcher variants measured:
- substring: the CURRENT Go source-side matcher (Aho-Corasick = pure substring, no boundary).
- script_boundary: emulates the D16.3 fix ("script/word-boundary on the source side for
phonetic keys"). For kana this means "the match must be flanked by NON-kana (kanji/latin/
punct) or a string edge". This is the critical ja finding: unlike Latin (rose⊂roseanne,
where spaces/letters bound words), Japanese has NO word spaces and names take kana PARTICLES
(メロスは, 名前が), so a kana-run boundary rule SUPPRESSES real names followed by a particle
recall collapse. Measured on both so the orchestrator sees the trade, not just the win.
Both operate on the NORMALIZED (kana-folded) text imported from the memory_eval Go-mirror so
this measures the SAME normalization the hot path uses (katakanahiragana, NFKC, ignorables).
Usage: eval/.venv/bin/python eval/pilot/kana_precision.py
"""
from __future__ import annotations
import json
from pathlib import Path
import memory_eval as me # reuse the Go-mirror normalization (norm_src, significant_len, any_han)
ROOT = Path(__file__).resolve().parent
TRAPS = json.loads((ROOT / "kana_traps.json").read_text(encoding="utf-8"))
def is_kana(c: str) -> bool:
"""Hiragana/katakana (incl. prolonged mark ー). After norm_src katakana folds to hiragana,
so the flanking test operates on a hiragana run; (U+30FC) is not folded, still kana."""
return 0x3040 <= ord(c) <= 0x30FF
def eligible(name_norm: str, min_phonetic: int) -> bool:
"""A kana key (no Han) is eligible only if significantLen >= the phonetic floor under test
(min_key_len_for with minKeyLenPhonetic := min_phonetic). Han keys are out of scope here."""
floor = 2 if me.any_han(name_norm) else min_phonetic
return me.significant_len(name_norm) >= floor
def collision_prone_at(name_norm: str, min_phonetic: int) -> bool:
"""Mirror of collisionProneKey with the floor == the const under test: a fired key of
significantLen <= min_phonetic is AMBIGUOUS (softened + forced post-check); a longer key
fires CONFIRMED (authoritative). The dangerous FP is the CONFIRMED one."""
return (not me.any_han(name_norm)) and me.significant_len(name_norm) <= min_phonetic
def fires_substring(name_norm: str, text_norm: str) -> bool:
return name_norm in text_norm
def fires_script_boundary(name_norm: str, text_norm: str) -> bool:
"""D16.3 emulation for kana: a substring hit is valid only if flanked by non-kana or edge."""
i = text_norm.find(name_norm)
n = len(name_norm)
while i != -1:
left_ok = i == 0 or not is_kana(text_norm[i - 1])
j = i + n
right_ok = j == len(text_norm) or not is_kana(text_norm[j])
if left_ok and right_ok:
return True
i = text_norm.find(name_norm, i + 1)
return False
def measure(matcher, min_phonetic: int) -> dict:
"""Precision over the trap set at one floor with one matcher. FP from collision_traps,
TP from true_positive_contexts. Partition FP by cause (substring/homograph) and by
disposition (ambiguous=softened / confirmed=authoritative)."""
fp = {"substring": 0, "homograph": 0, "confirmed": 0, "ambiguous": 0, "banned": 0, "items": []}
for t in TRAPS["collision_traps"]:
nk = me.norm_src(t["name_kana"])
cw = me.norm_src(t["containing_word_kana"])
if not eligible(nk, min_phonetic):
fp["banned"] += 1
continue
if matcher(nk, cw):
fp[t["type"]] += 1
disp = "ambiguous" if collision_prone_at(nk, min_phonetic) else "confirmed"
fp[disp] += 1
fp["items"].append(f'{t["name_kana"]}({t["len"]})⊂{t["containing_word_kana"]}[{t["type"]},{disp}]')
tp = {"fired": 0, "banned": 0, "missed_boundary": 0}
for c in TRAPS["true_positive_contexts"]:
nk = me.norm_src(c["name_kana"])
sent = me.norm_src(c["sentence_kana"])
if not eligible(nk, min_phonetic):
tp["banned"] += 1
continue
if matcher(nk, sent):
tp["fired"] += 1
else:
tp["missed_boundary"] += 1 # eligible but the boundary rule suppressed a REAL name
n_fp = fp["substring"] + fp["homograph"]
prec = round(tp["fired"] / (tp["fired"] + n_fp), 3) if (tp["fired"] + n_fp) else None
# CONFIRMED-precision: among AUTHORITATIVE firings only (AMBIGUOUS is softened by ⟨проверить⟩)
tp_conf = sum(1 for c in TRAPS["true_positive_contexts"]
if eligible(me.norm_src(c["name_kana"]), min_phonetic)
and not collision_prone_at(me.norm_src(c["name_kana"]), min_phonetic)
and matcher(me.norm_src(c["name_kana"]), me.norm_src(c["sentence_kana"])))
conf_prec = round(tp_conf / (tp_conf + fp["confirmed"]), 3) if (tp_conf + fp["confirmed"]) else None
return {"min": min_phonetic, "tp_fired": tp["fired"], "tp_banned": tp["banned"],
"tp_missed_boundary": tp["missed_boundary"], "fp_total": n_fp,
"fp_substring": fp["substring"], "fp_homograph": fp["homograph"],
"fp_confirmed": fp["confirmed"], "fp_ambiguous": fp["ambiguous"], "fp_banned": fp["banned"],
"precision": prec, "confirmed_precision": conf_prec, "fp_items": fp["items"]}
def main():
print("kana-precision (D16-tail). Trap set: "
f'{len(TRAPS["collision_traps"])} collision pairs, '
f'{len(TRAPS["true_positive_contexts"])} true-positive contexts. '
"FP by cause; disposition confirmed=authoritative / ambiguous=softened.\n")
for label, matcher in (("substring (CURRENT Go)", fires_substring),
("script_boundary (D16.3 emulation)", fires_script_boundary)):
print(f"### matcher = {label}")
print("MIN | TP_fire | TP_missed(bound) | FP_tot | FP_substr | FP_homog | FP_CONFIRMED | FP_ambig | precision | conf_prec")
rows = [measure(matcher, m) for m in (2, 3, 4)]
for r in rows:
print(f" {r['min']} | {r['tp_fired']:2} | {r['tp_missed_boundary']:2} |"
f" {r['fp_total']:2} | {r['fp_substring']:2} | {r['fp_homograph']:2} |"
f" {r['fp_confirmed']:2} | {r['fp_ambiguous']:2} | "
f"{r['precision'] if r['precision'] is not None else ' -'} | "
f"{r['confirmed_precision'] if r['confirmed_precision'] is not None else ' -'}")
print()
# dump machine-readable for the report / re-run comparison
out = {"substring": [measure(fires_substring, m) for m in (2, 3, 4)],
"script_boundary": [measure(fires_script_boundary, m) for m in (2, 3, 4)]}
outp = ROOT.parent / "data" / "pilot" / "kana_precision.json"
outp.parent.mkdir(parents=True, exist_ok=True)
outp.write_text(json.dumps(out, ensure_ascii=False, indent=2))
print(f"wrote {outp}")
# headline read
s3 = out["substring"][1]
print(f"\nHEADLINE (substring, MIN=3): precision={s3['precision']} conf_prec={s3['confirmed_precision']} "
f"| FP_confirmed(len>MIN)={s3['fp_confirmed']} are {[i for i in s3['fp_items'] if 'confirmed' in i]}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,76 @@
{
"_provenance": "ja-kana precision trap corpus for kana_precision.py (D16-tail / E1-protocol extension). Sourced 2026-07-09 by a research agent: names extracted by reading the four ja PD samples (soseki-neko, akutagawa-rashomon, akutagawa-hana, dazai-merosu); collision traps from Japanese lexical knowledge, each ADJACENCY- and VOICING-checked (3 faulty candidates おなか/みがく/みんな rejected). Every trap tagged type: substring (name hides inside a longer unrelated word) vs homograph (name == a whole common noun). KEY FINDING: the substring class vanishes by MIN=3-4; the homograph class is length-INVARIANT (a MIN threshold cannot filter it — needs POS/known-word gating). kana length = CHARACTER count a substring matcher sees (ディオニス = 5 chars). Matcher kana-folds katakana->hiragana, so same-fold substrings fire.",
"names_from_corpus": [
{"name_kana":"メロス","len":3,"work":"merosu","romaji":"Merosu","kind":"person(protagonist)","written_kana_in_source":true},
{"name_kana":"ゼウス","len":3,"work":"merosu","romaji":"Zeusu","kind":"deity","written_kana_in_source":true},
{"name_kana":"シラクス","len":4,"work":"merosu","romaji":"Shirakusu","kind":"place","written_kana_in_source":true},
{"name_kana":"アレキス","len":4,"work":"merosu","romaji":"Arekisu","kind":"person(minor)","written_kana_in_source":true},
{"name_kana":"ディオニス","len":5,"work":"merosu","romaji":"Dionis","kind":"person(antagonist)","written_kana_in_source":true},
{"name_kana":"セリヌンティウス","len":8,"work":"merosu","romaji":"Serinuntiusu","kind":"person","written_kana_in_source":true},
{"name_kana":"フィロストラトス","len":8,"work":"merosu","romaji":"Firosutoratosu","kind":"person(minor)","written_kana_in_source":true},
{"name_kana":"おさん","len":3,"work":"neko","romaji":"Osan","kind":"person(maid)","written_kana_in_source":true},
{"name_kana":"ギボン","len":3,"work":"neko","romaji":"Gibon","kind":"reference(person)","written_kana_in_source":true},
{"name_kana":"ニコラス","len":4,"work":"neko","romaji":"Nikorasu","kind":"reference(person)","written_kana_in_source":true},
{"name_kana":"ハリソン","len":4,"work":"neko","romaji":"Harison","kind":"reference(person)","written_kana_in_source":true},
{"name_kana":"アンドレア","len":5,"work":"neko","romaji":"Andorea","kind":"reference(person)","written_kana_in_source":true}
],
"collision_traps": [
{"name_kana":"リン","len":2,"romaji":"Rin","containing_word_kana":"リンゴ","containing_word_meaning":"apple","type":"substring","confidence":"high"},
{"name_kana":"アイ","len":2,"romaji":"Ai","containing_word_kana":"アイス","containing_word_meaning":"ice/ice cream","type":"substring","confidence":"high"},
{"name_kana":"ラン","len":2,"romaji":"Ran","containing_word_kana":"ランチ","containing_word_meaning":"lunch","type":"substring","confidence":"high"},
{"name_kana":"はる","len":2,"romaji":"Haru","containing_word_kana":"はるばる","containing_word_meaning":"all the way/from afar","type":"substring","confidence":"high","grounded":"merosu"},
{"name_kana":"かな","len":2,"romaji":"Kana","containing_word_kana":"かなしい","containing_word_meaning":"sad","type":"substring","confidence":"high"},
{"name_kana":"なお","len":2,"romaji":"Nao","containing_word_kana":"すなお","containing_word_meaning":"obedient/honest","type":"substring","confidence":"high"},
{"name_kana":"みか","len":2,"romaji":"Mika","containing_word_kana":"みかん","containing_word_meaning":"mandarin orange","type":"substring","confidence":"high"},
{"name_kana":"ゆき","len":2,"romaji":"Yuki","containing_word_kana":"ゆきだるま","containing_word_meaning":"snowman","type":"substring","confidence":"high"},
{"name_kana":"まり","len":2,"romaji":"Mari","containing_word_kana":"あまり","containing_word_meaning":"remainder/too much","type":"substring","confidence":"high"},
{"name_kana":"かい","len":2,"romaji":"Kai","containing_word_kana":"せかい","containing_word_meaning":"world","type":"substring","confidence":"high"},
{"name_kana":"れい","len":2,"romaji":"Rei","containing_word_kana":"きれい","containing_word_meaning":"pretty/clean","type":"substring","confidence":"high"},
{"name_kana":"ゆう","len":2,"romaji":"Yuu","containing_word_kana":"ゆうがた","containing_word_meaning":"evening","type":"substring","confidence":"high","grounded":"rashomon,neko"},
{"name_kana":"なな","len":2,"romaji":"Nana","containing_word_kana":"バナナ","containing_word_meaning":"banana","type":"substring","confidence":"high"},
{"name_kana":"さや","len":2,"romaji":"Saya","containing_word_kana":"ささやく","containing_word_meaning":"to whisper","type":"substring","confidence":"high","grounded":"hana,merosu"},
{"name_kana":"まい","len":2,"romaji":"Mai","containing_word_kana":"まいにち","containing_word_meaning":"every day","type":"substring","confidence":"high"},
{"name_kana":"ちか","len":2,"romaji":"Chika","containing_word_kana":"ちかい","containing_word_meaning":"near","type":"substring","confidence":"high"},
{"name_kana":"みな","len":2,"romaji":"Mina","containing_word_kana":"みなと","containing_word_meaning":"harbor","type":"substring","confidence":"high"},
{"name_kana":"ゆい","len":2,"romaji":"Yui","containing_word_kana":"ゆいいつ","containing_word_meaning":"the only/sole","type":"substring","confidence":"high"},
{"name_kana":"まこと","len":3,"romaji":"Makoto","containing_word_kana":"まことに","containing_word_meaning":"truly/indeed","type":"substring","confidence":"high"},
{"name_kana":"さくら","len":3,"romaji":"Sakura","containing_word_kana":"さくらんぼ","containing_word_meaning":"cherry (fruit)","type":"substring","confidence":"high"},
{"name_kana":"あきら","len":3,"romaji":"Akira","containing_word_kana":"あきらめる","containing_word_meaning":"to give up","type":"substring","confidence":"high"},
{"name_kana":"あすか","len":3,"romaji":"Asuka","containing_word_kana":"あすから","containing_word_meaning":"from tomorrow","type":"substring","confidence":"high"},
{"name_kana":"ひろし","len":3,"romaji":"Hiroshi","containing_word_kana":"ひろしま","containing_word_meaning":"Hiroshima","type":"substring","confidence":"high"},
{"name_kana":"ひとみ","len":3,"romaji":"Hitomi","containing_word_kana":"ひとみしり","containing_word_meaning":"shyness with strangers","type":"substring","confidence":"high"},
{"name_kana":"ちなみ","len":3,"romaji":"Chinami","containing_word_kana":"ちなみに","containing_word_meaning":"by the way/incidentally","type":"substring","confidence":"high"},
{"name_kana":"あさひ","len":3,"romaji":"Asahi","containing_word_kana":"あさひ","containing_word_meaning":"morning sun","type":"homograph","confidence":"high","grounded":"neko,hana"},
{"name_kana":"いずみ","len":3,"romaji":"Izumi","containing_word_kana":"いずみ","containing_word_meaning":"spring/fountain","type":"homograph","confidence":"high","grounded":"merosu"},
{"name_kana":"ひかる","len":3,"romaji":"Hikaru","containing_word_kana":"ひかる","containing_word_meaning":"to shine","type":"homograph","confidence":"high"},
{"name_kana":"かおり","len":3,"romaji":"Kaori","containing_word_kana":"かおり","containing_word_meaning":"fragrance/scent","type":"homograph","confidence":"high"},
{"name_kana":"のぞみ","len":3,"romaji":"Nozomi","containing_word_kana":"のぞみ","containing_word_meaning":"wish/hope","type":"homograph","confidence":"high"},
{"name_kana":"みなみ","len":3,"romaji":"Minami","containing_word_kana":"みなみ","containing_word_meaning":"south","type":"homograph","confidence":"high"},
{"name_kana":"ひなた","len":3,"romaji":"Hinata","containing_word_kana":"ひなた","containing_word_meaning":"a sunny spot","type":"homograph","confidence":"high"},
{"name_kana":"ひまわり","len":4,"romaji":"Himawari","containing_word_kana":"ひまわり","containing_word_meaning":"sunflower","type":"homograph","confidence":"high"},
{"name_kana":"なでしこ","len":4,"romaji":"Nadeshiko","containing_word_kana":"やまとなでしこ","containing_word_meaning":"idealized Japanese woman/dianthus","type":"substring","confidence":"high"},
{"name_kana":"すずらん","len":4,"romaji":"Suzuran","containing_word_kana":"すずらん","containing_word_meaning":"lily of the valley","type":"homograph","confidence":"high"},
{"name_kana":"あじさい","len":4,"romaji":"Ajisai","containing_word_kana":"あじさい","containing_word_meaning":"hydrangea","type":"homograph","confidence":"high"},
{"name_kana":"たんぽぽ","len":4,"romaji":"Tanpopo","containing_word_kana":"たんぽぽ","containing_word_meaning":"dandelion","type":"homograph","confidence":"high"},
{"name_kana":"きさらぎ","len":4,"romaji":"Kisaragi","containing_word_kana":"きさらぎ","containing_word_meaning":"February (old lunar calendar)","type":"homograph","confidence":"med"},
{"name_kana":"みなづき","len":4,"romaji":"Minazuki","containing_word_kana":"みなづき","containing_word_meaning":"June (old lunar calendar)","type":"homograph","confidence":"med"},
{"name_kana":"ことのは","len":4,"romaji":"Kotonoha","containing_word_kana":"ことのは","containing_word_meaning":"words/language (poetic)","type":"homograph","confidence":"med"}
],
"true_positive_contexts": [
{"name_kana":"メロス","sentence_kana":"メロスは激怒した。","source":"corpus:merosu"},
{"name_kana":"メロス","sentence_kana":"走れ! メロス。","source":"corpus:merosu"},
{"name_kana":"セリヌンティウス","sentence_kana":"竹馬の友、セリヌンティウスは、深夜、王城に召された。","source":"corpus:merosu"},
{"name_kana":"ディオニス","sentence_kana":"暴君ディオニスは静かに、けれども威厳を以て問いつめた。","source":"corpus:merosu"},
{"name_kana":"フィロストラトス","sentence_kana":"フィロストラトスでございます。","source":"corpus:merosu"},
{"name_kana":"ゼウス","sentence_kana":"待ってくれ、ゼウスよ。","source":"corpus:merosu"},
{"name_kana":"アレキス","sentence_kana":"それから、賢臣のアレキス様を。","source":"corpus:merosu"},
{"name_kana":"シラクス","sentence_kana":"きょう未明メロスは村を出発し、野を越え山越え、十里はなれた此のシラクスの市にやって来た。","source":"corpus:merosu"},
{"name_kana":"おさん","sentence_kana":"第一に逢ったのがおさんである。","source":"corpus:neko"},
{"name_kana":"おさん","sentence_kana":"おさんは未だに嫌いである。","source":"corpus:neko"},
{"name_kana":"リン","sentence_kana":"リンは今日も学校に遅刻した。","source":"constructed"},
{"name_kana":"あい","sentence_kana":"あいちゃんは私の妹です。","source":"constructed"},
{"name_kana":"まこと","sentence_kana":"まことは正直な男だ。","source":"constructed"},
{"name_kana":"さくら","sentence_kana":"さくらは転校生と仲良くなった。","source":"constructed"},
{"name_kana":"ひまわり","sentence_kana":"ひまわりは弟の名前です。","source":"constructed"}
]
}

View file

@ -1,36 +1,72 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Ф2.5 memory-eval (INDICATIVE run + harness scaffold) — the deciding-experiment design """Ф2.5 memory-eval (INDICATIVE run + harness scaffold) — the deciding-experiment design
for the memory-bank bet (research/13 §Вердикт, decisions-log D-пилот). for the memory-bank bet (research/13 §Вердикт, decisions-log D-пилот, D13.5).
QUESTION (owner's central fear + go/no-go on the bank bet): is DETERMINISTIC SELECTIVE QUESTION (owner's central fear + go/no-go on the bank bet): is DETERMINISTIC SELECTIVE
glossary injection (our bank) worth it vs just putting the WHOLE glossary in a long context, glossary injection (our bank) worth it vs just putting the WHOLE glossary in a long context,
and does a WRONG injection silently degrade the translation? WMT25 three-mode protocol: and does a WRONG injection silently degrade the translation? WMT25 three-mode protocol,
RENAMED M0M3 per D13.4 (the old C0C3 collided with exp09 §3 CORE configurations):
C0 no glossary (baseline model's base behaviour) M0 no glossary (baseline model's base behaviour)
C1 SELECTIVE bank injection (only records whose keys fire in the chunk) OUR bank M1 SELECTIVE bank injection (only records whose keys fire in the chunk) OUR bank
C2 FULL glossary + sliding summary in the prompt "just long context" M2 FULL glossary + sliding summary in the prompt "just long context"
C3 RANDOM/WRONG glossary (dst shuffled) adversarial arm, directly tests silent degradation M3 RANDOM/WRONG glossary (dst shuffled) adversarial arm, directly tests silent degradation
Decision rule (pre-registered): C1C2 quality but C1 cheaper bank justified; C2C1 Decision rule (pre-registered, exp09 §6): M1M2 quality but M1 cheaper bank justified; M2M1
need fuller context; C3 drops vs C0 on FIDELITY wrong injection harms post-check/disposition need fuller context; M3 drops vs M0 on FIDELITY wrong injection harms post-check/disposition
justified (owner's fear confirmed & mitigated, not just asserted). justified (owner's fear confirmed & mitigated, not just asserted).
INJECTION = eval-side MIRROR of backend/internal/pipeline/memory.go (THE SOURCE OF TRUTH): THREE AXES, measured SEPARATELY (exp09 §6):
normalized exact key match, per-language min-key ban (Han2 / phonetic3), collision-prone (a) consistency decl-aware (declmetric.py, pymorphy3 + Go-style stored-decl) approved-dst
short phonetic key AMBIGUOUS, longest-match containment, spoiler since/until window, sticky rendering across the chunks where src fires;
scene-inertia. Cross-checked against the Go unit tests; on any divergence the Go code wins. (b) FIDELITY/omission LLM judge of a DIFFERENT family vs the SOURCE (D13.3 family rule:
(eval/memory_hotpath.py is the PRE-cc57c7b prototype with a global MIN_KEY=2 and no judge NOT from the draft/translator model's family). This is the axis that makes the
collision-downgrade do NOT reuse it; this file reflects the current Go contract.) pre-registered "M3 drops vs M0 on fidelity" rule TESTABLE without it the central
owner-fear measurement is unverifiable (07-review §4.5). CometKiwi is OPTIONAL and only
with the caveat "not validated on ru-literature" (research/11-gap-2) not wired here.
(c) cost input/output tokens per mode.
METRICS: (a) consistency decl-aware (declmetric.py, pymorphy3) approved-dst rendering across INJECTION = eval-side MIRROR of backend/internal/pipeline/memory.go (THE SOURCE OF TRUTH),
overlapping chunks; (b) fidelity/omission LLM judge of a DIFFERENT family (cross-family) vs synced D13.5 across the 7 divergences the review flagged (07-review §4.5):
source + Rogov anchor; (c) cost input/output tokens per condition. 1. sticky depth = stickyDepth(2), union window, reset at chapter boundary (runner.go:534-560);
2. until_ch spoiler gate (memory.go:374-382), not only since_ch;
3. span-containment gated on a SPOILER-VALID suppressor (memory.go:591-617);
4. token budget + F2 eviction in priority order (memory.go:262-370);
5. sticky disposition INHERITANCE a collision-prone AMBIGUOUS carried by sticky stays
AMBIGUOUS, not upgraded to CONFIRMED. This is the ratified D16.2 fix as of 2026-07-09 the
backend is LANDING it (memoryMatchVersion bumped to memmatch-v3 "sticky-inherit-disp"): the
mirror already matches the v3 contract. Re-verify vs the final Go once the D16.2 logic edit
completes (PROGRESS §Полигон ping version const changed before the dispositionFor edit);
6. FULL vendored trad2simp table (eval/pilot/trad2simp.txt, hash-synced with the Go embed),
not a 12-entry stub;
7. default-ignorables = unicode.Cf + variation selectors FE00..FE0F / E0100..E01EF (memnorm.go
isIgnorableForMatch), not one hardcoded VS char.
Han-script tests cover ALL planes (Ext AI + non-decomposing compat), and significantLen counts
Unicode LETTERS only (mirrors Go's unicode.IsLetter branch; excludes kana middle-dot U+30FB and
combining marks) closes the adversarial-review deltas #1/#2 (supplementary-plane names). D16.3
source word-boundary (roseroseanne) is scoped by Go to LATIN/CYRILLIC phonetic keys and is NOT
applied to kana (Japanese has no word spaces; names take kana particles) see kana_precision.py.
On any divergence the Go code wins. (eval/memory_hotpath.py is the PRE-cc57c7b prototype do NOT reuse.)
INDICATIVE (small N, LLM judge). The deciding version adds human BWS + owner corpus (see ECHO CONTROL (D13.5 / 07-review §4.5): the model ECHOES the injected glossary preamble ("ГЛОССАРИЙ
09-pilot-protocol.md). Usage: eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run 阿Q А-кью ") before the translation. Scoring the whole output then counts the echoed dst
forms as "rendered" M-modes inflated, and the ADVERSARIAL M3 harm UNDER-counted (the review
traced chunk1-M3 inflated 0.6671.0). We now (a) DETECT the preamble/source echo (reusing
refusal_bench's CJK classifier), (b) STRIP it to the translation body before scoring, and (c)
FLAG the datapoint echo_contaminated and EXCLUDE it from the clean headline aggregate (optionally
--regen-on-echo re-asks once with a hardened prompt) never a silent strip.
Provenance (eval rule #1): FULL raw outputs saved (not out[:160]); every run stamps model id +
UTC date + usage; a NEW output file per run (never overwrite exp02 lost 66 raw calls that way).
Usage:
eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run # selection mechanism
eval/.venv/bin/python eval/pilot/memory_eval.py --self-test # mirror invariants (no API)
eval/.venv/bin/python eval/pilot/memory_eval.py --chapter 6 # indicative M0M3 on Ah-Q
""" """
from __future__ import annotations from __future__ import annotations
import argparse, json, re, sys, unicodedata import argparse, json, re, sys, time, unicodedata
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@ -41,16 +77,54 @@ ROOT = Path(__file__).resolve().parent
SAMPLES = ROOT.parent / "data" / "samples" SAMPLES = ROOT.parent / "data" / "samples"
OUTDIR = ROOT.parent / "data" / "pilot" OUTDIR = ROOT.parent / "data" / "pilot"
OUTDIR.mkdir(parents=True, exist_ok=True) OUTDIR.mkdir(parents=True, exist_ok=True)
TRAD_TABLE = ROOT / "trad2simp.txt" # vendored from backend/internal/pipeline/data/trad2simp.txt
# --- normalization (mirror of memnorm.go normalizeSourceKey) --------------------- # --- normalization (faithful mirror of memnorm.go) --------------------------------
TRAD2SIMP = {"": "", "": "", "": "", "": "", "": "", "": "", "": "",
"": "", "": "", "": "", "": "", "": ""} def _load_trad2simp(path: Path) -> dict[str, str]:
"""Parse the vendored '<trad> <simp>' table (mirror of parseTradTable). A corrupt line is
a loud error the same fail-loud contract as Go, so a truncated table can't silently
re-open the A4 orthography hole."""
m: dict[str, str] = {}
if not path.exists():
raise SystemExit(f"memory_eval: vendored trad2simp table missing: {path}\n"
f" cp backend/internal/pipeline/data/trad2simp.txt {path}")
for i, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
s = line.strip()
if not s or s.startswith("#"):
continue
fields = s.split()
if len(fields) != 2 or len(fields[0]) != 1 or len(fields[1]) != 1:
raise SystemExit(f"memory_eval: trad2simp line {i}: want 2 single-rune fields, got {s!r}")
m[fields[0]] = fields[1]
return m
TRAD2SIMP = _load_trad2simp(TRAD_TABLE)
def _is_ignorable(c: str) -> bool:
"""Mirror of Go isIgnorableForMatch: unicode.Cf + variation selectors FE00..FE0F / E0100..E01EF."""
o = ord(c)
return unicodedata.category(c) == "Cf" or (0xFE00 <= o <= 0xFE0F) or (0xE0100 <= o <= 0xE01EF)
def _is_letter(c: str) -> bool:
return unicodedata.category(c)[0] == "L" # unicode.IsLetter equivalent (L*)
def _is_han(c: str) -> bool:
"""Full-plane Han (mirror of Go unicode.Is(unicode.Han)): Ext A, URO, compat, Ext BI,
compat-supplement. Covers supplementary-plane ideographs (rare surnames/fantasy names a
webnovel corpus can hit) that BMP-only ranges miss adversarial-review delta #2."""
o = ord(c)
return (0x3400 <= o <= 0x4DBF or 0x4E00 <= o <= 0x9FFF or 0xF900 <= o <= 0xFAFF
or 0x20000 <= o <= 0x2A6DF or 0x2A700 <= o <= 0x2EBEF or 0x2F800 <= o <= 0x2FA1F
or 0x30000 <= o <= 0x323AF)
def norm_src(s: str) -> str: def norm_src(s: str) -> str:
"""Mirror of memnorm.go normalizeSourceKey: NFKC → drop ignorables → trad→simp (per rune)
katakanahiragana Unicode lower. Applied SYMMETRICALLY to keys and chunk text."""
s = unicodedata.normalize("NFKC", s) s = unicodedata.normalize("NFKC", s)
out = [] out = []
for c in s: for c in s:
if unicodedata.category(c) in ("Cf",) or c in "": if _is_ignorable(c):
continue continue
c = TRAD2SIMP.get(c, c) c = TRAD2SIMP.get(c, c)
o = ord(c) o = ord(c)
@ -59,92 +133,214 @@ def norm_src(s: str) -> str:
out.append(c.lower()) out.append(c.lower())
return "".join(out) return "".join(out)
HAN = re.compile(r"[一-鿿㐀-䶿]")
def significant_len(nk: str) -> int: def significant_len(nk: str) -> int:
return sum(1 for c in nk if HAN.match(c) or c.isalpha() or """Mirror of memnorm.go significantLen: count Unicode LETTERS (category L*). Since Han/kana/
("" <= c <= "") or ("" <= c <= "")) hangul letters are all category L across every plane, this subsumes Go's explicit
Han/Hiragana/Katakana/Hangul clause AND excludes the non-letters it also excludes
(kana middle-dot U+30FB Po, combining marks Mn) more faithful than a fixed block range."""
return sum(1 for c in nk if _is_letter(c))
def any_han(nk: str) -> bool: def any_han(nk: str) -> bool:
return bool(HAN.search(nk)) return any(_is_han(c) for c in nk)
def min_key_len(nk: str) -> int:
return 2 if any_han(nk) else 3 def min_key_len_for(nk: str) -> int:
return 2 if any_han(nk) else 3 # minKeyLenHan / minKeyLenPhonetic
def collision_prone(nk: str) -> bool: def collision_prone(nk: str) -> bool:
"""Short AND purely phonetic (no Han anchor) → likely to fire inside an unrelated word →
injected AMBIGUOUS (memory.go collisionProneKey; threshold = minKeyLenPhonetic=3)."""
return (not any_han(nk)) and significant_len(nk) <= 3 return (not any_han(nk)) and significant_len(nk) <= 3
# --- glossary (Ah-Q; dst + decl + lemma + aliases + spoiler window) -------------- STICKY_DEPTH = 2 # memory.go stickyDepth
# One deliberate SPOILER entry (未庄 revolution outcome) demonstrates the since/until gate.
# --- disposition (mirror of memory.go dispositionFor + injectionDisposition) ------
CONFIRMED, AMBIGUOUS, REJECT = "confirmed", "ambiguous", "reject"
def disposition_for(status: str, via: str, allow_short: bool) -> str:
"""Exact-match disposition. approved → confirmed; auto/draft → ambiguous; an approved
entry matched by a COLLISION-PRONE key is downgraded to ambiguous (A2 surface-collision).
via=='sticky' NEVER reaches here in the D16.2 mirror sticky INHERITS its disposition."""
if via != "sticky" and not allow_short and collision_prone(via):
return AMBIGUOUS
return CONFIRMED if status == "approved" else AMBIGUOUS
# --- glossary (Ah-Q) as store-like rows -------------------------------------------
# Fields mirror store.GlossaryEntry columns the hot path reads. One SPOILER entry (革命党,
# since_ch=7) demonstrates the since gate; 尼姑 is status=draft to exercise AMBIGUOUS-by-status.
GLOSSARY = [ GLOSSARY = [
{"src": "阿Q", "dst": "А-кью", "lemma_keys": ["а-кью"], "hyphen_pat": r"А[-\s]?кью", {"src": "阿Q", "dst": "А-кью", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"decl_forms": ["А-кью"], "aliases": [], "type": "name"}, "allow_short": False, "aliases": [], "type": "name",
{"src": "未庄", "dst": "Вэйчжуан", "lemma_keys": ["вэйчжуан"], "lemma_keys": ["а-кью"], "hyphen_pat": r"А[-\s]?кью", "decl_forms": ["А-кью"]},
"decl_forms": ["Вэйчжуане", "Вэйчжуан", "Вэйчжуана"], "aliases": [], "type": "place"}, {"src": "未庄", "dst": "Вэйчжуан", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
{"src": "赵太爷", "dst": "почтенный Чжао", "lemma_keys": ["чжао"], "allow_short": False, "aliases": [], "type": "place",
"decl_forms": ["почтенного Чжао", "почтенный Чжао", "Чжао"], "aliases": ["赵老太爷"], "type": "name"}, "lemma_keys": ["вэйчжуан"], "decl_forms": ["Вэйчжуане", "Вэйчжуан", "Вэйчжуана"]},
{"src": "王胡", "dst": "Бородатый Ван", "lemma_keys": ["ван"], {"src": "赵太爷", "dst": "почтенный Чжао", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"decl_forms": ["Бородатого Вана", "Бородатый Ван", "Бородатому Вану"], "aliases": ["王癞胡", "癞胡"], "type": "name"}, "allow_short": False, "aliases": ["赵老太爷"], "type": "name",
{"src": "小D", "dst": "Маленький Дэн", "lemma_keys": ["дэн"], "lemma_keys": ["чжао"], "decl_forms": ["почтенного Чжао", "почтенный Чжао", "Чжао"]},
"decl_forms": ["Маленького Дэна", "Маленький Дэн", "Дэна"], "aliases": [], "type": "name"}, {"src": "王胡", "dst": "Бородатый Ван", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
{"src": "假洋鬼子", "dst": "Поддельный заморский чёрт", "lemma_keys": ["заморский", "чёрт"], "allow_short": False, "aliases": ["王癞胡", "癞胡"], "type": "name",
"decl_forms": ["Поддельного заморского чёрта", "заморский чёрт"], "aliases": [], "type": "nickname"}, "lemma_keys": ["ван"], "decl_forms": ["Бородатого Вана", "Бородатый Ван", "Бородатому Вану"]},
{"src": "吴妈", "dst": "У-ма", "lemma_keys": ["у-ма"], "hyphen_pat": r"У[-\s]?ма", {"src": "小D", "dst": "Маленький Дэн", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"decl_forms": ["У-ма", "У-мы"], "aliases": [], "type": "name"}, "allow_short": False, "aliases": [], "type": "name",
{"src": "秀才", "dst": "сюцай", "lemma_keys": ["сюцай"], "lemma_keys": ["дэн"], "decl_forms": ["Маленького Дэна", "Маленький Дэн", "Дэна"]},
"decl_forms": ["сюцая", "сюцай", "сюцаю"], "aliases": [], "type": "title"}, {"src": "假洋鬼子", "dst": "Поддельный заморский чёрт", "status": "approved", "sense": "",
{"src": "尼姑", "dst": "монашка", "lemma_keys": ["монашка"], "since_ch": 0, "until_ch": 0, "allow_short": False, "aliases": [], "type": "nickname",
"decl_forms": ["монашку", "монашка", "монашки"], "aliases": [], "type": "term"}, "lemma_keys": ["заморский", "чёрт"], "decl_forms": ["Поддельного заморского чёрта", "заморский чёрт"]},
{"src": "吴妈", "dst": "У-ма", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": [], "type": "name",
"lemma_keys": ["у-ма"], "hyphen_pat": r"У[-\s]?ма", "decl_forms": ["У-ма", "У-мы"]},
{"src": "秀才", "dst": "сюцай", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": [], "type": "title",
"lemma_keys": ["сюцай"], "decl_forms": ["сюцая", "сюцай", "сюцаю"]},
{"src": "尼姑", "dst": "монашка", "status": "draft", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": False, "aliases": [], "type": "term",
"lemma_keys": ["монашка"], "decl_forms": ["монашку", "монашка", "монашки"]},
# SPOILER demo: only valid from chapter 7 (revolution). Injected earlier → hard reject. # SPOILER demo: only valid from chapter 7 (revolution). Injected earlier → hard reject.
{"src": "革命党", "dst": "революционеры", "lemma_keys": ["революционер"], {"src": "革命党", "dst": "революционеры", "status": "approved", "sense": "", "since_ch": 7, "until_ch": 0,
"decl_forms": ["революционеров", "революционеры"], "aliases": [], "type": "term", "since_ch": 7}, "allow_short": False, "aliases": [], "type": "term",
"lemma_keys": ["революционер"], "decl_forms": ["революционеров", "революционеры"]},
] ]
def eligible_keys(entry: dict) -> list[tuple[str, str]]: def eligible_keys(entry: dict) -> list[str]:
"""(normalized_key, raw) surfaces (src+aliases) passing the per-lang min-key ban.""" """Normalized source surfaces (src + aliases) passing the per-lang min-key ban (A3).
A record with empty dst is NOT matchable (renders nothing) mirror memory.go:167-173."""
if not str(entry.get("dst", "")).strip():
return []
out = [] out = []
for raw in [entry["src"]] + entry.get("aliases", []): for raw in [entry["src"]] + entry.get("aliases", []):
nk = norm_src(raw) nk = norm_src(raw)
if nk and significant_len(nk) >= min_key_len(nk): if nk and (significant_len(nk) >= min_key_len_for(nk) or entry.get("allow_short")):
out.append((nk, raw)) out.append(nk)
return out return out
def select(chunk: str, chapter: int, sticky_prev: set[str]) -> dict: def spoiler_blocked(entry: dict, chapter: int) -> bool:
"""Mirror of MemoryBank.Select: which entries inject, with disposition + spoiler rejects.""" """Mirror of memory.go spoilerBlocked: since_ch/until_ch window (0 = unbounded)."""
nchunk = norm_src(chunk) if entry.get("since_ch", 0) and chapter < entry["since_ch"]:
fired = {} # src → (matched_key, is_collision_prone) return True
for e in GLOSSARY: if entry.get("until_ch", 0) and chapter > entry["until_ch"]:
best = None return True
for nk, raw in eligible_keys(e): return False
if nk in nchunk and (best is None or len(nk) > len(best)):
best = nk
if best is not None:
fired[e["src"]] = best
# longest-match containment: drop a fired key fully inside a strictly-longer fired key
keys = {e["src"]: fired[e["src"]] for e in GLOSSARY if e["src"] in fired}
suppressed = set()
for s1, k1 in keys.items():
for s2, k2 in keys.items():
if s1 != s2 and k1 in k2 and len(k1) < len(k2):
suppressed.add(s1)
injected, rejected = [], []
active = set()
for e in GLOSSARY:
s = e["src"]
if s in fired and s not in suppressed:
if e.get("since_ch", 0) and chapter < e["since_ch"]:
rejected.append({"src": s, "reason": f"spoiler since_ch={e['since_ch']}>{chapter}"})
continue
disp = "ambiguous" if collision_prone(fired[s]) else "confirmed"
injected.append({**e, "_disp": disp, "_via": fired[s]})
active.add(s)
# sticky scene-inertia (depth handled by caller passing prior active set)
for e in GLOSSARY:
s = e["src"]
if s in sticky_prev and s not in active and not (e.get("since_ch", 0) and chapter < e["since_ch"]):
injected.append({**e, "_disp": "confirmed", "_via": "sticky"})
return {"injected": injected, "rejected": rejected, "active": active}
# --- prompt assembly per condition ----------------------------------------------- def glossary_line_tokens(entry: dict) -> int:
"""Mirror of memory.go glossaryLineTokens: cjk + other/3 over 'src → dst' (floor-free).
cjk bucket = full-plane Han + kana block + hangul (matches Go unicode.In(Han,Hiragana,
Katakana,Hangul); supplementary-plane Han now weighted ×1, not ÷3)."""
cjk = other = 0
for c in f"{entry['src']}{entry.get('dst','')}":
o = ord(c)
if _is_han(c) or (0x3040 <= o <= 0x30FF) or (0xAC00 <= o <= 0xD7A3):
cjk += 1
elif c.isspace():
pass
else:
other += 1
return cjk + other // 3
def _key_occurrences(ntext: str, key: str) -> list[tuple[int, int]]:
"""All [start,end) occurrences of key in ntext (rune indices == str indices in Python)."""
spans, i = [], ntext.find(key)
while i != -1:
spans.append((i, i + len(key)))
i = ntext.find(key, i + 1)
return spans
def select(chunk: str, chapter: int, sticky_prev: dict[str, str], budget_tokens: int = 0) -> dict:
"""Faithful mirror of MemoryBank.Select for ONE chunk: span-match → spoiler-gated
containment disposition sticky (inherited disposition) priority token budget.
sticky_prev: {id -> inherited disposition} from the prior stickyDepth chunks of this
chapter. Returns injected/rejected/evicted lists + active {id->disposition} for the next
chunk's sticky window."""
ntext = norm_src(chunk)
# 1. all key occurrences with owning entry indices
occ = [] # (start, end, key, entry_idx)
for ei, e in enumerate(GLOSSARY):
for k in eligible_keys(e):
for (s, t) in _key_occurrences(ntext, k):
occ.append((s, t, k, ei))
occ.sort(key=lambda m: (m[0], m[1], m[2]))
# 2. longest-match containment, suppressor gated on spoiler-VALIDITY (memory.go:591-617)
def valid_suppressor(m):
return not spoiler_blocked(GLOSSARY[m[3]], chapter)
kept = []
for i, m in enumerate(occ):
contained = False
for j, o in enumerate(occ):
if i == j:
continue
if o[0] <= m[0] and o[1] >= m[1] and (o[1]-o[0]) > (m[1]-m[0]) and valid_suppressor(o):
contained = True
break
if not contained:
kept.append(m)
# 3. surviving occ → entries, longest firing key per entry
matched_via: dict[int, str] = {}
for (s, t, k, ei) in kept:
if ei not in matched_via or len(k) > len(matched_via[ei]):
matched_via[ei] = k
injected_hits: dict[int, dict] = {} # entry_idx -> picked
rejected = []
active: dict[str, str] = {} # id -> disposition (exact matches, for next sticky)
for ei in range(len(GLOSSARY)):
via = matched_via.get(ei)
if via is None:
continue
e = GLOSSARY[ei]
eid = _eid(e)
if spoiler_blocked(e, chapter):
rejected.append({**e, "_via": via, "_disp": REJECT})
continue
disp = disposition_for(e["status"], via, e.get("allow_short", False))
injected_hits[ei] = {**e, "_via": via, "_disp": disp}
active[eid] = disp # exact-matched ids feed the next chunk's sticky (NOT sticky-carried)
# 4. sticky scene-inertia: carry prior-chunk exact matches not re-matched here, INHERITING
# their disposition (D16.2), unless the spoiler window blocks them (spoiler beats sticky).
hit_ids = {_eid(p) for p in injected_hits.values()}
for ei, e in enumerate(GLOSSARY):
eid = _eid(e)
if eid not in sticky_prev or eid in hit_ids or ei in injected_hits:
continue
if spoiler_blocked(e, chapter):
continue
injected_hits[ei] = {**e, "_via": "sticky", "_disp": sticky_prev[eid]}
# 5. priority order + F2 token budget/eviction (memory.go:342-368)
picked = list(injected_hits.values())
picked.sort(key=_priority_rank)
evicted = []
if budget_tokens > 0:
used, cut = 0, len(picked)
for i, p in enumerate(picked):
used += glossary_line_tokens(p)
if used > budget_tokens:
cut = i
break
picked, evicted = picked[:cut], picked[cut:]
return {"injected": picked, "rejected": rejected, "evicted": evicted, "active": active}
def _eid(e: dict) -> str:
return f'{e["src"]}\x1f{e.get("sense","")}\x1f{e.get("since_ch",0)}\x1f{e.get("until_ch",0)}'
def _priority_rank(p: dict) -> int:
rank = 0
if p["_disp"] != CONFIRMED:
rank |= 1 << 2
if p["_via"] == "sticky":
rank |= 1 << 1
if p["status"] != "approved":
rank |= 1 << 0
return rank
# --- prompt assembly per mode -----------------------------------------------------
SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент китайского " SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент китайского "
"произведения на русский язык. Сохрани все реплики и детали без пропусков; стиль — " "произведения на русский язык. Сохрани все реплики и детали без пропусков; стиль — "
"живой литературный русский. Выведи ТОЛЬКО перевод.") "живой литературный русский. Выведи ТОЛЬКО перевод — НЕ повторяй глоссарий и инструкции, "
"начни сразу с текста перевода.")
SYSTEM_HARDENED = SYSTEM + (" ВАЖНО: не выводи блок ГЛОССАРИЙ, строки вида «иероглиф → перевод», "
"заголовки или пояснения — только сам художественный перевод.")
GHEAD = "ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно):" GHEAD = "ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно):"
def gloss_block(entries: list[dict], full: bool = False) -> str: def gloss_block(entries: list[dict], full: bool = False) -> str:
@ -153,29 +349,143 @@ def gloss_block(entries: list[dict], full: bool = False) -> str:
lines = [] lines = []
for e in entries: for e in entries:
line = f"{e['src']}{e['dst']}" line = f"{e['src']}{e['dst']}"
if not full and e.get("_disp") == "ambiguous": if not full and e.get("_disp") == AMBIGUOUS:
line += " ⟨проверить⟩" line += " ⟨проверить⟩"
lines.append(line) lines.append(line)
return GHEAD + "\n" + "\n".join(lines) return GHEAD + "\n" + "\n".join(lines)
def build_prompt(chunk: str, condition: str, sel: dict, summary: str, shuffled: dict) -> str: def build_prompt(chunk: str, mode: str, sel: dict, summary: str, shuffled: dict) -> str:
if condition == "C0": if mode == "M0":
return "ФРАГМЕНТ:\n" + chunk return "ФРАГМЕНТ:\n" + chunk
if condition == "C1": # selective bank if mode == "M1": # selective bank
blk = gloss_block(sel["injected"]) blk = gloss_block(sel["injected"])
return (blk + "\n\n" if blk else "") + "ФРАГМЕНТ:\n" + chunk return (blk + "\n\n" if blk else "") + "ФРАГМЕНТ:\n" + chunk
if condition == "C2": # full glossary + sliding summary (long context) if mode == "M2": # full glossary + sliding summary (long context)
blk = gloss_block([{**e, "_disp": "confirmed"} for e in GLOSSARY if not e.get("since_ch")], full=True) blk = gloss_block([{**e, "_disp": CONFIRMED} for e in GLOSSARY
if not e.get("since_ch") and not e.get("until_ch")
and str(e.get("dst", "")).strip()], full=True)
pre = (f"КРАТКОЕ СОДЕРЖАНИЕ ПРЕДЫДУЩЕГО:\n{summary}\n\n" if summary else "") pre = (f"КРАТКОЕ СОДЕРЖАНИЕ ПРЕДЫДУЩЕГО:\n{summary}\n\n" if summary else "")
return pre + blk + "\n\nФРАГМЕНТ:\n" + chunk return pre + blk + "\n\nФРАГМЕНТ:\n" + chunk
if condition == "C3": # random/wrong glossary (adversarial): fired entries with SHUFFLED dst if mode == "M3": # random/wrong glossary (adversarial): fired entries with SHUFFLED dst
wrong = [{**e, "dst": shuffled[e["src"]], "_disp": "confirmed"} for e in sel["injected"] wrong = [{**e, "dst": shuffled[e["src"]], "_disp": CONFIRMED} for e in sel["injected"]
if e["_via"] != "sticky"] if e["_via"] != "sticky"]
blk = gloss_block(wrong, full=True) blk = gloss_block(wrong, full=True)
return (blk + "\n\n" if blk else "") + "ФРАГМЕНТ:\n" + chunk return (blk + "\n\n" if blk else "") + "ФРАГМЕНТ:\n" + chunk
raise ValueError(condition) raise ValueError(mode)
# --- chunking (reuse the coverage harness's chunker semantics) ------------------- # --- echo control (D13.5 / 07-review §4.5) ----------------------------------------
GLOSS_ECHO_MARKERS = ("ГЛОССАРИЙ", "глоссарий", "КАНОНИЧЕСКИЕ ПЕРЕВОД", "используй эти утверждённые",
"использовать последовательно", "используйте эти", "КРАТКОЕ СОДЕРЖАНИЕ",
"⟨проверить", "проверить⟩")
ARROW_RE = re.compile(r".+\s*(→|->|=>)\s*.+")
def detect_echo(raw: str) -> tuple[str, list[str]]:
"""Strip a leading glossary/instruction-echo preamble to the translation body AND report
contamination reasons. Preamble lines = a gloss marker, a 'src → dst' arrow line carrying
CJK, or blanks within the preamble region. Also flags source-echo (CJK leaked into the body,
the DeepSeek-style untranslated echo reuses refusal_bench.cjk_share)."""
lines = raw.split("\n")
body_start, saw_pre = 0, False
for i, ln in enumerate(lines):
s = ln.strip()
if not s:
if saw_pre:
body_start = i + 1
continue
is_marker = any(m in s for m in GLOSS_ECHO_MARKERS)
is_arrow_gloss = bool(ARROW_RE.match(s)) and bool(rb.CJK_RE.search(s))
if is_marker or is_arrow_gloss:
saw_pre, body_start = True, i + 1
continue
break # first prose line → body begins
body = "\n".join(lines[body_start:]).strip()
reasons = []
if saw_pre:
reasons.append("glossary_preamble_echo")
if not body: # whole output was preamble/echo → keep raw but flag hard
reasons.append("empty_after_strip")
body = raw.strip()
ces = rb.cjk_share(body)
if ces > 0.10:
reasons.append(f"source_echo(cjk={ces:.0%})")
return body, reasons
# --- fidelity judge (cross-family, D13.3) -----------------------------------------
FAMILY = {"deepseek": "deepseek", "grok": "xai", "gemini": "google", "gpt": "openai",
"glm": "zhipu", "kimi": "moonshot", "mistral": "mistral", "qwen": "alibaba"}
def family_of(model: str) -> str:
for k, v in FAMILY.items():
if k in model:
return v
return model
FIDELITY_SYSTEM = (
"Ты — строгий редактор-оценщик ВЕРНОСТИ художественного перевода. Тебе дают ИСХОДНЫЙ "
"фрагмент (китайский) и его ПЕРЕВОД на русский. Оцени ТОЛЬКО верность исходнику (не стиль): "
"искажения смысла (mistranslation), пропуски (omission), отсебятину (addition), особенно "
"НЕВЕРНО переданные имена/термины. Верни СТРОГО JSON без пояснений: "
'{"fidelity": <0-100>, "n_mistranslation": <int>, "n_omission": <int>, "n_addition": <int>, '
'"wrong_names": [<строки>], "notes": "<кратко>"}. fidelity=100 — идеально верно; ниже — за '
"каждое искажение/пропуск. Имена, переданные не как в источнике, — это mistranslation.")
def fidelity_judge(source: str, translation: str, judge_model: str, translator_model: str,
providers: dict) -> dict:
"""Cross-family fidelity judge vs the SOURCE (D13.3: judge family != translator family)."""
if family_of(judge_model) == family_of(translator_model):
raise ValueError(f"fidelity judge {judge_model} shares family with translator "
f"{translator_model} ({family_of(judge_model)}) — violates D13.3")
user = (f"ИСХОДНЫЙ ФРАГМЕНТ (китайский):\n{source}\n\n"
f"ПЕРЕВОД НА РУССКИЙ (оцени его верность):\n{translation}\n\nВерни JSON.")
p = providers[judge_model]
txt, err, usage = _retry_call({"name": judge_model, "model": p["model"], **p}, FIDELITY_SYSTEM, user, timeout=200)
if err or not txt:
return {"error": err or "empty", "usage": usage or {}}
m = re.search(r"\{.*\}", txt, re.S)
if not m:
return {"error": "no-json", "raw": txt[:300], "usage": usage or {}}
try:
out = json.loads(m.group(0))
except json.JSONDecodeError:
return {"error": "bad-json", "raw": txt[:300], "usage": usage or {}}
out["usage"] = usage or {}
out["judge_model"] = judge_model
return out
# --- providers (translator + judge) -----------------------------------------------
PROVIDERS = {
"deepseek-v4-flash": {"base_url": "https://api.deepseek.com/v1", "api_key_env": "DEEPSEEK_API_KEY",
"model": "deepseek-v4-flash", "max_tokens": 8000},
"grok-4.20-0309-non-reasoning": {"base_url": "https://api.x.ai/v1", "api_key_env": "XAI_API_KEY",
"model": "grok-4.20-0309-non-reasoning", "max_tokens": 8000, "temperature": 0.3},
"gemini-2.5-flash": {"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"api_key_env": "GEMINI_API_KEY", "model": "gemini-2.5-flash", "max_tokens": 8000,
"extra_body": {"extra_body": {"google": {"thinking_config": {"thinking_budget": 0}}}}},
"gpt-5-mini": {"base_url": "https://api.openai.com/v1", "api_key_env": "OPENAI_API_KEY",
"model": "gpt-5-mini", "reasoning_params": True, "max_tokens": 8000,
"extra_body": {"reasoning_effort": "minimal"}},
}
def _retry_call(p: dict, system: str, user: str, timeout: int = 200, attempts: int = 3):
"""rb.call_provider with backoff on TRANSIENT errors (503 overload / 429 rate / 5xx /
transport) so a provider spike does not punch a hole in the fidelity axis or a translation.
A content refusal / config error is NOT retried (it is a real signal)."""
last = (None, "unknown", {})
for i in range(attempts):
t, err, usage = rb.call_provider(p, system, user, timeout=timeout)
if not err or not re.match(r"(http\|(503|429|500|502|504))|transport\|", err or ""):
return t, err, usage
last = (t, err, usage)
time.sleep(2 * (i + 1))
return last
def translate(prompt: str, model: str, hardened: bool = False) -> tuple[str, dict]:
p = PROVIDERS[model]
sysmsg = SYSTEM_HARDENED if hardened else SYSTEM
t, err, usage = _retry_call({"name": model, **p}, sysmsg, prompt, timeout=200)
return (t or ""), (usage or {})
# --- chunking (reuse the coverage harness's chunker semantics) --------------------
def chunk_text(text: str, target=1200) -> list[str]: def chunk_text(text: str, target=1200) -> list[str]:
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks, buf = [], [] chunks, buf = [], []
@ -187,68 +497,205 @@ def chunk_text(text: str, target=1200) -> list[str]:
chunks.append("\n\n".join(buf)) chunks.append("\n\n".join(buf))
return chunks return chunks
def translate(chunk_prompt: str, model="deepseek-v4-flash") -> tuple[str, dict]: # --- self-test: mirror invariants without any API call ----------------------------
cfg = {"deepseek-v4-flash": {"base_url": "https://api.deepseek.com/v1", "api_key_env": "DEEPSEEK_API_KEY", "max_tokens": 8000}, def self_test() -> int:
"grok-4.20-0309-non-reasoning": {"base_url": "https://api.x.ai/v1", "api_key_env": "XAI_API_KEY", "max_tokens": 8000, "temperature": 0.3}}[model] fails = []
t, err, usage = rb.call_provider({"name": model, "model": model, **cfg}, SYSTEM, chunk_prompt, timeout=200) def check(cond, msg):
return (t or ""), (usage or {}) if not cond:
fails.append(msg)
# since gate: 革命党 rejected at ch6, injected at ch7
s6 = select("革命党来了,未庄震动。", 6, {})
check(any(r["src"] == "革命党" for r in s6["rejected"]), "since-gate: 革命党 not rejected at ch6")
s7 = select("革命党来了,未庄震动。", 7, {})
check(any(p["src"] == "革命党" for p in s7["injected"]), "since-gate: 革命党 not injected at ch7")
# until gate (synthetic): a term valid only until ch3 must be rejected at ch10
global GLOSSARY
saved = GLOSSARY
GLOSSARY = saved + [{"src": "旧党", "dst": "старая партия", "status": "approved", "sense": "",
"since_ch": 0, "until_ch": 3, "allow_short": False, "aliases": [], "type": "term",
"lemma_keys": ["партия"], "decl_forms": ["старой партии", "старая партия"]}]
check(spoiler_blocked(GLOSSARY[-1], 10), "until-gate: 旧党 not blocked at ch10")
check(not spoiler_blocked(GLOSSARY[-1], 2), "until-gate: 旧党 wrongly blocked at ch2")
GLOSSARY = saved
# collision downgrade: a short phonetic key (allow_short off) → AMBIGUOUS even if approved
check(disposition_for("approved", "ai", False) == AMBIGUOUS, "collision: 'ai' not downgraded")
check(disposition_for("approved", "мерос", False) == CONFIRMED, "collision: 5-char key wrongly downgraded")
check(disposition_for("approved", "リン", False) == AMBIGUOUS, "collision: 2-kana key not downgraded")
# supplementary-plane Han is Han-anchored (floor 2, CONFIRMED), not phonetic — review delta #2
check(any_han("\U00020000") and not collision_prone("\U00020000\U00020001"),
"supplementary-plane Han not treated as Han")
# significant_len counts letters only (kana middle-dot U+30FB excluded) — review delta #1
check(significant_len("リ・ン") == 2, "significant_len counts kana middle-dot U+30FB")
# sticky depth-2 + disposition inheritance: a draft term inherits AMBIGUOUS across a
# pronominal chunk, then falls out after depth 2.
win = []
def union(w): # mirror unionSticky
out = {}
for d in w:
out.update(d)
return out
c0 = select("这尼姑是谁。", 6, union(win)); win = (win + [c0["active"]])[-STICKY_DEPTH:]
check(any(p["src"] == "尼姑" and p["_disp"] == AMBIGUOUS for p in c0["injected"]),
"sticky: 尼姑(draft) not AMBIGUOUS on exact match")
c1 = select("他走了。", 6, union(win)); win = (win + [c1["active"]])[-STICKY_DEPTH:]
sk = [p for p in c1["injected"] if p["src"] == "尼姑"]
check(sk and sk[0]["_via"] == "sticky" and sk[0]["_disp"] == AMBIGUOUS,
"sticky: 尼姑 not carried with INHERITED ambiguous disposition (D16.2)")
# depth-2 window: a term exact-matched at c0 is carried across c1 AND c2 (the window holds the
# last stickyDepth active sets), then falls out at c3 — mirror of runner.go unionSticky/trim.
c2 = select("又走了。", 6, union(win)); win = (win + [c2["active"]])[-STICKY_DEPTH:]
check(any(p["src"] == "尼姑" and p["_via"] == "sticky" for p in c2["injected"]),
"sticky: 尼姑 should still be carried at depth 2 (c2)")
c3 = select("再走了。", 6, union(win)); win = (win + [c3["active"]])[-STICKY_DEPTH:]
check(not any(p["src"] == "尼姑" for p in c3["injected"]),
"sticky: 尼姑 should fall out after the depth-2 window (c3)")
# spoiler-gated containment (synthetic): a spoiler-blocked longer key must NOT suppress a
# valid shorter nested name.
GLOSSARY = saved + [
{"src": "", "dst": "Линь", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0,
"allow_short": True, "aliases": [], "type": "name", "lemma_keys": ["линь"], "decl_forms": ["Линь"]},
{"src": "林动的父亲", "dst": "отец Линь Дуна", "status": "approved", "sense": "", "since_ch": 0,
"until_ch": 3, "allow_short": False, "aliases": [], "type": "name",
"lemma_keys": ["отец"], "decl_forms": ["отца Линь Дуна"]}]
sc = select("林动的父亲来了。", 10, {})
check(any(p["src"] == "" for p in sc["injected"]),
"containment: 林 suppressed by spoiler-blocked longer key at ch10")
GLOSSARY = saved
# token budget eviction: tiny budget keeps only the highest-priority record
sb = select("阿Q和未庄和王胡和秀才。", 6, {}, budget_tokens=3)
check(len(sb["evicted"]) > 0 and len(sb["injected"]) >= 1, "budget: no eviction under tiny budget")
print("SELF-TEST:", "OK" if not fails else "FAIL")
for f in fails:
print("", f)
return 1 if fails else 0
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--src", default=str(SAMPLES / "zh" / "luxun-ah-q-ch5-9.txt")) ap.add_argument("--src", default=str(SAMPLES / "zh" / "luxun-ah-q-ch5-9.txt"))
ap.add_argument("--chapter", type=int, default=6, help="chapter number for the spoiler gate") ap.add_argument("--chapter", type=int, default=6, help="chapter number for the spoiler gate "
ap.add_argument("--conditions", default="C0,C1,C2,C3") "(single-chapter indicative: no per-chunk chapter-reset; the deciding run "
ap.add_argument("--model", default="grok-4.20-0309-non-reasoning") "feeds real multi-chapter books, where the sticky window resets per chapter)")
ap.add_argument("--modes", default="M0,M1,M2,M3")
ap.add_argument("--model", default="grok-4.20-0309-non-reasoning", help="translator/draft model")
ap.add_argument("--judge", default="gemini-2.5-flash", help="cross-family fidelity judge (D13.3)")
ap.add_argument("--no-fidelity", action="store_true", help="skip the LLM fidelity axis")
ap.add_argument("--budget", type=int, default=0, help="glossary token budget (0 = unbounded)")
ap.add_argument("--regen-on-echo", action="store_true", help="re-ask once with a hardened prompt on echo")
ap.add_argument("--max-chunks", type=int, default=5) ap.add_argument("--max-chunks", type=int, default=5)
ap.add_argument("--dry-run", action="store_true") ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--out", default=str(OUTDIR / "memory_eval.json")) ap.add_argument("--self-test", action="store_true")
ap.add_argument("--out", default="")
args = ap.parse_args() args = ap.parse_args()
if args.self_test:
sys.exit(self_test())
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out_path = Path(args.out) if args.out else OUTDIR / f"memory_eval_M_{stamp}.json"
raw_dir = OUTDIR / f"raw_{stamp}"
text = Path(args.src).read_text(encoding="utf-8") text = Path(args.src).read_text(encoding="utf-8")
chunks = chunk_text(text)[:args.max_chunks] chunks = chunk_text(text)[:args.max_chunks]
conditions = args.conditions.split(",") modes = args.modes.split(",")
# deterministic wrong-dst shuffle for C3 (rotate dst among present names) # deterministic wrong-dst shuffle for M3 (rotate dst among present names/places)
names = [e for e in GLOSSARY if e["type"] in ("name", "place")] names = [e for e in GLOSSARY if e["type"] in ("name", "place")]
rot = {names[i]["src"]: names[(i + 1) % len(names)]["dst"] for i in range(len(names))} rot = {names[i]["src"]: names[(i + 1) % len(names)]["dst"] for i in range(len(names))}
shuffled = {e["src"]: rot.get(e["src"], "НЕВЕРНО") for e in GLOSSARY} shuffled = {e["src"]: rot.get(e["src"], "НЕВЕРНО") for e in GLOSSARY}
print(f"src={Path(args.src).name} chapter={args.chapter} chunks={len(chunks)} " print(f"src={Path(args.src).name} chapter={args.chapter} chunks={len(chunks)} model={args.model} "
f"model={args.model} conditions={conditions}", file=sys.stderr) f"judge={args.judge} modes={modes} budget={args.budget}", file=sys.stderr)
rows, sticky_prev = [], set() if not args.dry_run and not args.no_fidelity:
if family_of(args.judge) == family_of(args.model):
sys.exit(f"D13.3: judge {args.judge} shares family with translator {args.model}")
raw_dir.mkdir(parents=True, exist_ok=True)
rows, sticky_win = [], [] # sticky_win: list of {id->disp}, capped at STICKY_DEPTH
def union_sticky():
out = {}
for d in sticky_win:
out.update(d)
return out
for ci, ch in enumerate(chunks): for ci, ch in enumerate(chunks):
sel = select(ch, args.chapter, sticky_prev) sel = select(ch, args.chapter, union_sticky(), args.budget)
present = {e["src"]: e for e in sel["injected"] if e["_via"] != "sticky"} present = {e["src"]: e for e in sel["injected"] if e["_via"] != "sticky"}
row = {"chunk": ci, "src_chars": len(ch), "n_injected": len(sel["injected"]), row = {"chunk": ci, "src_chars": len(ch),
"injected_srcs": [e["src"] for e in sel["injected"]], "injected": [{"src": e["src"], "disp": e["_disp"], "via": e["_via"]} for e in sel["injected"]],
"dispositions": {e["src"]: e["_disp"] for e in sel["injected"]}, "rejected_spoiler": [{"src": r["src"]} for r in sel["rejected"]],
"rejected_spoiler": sel["rejected"], "present_for_consistency": list(present)} "evicted": [{"src": e["src"]} for e in sel["evicted"]],
"present_for_consistency": list(present)}
if not args.dry_run: if not args.dry_run:
row["by_condition"] = {} row["by_mode"] = {}
for cond in conditions: for mode in modes:
prompt = build_prompt(ch, cond, sel, summary="", shuffled=shuffled) prompt = build_prompt(ch, mode, sel, summary="", shuffled=shuffled)
out, usage = translate(prompt, args.model) raw, usage = translate(prompt, args.model)
cons = dm.consistency(out, {s: {"dst": e["dst"], "lemma_keys": e.get("lemma_keys", []), body, echo = detect_echo(raw)
regen = None
if echo and args.regen_on_echo:
raw2, usage2 = translate(prompt, args.model, hardened=True)
body2, echo2 = detect_echo(raw2)
regen = {"raw": raw2, "echo": echo2, "usage": usage2}
if not echo2: # clean on retry → use it
body, echo, usage, raw = body2, echo2, usage2, raw2
cons = dm.consistency(body, {s: {"dst": e["dst"], "lemma_keys": e.get("lemma_keys", []),
"hyphen_pat": e.get("hyphen_pat"), "decl_forms": e.get("decl_forms", [])} "hyphen_pat": e.get("hyphen_pat"), "decl_forms": e.get("decl_forms", [])}
for s, e in present.items()}) if present else None for s, e in present.items()}) if present else None
row["by_condition"][cond] = { fid = None
if not args.no_fidelity:
fid = fidelity_judge(ch, body, args.judge, args.model, PROVIDERS)
# persist FULL raw output for post-hoc audit (never a preview)
(raw_dir / f"chunk{ci}_{mode}.txt").write_text(
f"# model={args.model} chunk={ci} mode={mode} chapter={args.chapter} echo={echo}\n"
f"# usage={usage}\n\n{raw}", encoding="utf-8")
row["by_mode"][mode] = {
"echo_contaminated": echo or None,
"consistency_pymorphy": cons["pymorphy"]["score"] if cons else None, "consistency_pymorphy": cons["pymorphy"]["score"] if cons else None,
"consistency_stored_decl": cons["stored_decl"]["score"] if cons else None, "consistency_stored_decl": cons["stored_decl"]["score"] if cons else None,
"missed_pymorphy": cons["pymorphy"]["missed"] if cons else [], "missed_pymorphy": cons["pymorphy"]["missed"] if cons else [],
"missed_stored_decl": cons["stored_decl"]["missed"] if cons else [],
"fidelity": fid,
"in_tok": usage.get("prompt_tokens"), "out_tok": usage.get("completion_tokens"), "in_tok": usage.get("prompt_tokens"), "out_tok": usage.get("completion_tokens"),
"out_preview": out[:160]} "out_full": raw, "body_scored": body, "regen": regen}
print(f" chunk{ci} {cond}: consistency(pymorphy)=" fscore = (fid or {}).get("fidelity") if isinstance(fid, dict) else None
f"{row['by_condition'][cond]['consistency_pymorphy']} " print(f" chunk{ci} {mode}: cons(pym)={row['by_mode'][mode]['consistency_pymorphy']} "
f"cons(decl)={row['by_mode'][mode]['consistency_stored_decl']} "
f"fidelity={fscore} echo={echo or '-'} "
f"in={usage.get('prompt_tokens')} out={usage.get('completion_tokens')}", file=sys.stderr) f"in={usage.get('prompt_tokens')} out={usage.get('completion_tokens')}", file=sys.stderr)
sticky_prev = sel["active"] sticky_win = (sticky_win + [sel["active"]])[-STICKY_DEPTH:]
rows.append(row) rows.append(row)
Path(args.out).write_text(json.dumps({"config": vars(args), "rows": rows}, ensure_ascii=False, indent=2)) meta = {"run_utc": stamp, "src": args.src, "chapter": args.chapter, "modes": modes,
print(f"\nwrote {args.out}", file=sys.stderr) "translator_model": args.model, "judge_model": None if args.no_fidelity else args.judge,
# dry-run: show the selection mechanism (spoiler reject, disposition, sticky) "budget_tokens": args.budget, "regen_on_echo": args.regen_on_echo,
"trad2simp_entries": len(TRAD2SIMP), "sticky_depth": STICKY_DEPTH,
"note": "M0-M3 = terminology modes (exp09 §6); mirror synced with memory.go D13.5; "
"sticky-disposition mirrors the D16.2 fix (Go v3, landing 2026-07-09)."}
out_path.write_text(json.dumps({"meta": meta, "rows": rows}, ensure_ascii=False, indent=2))
print(f"\nwrote {out_path}", file=sys.stderr)
if not args.dry_run:
print(f"raw outputs: {raw_dir}", file=sys.stderr)
_print_aggregate(rows, modes)
if args.dry_run: if args.dry_run:
for r in rows: for r in rows:
print(f"chunk{r['chunk']} inj={r['injected_srcs']} disp={r['dispositions']} " print(f"chunk{r['chunk']} inj={[(e['src'], e['disp'], e['via']) for e in r['injected']]} "
f"spoiler_rej={r['rejected_spoiler']}") f"spoiler_rej={[e['src'] for e in r['rejected_spoiler']]} evicted={[e['src'] for e in r['evicted']]}")
def _print_aggregate(rows, modes):
"""Headline aggregate with clean (echo-excluded) vs all points, per mode."""
print("\n=== AGGREGATE (mean over chunks; clean = echo-contaminated points excluded) ===", file=sys.stderr)
for mode in modes:
pts = [r["by_mode"][mode] for r in rows if "by_mode" in r]
clean = [p for p in pts if not p["echo_contaminated"]]
def mean(seq, key, src=pts):
vals = [p[key] for p in src if isinstance(p.get(key), (int, float))]
return round(sum(vals) / len(vals), 3) if vals else None
def fmean(src):
vals = [p["fidelity"]["fidelity"] for p in src
if isinstance(p.get("fidelity"), dict) and isinstance(p["fidelity"].get("fidelity"), (int, float))]
return round(sum(vals) / len(vals), 1) if vals else None
n_echo = sum(1 for p in pts if p["echo_contaminated"])
print(f" {mode}: cons(decl) all={mean(pts,'consistency_stored_decl')} clean={mean(clean,'consistency_stored_decl',clean)} "
f"| fidelity all={fmean(pts)} clean={fmean(clean)} | echo_pts={n_echo}/{len(pts)}", file=sys.stderr)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

563
eval/pilot/trad2simp.txt Normal file
View file

@ -0,0 +1,563 @@
# Traditional → Simplified Chinese character map (A4 normalization, memory bank v2).
#
# Format: one mapping per line, "<trad> <simp>" (whitespace-separated, single rune
# each). '#' comments and blank lines are ignored. Loaded once via go:embed and
# applied character-by-character in memnorm.go, SYMMETRICALLY to glossary keys and
# to chunk text — so a key written in Simplified still matches a Traditional source
# surface (and vice versa), closing the "тихо пусто" orthography hole (registry A4).
#
# PROVENANCE / COMPLETENESS (read before trusting): this is a curated HIGH-FREQUENCY
# set, NOT the full OpenCC TSCharacters table. It was authored offline (no OpenCC/
# Unihan data available on the stand) with accuracy prioritized over size — a WRONG
# trad→simp mapping silently corrupts matching, so only high-confidence single-char
# mappings are included; phrase-level OpenCC (one-to-many, e.g. 後→后 vs 后→后) is
# deliberately out of scope for v1. The table's content is versioned by
# memoryNormVersion (memnorm.go), folded into the snapshot via memoryVersion() — so
# COMPLETING it with the full OpenCC data is a data-file swap that fires a loud
# --resnapshot, never a silent behaviour change. The acceptance book is ja→ru, where
# trad→simp is not load-bearing (NFKC + kana-fold + ruby carry ja); this table matters
# for zh sources and is the one tracked data-completion item before a zh acceptance run.
# Known live-bug cases from research/14 (爺→爷 etc.) and every retrieval_bench trap
# character are covered and asserted in memnorm_test.go.
# --- referenced by the eval specs / research/14 live bugs (must stay covered) ---
魯 鲁
鎮 镇
趙 赵
蕭 萧
煉 炼
門 门
闆 板
莊 庄
錢 钱
陳 陈
萬 万
舊 旧
臉 脸
爺 爷
羅 罗
莊 庄
# --- 訁 (speech) radical ---
說 说
話 话
語 语
讀 读
誰 谁
課 课
談 谈
請 请
謝 谢
詩 诗
詞 词
試 试
記 记
訪 访
許 许
論 论
訴 诉
譯 译
議 议
護 护
譽 誉
讚 赞
變 变
讓 让
認 认
識 识
訊 讯
訂 订
計 计
訓 训
設 设
詳 详
誠 诚
語 语
誤 误
說 说
誦 诵
調 调
諒 谅
談 谈
謎 谜
講 讲
謙 谦
謠 谣
證 证
評 评
# --- 貝 (shell/money) radical ---
貝 贝
財 财
貨 货
貧 贫
貪 贪
責 责
貫 贯
貴 贵
買 买
費 费
貼 贴
賀 贺
貿 贸
資 资
賊 贼
賓 宾
賦 赋
賠 赔
賣 卖
賤 贱
賬 账
賭 赌
賴 赖
購 购
賽 赛
贈 赠
贏 赢
賺 赚
賞 赏
賢 贤
質 质
贖 赎
貸 贷
賃 赁
賄 贿
賂 赂
# --- 車 (vehicle) radical ---
車 车
輪 轮
軍 军
較 较
輕 轻
轉 转
輸 输
輩 辈
輝 辉
軟 软
軌 轨
轟 轰
輔 辅
輛 辆
載 载
轎 轿
# --- 門 (gate) radical ---
開 开
閉 闭
問 问
間 间
閃 闪
閒 闲
閑 闲
閣 阁
閩 闽
閱 阅
闊 阔
關 关
闖 闯
闕 阙
悶 闷
聞 闻
# --- 食 (food) radical ---
飛 飞
風 风
飯 饭
飲 饮
餓 饿
館 馆
養 养
餘 余
餅 饼
饑 饥
饞 馋
飄 飘
# --- 金 (metal) radical ---
鐘 钟
鐵 铁
銀 银
銅 铜
鋼 钢
錯 错
鎖 锁
鏡 镜
鑰 钥
鑽 钻
鍋 锅
鍵 键
錄 录
針 针
釘 钉
鈴 铃
鉛 铅
銷 销
鋒 锋
鏈 链
鑼 锣
鈔 钞
鑲 镶
# --- 頁 (head/page) radical ---
頁 页
頂 顶
項 项
順 顺
須 须
預 预
頑 顽
頓 顿
頗 颇
領 领
頸 颈
頻 频
顆 颗
題 题
額 额
顏 颜
願 愿
類 类
顧 顾
顯 显
顫 颤
# --- 馬 (horse) radical ---
馬 马
駕 驾
駛 驶
駐 驻
駝 驼
騎 骑
騙 骗
驕 骄
驗 验
驚 惊
驢 驴
駭 骇
駱 骆
騰 腾
# --- 鳥 (bird) radical ---
鳥 鸟
鴨 鸭
鵝 鹅
鴻 鸿
鵬 鹏
鶴 鹤
鷹 鹰
鷗 鸥
鴉 鸦
鳴 鸣
鴿 鸽
# --- 魚 (fish) radical ---
魚 鱼
鮮 鲜
鯨 鲸
鯉 鲤
鱗 鳞
# --- 糸 (silk/thread) radical ---
紀 纪
約 约
紅 红
紙 纸
級 级
納 纳
紛 纷
純 纯
紗 纱
綱 纲
網 网
綿 绵
線 线
練 练
組 组
細 细
終 终
結 结
絕 绝
給 给
絡 络
統 统
綠 绿
維 维
綜 综
緊 紧
緒 绪
編 编
緩 缓
締 缔
緣 缘
縣 县
縫 缝
縮 缩
績 绩
繩 绳
繫 系
繪 绘
繼 继
續 续
纏 缠
紋 纹
綁 绑
繳 缴
經 经
絲 丝
繡 绣
# --- water / fire / misc very-high-frequency ---
漢 汉
灣 湾
濟 济
潔 洁
澤 泽
濕 湿
灑 洒
滅 灭
溫 温
準 准
淚 泪
減 减
滾 滚
滿 满
漁 渔
漸 渐
潑 泼
濁 浊
瀉 泻
決 决
沒 没
沖 冲
況 况
淨 净
涼 凉
凍 冻
熱 热
煩 烦
燒 烧
燈 灯
燦 灿
爛 烂
營 营
爐 炉
煙 烟
熾 炽
燭 烛
氣 气
氫 氢
# --- 犭 (animal) radical ---
獨 独
獲 获
獸 兽
獻 献
獄 狱
猶 犹
狹 狭
獅 狮
獵 猎
豬 猪
# --- 阝 (mound/city) radical ---
陰 阴
陽 阳
階 阶
隊 队
隨 随
險 险
隱 隐
際 际
陸 陆
陝 陕
陣 阵
鄉 乡
鄰 邻
鄭 郑
鄧 邓
# --- 木 (wood) radical ---
條 条
極 极
樓 楼
標 标
樣 样
樹 树
橋 桥
機 机
檢 检
櫃 柜
欄 栏
權 权
檔 档
樸 朴
橫 横
檻 槛
櫥 橱
楊 杨
榮 荣
構 构
槍 枪
樑 梁
櫻 樱
樂 乐
業 业
東 东
# --- structural / very common standalone ---
國 国
學 学
會 会
對 对
時 时
見 见
這 这
們 们
個 个
來 来
為 为
麼 么
頭 头
過 过
還 还
進 进
種 种
應 应
關 关
點 点
動 动
於 于
實 实
發 发
現 现
長 长
兒 儿
東 东
與 与
舉 举
興 兴
覺 觉
觀 观
歡 欢
勸 劝
農 农
辦 办
務 务
勞 劳
勢 势
勝 胜
勵 励
單 单
嚴 严
嘗 尝
嘆 叹
嚇 吓
嚮 向
圖 图
團 团
圓 圆
園 园
圍 围
囑 嘱
專 专
將 将
尋 寻
導 导
屬 属
層 层
屢 屡
廣 广
廠 厂
廢 废
廟 庙
廚 厨
廈 厦
廳 厅
華 华
葉 叶
蓋 盖
蒼 苍
薦 荐
薩 萨
藍 蓝
藝 艺
藥 药
蘇 苏
蘭 兰
蘋 苹
蟲 虫
傷 伤
傳 传
債 债
傾 倾
僅 仅
僕 仆
僑 侨
價 价
儀 仪
億 亿
儉 俭
儲 储
償 偿
優 优
側 侧
偉 伟
偵 侦
師 师
帥 帅
幣 币
帳 帐
幫 帮
歲 岁
歸 归
歷 历
曆 历
歐 欧
殺 杀
毀 毁
愛 爱
節 节
範 范
築 筑
簡 简
籃 篮
籠 笼
籤 签
篤 笃
簾 帘
籌 筹
醫 医
產 产
麗 丽
麵 面
麥 麦
黃 黄
黨 党
龍 龙
鳳 凤
龜 龟
齒 齿
齊 齐
書 书
畫 画
邊 边
飛 飞
決 决
擊 击
擔 担
據 据
擁 拥
撲 扑
擴 扩
攔 拦
掃 扫
撿 捡
擋 挡
掛 挂
換 换
損 损
搶 抢
攜 携
擾 扰
撐 撑

View file

@ -70,6 +70,17 @@
"extra_body": { "reasoning_effort": "minimal" }, "extra_body": { "reasoning_effort": "minimal" },
"_comment": "reasoning-модель: max_completion_tokens вместо max_tokens, БЕЗ temperature, reasoning_effort=minimal (иначе выжигает бюджет до пустого). Замер transformation exception (gap-2 §1)." "_comment": "reasoning-модель: max_completion_tokens вместо max_tokens, БЕЗ temperature, reasoning_effort=minimal (иначе выжигает бюджет до пустого). Замер transformation exception (gap-2 §1)."
}, },
{
"name": "mistral",
"base_url": "https://api.mistral.ai/v1",
"model": "mistral-large-2512",
"api_key_env": "MISTRAL_API_KEY",
"rps_delay": 0.5,
"temperature": 0.3,
"max_tokens": 8000,
"extra_body": { "safe_prompt": false },
"_comment": "Канал-B фолбэк-кандидат (D14.2): Mistral Usage Policy eff.2026-06-11 без бланкетного запрета adult-текста (только CSAM/NCII). Слаг сверен вживую /models 2026-07-09 — реальный дейтед-слаг mistral-large-2512 (large-latest→2512). OpenAI-совместимый /v1/chat/completions, Bearer MISTRAL_API_KEY. safe_prompt=false: дефолт уже false + депрекейтед, но ставим явно (пре-пендит safety-промпт при true — вреден каналу B). Large 3 НЕ reasoning-модель → reasoning_effort НЕ ставим (риск 400); reasoning-линия = magistral-medium/small-2509. Эхо-мины НЕТ (проба zh→ru чисто, cjk_share=0). Refusal-срез SFW/L1/L2-violence 11/11 ok (exp02). Цена ~$0.50/$1.50 за 1M (API pricing 07-09; маркетинг-страница показывает старые $2/$6). temperature ≤0.7."
},
{ {
"name": "openrouter-deepseek", "name": "openrouter-deepseek",
"base_url": "https://openrouter.ai/api/v1", "base_url": "https://openrouter.ai/api/v1",

192
eval/webnovel_slice.py Normal file
View file

@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Вебновелл-срез — ОДНА команда, запускается по появлению корпуса владельца (POLYGON handoff
Task 6). Пока корпуса нет печатает, куда класть файлы, и выходит 0 (не падает).
ЗАЧЕМ (вся эмпирика полигона снята на КЛАССИКЕ из претрейна exp02 правило #2; вебновелл-режим
не измерен нигде, 07-review §4.5 / V1.6). Этот срез добирает три вещи ИМЕННО на невиданном тексте:
A. r-коэффициенты токенов (exp01/08) держатся ли множители токенов/символ на вебновелле
(COGS-модель exp08 висит на них). Через token_calc.py на корпусе среза.
B. частота DeepSeek-«эха» ВНЕ претрейна (exp02: на PD-классике deepseek эхал 13/24 zh-чанков
это претрейн-канон; на невиданном тексте частота ДРУГАЯ и решает, насколько эхо-мина реальна).
C. precision coverage-гейта на ТЕРСНЫХ репликах (exp07: 0 FP/57 на классике; терсный диалог
короткие реплики самый шумный вход для sent_cov/len_ratio гейтит снятие 1-флаг-толерантности).
КОРПУС: положи 35 глав реальных вебновелл в eval/data/samples/webnovel/<lang>/*.txt
(lang zh|ja|en; .txt UTF-8; главы разделены пустой строкой или по файлам). Затем:
eval/.venv/bin/python eval/webnovel_slice.py # прогонит A+B+C
eval/.venv/bin/python eval/webnovel_slice.py --dry-run # план без вызовов API
"""
from __future__ import annotations
import argparse, json, subprocess, sys, time
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import refusal_bench as rb
ROOT = Path(__file__).resolve().parent
CORPUS = ROOT / "data" / "samples" / "webnovel" # <lang>/*.txt
OUT = ROOT / "data" / "webnovel_slice"
SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент на русский язык. "
"Сохрани все реплики и детали без пропусков; стиль — живой литературный русский. "
"Выведи ТОЛЬКО перевод.")
# out/in char-ratio corridor by source lang (exp02 EXPECT_LEN_RATIO) — reused for the gate.
EXPECT = rb.EXPECT_LEN_RATIO
def discover() -> list[tuple[str, Path]]:
"""(lang, file) for every webnovel/<lang>/*.txt."""
out = []
if not CORPUS.exists():
return out
for lang_dir in sorted(CORPUS.iterdir()):
if lang_dir.is_dir():
for f in sorted(lang_dir.glob("*.txt")):
out.append((lang_dir.name, f))
return out
def chunk_text(text: str, target=1200) -> list[str]:
import re
paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
chunks, buf = [], []
for p in paras:
if buf and len("".join(buf)) + len(p) > target:
chunks.append("\n\n".join(buf)); buf = []
buf.append(p)
if buf:
chunks.append("\n\n".join(buf))
return chunks
def dialogue_density(chunk: str) -> float:
"""Fraction of lines that are quoted/terse dialogue (— … or 「」『』“”«» openers). Terse
dialogue is the noisy input for the coverage gate (exp07 §просьба)."""
lines = [l.strip() for l in chunk.splitlines() if l.strip()]
if not lines:
return 0.0
d = sum(1 for l in lines if l[:1] in "—–「『“«\"'" or l.startswith("- "))
return round(d / len(lines), 2)
def provider(name: str) -> dict:
provs = json.loads((ROOT / "providers.json").read_text())["providers"]
for p in provs:
if p["name"] == name:
return p
raise SystemExit(f"provider {name} not in providers.json")
def run_r_coefficients() -> dict:
"""A. token r-multipliers on the slice via token_calc.py (subprocess; source-only)."""
try:
r = subprocess.run([sys.executable, str(ROOT / "token_calc.py"),
"--samples-dir", str(CORPUS),
"--json-out", str(OUT / "token_calc_webnovel.json")],
capture_output=True, text=True, timeout=600)
return {"stdout_tail": r.stdout[-2000:], "rc": r.returncode,
"json": str(OUT / "token_calc_webnovel.json")}
except Exception as e:
return {"error": f"{type(e).__name__}: {e}"}
def run_echo_and_gate(files: list[tuple[str, Path]], translator: str, limit: int) -> dict:
"""B + C in one pass: translate each chunk once with `translator`, classify (echo / excision),
correlate excision flags with dialogue density. Resumable (per-chunk jsonl)."""
p = provider(translator)
res_path = OUT / f"{translator}_chunks.jsonl"
done = set()
if res_path.exists():
done = {json.loads(l)["id"] for l in res_path.read_text().splitlines() if l.strip()}
agg = {"n": 0, "echo": 0, "excision": 0, "ok": 0, "refusal": 0, "by_lang": {},
"excision_by_density": {"terse(>=0.5)": [0, 0], "prose(<0.5)": [0, 0]}}
with res_path.open("a") as fh:
for lang, f in files:
chunks = chunk_text(f.read_text(encoding="utf-8"))[:limit]
for ci, ch in enumerate(chunks):
cid = f"{f.stem}#{ci}"
if cid in done:
continue
t0 = time.time()
text, err, usage = rb.call_provider(p, SYSTEM, ch)
verdict = rb.classify_output(ch, text, err, EXPECT.get(lang, (0.5, 3.0)))
dens = dialogue_density(ch)
rec = {"id": cid, "lang": lang, "translator": translator, "provider_model": p["model"],
"dialogue_density": dens, "elapsed_s": round(time.time()-t0, 1),
"usage": usage, **verdict, "src_chars": len(ch), "output": text}
fh.write(json.dumps(rec, ensure_ascii=False) + "\n"); fh.flush()
time.sleep(p.get("rps_delay", 0.5))
# aggregate from the full jsonl (resumed + fresh)
for l in res_path.read_text().splitlines():
if not l.strip():
continue
r = json.loads(l)
agg["n"] += 1
lang = r["lang"]
bl = agg["by_lang"].setdefault(lang, {"n": 0, "echo": 0, "excision": 0, "ok": 0})
bl["n"] += 1
v = r["verdict"]
if v == "untranslated_echo":
agg["echo"] += 1; bl["echo"] += 1
elif v == "excision_suspect":
agg["excision"] += 1; bl["excision"] += 1
elif v == "ok":
agg["ok"] += 1; bl["ok"] += 1
elif v == "content_refusal":
agg["refusal"] += 1
bucket = "terse(>=0.5)" if r.get("dialogue_density", 0) >= 0.5 else "prose(<0.5)"
agg["excision_by_density"][bucket][1] += 1
if v == "excision_suspect":
agg["excision_by_density"][bucket][0] += 1
return agg
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--translator", default="deepseek", help="эхо-мина измеряется на deepseek (exp02)")
ap.add_argument("--limit", type=int, default=8, help="max chunks per file")
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
files = discover()
if not files:
print("ВЕБНОВЕЛЛ-СРЕЗ ЗАБЛОКИРОВАН: корпуса нет.\n"
f"Положи 35 глав реальных вебновелл в: {CORPUS}/<lang>/*.txt\n"
" (lang ∈ zh|ja|en; UTF-8; например eval/data/samples/webnovel/zh/novel-ch1.txt)\n"
"Затем повтори: eval/.venv/bin/python eval/webnovel_slice.py\n"
"Прогонит: A r-коэффициенты токенов · B частоту DeepSeek-эха вне претрейна · "
"C precision coverage-гейта на терсных репликах.", file=sys.stderr)
return
OUT.mkdir(parents=True, exist_ok=True)
print(f"Корпус: {len(files)} файлов — {[str(f.relative_to(CORPUS)) for _, f in files]}", file=sys.stderr)
if args.dry_run:
for lang, f in files:
n = len(chunk_text(f.read_text(encoding='utf-8'))[:args.limit])
print(f" {lang}/{f.name}: {n} чанков → translate({args.translator})+classify", file=sys.stderr)
print(f" + token_calc.py --samples-dir {CORPUS}", file=sys.stderr)
return
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
print("A. r-коэффициенты токенов…", file=sys.stderr)
r_coef = run_r_coefficients()
print("B+C. эхо + coverage-precision…", file=sys.stderr)
echo_gate = run_echo_and_gate(files, args.translator, args.limit)
report = {"run_utc": stamp, "corpus_files": [str(f.relative_to(CORPUS)) for _, f in files],
"A_r_coefficients": r_coef, "BC_echo_and_gate": echo_gate,
"baselines": {"B_pretrain_echo": "exp02: deepseek 13/24 zh-чанков на PD-классике",
"C_gate_precision": "exp07: 0 FP/57 на классике; терсный диалог — гейт-риск"}}
(OUT / f"webnovel_slice_{stamp}.json").write_text(json.dumps(report, ensure_ascii=False, indent=2))
e = echo_gate
print(f"\n=== ВЕБНОВЕЛЛ-СРЕЗ ({e['n']} чанков, translator={args.translator}) ===", file=sys.stderr)
print(f"B эхо: {e['echo']}/{e['n']} ({100*e['echo']//max(1,e['n'])}%) "
f"[претрейн-база exp02: 13/24 zh]", file=sys.stderr)
print(f"C excision_suspect: {e['excision']}/{e['n']}; по плотности диалога "
f"терсн={e['excision_by_density']['terse(>=0.5)']} проза={e['excision_by_density']['prose(<0.5)']} "
f"(FP/всего) [exp07-база: 0 FP/57]", file=sys.stderr)
print(f"по языкам: {e['by_lang']}", file=sys.stderr)
print(f"\nПРОВЕРЬ ВРУЧНУЮ excision-флаги (истинный FP требует сверки полноты) в "
f"{OUT}/{args.translator}_chunks.jsonl; отчёт: {OUT}/webnovel_slice_{stamp}.json", file=sys.stderr)
if __name__ == "__main__":
main()