Land polygon session 3: coverage-gate precision (0 FP/57, flip safe), cost-model v2 on grok-editor stack, and Phase-2.5 pilot protocol with a Go-mirror memory-eval
This commit is contained in:
parent
6a68dfd44e
commit
3911bafe30
9 changed files with 1302 additions and 2 deletions
|
|
@ -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/<lang>/` — закрывает (а) диалог-плотный срез coverage-precision (эксп.07), (б) довалидацию токен-калибровки r (эксп.01/08), (в) старт memory-eval на большом глоссарии. Для zh — вне претрейна (обход эхо-мины).
|
||||
|
||||
## Память
|
||||
(секция сессии «Валидация банка памяти» — записи добавлять сюда)
|
||||
|
||||
|
|
|
|||
128
docs/experiments/07-coverage-precision.md
Normal file
128
docs/experiments/07-coverage-precision.md
Normal file
|
|
@ -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/<lang>/`; харнесс пересчитает автоматически. Идеально — то, что реально пойдёт в продукт (для 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}`.
|
||||
102
docs/experiments/08-cost-model-v2.md
Normal file
102
docs/experiments/08-cost-model-v2.md
Normal file
|
|
@ -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).
|
||||
118
docs/experiments/09-pilot-protocol.md
Normal file
118
docs/experiments/09-pilot-protocol.md
Normal file
|
|
@ -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
|
||||
```
|
||||
77
eval/content_filter_probe.py
Normal file
77
eval/content_filter_probe.py
Normal file
|
|
@ -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()
|
||||
139
eval/cost_model_v2.py
Normal file
139
eval/cost_model_v2.py
Normal file
|
|
@ -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()
|
||||
357
eval/coverage_precision.py
Normal file
357
eval/coverage_precision.py
Normal file
|
|
@ -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()
|
||||
111
eval/pilot/declmetric.py
Normal file
111
eval/pilot/declmetric.py
Normal file
|
|
@ -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))
|
||||
254
eval/pilot/memory_eval.py
Normal file
254
eval/pilot/memory_eval.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Reference in a new issue