Land backend D15.2 stage A: bilingual editor with source injection, reflow, output-sanitizer gate, golden re-capture with floor/union/sanitizer pins, spec v3.1 design

This commit is contained in:
Claude (backend session) 2026-07-11 14:48:03 +03:00
parent 7487b782f8
commit 00f8e36fbd
24 changed files with 1101 additions and 200 deletions

View file

@ -55,10 +55,11 @@ providers:
reasoning: subset
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
xai: # Grok — центральная модель Фазы 1: дефолт-редактор SFW + главный тир канала B + судья 18+.
# ⚠️ ГИГИЕНА АККАУНТОВ (D8/оркестратор): XAI_API_KEY обязан быть ЧИСТЫМ прод-аккаунтом
# БЕЗ data-sharing. Промо-$150-аккаунт (data-sharing) — ТОЛЬКО eval/NSFW, реальные книги
# через него гнать нельзя. По ключу это не проверяется — подтвердить перед боевым прогоном.
xai: # Grok — главный тир канала B (18+) + судья 18+; из редакторских ролей СНЯТ (D30.1 — reasoning-off no-op).
# ⚠️ КЛЮЧЕВАЯ ПОЛИТИКА (D27, супрсидит «чистый ключ» D19.4/D20.3): ЕДИНЫЙ XAI_API_KEY С data-sharing
# (промо) идёт на ВСЕ тесты И пилот И прод; data-sharing ОТКЛЮЧАЕТСЯ перед релизом (сверить механику
# с вендор-докой — чек-лист первого боевого прогона). Риски приняты владельцем (копирайт 蛊真人 в обучение —
# только свои тесты, для клиентских книг НИКОГДА; NSFW-история аккаунта). По ключу не проверяется.
kind: openai
base_url: https://api.x.ai/v1
api_key_env: XAI_API_KEY
@ -73,9 +74,10 @@ providers:
# Gemini Фазы 2 (см. provider_anthropic.go). Эскалация — не-Anthropic (Kimi/GLM).
gemini: # Google Gemini через OpenAI-совместимый слой (base .../v1beta/openai). Апекс канала A +
# судья Фазы 2. ⚠️ Нативный Gemini API нужен для safety-off судьи 18+ (safety_settings не
# проходят через OpenAI-слой — exp00); OpenAI-слой достаточен для перевода/эскалации (фильтры OFF
# по дефолту у 3.x, кроме неконфигурируемого PROHIBITED_CONTENT — D21.9).
# судья violence/SFW Ф2 + премиум-эскалация редактора ЗА санитайзером (D30.1/30.3 — течёт преамбулой 6/6).
# ⚠️ Как судья 18+ ЭРОТИКИ НЕПРИГОДЕН (D22.6/exp11: 8× PROHIBITED_CONTENT, фильтр неконфигурируем — native
# НЕ спасает; первичный судья эротики = Grok). На violence/SFW Gemini-судья остаётся. OpenAI-слой достаточен
# для перевода/эскалации (фильтры OFF по дефолту у 3.x, кроме неконфигурируемого PROHIBITED_CONTENT — D21.9).
kind: openai
base_url: https://generativelanguage.googleapis.com/v1beta/openai
api_key_env: GEMINI_API_KEY
@ -187,9 +189,10 @@ models:
temperature: { mode: send }
reasoning: { control: effort, off_effort: none }
note: >-
Дефолт-редактор SFW (D3, bake-off эксп-04) + главный пермиссив-тир канала B + судья 18+.
reasoning additive: think-ON только после резерва в EstimateUSD (D6.2/техдолг F3). Включена
в edit-стадию C1/C2 (D1/D3, консолидация 05.07); прод-прогон — только с ЧИСТЫМ xAI-ключом без data-sharing.
Главный пермиссив-тир канала B + судья 18+ ЭРОТИКИ (первичный для НЕ-grok переводов — D22.6: Gemini
fail-closed на графичной эротике, native не спасает). Из редакторов СНЯТ (D30.1 — reasoning-off no-op;
editor теперь glm-5-билингв). reasoning additive: think-ON только после резерва в EstimateUSD (D6.2/техдолг F3).
Ключ — единый с data-sharing, off перед продом (D27).
gemini-3.1-pro-preview: # ⚠️ слаг ИМЕННО -preview: gemini-3.1-pro → HTTP 404 NOT_FOUND (live 2026-07-10)
provider: gemini
@ -204,8 +207,9 @@ models:
# Раньше «дать max_tokens ≥8000» — комментарий; теперь схема (thinking ⊆ бюджета, additive_total).
min_max_tokens: 8000
note: >-
Апекс канала A / судья Фазы 2 (D3). min_max_tokens 8000 держит thinking внутри бюджета (резерв не слепнет; settle
биллит thinking через additive_total). Судья 18+ требует НАТИВНОГО Gemini (safety-off) — Ф2. Живьём 2026-07-10: chat 200.
Апекс канала A / судья violence/SFW Ф2 / премиум-эскалация редактора за санитайзером (D30.1). min_max_tokens 8000
держит thinking внутри бюджета (резерв не слепнет; settle биллит thinking через additive_total). Как судья ЭРОТИКИ
непригоден (D22.6: PROHIBITED_CONTENT неконфигурируем, native не спасает — первичный судья эротики Grok). Живьём 2026-07-10: chat 200.
mistral-large-2512:
provider: mistral

View file

@ -34,9 +34,12 @@ retries:
stages:
- name: draft
role: translator
model: deepseek-v4-flash
model: deepseek-v4-flash # интерим — переводчик по exp13 (draft качеством не мерен, D30.6); флип при развязке бейк-оффа
prompt: ../prompts/translator.md
prompt_version: v0-draft
# D30.2 reflow: из translator.md убрано «Сохраняй разбивку на абзацы» (построчная
# вёрстка исходника деструктивна для ru — корень нечитаемости exp12 §2.5-S). Bump
# v0-draft→v1-reflow: сменил SHA промпта, осознанный --resnapshot.
prompt_version: v1-reflow
temperature: 0.3
reasoning: "off"
# single-hop echo-эскалация (D18: deepseek-эхо 25% на 蛊真人 → фолбэк выстрелит). deepseek-v4-pro —
@ -45,15 +48,18 @@ stages:
escalate_to: deepseek-v4-pro
- name: edit
role: editor
# Дефолт-редактор — grok-4.3 (D1/D3, bake-off эксп-04; glm-5 был плейсхолдером).
# reasoning:"off" → grok шлёт ЯВНЫЙ reasoning_effort:"none" (control:effort/off_effort:none,
# models.yaml) — омиссия у grok = low. Прод: только чистый xAI-ключ без data-sharing.
model: grok-4.3
# Дефолт-редактор — glm-5 БИЛИНГВ (флип D1→D30.1, supersede D17.в): моно-редактор
# пассивен и структурно слеп к искажениям черновика (exp12 §2.2/2.5); билингв видит
# исходник (editor.md получил {{text}}). glm-5 — кандидат value по exp12 §3 (судейский #1,
# верность 5.0, 0 утечек преамбул; grok reasoning-off из редакторских ролей СНЯТ — no-op).
# gemini-3.1-pro — премиум-эскалация редактора ТОЛЬКО за санитайзером (утечка преамбул 6/6).
model: glm-5
prompt: ../prompts/editor.md
# D1-уточнение №3: коммит 6a68dfd сделал editor.md монолингвальным (убрал {{text}}) →
# сменил SHA, но лейбл остался v0-draft (один лейбл на два SHA). Бамп на v1-monolingual;
# осознанно меняет снапшот (боевых прогонов ещё не было).
prompt_version: v1-monolingual
# Bump v1-monolingual→v2-bilingual-reflow: editor.md стал билингвальным (D30.1) + reflow
# (D30.2, убрана «Сохраняй разбивку на абзацы»). Оба меняют SHA промпта → осознанный
# --resnapshot. Моно-вариант сохранён отдельно (prompts/editor-mono.md) — подтверждающий
# пилот-арм D13.1.
prompt_version: v2-bilingual-reflow
temperature: 0.4
reasoning: "off"
@ -68,6 +74,16 @@ gates:
en-ru: [0.70, 1.4]
sent_cov_min: 0.75
min_chunk_chars: 500 # калибровка порогов — на фрагментах 5002500 симв.
sanitizer:
# Output-санитайзер (D30.3): детерминированный вердикт-гейт «мгновенной
# нечитаемости» на ФИНАЛЬНОМ выходе — ведущие служебные преамбулы («Вот
# отредактированный перевод…»), хвостовые блоки заметок/правок, markdown-###,
# латиница-врезки в ru-выходе (порог), битые словоформы («Первок предок»). Ни
# один существующий гейт этот класс не ловит (exp12/флагманы §5). Дисциплина D2
# (flag+skip): дефект → чанк flagged, мусор не течёт в TM/экспорт. precision>recall.
# ВКЛЮЧЁН для пере-прогона 25 глав (gemini течёт преамбулой 6/6 — премиум-редактор
# за санитайзером). Версия — вердикт-ось (переедет в verdictSnapshotID с D15.2).
enabled: true
escalation:
chains:

View file

@ -24,7 +24,7 @@ stages:
role: translator
model: deepseek-v4-flash
prompt: ../prompts/translator.md
prompt_version: v0-draft
prompt_version: v1-reflow # D30.2 reflow (тот же translator.md, что в C1) — label-SHA дисциплина
temperature: 0.8 # C2: кандидаты сэмплируются горячее (Р2: T=0.61.0)
reasoning: "off"
# Judge-селектор (пара судей, шкала Комиссарова) — Фаза 2; стадия объявлена,
@ -40,15 +40,14 @@ stages:
reasoning: "off"
- name: edit
role: editor
# Дефолт-редактор — grok-4.3 (D1/D3, bake-off эксп-04; glm-5 был плейсхолдером).
# reasoning:"off" → grok шлёт ЯВНЫЙ reasoning_effort:"none" (control:effort/off_effort:none).
# (Стадия select выше — judge/glm-5, Gemini-слот Фазы 2; её НЕ трогаем.)
model: grok-4.3
# C2 — неисполняемый скелет Фазы 0/1 (fan-out/select — механика Ф2). Модель/бамп следуют
# за C1: editor.md стал БИЛИНГВ (D30.1) + reflow (D30.2) — тот же общий файл. Дефолт-редактор
# по D30.1 — glm-5 билингв (grok reasoning-off из редакторов СНЯТ — no-op). (Стадию select
# выше — judge/glm-5, Gemini-слот Ф2 — НЕ трогаем.)
model: glm-5
prompt: ../prompts/editor.md
# D1-уточнение №3: editor.md стал монолингвальным (6a68dfd убрал {{text}}) → SHA сменился,
# лейбл v0-draft остался (один лейбл на два SHA). Бамп на v1-monolingual (тот же editor.md,
# что в C1). Стадию select выше НЕ трогаем.
prompt_version: v1-monolingual
# Бамп v1-monolingual→v2-bilingual-reflow (label-SHA дисциплина: тот же editor.md, что в C1).
prompt_version: v2-bilingual-reflow
temperature: 0.4
reasoning: "off"

View file

@ -1,10 +1,44 @@
# D15.2 — Спека v3: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
# D15.2 — Спека v3.1: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
**Статус:** **v3, сдана на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до
ратификации v3** (реализацию НЕ начинать без подписи оркестратора — D20.1). Зона: спека в
`backend/docs/` (зона бэкенда); при утверждении оркестратор сворачивает её в
**Статус:** **v3.1 — реализация РАЗБЛОКИРОВАНА (D30.9, промт `BACKEND_D152_SESSION_PROMPT.md`,
этап Б).** v3.1 закрывает два ОБЯЗАТЕЛЬНЫХ амендмента D22.2 (§0-бис ниже) + пре-имплементационные
пункты (axis-тест, паритетные не-цели §8, освежение line-refs). Зона: спека в `backend/docs/`
(зона бэкенда); при лендинге оркестратор сворачивает её в
`docs/architecture/03-implementation-notes.md` новым разделом.
## 0-бис. v3.1-амендменты (D22.2 два обязательных + пре-имплементационные)
Ревью D22.2 сконструировало и ИСПОЛНИЛО stale-hit, который v3 пропускал. Два обязательных
амендмента до реализации:
- **(v3.1-a) `Book.SourceLang`/`Book.TargetLang``verdictSnapshotID`.** Языки — ВЕРДИКТ-входы,
потребляемые ВНЕ рендера: `classify` использует `TargetLang` для cjk-echo-проверки
(`disposition.go:196` `isCJKTarget`), `coverageCheck` использует пару `SourceLang`/`TargetLang`
для коридора `len_ratio` (`chunkrun.go:56`). `editor.md` НЕ рендерит `{{target_lang}}` (после
флипа D30.1 билингв-редактор рендерит `{{source_lang}}`, но не target) → на editor-стадии
`content_hash` их НЕ ловит, а вердикт (cjk/coverage) от них зависит. Сегодня они в ключе через
`brief_hash` (book-поле), но v3 дропает `brief_hash``content_hash`; значит нужен ЯВНЫЙ
вердикт-фейт. **Правило §4 переформулировано: судьбу обязано получить ЛЮБОЕ brief-поле,
потребляемое ВНЕ рендера** (языки — в `verdictSnapshotID`; инцидентально они и в translator
`content_hash` через рендер, но вердикт-зависимость — несущая). §4-строка добавлена.
- **(v3.1-b) `cache_ttl`-расщепление: `Context.CacheTTL` — advisory (НЕ в `wireSnapshotID`);
`prov.CacheTTL` — в `wireSnapshotID`.** Проверено кодом: `clients.go:38` передаёт клиенту
`CacheTTL: prov.CacheTTL` (провайдер-уровень, models.yaml, Anthropic-kind cache_control) —
**`Context.CacheTTL` (pipeline `context.cache_ttl`, «5m») НЕ доходит НИ ДО ОДНОГО клиента =
wire-ИНЕРТЕН.** v3 клал в `wireSnapshotID` инертный `Context.CacheTTL` и терял wire-влияющий
`prov.CacheTTL` — задом наперёд. v3.1: `Context.CacheTTL` демотирован до advisory (response-
нейтрален, reuse безопасен; НЕ пиним); `prov.CacheTTL` (пер-стадийно через резолвнутый провайдер)
`wireSnapshotID` + `guard_hash` (меняет cache_control на Anthropic-проводе). Anthropic снят со
стека (models.yaml) → экспозиция нулевая, но механизм чист. §4-строки поправлены.
**Пре-имплементационные (D22.2):** (1) axis-sensitivity тест `RequestHash` — флип `role`/`stage`
меняет хеш (сейчас мутация «удалить role» пережила бы сьют) → §12.9 тест-лист (`г-тер`);
(2) абзац «паритетные не-цели» §8 (classify-Source для editor-строк; `Retries`/`Escal.BudgetUSD`
вне хешей; транспорт-ретраи вне ключа); (3) line-refs освежены — **⚠ рефактор D23 расщепил
`runner.go` на `stagerun.go`/`chunkrun.go`/`escalation.go`/`bookrun.go`/`resume.go`/`status.go`;
исторические `runner.go:NNN`-ссылки ниже ИНДИКАТИВНЫ (по классу кода), актуальные локации —
в §12-плане и по grep имени функции.**
**Supersedes:** v2 (направление v1+v2 ратифицировано D-лог §Ратификации 09.07 п.2 и D20.1: расщепить
`snapshotID` на wire/вердикт-части; ключ чекпоинта — на wire-идентичности; вердикт-смену
re-classify-ить на resume бесплатно; три дыры v1 закрыты v2). **v3 закрывает три правки D20.1**
@ -146,7 +180,7 @@ jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wir
| Поле (сегодня) | Класс | Куда в v2 | Почему |
|---|---|---|---|
| `brief_hash` | wire | **dropped**`content_hash` | Метаданные книги рендерятся в шаблон (`render.go:108-121`) → в `msgs`; per-chunk `content_hash` ловит правку брифа гранулярно. **Caveat закрыт v3:** НЕрендеримые брифа-поля не покрываются `content_hash` — их судьбы назначены явно: `YoPolicy`/`StyleAllowlist``verdictSnapshotID` (строки выше, кормят стиль-флаггер); `Adult` (`book.go`) остаётся **wire/вердикт-НЕЙТРАЛЬНЫМ** (D20.2-Q3): слой не назначается, вместо хеша — **load-time adult-линт** `Pipeline.CheckAdultChannel` (реализован пакетом №3: `adult:true` без `channel:adult`-стадии → fail-loud). Первый рантайм-потребитель `Adult` (если появится) обязан ЯВНО вернуть его в `wireSnapshotID`/`verdictSnapshotID` — §13-Q3. |
| `brief_hash` | wire | **dropped**`content_hash` | Метаданные книги рендерятся в шаблон (`render.go:108-121`) → в `msgs`; per-chunk `content_hash` ловит правку брифа гранулярно. **Правило (v3.1-a): ЛЮБОЕ brief-поле, потребляемое ВНЕ рендера, обязано получить явную судьбу** (`content_hash` его не ловит). Назначено: `SourceLang`/`TargetLang``verdictSnapshotID` (cjk-gate/coverage-коридор — строка ниже, v3.1-a); `YoPolicy`/`StyleAllowlist``verdictSnapshotID` (кормят стиль-флаггер); `Adult` (`book.go`) остаётся **wire/вердикт-НЕЙТРАЛЬНЫМ** (D20.2-Q3): слой не назначается, вместо хеша — **load-time adult-линт** `Pipeline.CheckAdultChannel` (реализован пакетом №3: `adult:true` без `channel:adult`-стадии → fail-loud). Первый рантайм-потребитель `Adult` (если появится) обязан ЯВНО вернуть его в `wireSnapshotID`/`verdictSnapshotID` — §13-Q3. |
| `chunker_version` | wire | **dropped**`content_hash` | Правила чанкера дают `ch.Text``msgs`. Ре-чанкинг меняет границы/число чанков → `content_hash` почти всех → честный broad re-bill, гранулярно. |
| `estimator_version` | wire (деривация) | **guard_hash** (не в `RequestHash`) | Деривирует `maxTokens`, а фактический `maxTokens` — уже прямое поле `RequestHash`. В ключе избыточен; fast-path нуждается — кладём в `guard_hash`. Бамп busts fast-path каждого чанка; платят только те, чей `maxTokens` реально сменился (остальные — checkpoint-hit, re-classify $0). |
| `max_tokens_policy` | wire (деривация) | **guard_hash** | Как `estimator_version`, для attempt≥1 (`maxTokensForAttempt`). Избыточен в ключе (фактический `maxTokens` там), нужен fast-path-у. |
@ -156,7 +190,9 @@ jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wir
| `context_assembly.glossary_token_budget` | wire | **dropped**`content_hash` | Бюджет → eviction → инъектнутые строки → `msgs` (§10). |
| `context_assembly.stm_depth` | wire | **dropped**`content_hash` | STM входит в `msgs`. |
| `context_assembly.overlap_tokens` | wire | **dropped**`content_hash` | Overlap меняет `ch.Text``msgs`. |
| `context_assembly.cache_ttl` | wire (cache-control) | **wireSnapshotID** | НЕ в `content_hash``msgs` только `Role`+`Content`; `msgsContentHash` опускает `CacheBoundary`/TTL). Response-нейтрален → reuse безопасен; пиним консервативно, чтобы смена TTL честно триггерила свежий вызов, но fast-path на `content_hash` не рушится (см. примечание ниже таблицы). |
| `context_assembly.cache_ttl` (`Context.CacheTTL`) | advisory | **dropped (v3.1-b)** | ⚠ **Правка v3.1-b: wire-ИНЕРТЕН**`clients.go:38` передаёт клиенту `prov.CacheTTL`, а `Context.CacheTTL` (pipeline `context.cache_ttl`) НЕ доходит НИ ДО ОДНОГО клиента. Response-нейтрален, reuse безопасен → НЕ пиним (v3 клал его в `wireSnapshotID` ошибочно). |
| `prov.CacheTTL` (провайдер, Anthropic cache_control) | wire (cache-control) | **wireSnapshotID** + `guard_hash` (v3.1-b) | Провайдер-уровневый TTL РЕАЛЬНО едет на провод (`clients.go:38``provider_anthropic.go` cache_control). Пер-стадийно через резолвнутый провайдер стадии. Anthropic снят со стека → экспозиция нулевая, механизм чист. |
| `Book.SourceLang` / `Book.TargetLang` | verdict | **verdictSnapshotID (v3.1-a)** | ⚠ **Правка v3.1-a.** Языки — ВЕРДИКТ-входы вне рендера: `TargetLang`→cjk-echo (`disposition.go:196`), пара→`len_ratio`-коридор (`chunkrun.go:56`). `editor.md` НЕ рендерит `{{target_lang}}``content_hash` editor-стадии их не ловит, а вердикт от них зависит. Инцидентально они и в translator `content_hash` (рендер `{{source_lang}}`/`{{target_lang}}`), но несущая — вердикт-зависимость. Уходят из ключа вместе с `brief_hash`; вердикт-фейт назначен явно. |
| `model` (пер-стадийный) | wire | **dropped из снапшота** → прямое поле `RequestHash` + `guard_hash` | `RequestHash` уже несёт `model` вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность. |
| `temperature` (`st.Temperature`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же. Именно этого не хватало fast-path-у v1 (дыра a). |
| `reasoning` (`st.Reasoning`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же (low→none — дыра a). |
@ -399,6 +435,20 @@ Reuse чекпоинта легален ⟺ **полная wire-идентичн
расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей
wire на новом конфиге был бы ДРУГИМ из-за temp/reasoning/maxTokens) — теперь исключён `guard_hash`.
**Паритетные НЕ-цели (v3.1, D22.2 — что осознанно ВНЕ хешей).** Формула суперсет-паритета не обязана
хешировать входы, которые ни wire, ни вердикт: (а) **classify-Source для editor-строк**`classify`
принимает `Source` для soft-refusal-порога и cjk-echo, но на editor-выходе источник — это черновик, а
не оригинал; это часть детерминированной классификации над `content_hash`-материалом, не отдельная
ось ключа. (б) **`Retries.RegenerateBeforeEscalate` и `Escalation.BudgetUSD`** — НЕ в снапшоте/ключе:
они управляют СКОЛЬКО попыток/хопов делать, а не байтами какого-либо вызова; их правка меняет, какие
`attempt`-оси материализуются, но каждый материализованный вызов уже уникально кейится своим `attempt`
(ретрай) или моделью эскалации (хоп) — уже покрыто. Смена `RegenerateBeforeEscalate` не пере-оплачивает
готовые чанки (их `attempt=0`-чекпоинты попадают), только разрешает/запрещает БУДУЩИЕ ретраи флагнутых.
(в) **Транспортные ретраи** (`timeouts` в models.yaml, backoff) — вне ключа by design (тот же
`RequestHash` через транспорт-ретраи и resume — F3-идемпотентность); их правка не инвалидирует ничего.
Эти три — паритетные не-цели: их отсутствие в `wireSnapshotID`/`guard_hash`/`verdictSnapshotID`
КОРРЕКТНО, а не пробел.
## 9. Судьба `chunk_status.snapshot_id` и status-ридеров
- **`chunk_status.snapshot_id`** — **сохраняется как advisory** (какой job-снапшот резолвнул строку;
@ -510,16 +560,19 @@ per chunk×stage, а не рассуждает аналитически):**
`ProjectedBookUSD=0`; флаг `--accept-rebill[=usd]` (опциональный потолок). `tmctl status`: заменить
булев `ConfigDrift` на проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф
целей + проекция (§9). Adult: слой НЕ назначать — линт `CheckAdultChannel` (уже в пакете №3).
9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**)
→ $0 re-classify, вердикт обновлён; (б) append-термина → re-bill ТОЛЬКО фаерящих чанков + sticky+2 +
editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/модель/**temperature**/**reasoning**/
capability/estimator) → честный re-bill; (г) **F1/дыра-a мутация**: ослабить `guard_hash` (убрать
temp) → тест ловит stale-serve при temp 0.3→0.4; **(г-бис) role-флип на wire-нейтральном чанке
(пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role` — re-classify под
coverage (§5-контрпример);** (д) dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис)
editor-каскад: смена temperature translator'а → проекция считает re-bill ОБЕИХ стадий чанка, не
только translator (§7.1-бис; мутация «убрать правило каскада» роняет проекцию до ~½);** (е)
миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`.
9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**/
**sanitizer**/**source_lang·target_lang** — v3.1-a) → $0 re-classify, вердикт обновлён; (б) append-термина
→ re-bill ТОЛЬКО фаерящих чанков + sticky+2 + editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/
модель/**temperature**/**reasoning**/capability/estimator) → честный re-bill; (г) **F1/дыра-a мутация**:
ослабить `guard_hash` (убрать temp) → тест ловит stale-serve при temp 0.3→0.4; **(г-бис) role-флип на
wire-нейтральном чанке (пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role`
re-classify под coverage (§5-контрпример);** **(г-тер, D22.2 axis-sensitivity) флип `role`/`stage` меняет
`RequestHash` — мутация «удалить role/stage из RequestHash» роняет тест (сейчас переживает сьют);** (д)
dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис) editor-каскад: смена temperature translator'а
→ проекция считает re-bill ОБЕИХ стадий чанка, не только translator (§7.1-бис; мутация «убрать правило
каскада» роняет проекцию до ~½);** (е) миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`;
**(ж, v3.1-b) `Context.CacheTTL` вне `wireSnapshotID` (правка «5m»→«1h» не re-bill-ит); `prov.CacheTTL`
в `wireSnapshotID` (правка re-bill-ит Anthropic-стадию).**
## 13. Решённые вопросы (ответы D20.2 — вписаны в текст v3)

View file

@ -165,45 +165,51 @@ func TestBoevoyConfigDeepSeekThinkingStaysOn(t *testing.T) {
}
}
// TestBoevoyConfigGrokEditorExplicitNone pins the consolidation fix (tasks 1-3): grok-4.3
// resolves to an EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT
// off-by-omission — grok's OMITTED default is "low" (it thinks, billed additively per xAI, so
// control:none would silently cost ~444 reasoning tokens/edit). It must not trip the echo-mine
// guard (xAI is not echo-prone: the editor input is a Russian draft), and the edit stage of both
// boevoy pipelines must be grok-4.3, while c2's select (judge) stage stays the glm-5 placeholder.
func TestBoevoyConfigGrokEditorExplicitNone(t *testing.T) {
// TestBoevoyConfigEditorGlm5AndGrokReasoning pins two invariants. (1) The D30.1 flip: the
// default editor of both boevoy pipelines is glm-5 BILINGUAL (mono-editor was passive and
// blind, exp12; grok reasoning-off is REMOVED from editor roles — a no-op) and its reasoning
// is off. (2) grok-4.3 (still the channel-B tier / 18+ judge in models.yaml) resolves to an
// EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT off-by-omission
// — grok's OMITTED default is "low" (it thinks, billed additively per xAI), and it must not
// trip the echo-mine guard. c2's select (judge) stage stays the glm-5 placeholder.
func TestBoevoyConfigEditorGlm5AndGrokReasoning(t *testing.T) {
m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
if err != nil {
t.Fatalf("load boevoy models.yaml: %v", err)
}
const model = "grok-4.3"
cap := m.ResolveCapability(model)
// (2) grok-4.3 reasoning invariant (channel B / 18+ judge; unchanged by the editor flip).
const grok = "grok-4.3"
cap := m.ResolveCapability(grok)
if cap.Reasoning.Control != llm.ReasoningEffortField || cap.Reasoning.OffEffort != "none" {
t.Errorf("%s must resolve to explicit reasoning_effort:\"none\" (control=effort, off_effort=none), got control=%q off_effort=%q — control:none leaves grok's \"low\" default (thinks, additive billing)",
model, cap.Reasoning.Control, cap.Reasoning.OffEffort)
grok, cap.Reasoning.Control, cap.Reasoning.OffEffort)
}
if why := m.echoMineViolation(model); why != "" {
t.Errorf("%s must not trip the echo-mine guard (xAI editor input is a Russian draft, not CJK): %s", model, why)
if why := m.echoMineViolation(grok); why != "" {
t.Errorf("%s must not trip the echo-mine guard (xAI editor input is a Russian draft, not CJK): %s", grok, why)
}
// (1) D30.1 editor flip: glm-5 bilingual editor, reasoning off, in both boevoy pipelines.
for _, pf := range []string{"pipeline-c1.yaml", "pipeline-c2.yaml"} {
p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m)
if err != nil {
t.Fatalf("load %s: %v", pf, err)
}
var editModel, selectModel string
var editModel, editReasoning, selectModel string
for _, st := range p.Stages {
switch st.Role {
case "editor":
editModel = st.Model
editModel, editReasoning = st.Model, st.Reasoning
case "judge":
selectModel = st.Model
}
}
if editModel != "grok-4.3" {
t.Errorf("%s edit stage model = %q, want grok-4.3 (D1/D3 default; glm-5 was the placeholder)", pf, editModel)
if editModel != "glm-5" {
t.Errorf("%s edit stage model = %q, want glm-5 (D30.1: bilingual editor; grok reasoning-off из редакторов СНЯТ — no-op)", pf, editModel)
}
if editReasoning != "off" {
t.Errorf("%s edit stage reasoning = %q, want off (glm-5 thinking:disabled — таймауты ×3)", pf, editReasoning)
}
if pf == "pipeline-c2.yaml" && selectModel != "glm-5" {
t.Errorf("%s select (judge) stage must stay glm-5 (Gemini Phase-2 slot, untouched by task 3), got %q", pf, selectModel)
t.Errorf("%s select (judge) stage must stay glm-5 (Gemini Phase-2 slot), got %q", pf, selectModel)
}
}
}

View file

@ -86,8 +86,24 @@ type Stage struct {
// Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).
type Gates struct {
Coverage CoverageGate `yaml:"coverage"`
Glossary GlossaryGate `yaml:"glossary"`
Coverage CoverageGate `yaml:"coverage"`
Glossary GlossaryGate `yaml:"glossary"`
Sanitizer SanitizerGate `yaml:"sanitizer"`
}
// SanitizerGate controls the output-sanitizer (D30.3): a deterministic verdict-axis
// gate on the FINAL chunk text that flags "instant unreadability" defects no other gate
// catches — leaked service preambles, trailing note/edit blocks, markdown ### headers,
// Latin-script insertions in the Russian output, and broken/split word forms (exp12 /
// flagman §5). Opt-in (default false), following the coverage gate's discipline (D12 Q4):
// when enabled a defect flips the chunk to flagged (D2 flag+skip — the garbage output
// never commits to TM/export). Its rules are tuned PRECISION over recall (each class fires
// only on a high-confidence signal), so a premature always-on default would false-flag
// legitimate prose. The rule VERSION is folded into the snapshot only when enabled
// (sanitizerVersion, mirroring coverage), so a rule edit is a loud --resnapshot — and it
// moves to verdictSnapshotID once content-addressed resume lands (D15.2).
type SanitizerGate struct {
Enabled bool `yaml:"enabled"`
}
// GlossaryGate controls the memory-bank post-check (E1). The post-check ALWAYS runs

View file

@ -23,9 +23,26 @@ import (
// re-pins loudly). The coverage gate runs ONLY on the TRANSLATOR role's output (vs the
// original source): a monolingual editor legitimately restructures sentences, so gating
// it against the source would false-flag a correct edit (see the role check below).
func (r *Runner) classifyOutput(role, source, output, finish string) classification {
func (r *Runner) classifyOutput(role, source, output, finish string, isFinal bool) classification {
cls := classify(classifyInput{Source: source, Output: output, Finish: finish, TargetLang: r.Book.TargetLang})
if !cls.ok() || !r.Pipeline.Gates.Coverage.Enabled {
if !cls.ok() {
return cls
}
// Output-sanitizer gate (D30.3): a deterministic verdict-axis check for "instant
// unreadability" defects no other gate catches — a leaked service preamble, a trailing
// note/edit block, a markdown ### header, a Latin-script insertion, a broken word form.
// Opt-in (Gates.Sanitizer). Runs ONLY on the FINAL stage's output (isFinal) — the text
// that SHIPS: an intermediate translator draft is legitimately rough and the EDITOR's job
// is to clean it, so sanitizing the draft would wrongly skip the recovering editor and drop
// the chunk to a placeholder (адверсариальное ревью). Runs on the completion text like the
// intrinsic classifier, so a resumed checkpoint re-derives the identical verdict for free;
// its rule version folds into the snapshot only when enabled (sanitizerSnapshot).
if isFinal && r.Pipeline.Gates.Sanitizer.Enabled {
if san := sanitizeOutput(output); san.total() > 0 {
return classification{FlagSanitizerDefect, san.summary()}
}
}
if !r.Pipeline.Gates.Coverage.Enabled {
return cls
}
// The coverage gate compares the output against the ORIGINAL source, which is a

View file

@ -81,6 +81,15 @@ const (
// retryable (a same-model retry re-produces the same rendering) and NOT auto-
// escalatable in v1 (the L3 targeted re-ask is a Phase-2 remedy, research/14 §9).
FlagGlossaryMiss FlagReason = "glossary_miss"
// FlagSanitizerDefect is the output-sanitizer verdict (D30.3, sanitizer.go): the stage
// output carries an "instant unreadability" defect no other gate catches — a leaked
// service preamble, a trailing note/edit block, a markdown ### header, a Latin-script
// insertion, or a broken word form. Emitted ONLY when the opt-in Gates.Sanitizer is
// enabled; the contaminated output is flagged (D2 flag+skip) so it never commits to
// TM/export. NOT retryable (a same-model retry re-produces the artifact) and NOT
// escalatable in v1 (the editor is pinned; a redrive surfaces the defect to a human).
FlagSanitizerDefect FlagReason = "sanitizer_defect"
)
// classifierVersion versions the INTRINSIC classify() verdict logic — the refusal

View file

@ -22,6 +22,14 @@ import (
// draft→deepseek-v4-pro hops on acceptance stage A: they inherited 2048/3291 and
// returned length). 0 floor (reasoning-off models) leaves the budget untouched.
// Applied BEFORE the request_hash so it is wire-visible and snapshot-folded.
//
// ⚠ LATENT INTERACTION (D28.3б, note not a defect — no floored local model exists today): a
// `provider_local` kind overrides max_tokens on the wire AFTER the request-hash (prov.MaxTokens,
// snapshot.go ProviderMaxTok fold), so for a FLOORED local model the provider override would win
// over this floor on the actual wire. Both are in the snapshot (floor via Capability.MinMaxTokens,
// override via ProviderMaxTok), so it is a loud --resnapshot either way — but if a local model ever
// gets a min_max_tokens floor, decide explicitly whether the provider max_tokens override or the
// floor takes precedence (today the local provider max_tokens=8192 already covers thinking budgets).
func (r *Runner) applyModelFloor(base int, model string) int {
if floor := r.Models.MinMaxTokens(model); floor > base {
return floor
@ -76,7 +84,7 @@ type escalationOutcome struct {
// (not a fresh retry budget): total calls/chunk = primary attempts + 1 hop, never
// reset on the fallback (the LiteLLM #19985 retry×fallback blow-up guard). primary is
// the attempt loop's terminal (flagged) attempt.
func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID string, ch Chunk, job *store.Job, baseMaxTokens int, msgs []llm.Message, primary stageAttempt) (escalationOutcome, error) {
func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID string, ch Chunk, job *store.Job, baseMaxTokens int, msgs []llm.Message, primary stageAttempt, isFinal bool) (escalationOutcome, error) {
var out escalationOutcome
if !primary.cls.Reason.escalatable() || st.EscalateTo == "" {
return out, nil
@ -108,7 +116,7 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
if !mayHop {
return out, nil
}
fb, err := r.runAttempt(ctx, st, st.EscalateTo, snapID, ch, job, 0, hopMaxTokens, msgs, true)
fb, err := r.runAttempt(ctx, st, st.EscalateTo, snapID, ch, job, 0, hopMaxTokens, msgs, true, isFinal)
if err != nil {
// An OPTIONAL hop that trips a USD ceiling must NOT abort the whole book
// (and re-abort on every resume): the chunk is already flagged, so keep

View file

@ -33,22 +33,31 @@ import (
// CJK-echo draft cured by the single-hop escalation re-gate (ch2), and a hard refusal
// where the fallback also refuses → flag + skipped edit + exit 2 (ch3).
//
// D23.4 note: the nested key here resolves to CONFIRMED — the capture shows ambiguous=0
// on every chunk (both keys are longest-match approved surfaces, not collision-prone
// downgrades). A cell with AMBIGUOUS>0 (an auto/draft candidate forcing a post-check)
// is an OPTIONAL fixture extension, deliberately NOT added in this pass: it would be a
// fresh ratified golden capture of its own, and this package's re-capture is scoped to
// the D24.3/D24.4 diff-class (floor max_tokens + post-check verdicts).
// D28.2 fixture expansion (D30.9 diff-class, this package): three ratified behaviours now
// PINNED by golden, not only by the dedicated unit tests —
// • FLOOR: fake-model floors max_tokens to 4000, fake-fallback to 6000 (per-model
// capabilities.min_max_tokens) → the wire max_tokens and the resolved capability move,
// and the escalation HOP takes ITS OWN model's floor (ch2 hop = 6000, primary = 4000);
// • UNION: ch5 fires an OBLIQUE-ONLY decl term (老人→старик) whose FINAL text carries the
// NOMINATIVE «старик» → postcheck_miss=0 only because the base-dstdecl union checks the
// base (revert the union → this chunk false-flags a miss);
// • SANITIZER: ch6 draft leaks a service preamble → the output-sanitizer gate (ON in the
// fixture pipeline) flags it FlagSanitizerDefect and the edit is skipped.
// A cell with AMBIGUOUS>0 (an auto/draft candidate forcing a post-check) remains an OPTIONAL
// future extension, deliberately NOT added here.
//
// Update (ONLY on a deliberate, ratified behaviour change — never to "fix" a red
// refactor): TM_UPDATE_GOLDEN=1 go test ./internal/pipeline/ -run TestGolden
const goldenFile = "testdata/golden/capture.golden"
// goldenEchoMarker / goldenRefusalMarker are unique substrings of chapters 2/3 of the
// fixture source; the mock provider keys its echo/refusal behaviour off them.
// goldenEchoMarker / goldenRefusalMarker / goldenUnionMarker / goldenSanitizerMarker are
// unique substrings of chapters 2/3/5/6 of the fixture source; the mock keys its
// echo / refusal / union / sanitizer behaviour off them.
const (
goldenEchoMarker = "響動計画"
goldenRefusalMarker = "拒絶計画"
goldenEchoMarker = "響動計画"
goldenRefusalMarker = "拒絶計画"
goldenUnionMarker = "老人の秘密" // ch5 — oblique-only decl union case
goldenSanitizerMarker = "序文漏洩" // ch6 — sanitizer preamble-leak case
)
// goldenRespond is the deterministic mock provider brain. The response text is a pure
@ -66,6 +75,16 @@ func goldenRespond(body string) (text, finish string) {
case strings.Contains(body, goldenRefusalMarker):
// Both the primary and the fallback refuse → the chunk stays flagged (1 hop).
return "Не могу помочь с этим фрагментом.", "refusal"
case strings.Contains(body, goldenSanitizerMarker) && !isEditBody(body):
// ch6 draft ALSO leaks a preamble AND carries a keying token («ПРЕАМБУЛА-СЦЕНА»). The
// sanitizer runs ONLY on the FINAL (edit) output (адверсариальное ревью: не санитайзить
// промежуточный черновик), so with isFinal this draft stays OK and the edit is the one
// flagged. This PINS isFinal: revert «isFinal &&» in classifyOutput and the DRAFT flags
// instead (edit skipped) → golden diff.
return "Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь.", "stop"
case strings.Contains(body, goldenUnionMarker) && !isEditBody(body):
// ch5 draft carries a keying token («СТАРИК-СЦЕНА») the edit branch below keys on.
return "ЧЕРНОВИК ПЕРЕВОДА " + tag + ". СТАРИК-СЦЕНА Судзуки увидел старика.", "stop"
case strings.Contains(body, goldenEchoMarker) && !isEditBody(body):
if req.Model == "fake-fallback" {
// The escalation hop returns a clean translation → the re-gate passes it.
@ -73,6 +92,14 @@ func goldenRespond(body string) (text, finish string) {
}
// The primary draft echoes CJK instead of translating → cjk_artifact.
return "夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。", "stop"
case isEditBody(body) && strings.Contains(body, "ПРЕАМБУЛА-СЦЕНА"):
// ch6 edit leaks a service preamble → the output-sanitizer flags the FINAL stage
// (FlagSanitizerDefect). Colon after «фрагмента» is the high-precision signal.
return "Вот перевод фрагмента: Судзуки открыл дверь книгохранилища.", "stop"
case isEditBody(body) && strings.Contains(body, "СТАРИК-СЦЕНА"):
// ch5 edit — the FINAL text carries the NOMINATIVE «старик» (not an oblique decl
// form of 老人→старик), exercising the base-dstdecl union → postcheck_miss=0.
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки увидел, как в тени ждал старик.", "stop"
case isEditBody(body):
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки шёл по коридорам Академии магии.", "stop"
default:
@ -107,9 +134,11 @@ models:
fake-model:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
capabilities: { min_max_tokens: 4000 } # D28.2: floor the PRIMARY model pins floored max_tokens on the wire
fake-fallback:
provider: fake
price: { input_per_m: 3.0, cached_per_m: 0.3, cache_write_per_m: 0, output_per_m: 6.0 }
capabilities: { min_max_tokens: 6000 } # D28.2: a DISTINCT hop floor pins that the hop re-floors by ITS own model
`, time.Now().UTC().Format("2006-01-02"), providerURL))
return filepath.Join(dir, "book.yaml")
}

View file

@ -81,7 +81,8 @@ func (b *MemoryBank) postcheck(injected []pickedEntry, output string) []postchec
// («караВАН», «диВАН», «ИВАН» — a different person). Decl-aware (the research/14
// requirement): the stored decl forms carry the inflections a boundary regexp would miss.
//
// The accepted set is the UNION of the base dst AND the stored decl forms (D24.3/D24.4):
// The accepted set is the UNION of the base dst AND the stored decl forms (D24.4 — D24.3 is
// the max_tokens floor, a different fix; the union is the post-check verdict change):
// the base translation is itself an approved form, so it is ALWAYS checked — not only as a
// fallback when decl is empty. Checking it only on empty decl false-flagged 37/55
// acceptance-stage-A misses (nominative_gap): an approved dst present in the NOMINATIVE

View file

@ -309,19 +309,31 @@ func TestRunnerMemoryResnapshotOnApprovedChange(t *testing.T) {
}
}
// TestEditorPromptIsMonolingual pins decisions-log D1: the real editor prompt sees only the
// draft (+ the approved glossary injected as dst constraints by the runner), never the
// source. A reintroduced {{text}} would silently re-anchor the editor to the source and
// breed кальки (the main translationese disease D1 exists to avoid).
func TestEditorPromptIsMonolingual(t *testing.T) {
// TestEditorPromptIsBilingual pins the D30.1 flip (D1 monolingual → BILINGUAL editor,
// supersede D17.в): exp12 proved the monolingual editor is both passive and structurally
// blind to draft distortions (it cannot know «ударил головой» is a 磕头 kowtow without the
// source). The default editor.md now sees BOTH the source ({{text}}) and the draft
// ({{draft}}). The monolingual variant is PRESERVED as prompts/editor-mono.md (the D13.1
// confirming pilot arm) — it must NOT reference {{text}}.
func TestEditorPromptIsBilingual(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "prompts", "editor.md"))
if err != nil {
t.Fatalf("read editor.md: %v", err)
}
if strings.Contains(string(raw), "{{text}}") {
t.Error("editor.md must NOT reference {{text}} (D1: the editor is monolingual — the source is not injected)")
if !strings.Contains(string(raw), "{{text}}") {
t.Error("editor.md must reference {{text}} (D30.1: the editor is now BILINGUAL — it sees the source)")
}
if !strings.Contains(string(raw), "{{draft}}") {
t.Error("editor.md must reference {{draft}} (it edits the draft)")
}
mono, err := os.ReadFile(filepath.Join("..", "..", "prompts", "editor-mono.md"))
if err != nil {
t.Fatalf("read editor-mono.md: %v (D30.1: the monolingual variant must be preserved for the D13.1 pilot arm)", err)
}
if strings.Contains(string(mono), "{{text}}") {
t.Error("editor-mono.md must NOT reference {{text}} (it is the monolingual variant)")
}
if !strings.Contains(string(mono), "{{draft}}") {
t.Error("editor-mono.md must reference {{draft}}")
}
}

View file

@ -0,0 +1,402 @@
package pipeline
import (
"fmt"
"regexp"
"sort"
"strings"
"unicode"
)
// sanitizer.go: the output-sanitizer (D30.3) — a deterministic verdict-axis gate on the
// FINAL chunk text that catches the "instant unreadability" defect classes NO existing
// gate detects (exp12 root-cause + flagman §5). It is OPT-IN (Gates.Sanitizer.Enabled),
// following the coverage gate's discipline: when enabled a defect flips the chunk to
// flagged (D2 flag+skip — the contaminated output never commits to TM/export), else it is
// a no-op. Pure and deterministic (no time/rand, sorted detail) so a resume re-derives the
// identical verdict — its rule version folds into the snapshot only when enabled
// (sanitizerVersion, mirroring coverageSnapshot), and moves to verdictSnapshotID once
// content-addressed resume lands (D15.2).
//
// Five classes, each tuned PRECISION over recall (fire only on a high-confidence signal;
// the ambiguous middle stays silent — a false flag turns a good chunk into an export
// placeholder, worse than a missed defect for an opt-in readability gate):
//
// 1. leading service preamble — «Вот отредактированный перевод: …» (gemini 6/6, exp12 §4.1)
// 2. trailing note/edit block — appended «Примечание:/Внесённые правки:/Что изменено:» meta
// 3. markdown ### header — «### Глава 1» (8/54 finals of stage A — D29 finding)
// 4. Latin-script insertion — an untranslated Latin phrase/heavy Latin in the ru output
// 5. broken word form — homoglyph-mixed or invalid-sign / split-duplicated tokens
//
// HONEST recall gap (documented, precision over recall): the broken-word class catches only
// the DETERMINISTIC signatures (invalid Cyrillic sign bigrams, mixed Cyrillic/Latin token,
// junction-duplicated split). A word broken WITHOUT an invalid character («Первок предок»
// split into two individually-valid tokens) or a grammatical-but-wrong form («валуне») is
// NOT offline-detectable without a morphology/dictionary pass (Ф2) — left silent.
// sanitizerVersion versions the rules below (folded into the snapshot when the gate is
// enabled, like cheapGateVersion/coverageGateVersion): editing any pattern shifts the
// resolved disposition of flagged chunks, so a bump is a loud --resnapshot. [D15.2 note:
// this is a VERDICT version — once content-addressed resume lands it moves to
// verdictSnapshotID and a rule edit re-classifies free instead of re-paying the book.]
const sanitizerVersion = "sanitizer-v1"
// sanitizer tuning constants (versioned by sanitizerVersion).
const (
// trailingRegionLines: a note/edit header counts only when it sits in the LAST N
// non-empty lines (a meta block the model appended), so a legitimate mid-narrative
// «Примечание» in dialogue does not fire.
trailingRegionLines = 6
// latinPhraseWords: a run of this many SPACE-adjacent Latin word-tokens is a leaked
// untranslated phrase/sentence (a couple of proper names never reaches it). Set to 5
// (адверсариальное ревью): a ≤4-word quoted foreign motto («sic transit gloria mundi»)
// is legit source-preserved content and must NOT be dropped. ⚠ ACCEPTED recall/precision
// edge: a deliberately-quoted foreign phrase of ≥5 words («to be or not to be») still
// trips it — rare, and the gate is opt-in (a redrive surfaces it to a human).
latinPhraseWords = 5
// latinHeavyShare / latinHeavyTokens: heavy Latin contamination — Latin letters are a
// large share of all letters AND there are several Latin tokens (both required, so a
// short chunk with one long Latin brand name does not trip the share alone).
latinHeavyShare = 0.25
latinHeavyTokens = 5
// junctionOverlap: minimal suffix==prefix overlap between two adjacent Cyrillic tokens
// to call it a split-duplication artifact («Первопред предок»); exact duplicates
// (emphatic «хорошо хорошо») are excluded.
junctionOverlap = 4
)
// sanitizerResult is the per-chunk outcome: a count per class plus deterministic detail.
type sanitizerResult struct {
Preamble int `json:"preamble,omitempty"`
TrailingNote int `json:"trailing_note,omitempty"`
MarkdownHeader int `json:"markdown_header,omitempty"`
LatinInsert int `json:"latin_insert,omitempty"`
BrokenWord int `json:"broken_word,omitempty"`
Detail []string `json:"detail,omitempty"`
}
func (s sanitizerResult) total() int {
return s.Preamble + s.TrailingNote + s.MarkdownHeader + s.LatinInsert + s.BrokenWord
}
// summary is the human-readable disposition detail for a sanitizer flag (deterministic —
// Detail is sorted in sanitizeOutput). Kept short (the first two findings) for the log/row.
func (s sanitizerResult) summary() string {
if len(s.Detail) == 0 {
return "output-sanitizer defect"
}
if len(s.Detail) > 2 {
return strings.Join(s.Detail[:2], "; ") + fmt.Sprintf("; …(+%d)", len(s.Detail)-2)
}
return strings.Join(s.Detail, "; ")
}
// sanitizeOutput runs all five classes over one chunk's FINAL translated text.
func sanitizeOutput(text string) sanitizerResult {
var r sanitizerResult
if det := detectLeadingPreamble(text); det != "" {
r.Preamble++
r.Detail = append(r.Detail, det)
}
if det := detectTrailingNote(text); det != "" {
r.TrailingNote++
r.Detail = append(r.Detail, det)
}
if n, det := detectMarkdownHeaders(text); n > 0 {
r.MarkdownHeader = n
r.Detail = append(r.Detail, det...)
}
if det := detectLatinInsertion(text); det != "" {
r.LatinInsert++
r.Detail = append(r.Detail, det)
}
if n, det := detectBrokenWords(text); n > 0 {
r.BrokenWord = n
r.Detail = append(r.Detail, det...)
}
sort.Strings(r.Detail)
return r
}
// --- 1. leading service preamble ------------------------------------------------
// leadingPreamblePatterns match a service preamble at the very START of the output — the
// «Вот отредактированный перевод: …» leak (gemini 6/6, exp12 §4.1). PRECISION over recall
// (адверсариальное ревью: a lax rule dropped clean chapter openings like «Вот такой вариант
// развития — самый вероятный.», «Вот его вариант: он молча ушёл.»). Two guards make it tight:
// 1. terminator is a COLON only — the em-dash «—» is ubiquitous in narration, so it is NOT
// a terminator (a service preamble is a labeled prefix ending in «:»);
// 2. the phrase must be a genuine TRANSLATION/EDIT service label, not merely «lead + common
// noun»: either an edit-adjective (отредактированн/исправленн/улучшенн) + a перевод/текст/
// вариант noun, or «перевод фрагмента/текста/…», or «ниже приведён … перевод/текст».
// Go RE2 \w/\b are ASCII-only, so Cyrillic uses [а-яё].
var leadingPreamblePatterns = []*regexp.Regexp{
// «Вот/Представляю/Привожу … отредактированный/исправленный перевод/текст/вариант …:»
// (the edit-adjective front-gate holds precision, so the tail-to-colon may be long — the
// gemini leak carries «… с соблюдением всех терминов из глоссария:»).
regexp.MustCompile(`(?is)^\s*(?:вот|представляю|привожу|держите)\s+[^\n:]{0,40}?(?:отредактированн|исправленн|улучшенн)[а-яё]+\s+(?:перевод|текст|вариант)[а-яё]*[^\n:]{0,120}?:`),
// «Отредактированный/Исправленный перевод/текст/вариант …:» (label at the very start)
regexp.MustCompile(`(?is)^\s*(?:отредактированн|исправленн|улучшенн)[а-яё]+\s+(?:перевод|текст|вариант)[а-яё]*[^\n:]{0,120}?:`),
// «(Вот) перевод фрагмента/текста/отрывка/главы/черновика:»
regexp.MustCompile(`(?is)^\s*(?:вот\s+)?перевод\s+(?:фрагмента|текста|отрывка|главы|черновика)\s*:`),
// «Ниже приведён/представлен … перевод/отредактированный текст:»
regexp.MustCompile(`(?is)^\s*ниже\s+(?:привед[её]н|представлен|дан|след)[а-яё]*\s+[^\n:]{0,40}?(?:перевод|отредактированн|текст)[а-яё]*[^\n:]{0,20}?:`),
}
func detectLeadingPreamble(text string) string {
t := strings.TrimSpace(text)
for _, re := range leadingPreamblePatterns {
if loc := re.FindStringIndex(t); loc != nil {
return "ведущая служебная преамбула: " + preview(strings.TrimSpace(t[:loc[1]]))
}
}
return ""
}
// --- 2. trailing note / edit block ----------------------------------------------
// trailingNoteRE matches a note/edit meta-HEADER at line start: «Примечание:», «Заметки
// переводчика», «Комментарий:», «(прим. перев.)». PRECISION over recall (адверсариальное
// ревью: «Комментатор захлёбывался…», «Заметка белела на столе.», «Пояснения не требовалось.»,
// «Сноска объясняла обычай.» are ordinary narrative nouns/gerunds — NOT headers). So the header
// word MUST be followed by a COLON, or be «примечание/заметки/комментарий + переводчика/редактора»,
// or be the «(прим. перев./ред.)» marker. Only counted inside the trailing region (below).
var trailingNoteRE = regexp.MustCompile(`(?i)^[*_>\s-]{0,4}(?:(?:примечани[ея]|заметк[аи]|комментари[йяю]|пояснени[ея]|сноск[аи])\s*:|(?:примечани[ея]|заметк[аи]|комментари[йяю])\s+(?:переводчик|редактор)[а-яё]*|прим\.\s*(?:перев|ред)\.?)`)
// editMetaAnywhereRE matches UNAMBIGUOUS editor meta-commentary that is a defect wherever it
// appears (not only trailing): «Внесённые правки:», «Что изменено:», «Список правок:». The
// generic «Изменения:/Правки:» alone were REMOVED (адверсариальное ревью — «Изменения — вот что
// пугало его.» / «Правка — дело тонкое.» are common narrative openers), keeping only the
// editor-specific compound phrases that never occur in prose.
var editMetaAnywhereRE = regexp.MustCompile(`(?im)^[*_>\s-]{0,4}(?:(?:внесённ|внесен)[а-яё]+\s+правк|что\s+(?:было\s+)?(?:изменен|исправлен)|список\s+(?:правок|изменений)|список\s+внесённых)`)
func detectTrailingNote(text string) string {
if loc := editMetaAnywhereRE.FindStringIndex(text); loc != nil {
return "утёкший блок правок редактора: " + preview(strings.TrimSpace(firstLineAt(text, loc[0])))
}
lines := nonEmptyLines(text)
start := len(lines) - trailingRegionLines
if start < 0 {
start = 0
}
for _, ln := range lines[start:] {
if trailingNoteRE.MatchString(ln) {
return "хвостовой блок заметок/примечаний: " + preview(strings.TrimSpace(ln))
}
}
return ""
}
// --- 3. markdown ### header -----------------------------------------------------
// markdownHeaderRE matches a markdown ATX header line («### Глава», «# Title»): 16 hashes,
// a space, then non-space content. «#1» / «№5» / a lone «#» do not match (no space+text).
var markdownHeaderRE = regexp.MustCompile(`(?m)^\s{0,3}#{1,6}\s+\S`)
func detectMarkdownHeaders(text string) (int, []string) {
var det []string
n := 0
for _, ln := range strings.Split(text, "\n") {
if markdownHeaderRE.MatchString(ln) {
n++
det = append(det, "markdown-заголовок в выходе: "+preview(strings.TrimSpace(ln)))
}
}
return n, det
}
// --- 4. Latin-script insertion --------------------------------------------------
// detectLatinInsertion flags an untranslated Latin PHRASE (≥ latinPhraseWords Latin words
// separated only by SINGLE SPACES — a real «the quick brown fox», not an alphanumeric id) OR
// heavy Latin contamination (Latin ≥ latinHeavyShare of all letters AND ≥ latinHeavyTokens
// Latin tokens). Requiring space-adjacency is load-bearing: a hex/id run like «138fd5c384e3»
// or «a1b2c3d4» yields several single-letter Latin tokens with NO space between them, which
// must NOT read as a Latin phrase (a false positive the golden fixture's tags exposed). A
// handful of Latin proper names stays silent.
func detectLatinInsertion(text string) string {
toks := scriptTokens(text)
maxRun, run, latTokens := 0, 0, 0
for _, tk := range toks {
switch {
case tk.lat:
if tk.adjacent && run > 0 {
run++ // a Latin word separated from the previous Latin word by one space
} else {
run = 1 // phrase start (digit/punct separator or a non-Latin predecessor)
}
latTokens++
if run > maxRun {
maxRun = run
}
case tk.cyr || tk.mixed:
run = 0
}
}
if maxRun >= latinPhraseWords {
return "латинская вставка (фраза из подряд идущих латинских слов)"
}
latinLetters, cyrLetters := 0, 0
for _, ru := range text {
if unicode.Is(unicode.Latin, ru) {
latinLetters++
} else if unicode.Is(unicode.Cyrillic, ru) {
cyrLetters++
}
}
total := latinLetters + cyrLetters
if total > 0 && latTokens >= latinHeavyTokens && float64(latinLetters)/float64(total) >= latinHeavyShare {
return "латиница-врезки: тяжёлая доля латиницы в ru-выходе"
}
return ""
}
// --- 5. broken word forms -------------------------------------------------------
// invalidSignRE matches Cyrillic sign bigrams that never occur in valid Russian: a hard/soft
// sign at a word START that is FOLLOWED BY A LETTER (a broken token like «ьзал»), or two signs
// adjacent (ъь/ьъ/ьь/ъъ). Requiring the following letter excludes a STANDALONE «ъ»/«ь» — a
// metalinguistic mention of the letter itself, «Буква ъ называлась ером.» (адверсариальное
// ревью nit). High precision otherwise: word-initial-sign-then-letter and doubled signs are
// impossible in well-formed text. \P{L} (non-letter), not RE2's ASCII-only \b.
var invalidSignRE = regexp.MustCompile(`(?i)(?:^|\P{L})[ъь][а-яё]|[ъь][ъь]`)
func detectBrokenWords(text string) (int, []string) {
var det []string
seen := map[string]bool{}
add := func(s string) {
if !seen[s] {
seen[s] = true
det = append(det, s)
}
}
// (a) invalid Cyrillic sign bigrams.
if invalidSignRE.MatchString(text) {
add("невалидная кириллическая биграмма (мягкий/твёрдый знак в начале слова или подряд)")
}
// (b) mixed Cyrillic+Latin within one letter-run token (homoglyph artifact) and
// (c) junction-duplicated adjacent split — walk the letter tokens once.
toks := scriptTokens(text)
for _, tk := range toks {
if tk.mixed {
add("смешение кириллицы и латиницы в одном слове (гомоглиф): " + preview(tk.text))
}
}
for i := 1; i < len(toks); i++ {
if !toks[i].adjacent {
continue
}
a, b := toks[i-1], toks[i]
if a.cyr && b.cyr && a.text != b.text && junctionDuplicated(a.text, b.text) {
add("похоже на разорванное/сдвоенное слово: " + preview(a.text+" "+b.text))
}
}
return len(det), det
}
type scriptToken struct {
text string
cyr bool // Cyrillic-only
lat bool // Latin-only
mixed bool // both scripts in one letter run (homoglyph artifact)
adjacent bool // separated from the previous token by exactly a single space (junction check)
}
// scriptTokens splits text into maximal letter-run tokens (letters only; digits/hyphens/
// spaces/punctuation are separators), tagging Cyrillic-only / Latin-only / mixed-script and
// whether the token is separated from the previous token by exactly a single space
// (adjacency — a duplication artifact sits inside a phrase, not across punctuation/newline).
func scriptTokens(text string) []scriptToken {
var out []scriptToken
var b strings.Builder
hasLat, hasCyr := false, false
gap := "" // separator run since the previous token: "" none yet, " " one space, "x" other
flush := func() {
if b.Len() > 0 {
out = append(out, scriptToken{
text: b.String(),
cyr: hasCyr && !hasLat,
lat: hasLat && !hasCyr,
mixed: hasLat && hasCyr,
adjacent: gap == " ",
})
b.Reset()
gap = ""
}
hasLat, hasCyr = false, false
}
for _, ru := range text {
switch {
case unicode.Is(unicode.Latin, ru):
b.WriteRune(ru)
hasLat = true
case unicode.Is(unicode.Cyrillic, ru):
b.WriteRune(ru)
hasCyr = true
default:
flush()
if ru == ' ' && gap == "" {
gap = " "
} else {
gap = "x" // newline / punctuation / tab / a second space breaks adjacency
}
}
}
flush()
return out
}
// junctionDuplicated reports whether adjacent tokens a, b share a PROPER suffix==prefix
// overlap of ≥ junctionOverlap runes (a split-duplication artifact like «Первопред предок»,
// overlap «пред»). CONTAINMENT is EXCLUDED (адверсариальное ревью major): if one token is a
// prefix of the other («старик старика», «город города» — a noun + its own inflection, i.e.
// legit polyptoton), the suffix==prefix loop would match at the containment length and
// false-flag it. A true broken split has a MID overlap where NEITHER token contains the
// other. Both must be reasonably long so a short common ending does not trip it.
func junctionDuplicated(a, b string) bool {
al := []rune(strings.ToLower(a))
bl := []rune(strings.ToLower(b))
if len(al) < junctionOverlap+1 || len(bl) < junctionOverlap+1 {
return false
}
if strings.HasPrefix(string(bl), string(al)) || strings.HasPrefix(string(al), string(bl)) {
return false // containment = polyptoton (word + its inflection), not a broken split
}
maxK := len(al)
if len(bl) < maxK {
maxK = len(bl)
}
// A proper overlap cannot equal a whole token (that would be containment, excluded above),
// so cap the search below the shorter length.
if maxK > 0 {
maxK--
}
for k := maxK; k >= junctionOverlap; k-- {
if runesEqual(al[len(al)-k:], bl[:k]) {
return true
}
}
return false
}
// --- shared helpers -------------------------------------------------------------
func nonEmptyLines(text string) []string {
var out []string
for _, ln := range strings.Split(text, "\n") {
if strings.TrimSpace(ln) != "" {
out = append(out, ln)
}
}
return out
}
// firstLineAt returns the line containing byte offset off.
func firstLineAt(text string, off int) string {
start := strings.LastIndexByte(text[:off], '\n') + 1
end := strings.IndexByte(text[off:], '\n')
if end < 0 {
return text[start:]
}
return text[start : off+end]
}

View file

@ -0,0 +1,205 @@
package pipeline
import (
"strings"
"testing"
)
// sanitizer_test.go — the output-sanitizer (D30.3) unit suite. Per the gate discipline
// (precision over recall) EVERY class asserts BOTH firing on the exp12/flagman defect and
// NON-firing on legitimate prose, so a future rule edit that starts false-flagging real
// Russian text fails loudly here.
func TestSanitizerLeadingPreamble(t *testing.T) {
fire := []string{
// gemini 6/6 leak (exp12 §4.1) — the canonical target.
"Вот отредактированный перевод с соблюдением всех терминов из глоссария:\n\nСудзуки шёл по коридору.",
"Вот отредактированный перевод:\nТекст главы.",
"Вот перевод фрагмента: Судзуки открыл дверь.",
"Ниже приведён отредактированный текст:\nГлава началась.",
"Отредактированный перевод: Судзуки вошёл.",
"Представляю исправленный вариант перевода: он ушёл.",
}
for _, s := range fire {
if got := sanitizeOutput(s); got.Preamble == 0 {
t.Errorf("preamble not detected in %q", preview(s))
}
}
clean := []string{
"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.",
"Вот и настал тот день, которого он так ждал.", // «Вот и…» narrative — no service phrase
"Вот дом стоял на холме, окружённый старыми соснами.", // «Вот дом…»
"— Вот твой перевод, — сказал он и протянул конверт.", // dialogue about a translation, not a preamble label
"Перевод стрелки часов занял у него всё утро.", // «Перевод» as a common noun, no service label
"Ниже по реке стоял старый мост, к которому вела тропа.", // «Ниже…» geographic
// адверсариальное ревью false positives — must NOT fire (em-dash terminator + common nouns):
"Вот такой вариант развития событий — самый вероятный.", // вот + вариант + em-dash, no edit-adjective
"Ниже текста была подпись — размашистая.", // ниже + текст + em-dash, not «ниже приведён … перевод:»
"Вот новая версия карты — с пометками отца.", // вот + версия + em-dash
"Итоговый текст письма — три строки.", // итоговый + текст + em-dash
"Вот его вариант: он молча ушёл.", // вот + вариант + COLON, but no edit-adjective / service phrase
}
for _, s := range clean {
if got := sanitizeOutput(s); got.Preamble != 0 {
t.Errorf("preamble FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
func TestSanitizerTrailingNote(t *testing.T) {
fire := []string{
"Судзуки открыл дверь.\n\nПримечание: имя героя оставлено без перевода.",
"Он ушёл в туман.\n\nВнесённые правки: заменил «фракции» на «школы».",
"Глава закончилась.\n\nЧто изменено: исправлена терминология гу.",
"Текст главы.\n\n*Примечание переводчика:* здесь игра слов.",
"Финал сцены.\n\nЗаметки редактора: стиль приведён к норме.",
"Список правок: 1) термины; 2) вёрстка.\nТекст главы дальше.", // edit-meta anywhere
}
for _, s := range fire {
if got := sanitizeOutput(s); got.TrailingNote == 0 {
t.Errorf("trailing note not detected in %q", preview(s))
}
}
clean := []string{
"Судзуки открыл дверь и вошёл в тёмный зал, где ждала тишина.",
"Примечательно, что никто не заметил его исчезновения в ту ночь.", // «Примечательно» ≠ «Примечание:»
"Он оставил заметку на столе и вышел, не оглядываясь назад.", // «заметку» mid-narrative, not a header
"— Что изменилось? — спросил он, оглядывая пустую комнату.", // dialogue, not «Что изменено:»
// адверсариальное ревью false positives — ordinary narrative nouns/gerunds ending a chapter:
"Комментатор захлёбывался от восторга, махая руками над головой.", // «Комментатор» ≠ «Комментарий:»
"Комментируя случившееся, старик лишь развёл руками в тишине.", // «Комментируя» gerund
"Заметка белела на столе, и никто не решался её тронуть.", // «Заметка» + no colon
"Пояснения не требовалось: всё было ясно без единого слова.", // «Пояснения» + no colon (the colon comes later)
"Сноска в конце страницы объясняла древний обычай клана.", // «Сноска» narrative subject
"Изменения — вот что пугало его больше всего на этом свете.", // «Изменения —» common narrative opener
"Правка — дело тонкое, и старый мастер знал это лучше всех.", // «Правка —» aphorism
}
for _, s := range clean {
if got := sanitizeOutput(s); got.TrailingNote != 0 {
t.Errorf("trailing note FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
func TestSanitizerMarkdownHeader(t *testing.T) {
fire := []string{
"### Глава 1\n\nСудзуки шёл по коридору.",
"# Пролог\n\nТекст.",
"Текст первой сцены.\n\n## Часть вторая\n\nПродолжение.",
}
for _, s := range fire {
if got := sanitizeOutput(s); got.MarkdownHeader == 0 {
t.Errorf("markdown header not detected in %q", preview(s))
}
}
clean := []string{
"Он купил дом №5 на этой улице за бесценок прошлой весной.", // «№5» not a header
"Комната #1 была заперта, и ключа ни у кого не нашлось.", // «#1» no space+text
"Язык C# он выучил ещё в университете, но давно забросил.", // «C#» mid-line
"Судзуки шёл по коридорам, вспоминая вчерашнюю долгую лекцию.",
}
for _, s := range clean {
if got := sanitizeOutput(s); got.MarkdownHeader != 0 {
t.Errorf("markdown header FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
func TestSanitizerLatinInsertion(t *testing.T) {
fire := []string{
"Судзуки открыл книгу и прочитал: the quick brown fox jumps over.", // ≥4-word latin phrase
"He walked into the ancient library at dawn and found nothing.", // whole latin sentence
}
for _, s := range fire {
if got := sanitizeOutput(s); got.LatinInsert == 0 {
t.Errorf("latin insertion not detected in %q", preview(s))
}
}
clean := []string{
"Судзуки купил iPhone и MacBook, чтобы работать в дороге по ночам.", // 2 latin brand names
"На экране светилась надпись Google, и он нажал на неё пальцем.", // single latin name
"Он listened внимательно.", // 1 stray latin word
"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.",
"Идентификатор 138fd5c384e3 и код a1b2c3d4e5f6 записаны в журнале.", // hex/id: single-letter latin tokens separated by digits — NOT a phrase
"На гербе была выбита надпись: sic transit gloria mundi.", // 4-word quoted Latin motto — legit preserved (адверсариальное ревью)
}
for _, s := range clean {
if got := sanitizeOutput(s); got.LatinInsert != 0 {
t.Errorf("latin insertion FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
func TestSanitizerBrokenWord(t *testing.T) {
fire := []string{
"Стольь высокая башня терялась в облаках над городом.", // doubled soft sign ьь
"Он вошёл в ьзал и замер на пороге, не решаясь шагнуть.", // soft sign word-initial
"Древний Первопред предок восстал из пепла забытых веков.", // junction-duplicated split «Первопред|предок»
}
for _, s := range fire {
if got := sanitizeOutput(s); got.BrokenWord == 0 {
t.Errorf("broken word not detected in %q", preview(s))
}
}
// Mixed Cyrillic+Latin homoglyph in one token (latin 'p'/'e'/'o' inside a Cyrillic word).
if got := sanitizeOutput(еок предок восстал из пепла."); got.BrokenWord == 0 {
t.Errorf("mixed-script homoglyph not detected")
}
clean := []string{
"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.",
"— Хорошо, хорошо, — устало ответил он, поднимая обе руки вверх.", // emphatic exact repeat, NOT a split
"Молоко около дома скисло за одну жаркую летнюю ночь под навесом.", // adjacent words, no ≥4 junction overlap
"На валуне у реки сидел одинокий путник в сером плаще.", // «валуне» is a VALID form (recall gap)
// адверсариальное ревью false positives:
"Старик старика видел издалека, но узнал не сразу в тумане.", // polyptoton (старик + старика): containment, NOT a split
"Город города не выдаёт — так говаривал его дед у очага.", // город + города: containment
"Буква ъ раньше называлась ером и стояла в конце многих слов.", // metalinguistic mention of the letter ъ (standalone)
"Хорошо хорошо всё это выглядело только со стороны, издалека.", // capitalized emphatic repeat (case-differing) — containment excludes it
}
for _, s := range clean {
if got := sanitizeOutput(s); got.BrokenWord != 0 {
t.Errorf("broken word FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
// TestSanitizerBrokenWordRecallGap documents the honest boundary of the DETERMINISTIC
// minimum (D30.3: precision over recall, «это ФЛАГГЕР»): a word split into two
// individually-valid tokens with NO invalid sign, NO homoglyph and NO ≥4-rune junction
// overlap — the bare «Первок предок» from exp12 — is NOT offline-detectable without a
// morphology/dictionary pass (Ф2). It is caught only when the split leaves a junction
// overlap («Первопред предок») or an invalid character. This test PINS that gap so a future
// stronger heuristic is a conscious change, not an accident.
func TestSanitizerBrokenWordRecallGap(t *testing.T) {
if got := sanitizeOutput("Первок предок восстал из пепла забытых веков."); got.BrokenWord != 0 {
t.Errorf("unexpected: the pure-orthographic split «Первок предок» is documented as a recall gap, but it fired: %v", got.Detail)
}
}
// TestSanitizerCleanNarrativePassesEntirely guards the whole gate against false positives
// on a realistic clean Russian passage with dialogue, names and numbers.
func TestSanitizerCleanNarrative(t *testing.T) {
passage := strings.Join([]string{
"Судзуки медленно шёл по длинным коридорам Академии магии.",
"",
"— Сегодня я наконец открою дверь книгохранилища, — прошептал он.",
"",
"За окном лил серебристый дождь, собираясь в ручьи на брусчатке двора.",
"Башня библиотеки тонула в тумане, и вершины её не было видно.",
}, "\n")
if got := sanitizeOutput(passage); got.total() != 0 {
t.Fatalf("clean narrative flagged by the sanitizer: %+v", got.Detail)
}
}
// TestSanitizerDeterministic asserts the verdict is a pure function of the text (resume-safe).
func TestSanitizerDeterministic(t *testing.T) {
s := "### Заголовок\nВот отредактированный перевод: текст. Стольь странно.\nПримечание: правка."
a, b := sanitizeOutput(s), sanitizeOutput(s)
if a.total() != b.total() || strings.Join(a.Detail, "|") != strings.Join(b.Detail, "|") {
t.Fatalf("sanitizeOutput not deterministic: %v vs %v", a, b)
}
if a.total() == 0 {
t.Fatalf("expected the multi-defect fixture to flag")
}
}

View file

@ -57,6 +57,25 @@ func (r *Runner) coverageSnapshot() coverageSnap {
return cov
}
// sanitizerSnap freezes the output-sanitizer gate (D30.3) inside the snapshot, mirroring
// coverageSnap: {enabled:false} when off (so toggling on is a visible --resnapshot), the
// algorithm version when on. It changes a checkpoint's RESOLVED disposition (a defect flips
// the chunk to flagged), never the wire, so a rule edit is a loud re-pin, not a silent
// re-verdict on resume. Folded ONLY when enabled — tweaking a disabled gate must not force a
// re-pin (it produces no verdicts while off). Moves to verdictSnapshotID with D15.2.
type sanitizerSnap struct {
Enabled bool `json:"enabled"`
Version string `json:"version,omitempty"`
}
func (r *Runner) sanitizerSnapshot() sanitizerSnap {
s := sanitizerSnap{Enabled: r.Pipeline.Gates.Sanitizer.Enabled}
if s.Enabled {
s.Version = sanitizerVersion
}
return s
}
// memoryVersion is the content-hash of the deterministically materialized injected
// memory (the frozen APPROVED glossary rows + the normalization/matcher algorithm
// versions), the memory component of the snapshot (D5.2/D8, F1 CLOSED). It is the
@ -178,8 +197,12 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
// are observability, not wire, but editing a rule shifts the recorded style-flag counts, so
// a bump is a loud --resnapshot (same verdict class as ClassifierVersion). The ё-policy is
// part of BriefHash (a book field), so a policy change already re-pins via brief_hash.
StyleCheckVersion string `json:"style_check_version"`
Stages []stageSnap `json:"stages"`
StyleCheckVersion string `json:"style_check_version"`
// Sanitizer — the output-sanitizer gate (D30.3). Like Coverage it does not touch the wire
// but determines a checkpoint's RESOLVED verdict (a defect flips the chunk to flagged), so a
// gate flip / rule edit is a loud --resnapshot. Folded only when enabled (sanitizerSnapshot).
Sanitizer sanitizerSnap `json:"sanitizer"`
Stages []stageSnap `json:"stages"`
}{
BriefHash: r.Book.BriefHash(),
ChunkerVersion: chunkerVersion,
@ -200,6 +223,7 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
Coverage: r.coverageSnapshot(),
StyleCheckVersion: cheapGateVersion,
Sanitizer: r.sanitizerSnapshot(),
}
for _, st := range r.Pipeline.Stages {
ss := stageSnap{

View file

@ -102,13 +102,17 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
maxRegen = 0
}
// isFinal marks the LAST stage of the chunk: the output-sanitizer (D30.3) runs only on the
// text that SHIPS, never on an intermediate draft the editor still cleans (chunkrun.go).
isFinal := stageIdx == len(r.Pipeline.Stages)-1
var cumCost, runCost float64
var last stageAttempt
anyFresh := false
attemptsMade := 0
for attempt := 0; ; attempt++ {
maxTokens := maxTokensForAttempt(baseMaxTokens, attempt)
att, err := r.runAttempt(ctx, st, st.Model, snapID, ch, job, attempt, maxTokens, msgs, false)
att, err := r.runAttempt(ctx, st, st.Model, snapID, ch, job, attempt, maxTokens, msgs, false, isFinal)
if err != nil {
return nil, err // infra failure
}
@ -136,7 +140,7 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
// runs at most ONCE, is re-gated, and never resets the retry budget; a ceiling-
// denied hop keeps the primary flag instead of aborting the book.
escalated, escModel := false, ""
esc, err := r.maybeEscalate(ctx, st, snapID, ch, job, baseMaxTokens, msgs, last)
esc, err := r.maybeEscalate(ctx, st, snapID, ch, job, baseMaxTokens, msgs, last, isFinal)
if err != nil {
return nil, err
}
@ -213,7 +217,7 @@ type stageAttempt struct {
// checkpoints — no re-billing), otherwise a fresh reserve → call → settle+
// checkpoint. It returns an error only on an infra failure; a bad completion
// comes back as a classification on the attempt.
func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation bool) (stageAttempt, error) {
func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation, isFinal bool) (stageAttempt, error) {
reqHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, attempt, st.Name, st.Role, model,
st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
att := stageAttempt{reqHash: reqHash, modelActual: model}
@ -235,7 +239,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
att.finish = cp.FinishReason
att.modelActual = cp.ModelActual
att.cumCost = cp.CostUSD
att.cls = r.classifyOutput(st.Role, ch.Text, cp.ResponseText, cp.FinishReason)
att.cls = r.classifyOutput(st.Role, ch.Text, cp.ResponseText, cp.FinishReason, isFinal)
rl := r.baseRequestLog(st, ch, model, reqHash)
rl.ModelActual = cp.ModelActual
rl.TMHit, rl.OK, rl.FinishReason, rl.Degraded = true, att.cls.ok(), cp.FinishReason, degradedTag(att.cls)
@ -401,7 +405,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
// non-empty truncated length draft is now classified (flagged/retried), never
// silently passed downstream as OK; an empty completion is flagged too, not a
// run-crash.
att.cls = r.classifyOutput(st.Role, ch.Text, resp.Text, resp.FinishReason)
att.cls = r.classifyOutput(st.Role, ch.Text, resp.Text, resp.FinishReason, isFinal)
rl := r.baseRequestLog(st, ch, model, reqHash)
rl.ModelActual = modelActual

View file

@ -103,16 +103,20 @@ func flagReasonSeverity(reason string) int {
return 0
case FlagCJKArtifact, FlagExcisionSuspect, FlagCoverageFail:
return 1
case FlagLoopDegenerate:
case FlagSanitizerDefect:
// A contaminated output (leaked preamble / notes / ###) is unreadable-as-shipped —
// ranked with the deterministic content failures, above a mere budget symptom.
return 2
case FlagDecodeError:
case FlagLoopDegenerate:
return 3
case FlagGlossaryMiss:
case FlagDecodeError:
return 4
case FlagLength, FlagEmpty:
case FlagGlossaryMiss:
return 5
case FlagLength, FlagEmpty:
return 6
}
return 6
return 7
}
// bookChunks re-derives the book's chunk manifest — Ingest + SplitChunks — for the honest N/M

File diff suppressed because one or more lines are too long

View file

@ -1,11 +1,20 @@
# Golden-fixture seed: exercises exact hits, decl forms, a spoiler window (since_ch: 2)
# and a nested key (紋章 inside 竜の紋章) for the collision/AMBIGUOUS path.
# Golden-fixture seed: exercises exact hits, decl forms, a spoiler window (since_ch: 2),
# a nested key (紋章 inside 竜の紋章) for the collision/AMBIGUOUS path, and (D28.2 fixture
# expansion) an OBLIQUE-ONLY decl entry (老人→старик — decl lists only genitive/dative/…, NO
# nominative) that fires in ch5 whose FINAL text carries the NOMINATIVE «старик»: it pins the
# base-dstdecl union (D24.3/D24.4) — reverting the union false-flags it as a postcheck miss.
terms:
- src: 鈴木
dst: Судзуки
type: name
gender: m
decl: { invariant: true }
- src: 老人
dst: старик
type: term
decl:
# Oblique-only ON PURPOSE (no «старик» nominative) — the union check must supply the base.
forms: [старика, старику, стариком, старике]
- src: 魔法学院
dst: Академия магии
type: org

View file

@ -1,9 +1,11 @@
# Golden-fixture pipeline: C1 draft→edit with a single-hop escalation on the draft.
# Golden-fixture pipeline: C1 draft→edit with a single-hop escalation on the draft and the
# output-sanitizer gate ON (D28.2 fixture expansion — pins the FlagSanitizerDefect path in ch6).
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
retries: { regenerate_before_escalate: 1 }
context: { glossary_injection: selective, glossary_token_budget: 800 }
gates: { sanitizer: { enabled: true } }
escalation: { budget_usd: 0.5 }
stages:
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-golden, temperature: 0.3, reasoning: "off", escalate_to: fake-fallback }

View file

@ -33,3 +33,11 @@
夜が明けると、街は霧に包まれていた。人々は何も知らないまま、いつもの朝を迎えた。
遠くの丘の上で、旅人がひとり、東の空を眺めていた。風は冷たく、道はまだ長い。
第五章 老人の秘密
鈴木は書庫の奥で老人に会った。老人は静かに古い巻物を差し出した。
第六章 序文漏洩
鈴木は最後の扉の前に立ち、深く息を吸い込んだ。その先に何があるのか、誰も知らなかった。

View file

@ -0,0 +1,16 @@
Ты — монолингвальный литературный редактор перевода на русский язык. Ты видишь ТОЛЬКО черновик перевода, без исходного текста.
Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».
Твой мандат УЗКИЙ — художественная редактура черновика перевода:
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции; пиши живым литературным русским.
- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи.
- Вёрстка: собирай повествование в естественные русские абзацы, НЕ копируя построчную разбивку черновика. Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет.
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков.
---USER---
Черновик перевода для редактуры:
{{draft}}

View file

@ -1,16 +1,21 @@
Ты — литературный редактор художественного перевода на русский язык.
Ты — литературный редактор художественного перевода на русский язык. Тебе даны ИСХОДНЫЙ текст и его черновой перевод.
Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».
Твой мандат УЗКИЙ — художественная редактура черновика перевода:
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции.
- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
Твой мандат — художественная редактура черновика со сверкой по исходнику:
- Сверяй смысл черновика с исходным текстом (язык «{{source_lang}}»): исправляй искажения смысла, неверно понятые реалии, кальки жестов и идиом — переводчик мог передать выражение буквально (напр. поклон как «ударил головой»). Опирайся на исходник, но пиши по-русски, а не подстрочником.
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции; пиши живым литературным русским.
- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи.
- Сохраняй разбивку на абзацы.
- Вёрстка: собирай повествование в естественные русские абзацы, НЕ копируя построчную разбивку исходника (в оригинале часто одно предложение — одна строка). Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет.
- Сохраняй ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
Выведи ТОЛЬКО отредактированный текст перевода, без комментариев и пояснений.
Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков.
---USER---
Исходный текст (язык «{{source_lang}}»):
{{text}}
Черновик перевода для редактуры:
{{draft}}

View file

@ -6,10 +6,10 @@
- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}.
- Сноски: {{footnotes}}.
- Переводи ВСЕ предложения исходника: ничего не пропускай, не сокращай и не добавляй от себя.
- Сохраняй разбивку на абзацы и прямую речь как в оригинале.
- Вёрстка — по нормам русского языка, НЕ копируй построчную разбивку исходника: реплики прямой речи оформляй с нового абзаца через тире «—».
- Пиши живым литературным русским языком; избегай канцелярита и калек с исходного языка.
Выведи ТОЛЬКО перевод, без комментариев, пояснений и заголовков.
Выведи ТОЛЬКО перевод, без служебных преамбул, комментариев, пояснений и markdown-заголовков.
---USER---
Переведи следующий фрагмент: