diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a704d9f..86bc8f4 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,7 +1,7 @@ # Журнал прогресса > **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (обновляемый блок; на 2026-07-09). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D17); этот файл — ЖУРНАЛ, не спека.** -> - **Фаза:** 0 (каркас) завершена; машинерия Фазы 1 в основном построена — банк памяти v2, чанкер + txt/epub импорт, disposition/coverage-гейт/эскалация, durable resume, глоссарий→редактор. **Проведено стратегическое ревью (`architecture/07-strategic-review.md`, 09.07): архитектура end-to-end цела; ратифицирован пакет пост-ревью решений D13–D17.** Следующее: пакет фиксов 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 цела; ратифицирован пакет пост-ревью решений D13–D17.** Следующее: пакет фиксов D15/D16 (бэкенд, **D16 лендится 09.07 — `memmatch-v3`**) → реальный ja→ru прогон end-to-end → пилот Ф2.5. **Пилот-харнесс D13 ПОЧИНЕН (полигон, 09.07): memory_eval эхо-контроль+fidelity-ось+M0–M3+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 ключей. > - **Экономика:** `experiments/08-cost-model-v2.md` (~$0.69 ранобэ / ~$10.94 вебновелла-500) — заменяет старые пороги $0.6 / $5 / $30. ⚠ Resume: любой `--resnapshot` = переоплата книги — онгоинг гейтится D15.2. > - **Ждём от владельца:** 3–5 глав реальных вебновелл (вся текущая эмпирика — классика из претрейна) + explicit-фрагменты для 18+ руки (единственный critical ревью) + провенанс двух внешних 12-*-доков. @@ -527,9 +527,64 @@ keep-alive (Ф1–2), инъекция глоссария (`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 не был в стадии) — пойман до флипа. +### 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); C0–C3→**M0–M3** (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-судейского плеча была структурно сломана (ключ+цена в `
` того же артефакта) → grok-4.3 ПРОВИЗОРЕН, страхуют объективная цена + пилот (7-bis + D13.1). + +**3. `09-pilot-protocol.md` v2 (D13):** армы D13.1 (моно-vs-билингв на одной модели, §3a) и гетерогенный C2 (D13.2); панель судей D13.3 (§5: 2–3 семейства, 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//*.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, после ревью твоих экспериментов 01–02).** Работа принята, качество высокое. Важные вводные: > 1. **Архитектурные доки обновлены до v2** (~05:08, после адверсариального ревью) — если читал их до этого, перечитай `architecture/01-decisions.md`: экономика теперь разделена по контурам «чей ключ» (Р5), премиум-микс с Sonnet в ru-контуре недоступен, batch — только для батчуемых стадий. Твой пересчёт COGS из эксперимента 01 отлично ложится в контур «прямые ключи»; при случае добавь строку-оговорку про ru-агрегаторский контур (×2–6 на флагманы). > 2. Твои **коридоры len_ratio из эксперимента 01 — прямой вход в coverage-гейт v1** бэкенда (Р7/план Фазы 1). Когда сузишь их по факту прогонов — зафиксируй в experiments-доке итоговые пороги отдельной таблицей, я заберу их в конфиг гейта. @@ -537,6 +592,18 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор > 4. Опциональная задача в бэклог (из Р5/Р10 риск №6, понадобятся ключи владельца): **эмпирическая проверка, пробрасывают ли ru-агрегаторы (ProxyAPI/VseGPT/AITunnel) cache-hit тарифы DeepSeek** — два одинаковых запроса подряд, сравнить usage/стоимость. Это разблокирует экономику ru-контура. > 5. Договорённость по журналу: фиксируй здесь краткие итоги каждого эксперимента (1–2 строки + ссылка на док) — основная сессия читает эту секцию, а не всю папку 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% РЕАЛЬНОГО эха** (выход 80–83% 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 полигона (ответ на вводные выше — приняты) Инфраструктура: `eval/` (venv, токенизаторы DeepSeek V3.2/Qwen3/Qwen2.5/GLM-4.6 + tiktoken), PD-корпус 16 текстов zh/ja/en/ru с 3 параллельными парами. Ключи — `eval/.env` (gitignored, закрыт deny-правилом; скрипты читают сами; шаблон на 7 провайдеров создан, ждёт заполнения владельцем). diff --git a/docs/experiments/02-refusal-benchmark.md b/docs/experiments/02-refusal-benchmark.md index 3839e1a..56ba5ca 100644 --- a/docs/experiments/02-refusal-benchmark.md +++ b/docs/experiments/02-refusal-benchmark.md @@ -27,6 +27,22 @@ Плюс порог покрытия предложений sent_cov < 0.75 (не сработал ложно ни разу). Оговорки: выборка — 11 литературных фрагментов × 6 провайдеров; нижняя граница en→ru задана локальной моделью (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-перевода (индикатив, 2–3 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-фреймингом («перевод предоставленного правообладателем текста, не генерация»), т.е. меряем именно то, что будет делать продукт. diff --git a/docs/experiments/04-editor-quality.md b/docs/experiments/04-editor-quality.md index 507ba7b..a4cfb43 100644 --- a/docs/experiments/04-editor-quality.md +++ b/docs/experiments/04-editor-quality.md @@ -62,6 +62,7 @@ - **Короткие фрагменты** (по одному отрывку) — срез, не приговор моделям в целом; полный выбор ядра пайплайна — пилот Фазы 2.5 (задача 4). - Судьи поймали реальный дефект данных: версия C (Gemini) на «Мелосе» была **обрезана** (бюджет 8k при обязательном thinking) — **исправлено** после оценки (перегенерирована с 16k, артефакт обновлён); вывод по «Мелосу» (лидер A) от этого не меняется. - «Якорь ≠ эталон»: оба судьи отметили места, где английский якорь сам упрощает оригинал (напр. оттенок «жалостливо усмехнуться» у Дадзая) — подтверждает методическое следствие ниже. +- **⚠ Слепота задним числом сломана (сноска D13.5, 2026-07-09; историю не переписываем).** Оценку вынесли LLM-судьи (GPT-фронтир + Opus), фетчащие артефакт целиком. А в том же артефакте лежали (а) **ключ «метка→модель»** (строка «Ключ слепой разметки» выше) и (б) **цена/латентность** — за `
`, что для 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) diff --git a/docs/experiments/09-pilot-protocol.md b/docs/experiments/09-pilot-protocol.md index 55cc66c..469b065 100644 --- a/docs/experiments/09-pilot-protocol.md +++ b/docs/experiments/09-pilot-protocol.md @@ -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 починен и валидирован индикативом); решающий прогон ждёт корпус владельца + человеческих аннотаторов. **Закрывает решения:** выбор ядра C0–C3 (Р2), go/no-go на банк памяти (research/13 §Вердикт), валидность LLM-судьи (research/11-gap-3), think-ON/OFF (D6), ценность пре-пасса Annotator (04-unhappy). Пилот — единственная точка, где сходятся все отложенные «решаемые только человеком» вопросы качества. Он же — **дециждающий эксперимент банка памяти** (ставка не морозится до него, вердикт GO-на-схему был условным). +> **⚠ Разведение имён (D13.4, устраняет коллизию exp09):** **C0–C3 = конфигурации ЯДРА** (§3, baseline/draft→editor/generate-select/select-refine). **M0–M3 = режимы ТЕРМИНОЛОГИИ memory-eval** (§6, no-gloss/selective-bank/full-context/wrong-gloss). Раньше оба назывались C0–C3 — риск путаницы в пре-регистрации; исправлено в доке, харнессе (`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)** — 2–3 семейства; 11+ повторов со свапом позиций; Bradley-Terry/медиана; grok не судит grok-выходы; валидация судьи — κ vs человеческий IAA + tie-rate/top-1. +- **§6 memory-eval починен (D13.5)** — эхо-контроль, полные выходы, **ось верности реализована**, C0–C3→M0–M3, зеркало синхронизировано с Go по 7 расхождениям; индикатив пере-прогнан (§6-результаты). +- **§9/§12 Пре-регистрация расширена (D13.4)** — MDE/мощность, N аннотаторов ≥3 (иначе «безκ-режим»), правила решения по каждой руке — неизменяемый каркас §12. +- **§10 Инструмент — Pearmut оценён (D13.5): НЕ берём как BWS-движок** (у него нет BWS), строим тонкий свой + крадём его attention-checks. Вердикт §10. + ## 1. Что пилот решает (и почему только он) | Решение | Почему не решено раньше | Рука пилота | @@ -22,7 +33,7 @@ - **Рекуррентные имена/термины** обязательны (≥8 сущностей, повторяющихся через главы) — иначе memory-eval нечего мерить. - **⚠ Методическое ограничение (эксп.04): владелец читает только en/ru.** → человеческая оценка **ВЕРНОСТИ** возможна лишь на **en→ru** (и русская сторона стиля любого перевода). Для zh/ja верность оценивается: (а) **английским якорь-подстрочником** (профессиональный en-перевод как мост), (б) LLM-судьёй чужого семейства против исходника, (в) сверкой с приватным русским эталоном. Заложено в дизайн (§4–5). -Пока корпуса нет — индикативный прогон на 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. Дизайн ядра (C0–C3, Р2) @@ -30,50 +41,93 @@ - **C0 baseline** — один проход переводчика (+ детерминированный глоссарий = уровень VseGPT; baseline метрики, PROGRESS §синтез). - **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). Каждая конфигурация × {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) **Почему BWS, не шкала Ликерта:** BWS (выбери ЛУЧШИЙ и ХУДШИЙ из набора) даёт более надёжные, менее шумные ранги, чем абсолютные оценки (устойчив к разнице калибровки аннотаторов) — стандарт для сравнения близких систем. LAIT показал: абсолютные оценки судей плывут, ранги — надёжнее. - **Единица:** абзац/короткий чанк (не вся глава — усталость аннотатора). Наборы из 3–4 систем (C0–C3, или think-ON vs OFF пары). -- **Слепота:** аннотатор не видит, какая система какой выход дал (рандомизация порядка + меток). +- **Слепота:** аннотатор не видит, какая система какой выход дал (рандомизация порядка + меток) — **и рандомизация ПО НАБОРУ, ключ вне артефакта** (урок exp04, D13.5: за `
` для фетча не скрытие). - **Оси оценки (раздельно, НЕ смешивать):** - **Стиль/естественность** (русская сторона) — владелец/русскоязычный аннотатор, любой языковой парой (не нужен исходник). - **Верность/полнота** — только 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 (индикатив: по консистентности не бьёт; стиль не мерен). -## 5. LLM-судейская панель (валидация + калибровка) +## 5. LLM-судейская панель (валидация + калибровка, D13.3) Судья держит две вещи: (а) **выбор в C2/C3** (селектор), (б) **валидацию качества** offline. Обе непроверены на домене → пилот их валидирует, не принимает на веру. -- **Панель чужих семейств** (анти-self-preference): Gemini-3.1-Pro (нативный, safety-off для 18+), grok-4.3, gpt-5-mini — судят выходы deepseek/grok. Никогда не судить моделью своего семейства свой выход. +- **Панель — 2–3 семейства МАКСИМУМ (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 / терминология / согласование рода / стиль-канцелярит. -- **Валидация #1 — корреляция судья↔человек** на BWS-ранге (LAIT: расходятся). Если ранговая корреляция (Kendall τ) судья↔человек низка → судья не годен как прокси, C2/C3 под вопросом. -- **Валидация #2 — калибровка порога s\*** (на нём вся ставка generate-select, arXiv:2603.20324): при каком судейском скоре «выбрать/переспросить» максимизируется человеческий win-rate. Пред-регистрировать s\* ДО финального прогона. -- **Cross-family fidelity-судья vs источника** — для memory-eval (§6, ось б). +- **Калибровка порога s\*** (на нём вся ставка generate-select, arXiv:2603.20324): при каком судейском скоре «выбрать/переспросить» максимизируется человеческий win-rate. **Пред-регистрировать s\* ДО финального прогона** (§12). -## 6. Memory-eval — дециждающий эксперимент банка (WMT25 три-режимный) +## 6. Memory-eval — дециждающий эксперимент банка (WMT25 три-режимный; M0–M3) **Вопрос (страх владельца + go/no-go):** оправдана ли ДЕТЕРМИНИРОВАННАЯ селективная инъекция (наш банк) vs просто весь глоссарий в длинном контексте, и **вредит ли ошибочная инъекция** (тихая деградация). -**Три режима терминологии (WMT25) × baseline:** -- **C0** без глоссария; **C1** селективная инъекция (наш банк, §ниже); **C2** полный глоссарий + скользящее резюме (длинный контекст); **C3** случайный/неверный глоссарий (dst перемешан) — **адверсариальная рука, прямой замер тихой деградации**. +**Три режима терминологии (WMT25) × baseline — переименованы M0–M3 (D13.4):** +- **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-осознанной** — наивный матч даёт 18–67% ложных флагов (research/14 §2; backend memory_e1_test.go full-decl 0% / base-only 67%). Заодно — **валидация E1-надёжности post-check на РЕАЛЬНОМ корпусе** (жёсткий гейт `postcheck_gate` флипается только после этого замера precision на живых книгах — mirror D12/Q4). -- **(б) верность/пропуски** — CometKiwi (валидировать на ru-литературе — не подтверждён, research/11-gap-2) + LLM-судья чужого семейства против источника + сверка с эталоном. ru-специфика: согласование рода (вкл. глагол прош.вр.), склонение dst. -- **(в) стоимость** — input/output токены по условиям (C2 заметно дороже — часть решения; эксп.08 цены). +- **(а) консистентность** — decl-осознанная (`eval/pilot/declmetric.py`, pymorphy3 + Go-style stored-decl): одинаково ли рендерится approved-dst во всех чанках, где встречается src. **ОБЯЗАНА быть decl-осознанной** — наивный матч даёт 18–67% ложных флагов (research/14 §2; backend memory_e1_test.go full-decl 0% / base-only 67%). Заодно — **валидация E1-надёжности post-check на РЕАЛЬНОМ корпусе** (жёсткий гейт `postcheck_gate` флипается только после этого замера precision на живых книгах). +- **(б) верность/пропуски — РЕАЛИЗОВАНА (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 токены по режимам. -**Пред-регистрированное правило решения:** 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, встроено в ядро) @@ -84,11 +138,11 @@ ## 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) на той же моно-задаче; -- крест с think-ON/OFF (§7): reasoning может значить для моноредактуры иначе, чем для билингвальной; -- метрика — BWS-стиль (§4, русская сторона, исходник не нужен) + консистентность глоссаря (§6, decl-метрика) + верность через сверку с эталоном (моноредактор не должен «улучшать» смысл прочь от исходника — риск калек убран D1, но проверить, что моноредактор не дрейфит factual); -- **вход в решение:** держится ли ранг grok-редактора при переходе билингв→моно; если нет — перевыбрать редактора Ф1 на моно-режиме. +- крест с think-ON/OFF (§7); метрика — BWS-стиль (§4) + консистентность (§6) + верность через сверку с эталоном; +- **вход в решение:** держится ли ранг grok-редактора при переходе билингв→моно; если нет — перевыбрать редактора Ф1. +- **Инструмент слепоты починен:** `eval/build_editor_artifact.py` теперь рандомизирует метки ПО ФРАГМЕНТУ (соль в ключе) и пишет ключ+цену в ОТДЕЛЬНЫЙ файл (`--key`), не публикуемый до оценки. ## 8. Пре-пасс Annotator (A/B) @@ -96,31 +150,53 @@ ## 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-наборов**, правила решения по КАЖДОЙ руке (C0–C3, D13.1 моно-vs-билингв, think, Annotator). Изменения после — отдельным разделом с обоснованием. -## 10. Харнесс `eval/pilot/` (построено / нужно) +## 10. Инструмент человеческой оценки — Pearmut vs своё (вердикт D13.5) -**Построено (runnable):** -- `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 (arXiv:2601.02933, Zouhar & Kocmi, ETH; MIT, `pip install pearmut`, self-host файловый, verified по GitHub/PyPI 2026-07-09).** -**Нужно достроить (когда есть корпус):** -- BWS item-generator + агрегатор (наборы, рандомизация, κ, ранги) → `bws.py`. -- CometKiwi-обёртка (+ её валидация на ru-литературе, research/11-gap-2) → `cometkiwi.py`. -- Судейская панель обёртка (cross-family, Комиссаров-анкета) → `judge_panel.py`. +**Вердикт: НЕ брать Pearmut как BWS-движок; строить тонкий свой BWS-тул + красть attention-checks Pearmut.** Три решающих факта: +1. **BWS в Pearmut ОТСУТСТВУЕТ (дилбрейкер).** Его протоколы — DA/ESA/cESA/MQM (скоринг + error-span), НЕ best-worst-из-набора. Наш дизайн (§4) целиком на BWS с рандомизацией меток и агрегацией в ранги — под data-model Pearmut это ломать. +2. **Self-host НЕ различает кандидатов** (все self-host'ятся: Pearmut MIT, Potato, Label-Studio Apache-2.0, Appraise BSD) → приватность/18+ проходят все, решает BWS-fit, где Pearmut проигрывает. +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 M0–M3 + **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. +- Судейская панель обёртка (2–3 семейства, 11+ повторов+свап, Bradley-Terry, family-guard, Комиссаров-анкета) → `judge_panel.py`. - en-якорь-подстрочник pipeline для zh/ja верности. -## 11. Ограничения и что нужно от владельца +## 12. Пред-регистрационный каркас (НЕИЗМЕНЯЕМЫЙ; заполнить ДО решающего прогона) -- **Корпус** (§2) — блокер решающего прогона: 25–35 глав с приватными эталонами, рекуррентные имена, диалог/нарратив баланс. -- **Человеческие аннотаторы** BWS (≥3) — минимум владелец (en/ru); для zh/ja верности — en-якорь. -- **18+ рука** (канал B, судья 18+) — explicit-фрагменты только от владельца, gitignored; на выделенном xAI-промо-аккаунте (не клиентские книги). -- CometKiwi на ru-литературе не валидирован (research/11-gap-2) — пилот его и валидирует; до того — не доверять как единственной оси верности. +> Заполняется ОДИН раз перед решающим прогоном и не меняется; правки — отдельным датированным подразделом с обоснованием (D13.4). + +- **Корпус:** ____ глав, языки ____, ≥8 рекуррентных сущностей: да/нет. +- **Аннотаторы:** N = ____ (≥3 иначе «безκ-режим: да»), κ-порог = ____ (низкий → расширять, не усреднять). +- **Мощность:** число BWS-наборов = ____ под MDE рангов = ____. +- **Судья:** семейства = ____ (2–3), повторов/вердикт = ____ (≥11) со свапом A/B, агрегация = медиана/Bradley-Terry, family-guard: судья ∉ семейства выхода. Валидация = κ vs IAA + tie-rate/top-1. +- **s\*:** порог селектора C2/C3 = ____ (калибр §5). +- **memory-eval:** правило = M1≈M2&дешевле→банк; M3/*.txt`; блокируется корпусом graceful | | `adaptive_*.py` | research/14 пробы (гибрид отклонён) | индикативные, не решения | | `memory_hotpath.py` | УСТАРЕЛ (контракт до cc57c7b) — не переиспользовать | актуальное зеркало — `pilot/memory_eval.py` | | `providers.json` | базовые URL/слаги для зондов | квирки — `docs/experiments/00-provider-quirks.md`, читать ПЕРЕД любым вызовом | diff --git a/eval/build_editor_artifact.py b/eval/build_editor_artifact.py index ff4be85..a8b60da 100644 --- a/eval/build_editor_artifact.py +++ b/eval/build_editor_artifact.py @@ -1,20 +1,40 @@ #!/usr/bin/env python3 """Генератор страницы слепого сравнения редакторов (эксперимент 04). -Читает eval/data/editor_bench//*.txt, применяет слепую разметку A/B/C/D, -эмитит самодостаточный HTML (контент для Artifact-обёртки). Запуск: - eval/.venv/bin/python eval/build_editor_artifact.py + +⚠ ГИГИЕНА СЛЕПОТЫ (D13.5 / 07-review §4.5 / POLYGON handoff Task 2): предыдущая версия была +структурно НЕ-слепой при LLM-оценке — (1) ключ «метка→модель» и цена лежали В ТОМ ЖЕ артефакте +(за
— для LLM-фетча это НЕ скрытие, весь DOM виден), (2) маппинг A/B/C/D был +ЗАФИКСИРОВАН одинаково на всех фрагментах (узнав раз — знаешь везде). Исправлено: + - метки рандомизируются ПО ФРАГМЕНТУ (детерминированная перестановка на соли, разная у каждого + фрагмента) — узнать порядок на одном фрагменте не даёт его на других; + - ключ (метка→модель) + цена/латентность пишутся в ОТДЕЛЬНЫЙ файл (--key), КОТОРЫЙ НЕ + ПУБЛИКУЕТСЯ до вынесения оценки; в самом артефакте НЕТ ни имён моделей, ни цены; + - соль генерится случайно на сборку и хранится ТОЛЬКО в ключ-файле → даже код-осведомлённый + оценщик не инвертирует перестановку без ключа. + +Читает eval/data/editor_bench//*.txt, эмитит слепой HTML (контент для Artifact-обёртки) +и рядом — приватный ключ-файл. Запуск: + eval/.venv/bin/python eval/build_editor_artifact.py [--key ] """ +import argparse +import hashlib import html import json import re -import sys +import secrets from pathlib import Path ROOT = Path(__file__).resolve().parent BENCH = ROOT / "data" / "editor_bench" -# Слепая разметка: фиксированный перемешанный порядок (одинаков на всех фрагментах) -BLIND = [("A", "grok-4.3"), ("B", "glm-4.6"), ("C", "gemini-3.1-pro"), ("D", "kimi-k2.6")] +MODELS = ["grok-4.3", "glm-4.6", "gemini-3.1-pro", "kimi-k2.6"] +LABELS = ["A", "B", "C", "D"] + +# стоимость/латентность — В КЛЮЧ-ФАЙЛ, НЕ в артефакт (цена коррелирует с моделью → утечка слепоты) +COST = {"grok-4.3": "~13 с, без «размышлений» — самый быстрый", + "glm-4.6": "~105 с (thinking off)", + "gemini-3.1-pro": "~60–86 с, обязательный thinking", + "kimi-k2.6": "~150 с, ~11 000 токенов «размышлений» на абзац — самый дорогой"} FRAGMENTS = [ ("rashomon", "японский → русский", "Акутагава Рюноскэ, «Расёмон»", False), @@ -23,11 +43,13 @@ FRAGMENTS = [ ("wells", "английский → русский", "Г. Уэллс, «Машина времени»", True), ] -# стоимость/латентность из results.json (последний прогон) — для секции-расшифровки -COST = {"grok-4.3": "~13 с, без «размышлений» — самый быстрый", - "glm-4.6": "~105 с (thinking off)", - "gemini-3.1-pro": "~60–86 с, обязательный thinking", - "kimi-k2.6": "~150 с, ~11 000 токенов «размышлений» на абзац — самый дорогой"} + +def label_map(salt: str, frag: str) -> list[tuple[str, str]]: + """Детерминированная перестановка меток ПО ФРАГМЕНТУ на секретной соли: сортируем модели по + sha256(salt|frag|model) и присваиваем A,B,C,D. Разная у каждого фрагмента; без соли (в ключе) + не инвертируется. Возвращает [(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: @@ -44,11 +66,18 @@ def read(frag: str, name: str) -> str: def main() -> None: - out = Path(sys.argv[1]) + ap = argparse.ArgumentParser() + ap.add_argument("out") + ap.add_argument("--key", default="", help="приватный ключ-файл (по умолчанию .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.append("Сравнение редакторов ru-стиля · Эксперимент 04") S.append(f"") - S.append('
') S.append('
') S.append('

TextMachine · полигон · эксперимент 04

') @@ -60,11 +89,19 @@ def main() -> None: '
  • стиль — какая русская проза читается живее, без канцелярита и калек;
  • ' '
  • верность — кто не потерял смысл и не добавил отсебятины (сверяй со «смысловым якорем»).
  • ' '') - S.append('

    Названия моделей и цена — внизу, под ключом-расшифровкой, чтобы не влияли на оценку. ' - 'Фрагмент Уэллса — англ.→рус.: оригинал перед тобой, судишь и стиль, и верность напрямую.

    ') + S.append('

    ⚠ Метки A–D перемешаны СВОИМ порядком в каждом фрагменте — узнав, кто есть кто ' + 'в одном, ты НЕ знаешь этого в другом. Имена моделей и цена в артефакте НЕ приводятся ' + '(ключ — в отдельном файле, вскрывается только ПОСЛЕ оценки). Фрагмент Уэллса — англ.→рус.: ' + 'оригинал перед тобой, судишь и стиль, и верность напрямую.

    ') S.append("
    ") + key = {"salt": salt, "note": "ПРИВАТНЫЙ КЛЮЧ эксп.04 — НЕ публиковать вместе с артефактом; " + "вскрывать только ПОСЛЕ вынесения слепой оценки (урок 07-review §4.5). " + "Метки рандомизированы по фрагменту.", "cost": COST, "fragments": {}} + 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_label = "Оригинал (английский)" if is_en else "Смысловой якорь — дословный английский перевод" draft = read(frag, "draft") @@ -74,7 +111,6 @@ def main() -> None: f'

    {html.escape(work)}

    ' + ('твой полный контроль' if is_en else '') + '') - S.append('
    Смысловой якорь и черновик' '
    ') S.append(f'

    {html.escape(anchor_label)}

    ' @@ -82,32 +118,23 @@ def main() -> None: S.append(f'

    Черновой перевод (вход редактора)

    ' f'
    {paras(draft)}
    ') S.append("
    ") - S.append('
    ') - for letter, model in BLIND: + for letter, model in lm: # per-fragment shuffled order txt = read(frag, model) S.append(f'
    {letter}
    ' f'
    {paras(txt)}
    ') S.append("
    ") S.append("") - # ключ-расшифровка + стоимость (свёрнуто) - S.append('
    Показать расшифровку и стоимость') - S.append('') - for letter, model in BLIND: - S.append(f'' - f'') - S.append("
    МеткаМодельЛатентность / цена редактуры
    {letter}{html.escape(model)}{html.escape(COST.get(model, ""))}
    ") - S.append('

    Черновик — DeepSeek (на китайском А-Кью DeepSeek «эхал» оригинал, ' - 'поэтому там черновик от GLM — деталь для бэкенда, на сравнение редакторов не влияет). ' - 'Латентность — на коротком фрагменте; в продакшене всё идёт батчем.

    ') - S.append("
    ") - + # NO reveal/key section in the artifact (the exp04 blindness break). Key → separate file. S.append('

    Выбор дефолтного редактора для Фазы 1. ' - 'Твоя оценка — золотой стандарт: LLM-судьи на художественном качестве расходятся с людьми.

    ') + 'Твоя оценка — золотой стандарт: LLM-судьи на художественном качестве расходятся с людьми. ' + 'Ключ «метка→модель» и цена — в приватном файле, вскрываются после оценки.

    ') S.append("") 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("KEY (do NOT publish until scored):", key_path) 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; font-family:var(--serif);font-weight:700;font-size:1.05rem;color:#fff;background:var(--accent); 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 p{margin:0 0 .75em;} .prose p:last-child{margin-bottom:0;} .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;} 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;}} diff --git a/eval/pilot/declmetric.py b/eval/pilot/declmetric.py index 5dabfd1..5100015 100644 --- a/eval/pilot/declmetric.py +++ b/eval/pilot/declmetric.py @@ -19,19 +19,66 @@ Two matchers, both reusable: 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 — 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 import re +import unicodedata import pymorphy3 _MORPH = pymorphy3.MorphAnalyzer() _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 «вана». 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: - """ё→е fold + lower + whitespace collapse (mirror of Go normalizeTargetForm, target side).""" - return re.sub(r"\s+", " ", s).lower().replace("ё", "е").strip() + """NFC → dash/hyphen-variant fold → drop default-ignorables → whitespace-collapse → lower → + ё→е. 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]: @@ -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: """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: return False + n = len(form_norm) i = text_norm.find(form_norm) while i != -1: - left_ok = i == 0 or not _CYR.match(text_norm[i - 1]) - j = i + len(form_norm) - right_ok = j == len(text_norm) or not _CYR.match(text_norm[j]) + left_ok = i == 0 or not _is_letter(text_norm[i - 1]) + j = i + n + right_ok = j == len(text_norm) or not _is_letter(text_norm[j]) if left_ok and right_ok: return True 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"]) -if __name__ == "__main__": # smoke: inflected forms must NOT false-flag +if __name__ == "__main__": # smoke: inflected forms must NOT false-flag; Latin-boundary parity out = "Бородатого Вана встретили в Вэйчжуане; Дэна и сюцая тоже." ents = { "王胡": {"dst": "Бородатый Ван", "lemma_keys": ["ван"], "decl_forms": ["Бородатого Вана", "Бородатый Ван"]}, @@ -109,3 +157,10 @@ if __name__ == "__main__": # smoke: inflected forms must NOT false-flag } import json 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") diff --git a/eval/pilot/kana_precision.py b/eval/pilot/kana_precision.py new file mode 100644 index 0000000..38f5c21 --- /dev/null +++ b/eval/pilot/kana_precision.py @@ -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 (katakana→hiragana, 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() diff --git a/eval/pilot/kana_traps.json b/eval/pilot/kana_traps.json new file mode 100644 index 0000000..498a167 --- /dev/null +++ b/eval/pilot/kana_traps.json @@ -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"} + ] +} diff --git a/eval/pilot/memory_eval.py b/eval/pilot/memory_eval.py index a3f2e15..2eb996f 100644 --- a/eval/pilot/memory_eval.py +++ b/eval/pilot/memory_eval.py @@ -1,36 +1,72 @@ #!/usr/bin/env python3 """Ф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 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 M0–M3 per D13.4 (the old C0–C3 collided with exp09 §3 CORE configurations): - C0 no glossary (baseline — model's base behaviour) - C1 SELECTIVE bank injection (only records whose keys fire in the chunk) — OUR bank - C2 FULL glossary + sliding summary in the prompt — "just long context" - C3 RANDOM/WRONG glossary (dst shuffled) — adversarial arm, directly tests silent degradation + M0 no glossary (baseline — model's base behaviour) + M1 SELECTIVE bank injection (only records whose keys fire in the chunk) — OUR bank + M2 FULL glossary + sliding summary in the prompt — "just long context" + M3 RANDOM/WRONG glossary (dst shuffled) — adversarial arm, directly tests silent degradation -Decision rule (pre-registered): C1≈C2 quality but C1 cheaper → bank justified; C2≫C1 → -need fuller context; C3 drops vs C0 on FIDELITY → wrong injection harms → post-check/disposition +Decision rule (pre-registered, exp09 §6): M1≈M2 quality but M1 cheaper → bank justified; M2≫M1 +→ need fuller context; M3 drops vs M0 on FIDELITY → wrong injection harms → post-check/disposition justified (owner's fear confirmed & mitigated, not just asserted). -INJECTION = eval-side MIRROR of backend/internal/pipeline/memory.go (THE SOURCE OF TRUTH): -normalized exact key match, per-language min-key ban (Han≥2 / phonetic≥3), collision-prone -short phonetic key → AMBIGUOUS, longest-match containment, spoiler since/until window, sticky -scene-inertia. Cross-checked against the Go unit tests; on any divergence the Go code wins. -(eval/memory_hotpath.py is the PRE-cc57c7b prototype with a global MIN_KEY=2 and no -collision-downgrade → do NOT reuse it; this file reflects the current Go contract.) +THREE AXES, measured SEPARATELY (exp09 §6): + (a) consistency — decl-aware (declmetric.py, pymorphy3 + Go-style stored-decl) approved-dst + rendering across the chunks where src fires; + (b) FIDELITY/omission — LLM judge of a DIFFERENT family vs the SOURCE (D13.3 family rule: + judge NOT from the draft/translator model's family). This is the axis that makes the + 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 -overlapping chunks; (b) fidelity/omission — LLM judge of a DIFFERENT family (cross-family) vs -source + Rogov anchor; (c) cost — input/output tokens per condition. +INJECTION = eval-side MIRROR of backend/internal/pipeline/memory.go (THE SOURCE OF TRUTH), +synced D13.5 across the 7 divergences the review flagged (07-review §4.5): + 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 A–I + 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 (rose⊄roseanne) 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 -09-pilot-protocol.md). Usage: eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run +ECHO CONTROL (D13.5 / 07-review §4.5): the model ECHOES the injected glossary preamble ("ГЛОССАРИЙ +… 阿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.667→1.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 M0–M3 on Ah-Q """ 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 sys.path.insert(0, str(Path(__file__).resolve().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" OUTDIR = ROOT.parent / "data" / "pilot" 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) --------------------- -TRAD2SIMP = {"趙": "赵", "莊": "庄", "錢": "钱", "陳": "陈", "萬": "万", "舊": "旧", "臉": "脸", - "魯": "鲁", "鎮": "镇", "剛": "刚", "動": "动", "們": "们"} +# --- normalization (faithful mirror of memnorm.go) -------------------------------- + +def _load_trad2simp(path: Path) -> dict[str, str]: + """Parse the vendored ' ' 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 B–I, + 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: + """Mirror of memnorm.go normalizeSourceKey: NFKC → drop ignorables → trad→simp (per rune) + → katakana→hiragana → Unicode lower. Applied SYMMETRICALLY to keys and chunk text.""" s = unicodedata.normalize("NFKC", s) out = [] for c in s: - if unicodedata.category(c) in ("Cf",) or c in "️": + if _is_ignorable(c): continue c = TRAD2SIMP.get(c, c) o = ord(c) @@ -59,92 +133,214 @@ def norm_src(s: str) -> str: out.append(c.lower()) return "".join(out) -HAN = re.compile(r"[一-鿿㐀-䶿]") def significant_len(nk: str) -> int: - return sum(1 for c in nk if HAN.match(c) or c.isalpha() or - ("぀" <= c <= "ヿ") or ("가" <= c <= "힯")) + """Mirror of memnorm.go significantLen: count Unicode LETTERS (category L*). Since Han/kana/ + 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: - return bool(HAN.search(nk)) -def min_key_len(nk: str) -> int: - return 2 if any_han(nk) else 3 + return any(_is_han(c) for c in nk) + +def min_key_len_for(nk: str) -> int: + return 2 if any_han(nk) else 3 # minKeyLenHan / minKeyLenPhonetic + 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 -# --- glossary (Ah-Q; dst + decl + lemma + aliases + spoiler window) -------------- -# One deliberate SPOILER entry (未庄 revolution outcome) demonstrates the since/until gate. +STICKY_DEPTH = 2 # memory.go stickyDepth + +# --- 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 = [ - {"src": "阿Q", "dst": "А-кью", "lemma_keys": ["а-кью"], "hyphen_pat": r"А[-\s]?кью", - "decl_forms": ["А-кью"], "aliases": [], "type": "name"}, - {"src": "未庄", "dst": "Вэйчжуан", "lemma_keys": ["вэйчжуан"], - "decl_forms": ["Вэйчжуане", "Вэйчжуан", "Вэйчжуана"], "aliases": [], "type": "place"}, - {"src": "赵太爷", "dst": "почтенный Чжао", "lemma_keys": ["чжао"], - "decl_forms": ["почтенного Чжао", "почтенный Чжао", "Чжао"], "aliases": ["赵老太爷"], "type": "name"}, - {"src": "王胡", "dst": "Бородатый Ван", "lemma_keys": ["ван"], - "decl_forms": ["Бородатого Вана", "Бородатый Ван", "Бородатому Вану"], "aliases": ["王癞胡", "癞胡"], "type": "name"}, - {"src": "小D", "dst": "Маленький Дэн", "lemma_keys": ["дэн"], - "decl_forms": ["Маленького Дэна", "Маленький Дэн", "Дэна"], "aliases": [], "type": "name"}, - {"src": "假洋鬼子", "dst": "Поддельный заморский чёрт", "lemma_keys": ["заморский", "чёрт"], - "decl_forms": ["Поддельного заморского чёрта", "заморский чёрт"], "aliases": [], "type": "nickname"}, - {"src": "吴妈", "dst": "У-ма", "lemma_keys": ["у-ма"], "hyphen_pat": r"У[-\s]?ма", - "decl_forms": ["У-ма", "У-мы"], "aliases": [], "type": "name"}, - {"src": "秀才", "dst": "сюцай", "lemma_keys": ["сюцай"], - "decl_forms": ["сюцая", "сюцай", "сюцаю"], "aliases": [], "type": "title"}, - {"src": "尼姑", "dst": "монашка", "lemma_keys": ["монашка"], - "decl_forms": ["монашку", "монашка", "монашки"], "aliases": [], "type": "term"}, + {"src": "阿Q", "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": "place", + "lemma_keys": ["вэйчжуан"], "decl_forms": ["Вэйчжуане", "Вэйчжуан", "Вэйчжуана"]}, + {"src": "赵太爷", "dst": "почтенный Чжао", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0, + "allow_short": False, "aliases": ["赵老太爷"], "type": "name", + "lemma_keys": ["чжао"], "decl_forms": ["почтенного Чжао", "почтенный Чжао", "Чжао"]}, + {"src": "王胡", "dst": "Бородатый Ван", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0, + "allow_short": False, "aliases": ["王癞胡", "癞胡"], "type": "name", + "lemma_keys": ["ван"], "decl_forms": ["Бородатого Вана", "Бородатый Ван", "Бородатому Вану"]}, + {"src": "小D", "dst": "Маленький Дэн", "status": "approved", "sense": "", "since_ch": 0, "until_ch": 0, + "allow_short": False, "aliases": [], "type": "name", + "lemma_keys": ["дэн"], "decl_forms": ["Маленького Дэна", "Маленький Дэн", "Дэна"]}, + {"src": "假洋鬼子", "dst": "Поддельный заморский чёрт", "status": "approved", "sense": "", + "since_ch": 0, "until_ch": 0, "allow_short": False, "aliases": [], "type": "nickname", + "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. - {"src": "革命党", "dst": "революционеры", "lemma_keys": ["революционер"], - "decl_forms": ["революционеров", "революционеры"], "aliases": [], "type": "term", "since_ch": 7}, + {"src": "革命党", "dst": "революционеры", "status": "approved", "sense": "", "since_ch": 7, "until_ch": 0, + "allow_short": False, "aliases": [], "type": "term", + "lemma_keys": ["революционер"], "decl_forms": ["революционеров", "революционеры"]}, ] -def eligible_keys(entry: dict) -> list[tuple[str, str]]: - """(normalized_key, raw) surfaces (src+aliases) passing the per-lang min-key ban.""" +def eligible_keys(entry: dict) -> list[str]: + """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 = [] for raw in [entry["src"]] + entry.get("aliases", []): nk = norm_src(raw) - if nk and significant_len(nk) >= min_key_len(nk): - out.append((nk, raw)) + if nk and (significant_len(nk) >= min_key_len_for(nk) or entry.get("allow_short")): + out.append(nk) return out -def select(chunk: str, chapter: int, sticky_prev: set[str]) -> dict: - """Mirror of MemoryBank.Select: which entries inject, with disposition + spoiler rejects.""" - nchunk = norm_src(chunk) - fired = {} # src → (matched_key, is_collision_prone) - for e in GLOSSARY: - best = None - for nk, raw in eligible_keys(e): - 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} +def spoiler_blocked(entry: dict, chapter: int) -> bool: + """Mirror of memory.go spoilerBlocked: since_ch/until_ch window (0 = unbounded).""" + if entry.get("since_ch", 0) and chapter < entry["since_ch"]: + return True + if entry.get("until_ch", 0) and chapter > entry["until_ch"]: + return True + return False -# --- 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_HARDENED = SYSTEM + (" ВАЖНО: не выводи блок ГЛОССАРИЙ, строки вида «иероглиф → перевод», " + "заголовки или пояснения — только сам художественный перевод.") GHEAD = "ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно):" 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 = [] for e in entries: line = f"{e['src']} → {e['dst']}" - if not full and e.get("_disp") == "ambiguous": + if not full and e.get("_disp") == AMBIGUOUS: line += " ⟨проверить⟩" lines.append(line) return GHEAD + "\n" + "\n".join(lines) -def build_prompt(chunk: str, condition: str, sel: dict, summary: str, shuffled: dict) -> str: - if condition == "C0": +def build_prompt(chunk: str, mode: str, sel: dict, summary: str, shuffled: dict) -> str: + if mode == "M0": return "ФРАГМЕНТ:\n" + chunk - if condition == "C1": # selective bank + if mode == "M1": # selective bank blk = gloss_block(sel["injected"]) return (blk + "\n\n" if blk else "") + "ФРАГМЕНТ:\n" + chunk - if condition == "C2": # full glossary + sliding summary (long context) - blk = gloss_block([{**e, "_disp": "confirmed"} for e in GLOSSARY if not e.get("since_ch")], full=True) + 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") and not e.get("until_ch") + and str(e.get("dst", "")).strip()], full=True) pre = (f"КРАТКОЕ СОДЕРЖАНИЕ ПРЕДЫДУЩЕГО:\n{summary}\n\n" if summary else "") return pre + blk + "\n\nФРАГМЕНТ:\n" + chunk - if condition == "C3": # random/wrong glossary (adversarial): fired entries with SHUFFLED dst - wrong = [{**e, "dst": shuffled[e["src"]], "_disp": "confirmed"} for e in sel["injected"] + 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"] if e["_via"] != "sticky"] blk = gloss_block(wrong, full=True) 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": , "n_omission": , "n_addition": , ' + '"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]: paras = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()] chunks, buf = [], [] @@ -187,68 +497,205 @@ def chunk_text(text: str, target=1200) -> list[str]: chunks.append("\n\n".join(buf)) return chunks -def translate(chunk_prompt: str, model="deepseek-v4-flash") -> tuple[str, dict]: - cfg = {"deepseek-v4-flash": {"base_url": "https://api.deepseek.com/v1", "api_key_env": "DEEPSEEK_API_KEY", "max_tokens": 8000}, - "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] - t, err, usage = rb.call_provider({"name": model, "model": model, **cfg}, SYSTEM, chunk_prompt, timeout=200) - return (t or ""), (usage or {}) +# --- self-test: mirror invariants without any API call ---------------------------- +def self_test() -> int: + fails = [] + def check(cond, msg): + 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(): ap = argparse.ArgumentParser() 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("--conditions", default="C0,C1,C2,C3") - ap.add_argument("--model", default="grok-4.20-0309-non-reasoning") + ap.add_argument("--chapter", type=int, default=6, help="chapter number for the spoiler gate " + "(single-chapter indicative: no per-chunk chapter-reset; the deciding run " + "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("--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() + 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") chunks = chunk_text(text)[:args.max_chunks] - conditions = args.conditions.split(",") - # deterministic wrong-dst shuffle for C3 (rotate dst among present names) + modes = args.modes.split(",") + # deterministic wrong-dst shuffle for M3 (rotate dst among present names/places) 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))} shuffled = {e["src"]: rot.get(e["src"], "НЕВЕРНО") for e in GLOSSARY} - print(f"src={Path(args.src).name} chapter={args.chapter} chunks={len(chunks)} " - f"model={args.model} conditions={conditions}", file=sys.stderr) - rows, sticky_prev = [], set() + print(f"src={Path(args.src).name} chapter={args.chapter} chunks={len(chunks)} model={args.model} " + f"judge={args.judge} modes={modes} budget={args.budget}", file=sys.stderr) + 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): - 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"} - row = {"chunk": ci, "src_chars": len(ch), "n_injected": len(sel["injected"]), - "injected_srcs": [e["src"] for e in sel["injected"]], - "dispositions": {e["src"]: e["_disp"] for e in sel["injected"]}, - "rejected_spoiler": sel["rejected"], "present_for_consistency": list(present)} + row = {"chunk": ci, "src_chars": len(ch), + "injected": [{"src": e["src"], "disp": e["_disp"], "via": e["_via"]} for e in sel["injected"]], + "rejected_spoiler": [{"src": r["src"]} for r in sel["rejected"]], + "evicted": [{"src": e["src"]} for e in sel["evicted"]], + "present_for_consistency": list(present)} if not args.dry_run: - row["by_condition"] = {} - for cond in conditions: - prompt = build_prompt(ch, cond, sel, summary="", shuffled=shuffled) - out, usage = translate(prompt, args.model) - cons = dm.consistency(out, {s: {"dst": e["dst"], "lemma_keys": e.get("lemma_keys", []), + row["by_mode"] = {} + for mode in modes: + prompt = build_prompt(ch, mode, sel, summary="", shuffled=shuffled) + raw, usage = translate(prompt, args.model) + 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", [])} - for s, e in present.items()}) if present else None - row["by_condition"][cond] = { + for s, e in present.items()}) if present else None + 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_stored_decl": cons["stored_decl"]["score"] if cons else None, "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"), - "out_preview": out[:160]} - print(f" chunk{ci} {cond}: consistency(pymorphy)=" - f"{row['by_condition'][cond]['consistency_pymorphy']} " + "out_full": raw, "body_scored": body, "regen": regen} + fscore = (fid or {}).get("fidelity") if isinstance(fid, dict) else None + 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) - sticky_prev = sel["active"] + sticky_win = (sticky_win + [sel["active"]])[-STICKY_DEPTH:] rows.append(row) - Path(args.out).write_text(json.dumps({"config": vars(args), "rows": rows}, ensure_ascii=False, indent=2)) - print(f"\nwrote {args.out}", file=sys.stderr) - # dry-run: show the selection mechanism (spoiler reject, disposition, sticky) + meta = {"run_utc": stamp, "src": args.src, "chapter": args.chapter, "modes": modes, + "translator_model": args.model, "judge_model": None if args.no_fidelity else args.judge, + "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: for r in rows: - print(f"chunk{r['chunk']} inj={r['injected_srcs']} disp={r['dispositions']} " - f"spoiler_rej={r['rejected_spoiler']}") + print(f"chunk{r['chunk']} inj={[(e['src'], e['disp'], e['via']) for e in r['injected']]} " + 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__": main() diff --git a/eval/pilot/trad2simp.txt b/eval/pilot/trad2simp.txt new file mode 100644 index 0000000..8e8cf20 --- /dev/null +++ b/eval/pilot/trad2simp.txt @@ -0,0 +1,563 @@ +# Traditional → Simplified Chinese character map (A4 normalization, memory bank v2). +# +# Format: one mapping per line, " " (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 --- +國 国 +學 学 +會 会 +對 对 +時 时 +見 见 +這 这 +們 们 +個 个 +來 来 +為 为 +麼 么 +頭 头 +過 过 +還 还 +進 进 +種 种 +應 应 +關 关 +點 点 +動 动 +於 于 +實 实 +發 发 +現 现 +長 长 +兒 儿 +東 东 +與 与 +舉 举 +興 兴 +覺 觉 +觀 观 +歡 欢 +勸 劝 +農 农 +辦 办 +務 务 +勞 劳 +勢 势 +勝 胜 +勵 励 +單 单 +嚴 严 +嘗 尝 +嘆 叹 +嚇 吓 +嚮 向 +圖 图 +團 团 +圓 圆 +園 园 +圍 围 +囑 嘱 +專 专 +將 将 +尋 寻 +導 导 +屬 属 +層 层 +屢 屡 +廣 广 +廠 厂 +廢 废 +廟 庙 +廚 厨 +廈 厦 +廳 厅 +華 华 +葉 叶 +蓋 盖 +蒼 苍 +薦 荐 +薩 萨 +藍 蓝 +藝 艺 +藥 药 +蘇 苏 +蘭 兰 +蘋 苹 +蟲 虫 +傷 伤 +傳 传 +債 债 +傾 倾 +僅 仅 +僕 仆 +僑 侨 +價 价 +儀 仪 +億 亿 +儉 俭 +儲 储 +償 偿 +優 优 +側 侧 +偉 伟 +偵 侦 +師 师 +帥 帅 +幣 币 +帳 帐 +幫 帮 +歲 岁 +歸 归 +歷 历 +曆 历 +歐 欧 +殺 杀 +毀 毁 +愛 爱 +節 节 +範 范 +築 筑 +簡 简 +籃 篮 +籠 笼 +籤 签 +篤 笃 +簾 帘 +籌 筹 +醫 医 +產 产 +麗 丽 +麵 面 +麥 麦 +黃 黄 +黨 党 +龍 龙 +鳳 凤 +龜 龟 +齒 齿 +齊 齐 +書 书 +畫 画 +邊 边 +飛 飞 +決 决 +擊 击 +擔 担 +據 据 +擁 拥 +撲 扑 +擴 扩 +攔 拦 +掃 扫 +撿 捡 +擋 挡 +掛 挂 +換 换 +損 损 +搶 抢 +攜 携 +擾 扰 +撐 撑 diff --git a/eval/providers.json b/eval/providers.json index a4ef564..4ee4a92 100644 --- a/eval/providers.json +++ b/eval/providers.json @@ -70,6 +70,17 @@ "extra_body": { "reasoning_effort": "minimal" }, "_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", "base_url": "https://openrouter.ai/api/v1", diff --git a/eval/webnovel_slice.py b/eval/webnovel_slice.py new file mode 100644 index 0000000..325f9e6 --- /dev/null +++ b/eval/webnovel_slice.py @@ -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-флаг-толерантности). + +КОРПУС: положи 3–5 глав реальных вебновелл в eval/data/samples/webnovel//*.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" # /*.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//*.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"Положи 3–5 глав реальных вебновелл в: {CORPUS}//*.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()