diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 21b0747..ac00be0 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -544,8 +544,8 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор - [x] ~~Проверка ru-агрегаторов~~ — **СНЯТА** (BYOK отменён владельцем, см. коррекцию выше). - [—] **Банк памяти (задача 5 + шире)** — по решению владельца выносится в **отдельную сессию «Валидация банка памяти»** (главная точка отказа пайплайна; не бросаться в код без разбора опасных сценариев). Полигон подготовил черновик промта → `docs/MEMORY_RESEARCH_SESSION_PROMPT.md`; **оркестратору вычитать/финализировать**, затем владелец запускает. Замер эмбеддингов (bge-m3 vs Qwen3-Embedding vs LaBSE) — часть мандата той сессии. - [ ] Довалидация токен-калибровки на 3–5 главах реальных вебновелл (файлы владельца). -- [ ] Задача 4 (пилот выбора ядра, Фаза 2.5): протокол BWS + панель LLM-судей. -- [ ] **Валидация precision coverage-гейта — гейтит боевой флип** (от оркестратора, D12 Q4). Бэкенд-мини-набор (выпиленные предложения) проверяет recall (≥90%); флип `gates.coverage.enabled:true` упирается в **false-positive rate** на легитимно диалого-плотных главах (сегментация наивная, quote-absorbing §3.7 в v1.1 — на диалогах `sent_cov` может ложно проседать). Прогони гейт (пороги эксп.02: zh-ru<2.2/ja-ru<1.4/en-ru<0.70, sent_cov<0.75) на реальных чанках zh/ja→ru, замерь долю ложных excision_suspect. Оракул = `eval/refusal_bench.py::classify_output` (тот же, что порт в Go — держать в синхроне). Выход: доля ложных флагов по типам глав → оркестратор/владелец ставит порог N допустимых флагов и решает флип. +- [~] Задача 4 (пилот выбора ядра, Фаза 2.5): протокол BWS + панель LLM-судей — **протокол готов** ([experiments/09](experiments/09-pilot-protocol.md)) + харнесс `eval/pilot/`; решающий прогон ждёт корпус владельца (см. Сессия 3). +- [x] **Валидация precision coverage-гейта — гейтит боевой флип** (D12 Q4) → [experiments/07](experiments/07-coverage-precision.md): 0 FP/57, флип по precision безопасен, порог ≥2 флага/главу; вебновелл-срез ждёт корпус владельца (см. Сессия 3). Бэкенд-мини-набор (выпиленные предложения) проверяет recall (≥90%); флип `gates.coverage.enabled:true` упирается в **false-positive rate** на легитимно диалого-плотных главах (сегментация наивная, quote-absorbing §3.7 в v1.1 — на диалогах `sent_cov` может ложно проседать). Прогони гейт (пороги эксп.02: zh-ru<2.2/ja-ru<1.4/en-ru<0.70, sent_cov<0.75) на реальных чанках zh/ja→ru, замерь долю ложных excision_suspect. Оракул = `eval/refusal_bench.py::classify_output` (тот же, что порт в Go — держать в синхроне). Выход: доля ложных флагов по типам глав → оркестратор/владелец ставит порог N допустимых флагов и решает флип. - [x] ~~**Эксперимент 05 — memory-bet**~~ — **СНЯТ как низкосигнальный** (решение владельца). Прогон был, но его ось C0-vs-C1 («бредит ли модель без глоссария») предрешена, а C3/C2 лишь пере-подтвердили уже процитированную литературу (2510.00829) — тест не мог изменить ни одного решения. Артефакты (doc/скрипт/данные) удалены. Единственный actionable-вывод (post-check + disposition обязательны) уже зафиксирован в реестре `architecture/06` (A2/E1) независимо от этого прогона. **Урок для eval:** тест ставить только если его исход мог бы перевернуть решение И не установлен литературой. Ценность вместо этого — валидация самого МЕХАНИЗМА (см. ниже). - [x] **Референс горячего пути банка памяти + self-tests** → `eval/memory_hotpath.py` (11/11 PASS). Исполняемая спека механизма (не продукт): нормализация trad→simp/kana, точный матч ключей/алиасов с запретом одиночных (омографы 送灶/炎/修/气 НЕ инъектируются), спойлер-фильтр `since_ch/until_ch` (hard-reject), sticky для местоименных чанков, disposition confirmed/ambiguous/reject, post-check (ловит и утечку, и отравление). **Бэкенду:** порт в Go 1:1, тесты T1–T10 → unit-тесты; `[PROD]`-замены: OpenCC, Aho-Corasick, pymorphy/`decl`. @@ -567,6 +567,20 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор > 5. **Ключевой набор расширен:** теперь живы и `OPENAI_API_KEY`, и `GEMINI_API_KEY`, и `XAI_API_KEY` — Gemini-судья и Grok-канал-B можно гонять в евалах сразу. > 6. **xAI/Grok НЕ выведен** (уточнение владельца): ретайрнулся только дешёвый Grok 4.1 Fast, сам провайдер жив. Роли Grok — переводчик канала B **и судья 18+** (не отказывается оценивать explicit). У xAI **промо $150 за data-sharing** — бери на выделенный NSFW-аккаунт: этим разблокируется висящая 18+ часть refusal-бенчмарка (эксп.02) и 18+ плечо пилота. Гони на промо-кредитах: (а) 18+ refusal/excision на Grok/DeepSeek/локальной abliterated; (б) Grok в роли 18+-судьи — качество оценки explicit-сцен. Data-sharing только PD/тест-корпус, реальные книги через него не гнать. Grok в евалах — thinking OFF (reasoning аддитивный). +### 2026-07-05 — Сессия 3 полигона: coverage-precision (07), cost-model-v2 (08), пилот-подготовка (09) + +Три ближние задачи закрыты; всё адверсариально верифицировано воркфлоу (5 линз, вердикт CONDITIONAL GO → все must-fix внесены). Артефакты — `eval/coverage_precision.py`, `eval/content_filter_probe.py`, `eval/cost_model_v2.py`, `eval/pilot/{declmetric,memory_eval}.py`, `docs/experiments/07–09`. + +- **Задача 1 — precision coverage-гейта → флип МОЖНО (D12/Q4).** [experiments/07](experiments/07-coverage-precision.md). **0 ложных срабатываний на 57 хороших переводах** (LLM deepseek+grok × 2 + канон Рогова/Фельдмана), 0/26 нарратив, 0/31 «диалог» (маркер-прокси). Диалог: русский дробит CJK → `sent_cov ≥ 1` (не проседает); ближайший к полу 0.75 — 0.886 (ja-нарратив, запас 18%). Коридоры len_ratio НЕ узки (zh запас +20%, ja +14%). Go↔Python паритет-тест зелёный. **Оговорка широты (честно):** «диалог»-страт — классика Лу Синя с цитируемыми ТЕРМИНАМИ, НЕ вебновелл-диалог; §3.7 НЕ воспроизведён но и НЕ опровергнут; человеческий слой глава-гранулярен. **[→ бэкенду/владельцу]** флип `gates.coverage.enabled:true` по precision безопасен; порог главы **≥2 флага=fail, 1=attention**; коридоры не менять; **пере-снять на реальных вебновеллах владельца** до снятия 1-флаг-толерантности (просьба ниже). + - **Побочно (D§85, → бэкенду):** `finish_reason=content_filter` **не эмитит ни один** из 5 ядровых провайдеров на SFW-violence (все `stop`/200/перевели) → ветка мертва, страховка = refusal-blacklist (подтверждает D2.4). Нюанс: **gemini-3.1-pro** (апекс) единожды дал `content_filter:PROHIBITED_CONTENT`, но не воспроизвёл на 4 контрольных → изредка срабатывает, не системно. + - **Побочно (→ бэкенду):** **draft-эхо классической zh — системно:** deepseek-v4-flash эхнул **13/24** zh-чанков Лу Синя, grok-non-reason **10/24** (лёгкая правка исходника вместо перевода, не усечение) → ловит интринсик echo-гейт; single-hop эскалация черновика оправдана; вебновеллы вне претрейна — риск ниже. + +- **Задача 2 — COGS v2 на grok-editor стеке → оба порога мертвы.** [experiments/08](experiments/08-cost-model-v2.md). Прайсы — офиц. доки, датированы 2026-07-05, кросс-верифицированы (воркфлоу). **Стандарт-микс (draft+editor):** ja→ru ранобэ **$0.69**, zh→ru вебновелла-500 **$10.94**, en→ru роман **$0.93** — **редактор ~88–90% стоимости** (grok-выход ×9 draft). **$0.6 мёртв.** **Премиум (Gemini apex):** ранобэ $5.0, вебновелла центр **~$82** (rf-условно $66–112; ниже якоря $100), роман $6.8 — **полный apex больших работ НЕ дефолт** (переполняет $30). Селективный apex (15%): вебновелла $24.6. **[→ бэкенду/оркестратору]** (1) editor slug = **`grok-4.20-0309-non-reasoning`** — grok-4.3 форсирует reasoning (не отключается effort-параметром, +25% output; живая проба); (2) `budget_usd` под селективный apex ($2 ранобэ/роман; вебновелла — потолок-на-главу/`apex_fraction`); (3) батч −50% только Gemini-судье; (4) **rf Gemini НЕ измерим через OpenAI-слой** (thoughts=0) — нужен нативный API перед привязкой премиум-бюджета. + +- **Задача 3 — подготовка пилота Ф2.5.** [experiments/09](experiments/09-pilot-protocol.md) (протокол: ядро C0–C3 BWS, судья-панель+s\*, memory-eval WMT25-3-режим, think-ON/OFF, Annotator A/B, en-якорь для zh/ja верности) + харнесс `eval/pilot/`. **Decl-метрика** (`declmetric.py`, pymorphy3+stored-decl, mirror Go post-check) — склонённые формы (Вэйчжуане/Вана/Дэна) 0 ложных флагов. **memory_eval.py** — WMT25-3-режим, инъекция = **зеркало Go-контракта memory.go** (спойлер/disposition/sticky/containment/per-lang-minkey валидированы; `memory_hotpath.py` устарел до cc57c7b — не переиспользовал). **Индикатив (Ah-Q, N=5):** C0 консист. 0.58, **C1(банк) 1.0 = C2(длинный контекст) 1.0**, C3(неверный) 0.60 → банк ≈ длинный контекст по консистентности при меньшей инъекции; неверная инъекция не помогает. **[← владельцу нужно]** корпус 25–35 глав с приватными эталонами (рекуррентные имена, диалог/нарратив); человеческие BWS-аннотаторы; explicit-18+ фрагменты для канала B (gitignored). + +**[ПРОСЬБА ВЛАДЕЛЬЦУ]** 3–5 глав реальной вебновеллы/ранобэ zh/ja с любым русским ориентиром → `eval/data/samples//` — закрывает (а) диалог-плотный срез coverage-precision (эксп.07), (б) довалидацию токен-калибровки r (эксп.01/08), (в) старт memory-eval на большом глоссарии. Для zh — вне претрейна (обход эхо-мины). + ## Память (секция сессии «Валидация банка памяти» — записи добавлять сюда) diff --git a/docs/experiments/07-coverage-precision.md b/docs/experiments/07-coverage-precision.md new file mode 100644 index 0000000..05c0792 --- /dev/null +++ b/docs/experiments/07-coverage-precision.md @@ -0,0 +1,128 @@ +# Эксперимент 07. Precision (доля ложных срабатываний) excision coverage-гейта + +**Дата:** 2026-07-05 · **Зона:** полигон · **Гейтит:** боевой флип `gates.coverage.enabled:true` (D12/Q4). + +## TL;DR + +- **0 ложных срабатываний excision_suspect на 57 хороших переводах** (реальные zh/ja→ru чанки), в обоих слоях: **26 нарративных + 31 «диалоговый»** (по маркер-прокси), три источника «хорошего перевода» (LLM × 2 модели + канон Рогова/Фельдмана). **Оговорка широты:** источники пересекаются — zh-диалог = один автор (Лу Синь), переведённый 2–3 стеками на пересекающихся главах; ja — один draft-модель + одна человеческая пара. Distinct-источников мало (см. §методика). 0 флагов — надёжный факт; **широта — узкая**. +- **Диалого-плотный страх (§3.7) НЕ воспроизведён на классической прозе** — но и **не «опровергнут»**: русский **дробит** длинные CJK-предложения → `sent_cov ≥ 1.0` на «диалоговых» чанках (пул-медиана 1.375; разброс 1.05–1.66). НО «диалоговый» страт здесь — в основном **классическая проза Лу Синя с цитируемыми ТЕРМИНАМИ** (напр. 序-предисловие с 『』-цитатами литературоведческих терминов), НЕ вебновелл-диалог из терсных реплик/звукоподражаний. **Вебновелл-режим остаётся непроверенным** (см. просьбу владельцу). Ближайший к порогу — `sent_cov=0.886` (ja-нарратив), запас 18%. +- **Коридоры len_ratio не слишком узкие:** zh хорошие 2.64–3.74 (пол 2.2, запас ≥20%), ja хорошие 1.59–2.35 (пол 1.4, запас ≥14%). Ложных срабатываний по нижней границе len_ratio — ноль. +- **Рекомендация: флип МОЖНО (по precision).** Порог главы: **≥2 флага/главу = «fail», 1 флаг = «attention»** — консервативно к невиданному диалого-плотному вебновелл-корпусу. Коридоры/пороги менять не нужно. +- **Побочно (важно бэкенду): draft-эхо на классическом zh — не единичный баг, а системный.** deepseek-v4-flash эхнул **13/24** zh-чанков Лу Синя, grok-4.20-non-reasoning — **10/24** (лёгкая правка исходника вместо перевода, cjk_share 78–85%). Это ловит **интринсик echo-гейт** (не coverage) — но означает, что классическая/литературная zh требует эскалации черновика. +- **Побочно (D§85): `finish_reason=content_filter` не эмитит НИ ОДИН** из 5 провайдеров (deepseek/glm/kimi/gemini/grok) на SFW-violence — все `finish=stop`, 200, перевели. Ветка диспозиции по content_filter практически мертва на переводимом контенте → реальная страховка — refusal-blacklist (подтверждает D2.4). +- **Оракул↔Go:** Go-тесты паритета (`Oracle|Coverage|Split`) зелёные на HEAD → замер Python-оракулом Go-верен. + +**Ограничение:** корпус — PD-классика (нарратив-смещённая проза), не современные вебновеллы/ранобэ (плотнее диалогом, звукоподражания, терсные реплики). Механизм («ru дробит → sent_cov растёт») должен держаться и там, но **решающий диалого-плотный срез — на реальных главах владельца** (просьба ниже). + +## Вопрос + +Из D12/Q4: recall гейта (ловит ли реальные вырезания ≥90%) бэкенд уже провалидировал на мини-сете выпиленных предложений. Флип упирается в **precision — долю ХОРОШИХ переводов, которые гейт ложно метит `excision_suspect`**, особенно на диалого-плотных главах, где наивная сегментация может недосчитать русских предложений против CJK-исходника (`sent_cov` ложно < 0.75). + +Гейт (`backend/internal/pipeline/coverage.go`, порт `eval/refusal_bench.py::classify_output`): флаг, если `sent_cov < 0.75` ИЛИ `len_ratio < нижняя_граница_коридора` (zh<2.2, ja<1.4, en<0.70). Метрика — символы БЕЗ пробелов. Только нижняя граница. Чанки с исходником < `min_chunk_chars=500` (без пробелов) не гейтятся. + +## Методика + +**Принцип:** флаг на заведомо ХОРОШЕМ (полном, неусечённом) переводе = ложное срабатывание по построению. Три независимых источника хороших переводов: + +1. **LLM продакшн-стек, deepseek-v4-flash draft** (реальная Ф1-модель черновика, thinking-ON). 49 чанков zh+ja. +2. **LLM, grok-4.20-non-reasoning** — второй переводчик zh (deepseek эхает классику; grok даёт полный перевод) → **робастный zh-LLM-слой**. +3. **Канонический человеческий перевод** (Рогов «А-кью», Акутагава «Нос»), выровненный **по главам** (выравнивание подтверждено сверкой заголовков 1:1: 第一章 序 ↔ Ⅰ Введение, 第四章 恋爱的悲剧 ↔ Ⅳ Трагедия любви, offset-4 для ch5-9). **⚠ Гранулярность: человеческий слой гейтит ГЛАВЫ, не продакшн-чанки** — каждая zh-глава «А-кью» (est 1515–2577 ток.) в проде разбилась бы на 2 чанка, ja «Нос» — на 4. → человеческие 10 точек — **глава-гранулярны, слабее для пер-чанкового гейта** (усреднение смягчает вариацию), поэтому острый чанковый тест несёт LLM-слой, человеческий — подтверждающий на реальной published-прозе. + +**Distinct-источники (честно):** zh-диалог — 3 файла Лу Синя (ah-q ch1-4, ch5-9, zhufu), переведённые deepseek(8 ok)+grok(11 ok)+канон(9 глав) = **один автор, 2–3× перевод на пересекающихся главах**; ja — Акутагава/Дазай/Сосэки (нарратив-смещены) на одном draft-модели. Так что 31 «диалоговая» точка = НЕ 31 независимый источник. + +**Чанкер** — верный порт `chunker.go`: абзацы (пустая строка) пакуются до ≤1500 est-токенов (`est = cjk + other/3`, пол 16), при абзаце сверх бюджета спуск к предложениям. Размеры чанков 500–1700 симв. (в калибровочной полосе 500–2500). + +**Стратификация нарратив/диалог:** плотность диалог-маркеров `[「」『』“”《》:]` на предложение исходника; ≥0.5 → «диалог». **Грубый прокси:** считает отдельные МАРКЕРЫ (не пары кавычек) и не отличает цитируемый термин от многоклаузной реплики — потому «диалоговый» страт включает и цитат-плотную нарративную прозу (序-предисловие). Точнее было бы считать пары и исключать короткие цитаты; здесь — как ориентир, не строгая метка. + +**Гейт-оракул:** `classify_output` (санкционирован D12/Q1 как источник истины; Go — порт 1:1, паритет-тест зелёный). `min_chunk_chars=500` применён (продакшн пропускает короче; исключено 2+2 коротких чанка, ни один не был бы FP). Из знаменателя coverage-FP исключены не-coverage диспозиции (echo/empty/refusal). **Это исключение НЕ прячет промахов гейта:** оракул проверяет cjk_share>0.15 (echo) ДО coverage-ветки, а сам эхо (78–86% CJK) всё равно провалил бы и len_ratio<коридор — так что ни один чанк, который ДОЛЖЕН флагаться, не потерян. + +Харнесс: `eval/coverage_precision.py`; данные: `eval/data/coverage_precision/`. + +## Результаты + +### Доля ложных срабатываний по слоям и стратам + +| Слой (модель) | n гейтнутых | флагов | нарратив | диалог | len_ratio диапазон | sent_cov диапазон | +|---|---|---|---|---|---|---| +| deepseek-v4-flash (ja+zh) | 35 | **0** | 24 | 11 | 1.59–3.53 | 0.89–1.72 | +| grok-4.20-non-reason (zh) | 12 | **0** | 1 | 11 | 3.03–3.74 | 1.05–1.66 | +| человеческий канон | 10 | **0** | 1 | 9 | 2.07–4.02 | 0.94–1.65 | +| **ИТОГО** | **57** | **0** | **26** | **31** | — | — | + +**Доля ложных срабатываний = 0/57 = 0.0%**, в т.ч. **0/31 на «диалоговых» (маркер-прокси) чанках**. §3.7-страх **НЕ воспроизведён на классической прозе с цитируемыми терминами, но и НЕ опровергнут** — вебновелл-режим (терсные реплики/звукоподражания) непроверен; настоящее опровержение §3.7 требует реального вебновелл-корпуса владельца (рекомендация #4), не этих данных. + +### Почему диалог не роняет sent_cov + +Русский литературный перевод **дробит** длинные CJK-периоды на несколько предложений (и разворачивает диалог в тире-реплики с точками), поэтому на диалого-плотных главах `sent_cov` **растёт выше 1**, а не проседает: +- человеческие диалоговые главы «А-кью»: `sent_cov` 1.36–1.65; +- grok диалоговые zh-чанки: 1.05–1.66; +- 6 самых близких к полу 0.75 — все **ja-НАРРАТИВ** (Акутагава «Нос», Дазай), где японский из коротких 。-предложений и русский их слегка сливает: минимум **0.886** — запас 18% до порога. **Оговорка:** этот приграничный ja-страт — только deepseek-draft (второй модели нет; ×2-корроборация — только zh); запас 18% — наблюдение одного переводчика. + +### Запас коридоров len_ratio (нижняя граница) + +| Пара | хорошие переводы, len_ratio min–max | пол коридора | запас минимума | +|---|---|---|---| +| zh→ru | 2.64–3.74 (LLM), 3.05–4.02 (канон) | 2.2 | +20% | +| ja→ru | 1.59–2.35 (LLM), 2.07 (канон) | 1.4 | +14% | + +Ни один хороший перевод не подошёл к нижней границе len_ratio ближе 14%. **Коридоры не слишком узкие** — источник ложных флагов по len_ratio не обнаружен. (Пороги — из эксп.02 на этом же классе текстов, самосогласованно.) + +### Флагов на главу + +Максимум флагов на любую главу (по всем слоям) = **0**. Даже порог «1 флаг/главу = fail» на этом корпусе не сработал бы ложно ни разу. + +## Побочные находки + +### 1. Draft-эхо на классическом zh — системное, не единичное (→ бэкенду) + +deepseek-v4-flash вернул исходник (лёгкая модернизация: 耳朶→耳朵, 『』→"") вместо перевода на **13 из 24** zh-чанков Лу Синя; grok-4.20-non-reasoning — на **10/24**. Это НЕ усечение (reasoning_tokens 64–195, completion не упёрся в лимит) — модель «почистила» классику, а не перевела. cjk_share 78–86% → ловит интринсик **untranslated_echo**-гейт (правильно; это его работа, не coverage). Уроки: +- эхо-мина deepseek воспроизводится **на масштабе** на литературной/классической zh, не только на редких фрагментах (эксп.02 её не видел на коротких refusal-фрагментах); +- эхо не уникально для deepseek — **grok-non-reasoning тоже эхает** классику (частично другие чанки) → это свойство класса «плотная классическая zh», вероятно усиленное присутствием текста в претрейне; +- **следствие для Ф1:** zh-черновик классики требует single-hop эскалации на эхо (уже реализовано, Веха 2.5); для современных вебновелл (не в претрейне) риск ниже, но перепроверить на корпусе владельца. + +### 2. content_filter не эмитится (D§85, → бэкенду) + +`eval/content_filter_probe.py`, самый refusal-близкий SFW-фрагмент (l2-violence, Говард, 1470 симв.), 5 ядровых провайдеров: + +| Провайдер | модель | finish_reason | http | итог | +|---|---|---|---|---| +| deepseek | deepseek-v4-flash | stop | 200 | перевёл | +| grok | grok-4.20-non-reason | stop | 200 | перевёл | +| glm | glm-4.5-air | stop | 200 | перевёл | +| kimi | kimi-k2.6 | stop | 200 | перевёл | +| gemini | gemini-2.5-flash | stop | 200 | перевёл | + +**Ни один не эмитит `finish_reason=content_filter`** на переводимом (даже насильственном) контенте — все `stop`, перевели целиком. Подтверждает D2.4: ветка диспозиции по content_filter практически мертва; реальная страховка на SFW-диапазоне — **refusal-blacklist гейт**, не finish_reason. + +**Нюанс по Gemini-3.1-pro (апекс/судья, отдельно от 2.5-flash):** в apex-пробе (эксп.08) gemini-3.1-pro-preview **единожды** вернул `finish=content_filter: PROHIBITED_CONTENT` на одном ja-черновике, но **НЕ воспроизвёл** на 4 контрольных (l2-violence / rashomon / hashire / ah-q — все `stop`, перевели). → на Gemini-апексе ветка content_filter **изредка срабатывает** (в отличие от 5 ядровых), но не системна — как надёжный сигнал не годится, refusal-blacklist остаётся страховкой. Определяющий 18+-тест — на explicit-фрагментах владельца (18+ рука эксп.02). + +## Рекомендация (владельцу/оркестратору → бэкенду) + +1. **Флип `gates.coverage.enabled:true` по precision безопасен** — 0/57 ложных, оба страта. Гейтит только остаточный риск диалого-плотных вебновелл (см. ограничение). +2. **Порог главы (паспорт качества, D12):** **≥2 флага/главу → chapter-verdict `fail`; 1 флаг → `attention`** (не fail). Обоснование: на PD-корпусе max флагов/главу = 0 с большим запасом, но держим 1-флаг-толерантность как страховку под непроверенный вебновелл-корпус. Одиночный флаг всё равно даёт single-hop эскалацию чанка (D12) — это дёшево при FP≈0. +3. **Коридоры/пороги НЕ менять:** sent_cov 0.75 (запас 18%), zh 2.2 / ja 1.4 (запас 14–20%) — не источник ложных срабатываний. +4. **Пере-снять precision на 25–35 главах реальных вебновелл/ранобэ владельца** перед снятием 1-флаг-толерантности (диалог-плотность выше классики). + +## Ограничения + +- Корпус — PD-классика начала XX в. (Лу Синь, Акутагава, Дазай, Сосэки): нарратив-смещённая, плотная проза; современные вебновеллы/ранобэ диалого-плотнее (быстрый обмен репликами, звукоподражания, терсные строки). Механизм «ru дробит CJK → sent_cov растёт» должен держаться и усиливаться на коротких репликах, но **терсные междометия/звукоподражания-строки** — единственный неизмеренный остаточный путь к низкому sent_cov. Нужен реальный корпус. +- zh-LLM-слой частично держится на grok (deepseek эхал половину) — но человеческий канон покрывает 9 диалого-плотных глав «А-кью» независимо, так что zh-диалог-вывод не висит на одной модели. +- Один переводчик канона на пару; порядок величин надёжен. + +## Просьба владельцу + +Для решающего диалого-плотного среза (и довалидации токен-калибровки эксп.01) — **3–5 глав реальной вебновеллы/ранобэ zh или ja с любым русским ориентиром** (даже черновым), файлы в `eval/data/samples//`; харнесс пересчитает автоматически. Идеально — то, что реально пойдёт в продукт (для zh — что-то вне претрейна, чтобы обойти эхо-мину). + +## Расхождение с планом + +Нет расхождений с архитектурой. Одно уточнение к D2.4 (content_filter) — подтверждено эмпирически (ветка мертва на SFW), выше. + +## Воспроизведение + +```bash +eval/.venv/bin/python eval/coverage_precision.py --arm both --workers 6 # deepseek draft + human canon +eval/.venv/bin/python eval/coverage_precision.py --arm llm --translator grok --langs zh # robust zh LLM arm +eval/.venv/bin/python eval/content_filter_probe.py # D§85 finish_reason probe +cd backend && go test ./internal/pipeline/ -run 'Oracle|Coverage|Split' # Go↔oracle parity +``` +Данные: `eval/data/coverage_precision/{results.json,results_grok_zh.json,content_filter_probe.json}`. diff --git a/docs/experiments/08-cost-model-v2.md b/docs/experiments/08-cost-model-v2.md new file mode 100644 index 0000000..713b08d --- /dev/null +++ b/docs/experiments/08-cost-model-v2.md @@ -0,0 +1,102 @@ +# Эксперимент 08. COGS v2 на ратифицированном Ф1-стеке (draft=DeepSeek, editor=grok, apex=Gemini) + +**Дата:** 2026-07-05 · **Зона:** полигон · **Гейтит:** дефолты `budget_usd` и приёмочные $-пороги Ф1 (D-эконом, D11/Q4). +**Заменяет:** цифры эксп.01 (были на Sonnet-редакторе) и оба устаревших порога — стандарт $0.6, премиум $5/$30. + +## TL;DR + +- **Редактор теперь ~88–90% стоимости стандарт-микса** (робастно 87–90% по h_e и с учётом deepseek-reasoning). grok-выход $2.50/M ≈ **9× дешёвого draft-выхода** ($0.28/M) при одинаковом объёме → один смешанный множитель M_out (эксп.01) больше неприменим, каждую стадию считаем по цене её модели. +- **Стандарт-микс (draft+editor):** ja→ru **ранобэ том $0.69**, zh→ru **вебновелла-500 $10.94**, en→ru **роман $0.93**. Порог приёмки **$0.6 — мёртв** (был на дешёвом редакторе). Совпадает с прикидкой decisions-log ($0.73). +- **⚠ grok-4.3 форсирует reasoning, который НЕ отключается** (`reasoning_effort:low/none` не помогают, живая проба) и **биллится как output** (+25% output на продакшн-размере). **Истинный «thinking-OFF редактор» — `grok-4.20-0309-non-reasoning`** (тот же прайс-кард, тот же видимый выход, reasoning=0). Если оставить grok-4.3 ради качества reasoning — стандарт-микс **+13–14%**. +- **Премиум full-apex (Gemini-3.1-Pro, форс-thinking → reasoning-as-output):** ранобэ **$5.0**, вебновелла-500 **~$81.5** (центр rf=1.0; диапазон $66–112), роман **$6.8**. **Полный apex вебновеллы приближается к человеческому якорю $100** (достигает/превышает лишь при rf≳1.4 или >200k-тарифе) → как дефолт не годится. +- **Селективный премиум** (apex только на ~15% трудных/флагнутых чанков): ранобэ $1.5, вебновелла **$24.6**, роман $2.1 — экономически жив. +- **`budget_usd`:** $5 ≈ полный apex ранобэ; $30 вебновеллы держит только **селективный** премиум (apex на **~23%** чанков), не full-apex ($82). **Полный apex больших работ — не дефолт; премиум крупных работ ОБЯЗАН быть селективным** (эскалация флагнутых, согласовано с D11: escalation уважает budget_usd, inline-последователен). +- **Батч −50% только у Gemini** (судья/QA), не у draft/editor (deepseek/grok батч не публикуют). **Reasoning-as-output:** deepseek-draft reasoning ≈**26%** выхода черновика (эксп.07 данные: 20462/77637), но **<2% стандарта** (черновик — ~10% стоимости) → в модели опущен (не «мал сам по себе»); grok-4.3 +25%; Gemini форс (rf, не измерен — см. ниже). + +## Прайсы — официальные доки, датированы, кросс-верифицированы (2026-07-05, НЕ по памяти) + +Собраны воркфлоу-фетчем (по агенту на провайдера) + независимая верификация 3 ядровых по второму источнику. Все — стандартные (не промо) USD/1M токенов. + +| Модель | вход | вход cache-hit | выход | батч | reasoning | источник | +|---|---|---|---|---|---|---| +| **deepseek-v4-flash** (draft) | $0.14 | $0.0028 | $0.28 | нет | биллится как output (мал) | api-docs.deepseek.com/quick_start/pricing | +| deepseek-v4-pro | $0.435 | $0.003625 | $0.87 | нет | как output | там же | +| **grok-4.3** (editor/канал B/судья 18+) | $1.25 | $0.20 | $2.50 | нет | форс-ON, аддитивный→output | docs.x.ai/developers/models/grok-4.3 | +| **grok-4.20-0309-non-reasoning** (истинный editor) | $1.25 | $0.20 | $2.50 | нет | **reasoning=0** | docs.x.ai/…/grok-4.20-0309-non-reasoning | +| **Gemini 3.1 Pro** (apex/судья) | $2.00 (≤200k) / $4.00 (>200k) | $0.20 / $0.40 | **$12.00 / $18.00** (вкл. thinking) | **−50%** | форс-thinking → output | ai.google.dev/gemini-api/docs/pricing (upd 2026-06-30) | +| glm-4.5-air / 4.6 / 5 / 5.1 | 0.2 / 0.6 / 1.0 / 1.4 | 0.03 / 0.11 / 0.2 / 0.26 | 1.1 / 2.2 / 3.2 / 4.4 | нет | — | docs.z.ai | +| kimi-k2.6 | $0.95 | $0.16 | $4.00 | −40% | многословный (~11k/абз) | platform.moonshot.ai | +| gpt-5-mini / nano | 0.25 / 0.05 | 0.025 / 0.005 | 2.0 / 0.4 | −50% | как output | openai.com/api/pricing | + +**Оговорки прайсов:** даты — дата наблюдения (офиц. страницы deepseek/xai без видимого стампа; Gemini «upd 2026-06-30»). Gemini cache-storage $4.50/M·час — отдельно (не в этих цифрах). $150 xAI data-sharing — **кредит, не скидка тарифа**. Reseller-цены (OpenRouter $0.09/$0.18 на deepseek) — не офиц. лист-прайс, не используем. + +## Токен-модель (покомпонентно, цена — по модели стадии) + +На книгу из **B** исходных токенов, коэффициент **r = выход/вход** (из эксп.01, параллельные пары): + +| Стадия | вход | выход | +|---|---|---| +| Draft (билингв. переводчик, deepseek) | (1+h_d)·B, h_d=1.0 | r·B (deepseek-reasoning ≈26% выхода → +1.6% стандарта, опущен) | +| Editor (**моноязычный** D1, grok-non-reason) | (r+h_e)·B, h_e=0.5 | r·B (reasoning=0) | +| Apex (Gemini билингв. финал, форс-think) | (1.5+r)·B | r·B·(1+rf) | +| Judge (Gemini, 20% выборки, батч) | 0.2·(1+r)·B | 0.1·r·B·(1+rf) | + +`h_d=1.0` (исходник + систем-промпт + селективный глоссарий + STM ≈ B); `h_e=0.5` (моноредактор видит черновик r·B + малый промпт, БЕЗ исходника/большого STM — воспроизводит decisions-log $0.67–0.73); `rf` — Gemini thinking-as-output (центр 1.0; чувствительность 0.5–2.0). Книги: ja→ru ранобэ B=113k/r=1.29; zh→ru вебновелла-500 B=1.29M/r=1.88; en→ru роман B=131k/r=1.53. + +## Результаты + +### Стандарт-микс (draft + editor) — приёмка Ф1 + +| Книга | было (эксп.01) | **стало (v2, non-reason editor)** | editor доля | grok-4.3 reasoning | 50% cache draft | +|---|---|---|---|---|---| +| ja→ru ранобэ том | $0.17 | **$0.69** | 89% | $0.78 (+13%) | $0.67 | +| zh→ru вебновелла-500 | $2.57 | **$10.94** | 90% | $12.46 (+14%) | $10.76 | +| en→ru роман | $0.22 | **$0.93** | 90% | $1.05 (+14%) | $0.91 | + +Скачок от эксп.01 — целиком редактор (grok-выход ×9 против прежнего GLM/DeepSeek). Экономика **жива** (том <$1 против якоря $100), но приёмочный порог $0.6 устарел на всех парах. **Это zero-defect нижняя граница:** без ретраев/эскалаций. Для эхо-тяжёлой классической zh (эксп.07: deepseek эхает 54%) single-hop эскалация черновика поднимает draft-стоимость (editor-доля → ~80%); современные вебновеллы вне претрейна — риск ниже. + +### Премиум-микс (+ Gemini-3.1-Pro apex + судья) + +| Книга | apex (rf=1.0) | судья | **PREMIUM full (rf=1.0)** | диапазон rf=0.5–2.0 | **селективный 15%** | +|---|---|---|---|---|---| +| ja→ru ранобэ том | $4.13 | $0.23 | **$5.05** | $4.1–$6.9 | **$1.54** | +| zh→ru вебновелла-500 | $66.93 | $3.65 | **$81.52** | $66–$112 | **$24.63** | +| en→ru роман | $5.60 | $0.31 | **$6.84** | $5.6–$9.4 | **$2.07** | + +**Полный apex вебновеллы = центр ~$82 (rf=1.0; диапазон $66–112) — в основном НИЖЕ человеческого якоря $100** (дешевле человека — это ценность, не дисквалификатор). Он не годится как **дефолт** по другой причине: **переполняет потолок $30** и его наценка над стандартом $11 слишком велика для прогона на КАЖДОЙ книге. Дефолт $30 держит только селективный премиум (apex на **~23%** чанков; 25% = $31.3 уже чуть за бюджетом). Для ранобэ/романа полный apex ($5–7) остаётся жив. + +## ⚠ Живая проба: grok-4.3 форсирует reasoning (→ бэкенду, важно для ledger и config) + +`reasoning_effort:low` и `extra_body.reasoning.effort:none` **НЕ отключают** reasoning у grok-4.3 — на тривиальной правке 873 reasoning-токена, на продакшн-правке (~2428 ток.) **495 reasoning на 1972 видимых = +25% output**. reasoning у xAI **аддитивен к completion** (в usage отдельным полем; видимый completion + reasoning = биллинг). `grok-4.20-0309-non-reasoning` даёт **тот же видимый выход с reasoning=0** по тому же прайс-карду. + +**Следствие:** заявленный «дефолт-редактор grok-4.3 @ thinking-OFF» (D3) на практике = **`grok-4.20-0309-non-reasoning`** (иначе ledger недосчитает 25% output — тот же класс ошибки, что сжёг vojo 30–44%). Если reasoning grok-4.3 реально улучшает ru-редактуру (НЕ измерено — эксп.04 сравнивал стиль, не reasoning-вклад) — это осознанный размен +14% COGS, но тогда `EstimateUSD` ОБЯЗАН резервировать reasoning-буфер редактора. Уточнить в пилоте Ф2.5 (think-ON/OFF рука уже там). + +## Рекомендации (→ оркестратору/бэкенду) + +1. **Приёмочный $-порог Ф1:** снять жёсткий $0.6. Мягкий sanity — **~$0.7/ранобэ, ~$11/вебновелла-500** (non-reason editor); приёмка держится на точности телеметрии денег + escalation-respects-budget (D11), НЕ на конкретном числе. +2. **`budget_usd` дефолты:** премиум-потолок задавать **под селективный apex**, не под полный. Предложение: ранобэ/роман — **$2** (полный apex жив); вебновелла — либо **потолок-на-главу**, либо явная `apex_fraction` (селективная эскалация трудных чанков). Полный apex вебновеллы ($82) как дефолт — НЕТ (переполняет $30-потолок; при этом дешевле человека-$100). +3. **Editor slug = `grok-4.20-0309-non-reasoning`** (reasoning=0), не `grok-4.3`, если только пилот не докажет ценность reasoning-редактуры. `models.yaml`: `reasoning_control` для grok-4.3 = `mandatory` (не `extra_body_disable` — не отключается), бюджетировать reasoning как output. +4. **Батч −50% только к Gemini-судье/QA** (deepseek/grok батч не имеют; inline-эскалация последовательна из-за STM — не батчуется). Gemini = и apex, и судья → эскалированный чанк платит Gemini дважды, оба с форс-reasoning. +5. **deepseek auto-cache** экономит ~2% стандарта (стабильный префикс мал против r·B выхода) — не рычаг здесь; рычаг — выбор редактора. + +## Расхождения + +- **эксп.01 / research/09:** оба порога ($0.6 стандарт, $5/$30 премиум) устарели после смены редактора на grok. Стандарт вырос ×3–4 (редактор ×9 по выходу); премиум вебновеллы вырос до якоря (Gemini $12–18/M выхода + форс-reasoning против прежнего Sonnet $15). Направление то же, что предупреждал эксп.01 («буфер премиума сжался»), но теперь количественно: **полный премиум вебновеллы экономически не дефолтен**. +- **decisions-log D3 «grok-4.3 thinking-OFF»** — фактически недостижимо на slug grok-4.3; истинный не-reasoning editor — grok-4.20-non-reasoning (выше). + +## Ограничения + +- B/r — эксп.01 (PD-классика, одна пара/направление); порядок величин надёжен, второй знак — нет. Довалидация r на реальных вебновеллах владельца (эксп.07 просьба) уточнит и это. +- Токенизаторы grok/Gemini закрыты → r-множитель на их сторонах — o200k/символьный прокси (эксп.01 §Ограничения): премиум-цифры могут ещё подрасти (кириллица у закрытых токенизаторов +15–20%). +- Премиум-композиция (полный apex + судья 20%) — модельный выбор; точную форму (что именно делает apex, доля судьи) финализирует оркестратор. Селективный apex — лишь параметризация frac. +- `h_d=1.0`/`h_e=0.5` — оценка накладных; чувствительность ±0.5 меняет стандарт на ±5–10% (editor доминирует выходом, не входом). **h_e=0.5 привязан к D1 (моноязычный редактор, БЕЗ исходника).** Если редактор гонять **БИЛИНГВАЛЬНО** (исходник+черновик), вход = (r+1.5)·B → стандарт **+15–20%** (больше заявленной ±10% полосы). ⚠ **Quality-transfer риск:** эксп.04 ВЫБРАЛ grok, кормя его исходник+черновик (билингвально); его рейтинг качества установлен в ДРУГОМ режиме, чем оцениваемый здесь моноязычный — выбор grok на моноредактуре строго не перенесён. +- **rf (Gemini thinking-as-output) НЕ ИЗМЕРЕН** и доминирует в каждом премиум-числе (при rf=1.0 половина apex-выхода — предполагаемые thinking-токены). **Живая проба (2026-07-05): OpenAI-совместимый слой Gemini НЕ отдаёт thinking-токены** (`completion_tokens_details.reasoning_tokens=0`, completion≈видимый выход) → rf через этот слой неизмерим; нужен **нативный Gemini API** (`usageMetadata.thoughtsTokenCount`) на 5–10 реальных чанках ПЕРЕД привязкой бюджета к rf=1.0. Все премиум-цифры — **rf-условны**; go/no-go выживает всю полосу (даже rf=0 → $51 ≫ $30-потолок). +- **Gemini apex — тариф ≤200k** (при чанкинге ~1.5k ток./вызов apex всегда <200k). Если apex гонять длинным контекстом (>200k), тариф прыгает на $4/$18 → полный премиум **+~55%** (вебновелла apex → ~$104). Селективный чанковый apex остаётся на ≤200k. +- **deepseek cache-hit $0.0028** — с офиц. страницы (api-docs), но соотношение к cache-miss $0.14 (÷50) необычно низко (V3 исторически ÷10 ≈ $0.014); **не нагружает вывод** (cache — ~2% стандарта), но перепроверить на живой странице при использовании как рычага. + +## Воспроизведение + +```bash +eval/.venv/bin/python eval/cost_model_v2.py # таблицы + eval/data/cost_model_v2.json +``` +Прайсы: workflow `fetch-provider-prices` (per-provider фетч + верификация), 2026-07-05. grok-reasoning проба — inline в этом отчёте (usage.reasoning_tokens на grok-4.3 vs grok-4.20-non-reasoning). diff --git a/docs/experiments/09-pilot-protocol.md b/docs/experiments/09-pilot-protocol.md new file mode 100644 index 0000000..16dfa88 --- /dev/null +++ b/docs/experiments/09-pilot-protocol.md @@ -0,0 +1,118 @@ +# Эксперимент 09. Протокол пилота Фазы 2.5 — выбор ядра, memory-bet, судья, think-режим + +**Дата:** 2026-07-05 · **Зона:** полигон (флагман) · **Статус:** протокол + харнесс-скелет; решающий прогон ждёт корпус владельца + человеческих аннотаторов. +**Закрывает решения:** выбор ядра C0–C3 (Р2), go/no-go на банк памяти (research/13 §Вердикт), валидность LLM-судьи (research/11-gap-3), think-ON/OFF (D6), ценность пре-пасса Annotator (04-unhappy). + +Пилот — единственная точка, где сходятся все отложенные «решаемые только человеком» вопросы качества. Он же — **дециждающий эксперимент банка памяти** (ставка не морозится до него, вердикт GO-на-схему был условным). + +## 1. Что пилот решает (и почему только он) + +| Решение | Почему не решено раньше | Рука пилота | +|---|---|---| +| **Ядро C0–C3** (baseline / draft→editor / generate-select / select-refine) | arXiv:2603.20324 «When Agents Disagree» не покрывает перевод; на zh/ja→ru нужен свой (research/11-gap-3) | человеческий BWS + судья-панель | +| **Банк памяти vs длинный контекст** (go/no-go на ставку) | DelTA — LLM-в-цикле, не наш детерминированный путь; сравнение «банк vs длинный контекст» на zh/ja→ru не проведено (research/13 Q6) | memory-eval (§6), WMT25-3-режим | +| **Валиден ли LLM-судья вообще** | LAIT: судьи расходятся с людьми; на s\* висит вся ставка generate-select C2/C3 | корреляция судья↔человек + калибровка s\* (§5) | +| **think-ON vs OFF по качеству** (draft/translator + editor) | локально подтвердилось на реалиях (羅生門→Радзёмон), но COGS +13–25% (эксп.08) — стоит ли | think-рука (§7), встроена в ядро | +| **Ценность пре-пасса Annotator** | улучшает ли настолько, чтобы оправдать вызов | A/B в ядре (§8) | + +## 2. Корпус (нужен от владельца) + +- **25–35 глав** реальных произведений с **приватными эталонными переводами** (издательскими или выверенными). GuoFeng V2 — только dev, non-commercial (нельзя в продукт-оценку). +- Покрытие: zh→ru (вебновелла + данмэй), ja→ru (ранобэ), en→ru (роман) — по 8–12 глав. Диалого-плотные и нарративные главы (см. эксп.07: диалог-плотность — ключевая ось). +- **Рекуррентные имена/термины** обязательны (≥8 сущностей, повторяющихся через главы) — иначе memory-eval нечего мерить. +- **⚠ Методическое ограничение (эксп.04): владелец читает только en/ru.** → человеческая оценка **ВЕРНОСТИ** возможна лишь на **en→ru** (и русская сторона стиля любого перевода). Для zh/ja верность оценивается: (а) **английским якорь-подстрочником** (профессиональный en-перевод как мост), (б) LLM-судьёй чужого семейства против исходника, (в) сверкой с приватным русским эталоном. Заложено в дизайн (§4–5). + +Пока корпуса нет — индикативный прогон на PD-Ah-Q (`eval/pilot/memory_eval.py`) валидирует харнесс и decl-метрику. + +## 3. Дизайн ядра (C0–C3, Р2) + +Один и тот же чанк проходит 4 конфигурации ядра, все на ратифицированном стеке (draft=DeepSeek-v4-flash, editor=grok-non-reason): + +- **C0 baseline** — один проход переводчика (+ детерминированный глоссарий = уровень VseGPT; baseline метрики, PROGRESS §синтез). +- **C1 draft→editor** — черновик → моноязычный редактор (D1). Дефолт Ф1. +- **C2 generate-then-select** — N черновиков → судья-селектор выбирает лучший (arXiv:2603.20324). Висит на калибровке s\* и валидности судьи (§5). +- **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 на подвыборке). + +## 4. Человеческая оценка — Best-Worst Scaling (BWS) + +**Почему BWS, не шкала Ликерта:** BWS (выбери ЛУЧШИЙ и ХУДШИЙ из набора) даёт более надёжные, менее шумные ранги, чем абсолютные оценки (устойчив к разнице калибровки аннотаторов) — стандарт для сравнения близких систем. LAIT показал: абсолютные оценки судей плывут, ранги — надёжнее. + +- **Единица:** абзац/короткий чанк (не вся глава — усталость аннотатора). Наборы из 3–4 систем (C0–C3, или think-ON vs OFF пары). +- **Слепота:** аннотатор не видит, какая система какой выход дал (рандомизация порядка + меток). +- **Оси оценки (раздельно, НЕ смешивать):** + - **Стиль/естественность** (русская сторона) — владелец/русскоязычный аннотатор, любой языковой парой (не нужен исходник). + - **Верность/полнота** — только en→ru напрямую; для zh/ja — через en-якорь-подстрочник (§2). Отдельная BWS-задача с исходником/якорем на виду. +- **N:** ≥3 аннотатора на набор для inter-annotator agreement (κ); при низком κ — расширять, не усреднять шум. +- **Стилевая ось для M1** (in-context демонстрации, research/14): бьёт ли демо-рука стиль БЕЗ потери консистентности/fidelity (индикатив: по консистентности не бьёт; стиль не мерен). + +## 5. LLM-судейская панель (валидация + калибровка) + +Судья держит две вещи: (а) **выбор в C2/C3** (селектор), (б) **валидацию качества** offline. Обе непроверены на домене → пилот их валидирует, не принимает на веру. + +- **Панель чужих семейств** (анти-self-preference): Gemini-3.1-Pro (нативный, safety-off для 18+), grok-4.3, gpt-5-mini — судят выходы deepseek/grok. Никогда не судить моделью своего семейства свой выход. +- **Шкала Комиссарова** (анкета 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, ось б). + +## 6. Memory-eval — дециждающий эксперимент банка (WMT25 три-режимный) + +**Вопрос (страх владельца + go/no-go):** оправдана ли ДЕТЕРМИНИРОВАННАЯ селективная инъекция (наш банк) vs просто весь глоссарий в длинном контексте, и **вредит ли ошибочная инъекция** (тихая деградация). + +**Три режима терминологии (WMT25) × baseline:** +- **C0** без глоссария; **C1** селективная инъекция (наш банк, §ниже); **C2** полный глоссарий + скользящее резюме (длинный контекст); **C3** случайный/неверный глоссарий (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`. + +**Метрики (три оси раздельно):** +- **(а) консистентность** — 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 цены). + +**Пред-регистрированное правило решения:** C1≈C2 по качеству но C1 дешевле → банк оправдан; C2≫C1 → нужен полнее контекст; **C3 роняет vs C0 по верности → страх владельца подтверждён, post-check/disposition обоснованы** (и мы их конвертируем в громкое, а не устраняем). + +**Спойлер-рука:** отдельный замер — инъектится ли спойлер-запись (since_ch) до её главы (должна hard-reject) — валидировано в харнессе (глава 6 отвергает 革命党, глава 7 инъектит). + +## 7. Think-ON vs OFF (D6, встроено в ядро) + +Гипотеза владельца частично подтвердилась локально (羅生門→Радзёмон) + DRT (arXiv:2412.17498). Но COGS: deepseek-reasoning мал, grok-4.3 +25% (эксп.08), Gemini форс. Мерить **по качеству** на draft/translator И editor (не только Terminologist): +- пары think-ON vs think-OFF того же ядра → BWS + судья; +- скоуп think-ON — subset-billing провайдеры (deepseek/glm/kimi, reasoning ⊆ completion); xAI/Grok think-ON только после reasoning-буфера в EstimateUSD (D6.2); +- **вход в решение:** оправдывает ли прирост качества +13–25% COGS редактора (эксп.08 §grok-4.3). + +## 8. Пре-пасс Annotator (A/B) + +Улучшает ли пре-пасс (извлечение терминов/резюме/контекста ДО перевода) качество настолько, чтобы оправдать вызов. A/B на подвыборке: ядро с Annotator-pre-pass vs без. Метрика — та же BWS + консистентность (пре-пасс должен поднимать recall глоссария). + +## 9. Пред-регистрация (до финального прогона — против p-hacking) + +Зафиксировать ДО прогона: пороги s\*, допустимый flag-volume на главу (эксп.07: ≥2 excision-флага = fail), decl-метрику и её матчер, состав судейской панели, правило решения memory-eval, N аннотаторов и κ-порог. Изменения после — отдельным разделом с обоснованием. + +## 10. Харнесс `eval/pilot/` (построено / нужно) + +**Построено (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`). + +**Нужно достроить (когда есть корпус):** +- BWS item-generator + агрегатор (наборы, рандомизация, κ, ранги) → `bws.py`. +- CometKiwi-обёртка (+ её валидация на ru-литературе, research/11-gap-2) → `cometkiwi.py`. +- Судейская панель обёртка (cross-family, Комиссаров-анкета) → `judge_panel.py`. +- en-якорь-подстрочник pipeline для zh/ja верности. + +## 11. Ограничения и что нужно от владельца + +- **Корпус** (§2) — блокер решающего прогона: 25–35 глав с приватными эталонами, рекуррентные имена, диалог/нарратив баланс. +- **Человеческие аннотаторы** BWS (≥3) — минимум владелец (en/ru); для zh/ja верности — en-якорь. +- **18+ рука** (канал B, судья 18+) — explicit-фрагменты только от владельца, gitignored; на выделенном xAI-промо-аккаунте (не клиентские книги). +- CometKiwi на ru-литературе не валидирован (research/11-gap-2) — пилот его и валидирует; до того — не доверять как единственной оси верности. + +## Воспроизведение (индикатив, без корпуса владельца) + +```bash +eval/.venv/bin/python eval/pilot/declmetric.py # decl-метрика smoke +eval/.venv/bin/python eval/pilot/memory_eval.py --dry-run --chapter 6 # инъекция-зеркало + спойлер-гейт +eval/.venv/bin/python eval/pilot/memory_eval.py --chapter 6 --max-chunks 5 # индикативный C0–C3 на Ah-Q +``` diff --git a/eval/content_filter_probe.py b/eval/content_filter_probe.py new file mode 100644 index 0000000..3e4af85 --- /dev/null +++ b/eval/content_filter_probe.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""D§85 side-probe: do DeepSeek/GLM/Kimi/Gemini/grok emit `finish_reason=content_filter`? + +The backend branches chunk disposition on finish_reason=content_filter (D2.4), but the +OpenAI-compatible layer passes finish_reason raw and it is unverified whether these +providers ever set it. If they signal refusals only via a 200-body message (or an HTTP +error), the content_filter branch is dead and the refusal-blacklist gate is the real +safety. This probe captures the RAW finish_reason on the most refusal-adjacent SFW content +(l2-violence — graphic but non-sexual; the 18+ arm stays owner-gated). Cheap, ~5 calls. + +Usage: eval/.venv/bin/python eval/content_filter_probe.py +""" +from __future__ import annotations +import json, sys, urllib.request, urllib.error +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from refusal_bench import load_env_file, SYSTEM_PROMPT, REFUSAL_RE +import os +load_env_file() +ROOT = Path(__file__).resolve().parent +CORE = {"deepseek", "glm", "kimi", "gemini", "grok"} + +def raw_call(p: dict, system: str, user: str): + """Minimal OpenAI-compat call that RETURNS finish_reason (call_provider hides it).""" + key = os.environ.get(p.get("api_key_env") or "", "") + body = {"model": p["model"], + "messages": [{"role": "system", "content": system}, {"role": "user", "content": user}], + "temperature": p.get("temperature", 0.3), "max_tokens": p.get("max_tokens", 4000)} + if p.get("reasoning_params"): + body.pop("temperature"); body["max_completion_tokens"] = body.pop("max_tokens") + body.update(p.get("extra_body", {})) + req = urllib.request.Request(p["base_url"].rstrip("/") + "/chat/completions", + data=json.dumps(body).encode(), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}) + try: + with urllib.request.build_opener().open(req, timeout=p.get("timeout", 180)) as resp: + data = json.loads(resp.read()) + ch = data["choices"][0] + return {"finish_reason": ch.get("finish_reason"), "text": (ch.get("message", {}) or {}).get("content") or "", + "http": 200, "error": None} + except urllib.error.HTTPError as e: + return {"finish_reason": None, "text": "", "http": e.code, + "error": e.read().decode(errors="replace")[:200].replace("\n", " ")} + except Exception as e: + return {"finish_reason": None, "text": "", "http": None, "error": f"{type(e).__name__}:{e}"} + +def main(): + providers = [p for p in json.loads((ROOT / "providers.json").read_text())["providers"] + if p["name"] in CORE] + frag = [json.loads(l) for l in (ROOT / "data" / "refusal_corpus" / "l2-violence.jsonl").read_text().splitlines() if l.strip()][0] + system = SYSTEM_PROMPT.format(target="русский язык") + print(f"probe fragment: {frag['id']} ({frag['lang']}, {len(frag['text'])} chars, category={frag['category']})\n") + results = [] + for p in providers: + r = raw_call(p, system, frag["text"]) + cjk = None + refusal = bool(REFUSAL_RE.search(r["text"])) if r["text"] else None + emitted_cf = r["finish_reason"] == "content_filter" + verdict = ("content_filter!" if emitted_cf else + "http_error" if r["http"] and r["http"] != 200 else + "refusal_in_body" if refusal else + "translated" if r["text"].strip() else "empty") + row = {"provider": p["name"], "model": p["model"], "finish_reason": r["finish_reason"], + "http": r["http"], "emitted_content_filter": emitted_cf, "verdict": verdict, + "out_len": len(r["text"]), "error": r["error"]} + results.append(row) + print(f" {p['name']:9s} {p['model']:26s} finish={str(r['finish_reason']):14s} " + f"http={r['http']} → {verdict} (out {len(r['text'])} chars)") + if r["error"]: + print(f" err: {r['error'][:120]}") + (ROOT / "data" / "coverage_precision" / "content_filter_probe.json").write_text( + json.dumps({"fragment": frag["id"], "results": results}, ensure_ascii=False, indent=2)) + any_cf = any(x["emitted_content_filter"] for x in results) + print(f"\nANY provider emitted finish_reason=content_filter on SFW-violence: {any_cf}") + +if __name__ == "__main__": + main() diff --git a/eval/cost_model_v2.py b/eval/cost_model_v2.py new file mode 100644 index 0000000..6ba2328 --- /dev/null +++ b/eval/cost_model_v2.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""experiments/08 — COGS v2 on the ratified Phase-1 stack (draft=DeepSeek-v4-flash, +editor=grok-4.3, premium apex=Gemini-3.1-Pro). Supersedes exp01's Sonnet-editor numbers. + +WHY v2: the editor changed from a cheap model (GLM/DeepSeek, ~$0.28/M out) to grok-4.3 +($2.50/M out ≈ 9× draft output). Because the editor output is the same volume as the +draft (~r×B tokens) but ~9× the price, the EDITOR now dominates standard COGS — a single +blended M_out multiplier (exp01) can no longer be used; each stage is priced by its own model. + +PRICES — official docs, dated (fetched + cross-verified 2026-07-05; NOT from memory): + deepseek-v4-flash in $0.14 cache-hit $0.0028 out $0.28 (reasoning billed as output; no batch) + src: api-docs.deepseek.com/quick_start/pricing + grok-4.3 in $1.25 cached-in $0.20 out $2.50 (reasoning additive→output; no batch) + src: docs.x.ai/developers/models/grok-4.3 + gemini-3.1-pro in $2.00(<=200k)/$4.00(>200k) out $12.00/$18.00 (INCLUDES forced thinking) + cached-in $0.20/$0.40 BATCH -50% src: ai.google.dev/gemini-api/docs/pricing (upd 2026-06-30) + +TOKEN MODEL (per book of B source-tokens, output ratio r=out/in from exp01 parallel pairs): + Draft (bilingual translator): in = (1 + h_draft)·B out = r·B [+ small deepseek reasoning, in noise] + Editor (MONOLINGUAL, D1): in = (r + h_edit)·B out = r·B [thinking-OFF, ledger clean] + Apex (Gemini bilingual final, forced thinking): in = (1.5 + r)·B out = r·B·(1 + rf_gem) + Judge (Gemini, 20% sample, batch): in = 0.2·(1+r)·B out = 0.1·r·B·(1 + rf_gem) + h_draft=1.0 (source + system + selective glossary + STM overhead ≈ B); + h_edit=0.5 (monolingual editor sees the draft r·B + a small prompt/glossary, no source, no big STM); + rf_gem = Gemini thinking-as-output factor (central 1.0; sensitivity 0.5–2.0). + +BOOKS (B, r from exp01-token-calibration, direct-keys contour, EU SaaS): + ja→ru ранобэ (том) B=113_000 r=1.29 + zh→ru вебновелла 500гл B=1_290_000 r=1.88 + en→ru роман B=131_000 r=1.53 +""" +from __future__ import annotations +import json +from pathlib import Path + +P = { # USD per token (per-1M / 1e6) + "ds_in": 0.14e-6, "ds_hit": 0.0028e-6, "ds_out": 0.28e-6, + "grok_in": 1.25e-6, "grok_hit": 0.20e-6, "grok_out": 2.50e-6, + "gem_in": 2.00e-6, "gem_out": 12.00e-6, # <=200k tier (chunks are ~1.5k tok → always <=200k) + "gem_in_b": 1.00e-6, "gem_out_b": 6.00e-6, # batch -50% +} +H_DRAFT, H_EDIT = 1.0, 0.5 +BOOKS = { + "ja→ru ранобэ (том)": {"B": 113_000, "r": 1.29}, + "zh→ru вебновелла 500гл": {"B": 1_290_000, "r": 1.88}, + "en→ru роман": {"B": 131_000, "r": 1.53}, +} + +def draft_cost(B, r, cache_frac=0.0): + """DeepSeek draft. cache_frac = fraction of INPUT served from auto-cache (stable prefix).""" + tin = (1 + H_DRAFT) * B + hit = tin * cache_frac + miss = tin - hit + c_in = miss * P["ds_in"] + hit * P["ds_hit"] + c_out = r * B * P["ds_out"] + return c_in + c_out, c_in, c_out + +# grok reasoning is ADDITIVE (separate from completion tokens) and UNSTOPPABLE on grok-4.3 +# (reasoning_effort=low/none do NOT disable it — live probe 2026-07-05). Measured surcharge at +# PRODUCTION output size (~2428-tok edit): reasoning=495 on visible=1972 → +0.25× output. +# grok-4.20-0309-non-reasoning gives the SAME visible output with reasoning=0 at the SAME rate +# card → it is the true "thinking-OFF editor". EDITOR_REASONING_FACTOR=0 is the recommended +# (non-reasoning) config; 0.25 models keeping grok-4.3. +EDITOR_REASONING_FACTOR = 0.0 + +def editor_cost(B, r, cache_frac=0.0, reasoning_factor=0.0): + """Monolingual editor. reasoning_factor=0 → grok-4.20-non-reasoning (thinking-OFF); + 0.25 → grok-4.3 with its unstoppable reasoning billed as output.""" + tin = (r + H_EDIT) * B + hit = tin * cache_frac + miss = tin - hit + c_in = miss * P["grok_in"] + hit * P["grok_hit"] + c_out = r * B * (1 + reasoning_factor) * P["grok_out"] + return c_in + c_out, c_in, c_out + +def apex_cost(B, r, rf): + """Gemini-3.1-Pro final bilingual pass; forced thinking → output×(1+rf).""" + c_in = (1.5 + r) * B * P["gem_in"] + c_out = r * B * (1 + rf) * P["gem_out"] + return c_in + c_out + +def judge_cost(B, r, rf): + """Gemini judge on 20% sample, batch -50%.""" + c_in = 0.2 * (1 + r) * B * P["gem_in_b"] + c_out = 0.1 * r * B * (1 + rf) * P["gem_out_b"] + return c_in + c_out + +def money(x): return f"${x:,.3f}" if x < 10 else f"${x:,.2f}" + +def main(): + out = {"prices_asof": "2026-07-05", "h_draft": H_DRAFT, "h_edit": H_EDIT, "books": {}} + for name, bk in BOOKS.items(): + B, r = bk["B"], bk["r"] + d, d_in, d_out = draft_cost(B, r) + d_c, dc_in, dc_out = draft_cost(B, r, cache_frac=0.5) # 50% prefix cache-hit sensitivity + e, e_in, e_out = editor_cost(B, r) # non-reasoning editor (recommended) + e43, _, _ = editor_cost(B, r, reasoning_factor=0.25) # grok-4.3 reasoning kept + std = d + e + std_grok43 = d + e43 + std_cached = d_c + e + premium_by_rf = {} + for rf in (0.5, 1.0, 2.0): + ap = apex_cost(B, r, rf) + jg = judge_cost(B, r, rf) + premium_by_rf[f"rf={rf}"] = {"apex": ap, "judge": jg, "premium_total": std + ap + jg} + # Selective premium (realistic for large works): apex re-touches only a FRACTION of + # chunks (flagged/hard); the rest ship at standard. rf=1.0 central. + ap_full = apex_cost(B, r, 1.0) + jg_full = judge_cost(B, r, 1.0) + selective = {f"apex_{int(f*100)}pct": std + f * ap_full + jg_full for f in (0.10, 0.15, 0.25)} + rec = { + "B": B, "r": r, + "draft": {"total": d, "in": d_in, "out": d_out}, + "editor": {"total": e, "in": e_in, "out": e_out}, + "standard_mix": std, + "standard_mix_grok43_reasoning": std_grok43, + "standard_mix_50pct_cache": std_cached, + "editor_share_of_standard": round(e / std, 3), + "premium_mix": premium_by_rf, + "selective_premium_rf1": selective, + } + out["books"][name] = rec + print(f"\n=== {name} (B={B:,} src-tok, r={r}) ===") + print(f" draft (deepseek): {money(d)} [in {money(d_in)} + out {money(d_out)}]") + print(f" editor (grok-4.20-nonreason): {money(e)} [in {money(e_in)} + out {money(e_out)}]") + print(f" STANDARD-MIX (draft+editor): {money(std)} (editor = {e/std:.0%} of it)") + print(f" if editor = grok-4.3 (reasoning): {money(std_grok43)} (+{(std_grok43/std-1)*100:.0f}%)") + print(f" with 50% input cache-hit on draft: {money(std_cached)}") + for rf, pv in premium_by_rf.items(): + print(f" PREMIUM full apex (+judge, {rf:>7}): {money(pv['premium_total'])}" + f" [apex {money(pv['apex'])} + judge {money(pv['judge'])}]") + print(f" SELECTIVE premium (apex on X% of chunks, rf=1.0):" + f" 10%={money(selective['apex_10pct'])} 15%={money(selective['apex_15pct'])} 25%={money(selective['apex_25pct'])}") + Path(__file__).resolve().parent.joinpath("data", "cost_model_v2.json").write_text( + json.dumps(out, ensure_ascii=False, indent=2)) + print("\nsaved → eval/data/cost_model_v2.json") + +if __name__ == "__main__": + main() diff --git a/eval/coverage_precision.py b/eval/coverage_precision.py new file mode 100644 index 0000000..2d5b72a --- /dev/null +++ b/eval/coverage_precision.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""experiments/07 — precision (false-positive rate) of the EXCISION coverage gate on +real zh/ja→ru chunks. Gates the go-live flip of `gates.coverage.enabled` (D12/Q4). + +WHAT THE GATE DOES (backend/internal/pipeline/coverage.go, ported 1:1 from the oracle +eval/refusal_bench.py::classify_output): given a stage OUTPUT and the ORIGINAL source, +flag `excision_suspect` when `sent_cov < 0.75` OR `len_ratio < corridor_low` +(zh<2.2, ja<1.4, en<0.70). Metric = characters WITHOUT spaces. Only the LOWER bound +is checked. Chunks with < min_chunk_chars non-space source are not gated. + +WHY PRECISION, NOT RECALL: the backend already validated recall (≥90%) on a mini-set of +sentence-excised outputs. The blocker for the flip is the FALSE-POSITIVE rate — how many +GOOD translations the gate wrongly flags — especially on DIALOGUE-DENSE chapters, where +naive sentence segmentation can undercount RU sentences vs a CJK source (quote-absorbing +§3.7 is deferred to v1.1), dropping sent_cov below 0.75. + +ARMS (both are GOOD translations — a flag on them is a false positive by construction): + 1. LLM production stack: deepseek-v4-flash draft (+ optional grok-4.3 edit) per chunk. + 2. Human canonical: published RU translation, aligned by chapter (clean, no fuzzy align). + +Chunker mirrors chunker.go: blank-line paragraphs packed to ≤1500 est-tokens +(est = cjk + other/3, floor 16), descending to sentence boundaries on an oversize paragraph. + +Usage: + eval/.venv/bin/python eval/coverage_precision.py --dry-run # plan, no API calls + eval/.venv/bin/python eval/coverage_precision.py --arm llm # run LLM arm + eval/.venv/bin/python eval/coverage_precision.py --arm human # human-canonical arm + eval/.venv/bin/python eval/coverage_precision.py --arm both --edit # + grok-4.3 edit pass +""" +from __future__ import annotations +import argparse, json, re, sys, time, unicodedata +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from refusal_bench import (load_env_file, call_provider, classify_output, split_sentences, + EXPECT_LEN_RATIO, SYSTEM_PROMPT, TARGET_NAMES) + +load_env_file() +ROOT = Path(__file__).resolve().parent +SAMPLES = ROOT / "data" / "samples" +OUT = ROOT / "data" / "coverage_precision" +OUT.mkdir(parents=True, exist_ok=True) + +# --- chunker (faithful port of backend/internal/pipeline/chunker.go) ------------- +TARGET_TOKENS = 1500 +CJK_CLASS = re.compile(r"[一-鿿㐀-䶿぀-ゟ゠-ヿ가-힯]") + +def est_tokens(s: str) -> int: + cjk = len(CJK_CLASS.findall(s)) + other = sum(1 for c in s if not c.isspace() and not CJK_CLASS.match(c)) + est = cjk + other // 3 + return max(16, est) + +def split_paragraphs(chapter: str) -> list[str]: + return [p.strip() for p in re.split(r"\n\s*\n", chapter) if p.strip()] + +def pack_chapter(chapter_no: int, paras: list[str]) -> list[dict]: + """Greedy paragraph packing to TARGET_TOKENS; oversize paragraph → sentence packing.""" + out, buf = [], [] + def flush(): + nonlocal buf + if buf: + text = "\n\n".join(buf) + out.append({"chapter": chapter_no, "idx": len(out), "text": text}) + buf = [] + for p in paras: + if est_tokens(p) > TARGET_TOKENS: + flush() + for sub in pack_sentences(p): + out.append({"chapter": chapter_no, "idx": len(out), "text": sub}) + continue + if buf and est_tokens("\n\n".join(buf) + "\n\n" + p) > TARGET_TOKENS: + flush() + buf.append(p) + flush() + for i, c in enumerate(out): # ChunkIdx resets per chapter (chunker.go contract) + c["idx"] = i + return out + +def pack_sentences(paragraph: str) -> list[str]: + segs = split_sentences(paragraph) or [paragraph] + out, buf = [], [] + for s in segs: + if buf and est_tokens(" ".join(buf) + " " + s) > TARGET_TOKENS: + out.append(" ".join(buf)); buf = [] + buf.append(s) + if buf: + out.append(" ".join(buf)) + return out + +# --- chapter splitting per language --------------------------------------------- +ZH_CH = re.compile(r"^\s*第[一二三四五六七八九十百]+章.*$", re.M) +RU_ROMAN = re.compile(r"^\s*[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+\b.*$", re.M) + +def split_chapters(text: str, marker: re.Pattern | None) -> list[str]: + text = unicodedata.normalize("NFC", text) + if marker is None: + return [text.strip()] + idxs = [m.start() for m in marker.finditer(text)] + if not idxs: + return [text.strip()] + idxs.append(len(text)) + return [text[idxs[i]:idxs[i + 1]].strip() for i in range(len(idxs) - 1)] + +# --- dialogue-density stratification -------------------------------------------- +# Quote/colon markers that introduce or bound speech in zh/ja prose. +DLG_MARK = re.compile(r"[「」『』“”《》::]") + +def dialogue_density(src: str) -> tuple[float, int]: + n_sent = max(1, len(split_sentences(src))) + marks = len(DLG_MARK.findall(src)) + return marks / n_sent, marks + +def stratum(density: float) -> str: + return "dialogue" if density >= 0.5 else "narrative" + +# --- sources --------------------------------------------------------------------- +LLM_SOURCES = [ + ("zh", "zh/luxun-ah-q-ch1-4.txt", ZH_CH), + ("zh", "zh/luxun-ah-q-ch5-9.txt", ZH_CH), + ("zh", "zh/luxun-zhufu.txt", None), + ("ja", "ja/akutagawa-rashomon.txt", None), + ("ja", "ja/akutagawa-hana.txt", None), + ("ja", "ja/dazai-hashire-merosu.txt", None), + ("ja", "ja/soseki-wagahai-neko-ch1.txt", None), +] +# Human-canonical pairs (source, canonical-ru), aligned by chapter (or whole story). +HUMAN_PAIRS = [ + ("ja", "ja/akutagawa-hana.txt", None, "ru/akutagawa-hana-ru.txt", None), + ("zh", "zh/luxun-ah-q-ch1-4.txt", ZH_CH, "ru/luxun-ah-q-ru.txt", RU_ROMAN), + ("zh", "zh/luxun-ah-q-ch5-9.txt", ZH_CH, "ru/luxun-ah-q-ru.txt", RU_ROMAN), +] + +DRAFT = {"name": "deepseek", "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-v4-flash", "api_key_env": "DEEPSEEK_API_KEY", "max_tokens": 12000} +# Alternate translator for the zh arm: grok-4.20 non-reasoning does NOT echo classical zh +# (deepseek-v4-flash echoes ~54% of Lu Xun zh chunks — a draft-model artifact, not a gate +# issue). Used to build a robust zh LLM coverage-FP arm from a non-echoing model. +DRAFT_GROK = {"name": "grok", "base_url": "https://api.x.ai/v1", + "model": "grok-4.20-0309-non-reasoning", "api_key_env": "XAI_API_KEY", + "max_tokens": 8000, "temperature": 0.3} +_TRANSLATOR = DRAFT +# grok-4.3 = the Phase-1 production EDITOR (monolingual ru→ru polish). reasoning-by-default; +# thinking OFF for a clean ledger (D3). temp 0.4 per the ratified editor config. +EDITOR = {"name": "grok", "base_url": "https://api.x.ai/v1", "model": "grok-4.3", + "api_key_env": "XAI_API_KEY", "max_tokens": 8000, "temperature": 0.4, + "extra_body": {}} +EDIT_SYS = ("Ты — литературный редактор. Отредактируй русский перевод: улучши стиль, " + "естественность и связность, сохранив ВСЕ детали, реплики и предложения без " + "пропусков. Выведи ТОЛЬКО отредактированный текст, без комментариев.") + +def translate(chunk_text: str, target="ru") -> tuple[str | None, str | None, dict]: + sys_p = SYSTEM_PROMPT.format(target=TARGET_NAMES[target]) + return call_provider(_TRANSLATOR, sys_p, chunk_text, timeout=240) + +def edit(draft_text: str) -> tuple[str | None, str | None, dict]: + return call_provider(EDITOR, EDIT_SYS, draft_text, timeout=240) + +# --- gate application (uses the sanctioned Python oracle) ------------------------ +def gate(src: str, out: str, lang: str) -> dict: + return classify_output(src, out, None, EXPECT_LEN_RATIO[lang]) + +def _process_chunk(job: dict, edit_pass: bool) -> dict: + c, lang, rel = job["c"], job["lang"], job["file"] + dens, marks = dialogue_density(c["text"]) + row = {"arm": "llm", "file": rel, "lang": lang, "chapter": c["chapter"], + "idx": c["idx"], "src_chars": sum(1 for x in c["text"] if not x.isspace()), + "src_tokens_est": est_tokens(c["text"]), "dlg_density": round(dens, 2), + "dlg_marks": marks, "stratum": stratum(dens)} + dtext, derr, dusage = translate(c["text"], "ru") + row["draft_err"] = derr + if derr or not (dtext or "").strip(): + row["draft_verdict"] = {"verdict": "STAGE_ERROR", "detail": str(derr)} + print("E", end="", flush=True, file=sys.stderr) + return row + row["draft"] = dtext + row["draft_verdict"] = gate(c["text"], dtext, lang) + row["draft_usage"] = dusage + if edit_pass: + etext, eerr, eusage = edit(dtext) + row["edit_err"] = eerr + if etext and etext.strip(): + row["edit"] = etext + row["edit_verdict"] = gate(c["text"], etext, lang) + row["edit_usage"] = eusage + print("x" if row["draft_verdict"]["verdict"] == "excision_suspect" else ".", + end="", flush=True, file=sys.stderr) + return row + +def run_llm(edit_pass: bool, dry: bool, limit: int | None, workers: int) -> list[dict]: + jobs = [] + for lang, rel, marker in LLM_SOURCES: + chapters = split_chapters((SAMPLES / rel).read_text(), marker) + chunks = [] + for ci, ch in enumerate(chapters, 1): + chunks.extend(pack_chapter(ci, split_paragraphs(ch))) + if limit: + chunks = chunks[:limit] + print(f"[{rel}] {len(chapters)} chap → {len(chunks)} chunks", file=sys.stderr) + jobs += [{"c": c, "lang": lang, "file": rel} for c in chunks] + if dry: + return [{"arm": "llm", "file": j["file"], "lang": j["lang"], + "chapter": j["c"]["chapter"], "idx": j["c"]["idx"], + "src_chars": sum(1 for x in j["c"]["text"] if not x.isspace()), + "src_tokens_est": est_tokens(j["c"]["text"]), + "dlg_density": round(dialogue_density(j["c"]["text"])[0], 2), + "dlg_marks": dialogue_density(j["c"]["text"])[1], + "stratum": stratum(dialogue_density(j["c"]["text"])[0])} for j in jobs] + print(f"translating {len(jobs)} chunks with {workers} workers…", file=sys.stderr) + with ThreadPoolExecutor(max_workers=workers) as ex: + rows = list(ex.map(lambda j: _process_chunk(j, edit_pass), jobs)) + print("", file=sys.stderr) + return rows + +def run_human(dry: bool) -> list[dict]: + rows = [] + for lang, srel, smark, rrel, rmark in HUMAN_PAIRS: + s_ch = split_chapters((SAMPLES / srel).read_text(), smark) + r_ch = split_chapters((SAMPLES / rrel).read_text(), rmark) + # Whole-story pair (single chapter both sides) OR chapter-index alignment. + if smark is None: + pairs = [(s_ch[0], r_ch[0], 1)] + else: + # Align by chapter index. The RU canon has a leading title/前言 block before + # Ⅰ; RU_ROMAN captures from Ⅰ. Source ZH_CH captures from 第一章. ch5-9 file's + # chapters map to RU chapters 5..9 → offset applied by filename. + offset = 4 if "ch5-9" in srel else 0 + pairs = [] + for i, sc in enumerate(s_ch): + rj = i + offset + if rj < len(r_ch): + pairs.append((sc, r_ch[rj], rj + 1)) + for sc, rc, chn in pairs: + dens, marks = dialogue_density(sc) + row = {"arm": "human", "file": srel, "lang": lang, "ru_chapter": chn, + "src_chars": sum(1 for x in sc if not x.isspace()), + "ru_chars": sum(1 for x in rc if not x.isspace()), + "dlg_density": round(dens, 2), "stratum": stratum(dens)} + if not dry: + row["verdict"] = gate(sc, rc, lang) + rows.append(row) + print(f" human {srel} ch{chn}: {row.get('verdict', {}).get('verdict','(dry)')}", + file=sys.stderr) + return rows + +MIN_CHUNK_CHARS = 500 # production skips shorter chunks (pipeline-c1.yaml) +# Verdicts eligible for the coverage-FP denominator: only genuine complete translations +# reach the coverage gate in production. echo/empty/refusal are separate (correct) +# dispositions handled before the gate, so they are excluded from the FP measurement. +COVERAGE_ELIGIBLE = {"ok", "excision_suspect"} + +def _which_fired(v: dict) -> str: + d = v.get("detail", "") + parts = [] + if "sent_cov" in d: + parts.append("sent_cov") + if "len_ratio" in d: + parts.append("len_ratio") + return "+".join(parts) or "?" + +def summarize(rows: list[dict]) -> dict: + llm = [r for r in rows if r["arm"] == "llm"] + summ = {"min_chunk_chars": MIN_CHUNK_CHARS} + for stage in ("draft", "edit"): + key = f"{stage}_verdict" + # production-gated: complete translation AND src >= min_chunk_chars + gated = [r for r in llm if key in r and r[key]["verdict"] in COVERAGE_ELIGIBLE + and r["src_chars"] >= MIN_CHUNK_CHARS] + if not gated: + continue + fp = [r for r in gated if r[key]["verdict"] == "excision_suspect"] + s = {"n_gated": len(gated), "flags": len(fp), + "flag_rate": round(len(fp) / max(1, len(gated)), 3), + "fired": {}, "flagged_chunks": []} + for r in fp: + f = _which_fired(r[key]) + s["fired"][f] = s["fired"].get(f, 0) + 1 + s["flagged_chunks"].append({"file": r["file"], "ch": r["chapter"], "idx": r["idx"], + "stratum": r["stratum"], "detail": r[key]["detail"], + "sent_cov": r[key].get("sent_cov"), + "len_ratio": r[key].get("len_ratio")}) + for st in ("narrative", "dialogue"): + sub = [r for r in gated if r["stratum"] == st] + subfp = [r for r in sub if r[key]["verdict"] == "excision_suspect"] + s[st] = {"n": len(sub), "flags": len(subfp), + "flag_rate": round(len(subfp) / max(1, len(sub)), 3)} + # per-chapter flag counts → informs the "N flags per chapter" threshold + perch = {} + for r in gated: + k = f"{r['file']}::ch{r['chapter']}" + perch.setdefault(k, {"chunks": 0, "flags": 0}) + perch[k]["chunks"] += 1 + perch[k]["flags"] += int(r[key]["verdict"] == "excision_suspect") + s["per_chapter_max_flags"] = max((v["flags"] for v in perch.values()), default=0) + s["per_chapter"] = perch + # sent_cov / len_ratio distribution over gated OK chunks (for threshold tuning) + covs = sorted(round(r[key]["sent_cov"], 3) for r in gated if r[key].get("sent_cov") is not None) + lrs = sorted(round(r[key]["len_ratio"], 3) for r in gated if r[key].get("len_ratio") is not None) + if covs: + s["sent_cov_min_observed"] = covs[0] + s["sent_cov_p05"] = covs[max(0, int(0.05 * len(covs)) - 1)] + if lrs: + s["len_ratio_min_observed"] = lrs[0] + summ[stage] = s + # sub-min chunks excluded from prod gating (reported for transparency) + submin = [r for r in llm if "draft_verdict" in r and r["src_chars"] < MIN_CHUNK_CHARS] + summ["submin_excluded"] = len(submin) + stage_err = [r for r in llm if r.get("draft_verdict", {}).get("verdict") + not in COVERAGE_ELIGIBLE and "draft_verdict" in r] + summ["non_coverage_dispositions"] = [ + {"file": r["file"], "ch": r["chapter"], "idx": r["idx"], + "verdict": r["draft_verdict"]["verdict"]} for r in stage_err] + # human arm: ANY flag on a published complete translation is a clean false positive + human = [r for r in rows if r["arm"] == "human" and "verdict" in r] + hfp = [r for r in human if r["verdict"]["verdict"] == "excision_suspect"] + summ["human"] = {"n": len(human), "flags": len(hfp), + "flag_rate": round(len(hfp) / max(1, len(human)), 3), + "flagged": [{"file": r["file"], "ru_ch": r.get("ru_chapter"), + "stratum": r["stratum"], "detail": r["verdict"]["detail"], + "sent_cov": r["verdict"].get("sent_cov"), + "len_ratio": r["verdict"].get("len_ratio")} for r in hfp], + "sent_cov_all": sorted(round(r["verdict"].get("sent_cov", 9), 3) for r in human), + "len_ratio_all": sorted(round(r["verdict"].get("len_ratio", 9), 3) for r in human)} + return summ + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--arm", choices=["llm", "human", "both"], default="both") + ap.add_argument("--edit", action="store_true", help="also run grok-4.3 editor pass") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--limit", type=int, help="max chunks per file (LLM arm)") + ap.add_argument("--workers", type=int, default=5) + ap.add_argument("--translator", choices=["deepseek", "grok"], default="deepseek", + help="draft model for the LLM arm (grok = non-echoing zh translator)") + ap.add_argument("--langs", help="comma-separated source langs to include (e.g. zh)") + ap.add_argument("--out", default=str(OUT / "results.json")) + args = ap.parse_args() + global _TRANSLATOR, LLM_SOURCES + _TRANSLATOR = DRAFT_GROK if args.translator == "grok" else DRAFT + if args.langs: + keep = set(args.langs.split(",")) + LLM_SOURCES = [s for s in LLM_SOURCES if s[0] in keep] + rows = [] + if args.arm in ("llm", "both"): + rows += run_llm(args.edit, args.dry_run, args.limit, args.workers) + if args.arm in ("human", "both"): + rows += run_human(args.dry_run) + summ = summarize(rows) + Path(args.out).write_text(json.dumps({"summary": summ, "rows": rows}, + ensure_ascii=False, indent=2)) + print("\n=== SUMMARY ===") + print(json.dumps(summ, ensure_ascii=False, indent=2)) + print(f"\nwrote {args.out}") + +if __name__ == "__main__": + main() diff --git a/eval/pilot/declmetric.py b/eval/pilot/declmetric.py new file mode 100644 index 0000000..5dabfd1 --- /dev/null +++ b/eval/pilot/declmetric.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Decl-aware (склонение-осознанная) consistency metric for the Ф2.5 memory-eval. + +WHY decl-aware is MANDATORY (research/14 §2, quantified; backend memory_e1_test.go): +a naive word-boundary regexp on the base form gives 18-67 pct FALSE flags on inflected Russian — forms +rendered CORRECTLY ("Вэйчжуане", "Бородатого Вана", "Дэна", "сюцая") that a boundary regexp +on the lemma misses. Without decl-awareness the eval is noise and the bank-vs-long-context +verdict is untrustworthy. This module is the eval-side analogue of the Go post-check +(backend/internal/pipeline/mempostcheck.go): whole-word, decl-tolerant, letter-boundary. + +Two matchers, both reusable: + - pymorphy3 lemma-set: a name is "rendered" if its canonical LEMMA(s) appear among the + lemmatized words of the output (folds Вэйчжуане→вэйчжуан, Вана→ван). Mirrors the Go + post-check's intent with an OOV-tolerant morphological analyzer (pymorphy3 lemmatizes + even unknown translit names). + - hyphen-translit regex fallback: for hyphenated translit names (А-кью, У-ма) pymorphy3 + splits on the hyphen, so a regex like "А[-space]?кью" is the correct decl-tolerant matcher. + +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. +""" +from __future__ import annotations +import re +import pymorphy3 + +_MORPH = pymorphy3.MorphAnalyzer() +_WORD_RE = re.compile(r"[А-Яа-яЁё]+(?:-[А-Яа-яЁё]+)?") +_CYR = re.compile(r"[А-Яа-яЁё]") + + +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() + + +def lemmas(text: str) -> set[str]: + """Lemma set of the Cyrillic words in text (ё→е folded).""" + out = set() + for w in _WORD_RE.findall(text): + w = w.replace("ё", "е") + out.add(_MORPH.parse(w)[0].normal_form) + # also add the raw lowered form (for hyphenated/translit that pymorphy keeps whole) + out.add(w.lower()) + return out + + +def rendered_pymorphy(output: str, lemma_keys: list[str], hyphen_pat: str | None = None) -> bool: + """A term is rendered if ALL its canonical lemma keys appear in the output's lemma set. + hyphen_pat: a regex for hyphenated translit (А-кью) matched directly on the output.""" + if hyphen_pat: + return bool(re.search(hyphen_pat, output, re.I)) + lem = lemmas(output) + return all(k.replace("ё", "е").lower() in lem for k in lemma_keys) + + +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.""" + if not form_norm: + return False + 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]) + if left_ok and right_ok: + return True + i = text_norm.find(form_norm, i + 1) + return False + + +def rendered_stored_decl(output: str, decl_forms: list[str], base_dst: str) -> bool: + """Go post-check's REAL mechanism: whole-word match against STORED decl forms + (fallback to the base dst when decl is empty — the naive case E1 quantifies).""" + nout = normalize_target(output) + forms = [normalize_target(f) for f in decl_forms] or [normalize_target(base_dst)] + return any(f and contains_whole_word(nout, f) for f in forms) + + +def consistency(output: str, entries: dict) -> dict: + """Consistency score over the glossary entries whose SRC is present in the chunk. + entries: {src: {"dst":..., "lemma_keys":[...], "hyphen_pat":..., "decl_forms":[...]}}. + Returns per-matcher rendered/missed sets + score. `present` filter is the caller's job + (pass only entries whose src fired in this chunk).""" + result = {"n": len(entries), "pymorphy": {}, "stored_decl": {}} + for name, matcher_fn in (("pymorphy", _score_pymorphy), ("stored_decl", _score_stored)): + rendered = [s for s, e in entries.items() if matcher_fn(output, e)] + result[name] = {"rendered": rendered, "missed": [s for s in entries if s not in rendered], + "score": round(len(rendered) / max(1, len(entries)), 3)} + return result + + +def _score_pymorphy(output: str, e: dict) -> bool: + return rendered_pymorphy(output, e.get("lemma_keys", []), e.get("hyphen_pat")) + + +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 + out = "Бородатого Вана встретили в Вэйчжуане; Дэна и сюцая тоже." + ents = { + "王胡": {"dst": "Бородатый Ван", "lemma_keys": ["ван"], "decl_forms": ["Бородатого Вана", "Бородатый Ван"]}, + "未庄": {"dst": "Вэйчжуан", "lemma_keys": ["вэйчжуан"], "decl_forms": ["Вэйчжуане", "Вэйчжуан"]}, + "小D": {"dst": "Маленький Дэн", "lemma_keys": ["дэн"], "decl_forms": ["Дэна", "Дэн"]}, + "秀才": {"dst": "сюцай", "lemma_keys": ["сюцай"], "decl_forms": ["сюцая", "сюцай"]}, + } + import json + print(json.dumps(consistency(out, ents), ensure_ascii=False, indent=2)) diff --git a/eval/pilot/memory_eval.py b/eval/pilot/memory_eval.py new file mode 100644 index 0000000..a3f2e15 --- /dev/null +++ b/eval/pilot/memory_eval.py @@ -0,0 +1,254 @@ +#!/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-пилот). + +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: + + 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 + +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 +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.) + +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. + +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 +""" +from __future__ import annotations +import argparse, json, re, sys, unicodedata +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import refusal_bench as rb +import declmetric as dm + +ROOT = Path(__file__).resolve().parent +SAMPLES = ROOT.parent / "data" / "samples" +OUTDIR = ROOT.parent / "data" / "pilot" +OUTDIR.mkdir(parents=True, exist_ok=True) + +# --- normalization (mirror of memnorm.go normalizeSourceKey) --------------------- +TRAD2SIMP = {"趙": "赵", "莊": "庄", "錢": "钱", "陳": "陈", "萬": "万", "舊": "旧", "臉": "脸", + "魯": "鲁", "鎮": "镇", "剛": "刚", "動": "动", "們": "们"} + +def norm_src(s: str) -> str: + s = unicodedata.normalize("NFKC", s) + out = [] + for c in s: + if unicodedata.category(c) in ("Cf",) or c in "️": + continue + c = TRAD2SIMP.get(c, c) + o = ord(c) + if 0x30A1 <= o <= 0x30F6: # katakana → hiragana + c = chr(o - 0x60) + 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 <= "힯")) +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 +def collision_prone(nk: str) -> bool: + 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. +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"}, + # SPOILER demo: only valid from chapter 7 (revolution). Injected earlier → hard reject. + {"src": "革命党", "dst": "революционеры", "lemma_keys": ["революционер"], + "decl_forms": ["революционеров", "революционеры"], "aliases": [], "type": "term", "since_ch": 7}, +] + +def eligible_keys(entry: dict) -> list[tuple[str, str]]: + """(normalized_key, raw) surfaces (src+aliases) passing the per-lang min-key ban.""" + 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)) + 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} + +# --- prompt assembly per condition ----------------------------------------------- +SYSTEM = ("Ты профессиональный литературный переводчик. Переведи фрагмент китайского " + "произведения на русский язык. Сохрани все реплики и детали без пропусков; стиль — " + "живой литературный русский. Выведи ТОЛЬКО перевод.") +GHEAD = "ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно):" + +def gloss_block(entries: list[dict], full: bool = False) -> str: + if not entries: + return "" + lines = [] + for e in entries: + line = f"{e['src']} → {e['dst']}" + 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": + return "ФРАГМЕНТ:\n" + chunk + if condition == "C1": # 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) + 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 e["_via"] != "sticky"] + blk = gloss_block(wrong, full=True) + return (blk + "\n\n" if blk else "") + "ФРАГМЕНТ:\n" + chunk + raise ValueError(condition) + +# --- 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 = [], [] + 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 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 {}) + +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("--max-chunks", type=int, default=5) + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--out", default=str(OUTDIR / "memory_eval.json")) + args = ap.parse_args() + + 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) + 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() + for ci, ch in enumerate(chunks): + sel = select(ch, args.chapter, sticky_prev) + 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)} + 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", []), + "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] = { + "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 [], + "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']} " + f"in={usage.get('prompt_tokens')} out={usage.get('completion_tokens')}", file=sys.stderr) + sticky_prev = sel["active"] + 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) + 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']}") + +if __name__ == "__main__": + main()