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

View file

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

View file

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

View file

@ -1,10 +1,44 @@
# D15.2 — Спека v3: content-addressed переиспользование чекпоинтов (дизайн, НЕ код) # D15.2 — Спека v3.1: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
**Статус:** **v3, сдана на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до **Статус:** **v3.1 — реализация РАЗБЛОКИРОВАНА (D30.9, промт `BACKEND_D152_SESSION_PROMPT.md`,
ратификации v3** (реализацию НЕ начинать без подписи оркестратора — D20.1). Зона: спека в этап Б).** v3.1 закрывает два ОБЯЗАТЕЛЬНЫХ амендмента D22.2 (§0-бис ниже) + пре-имплементационные
`backend/docs/` (зона бэкенда); при утверждении оркестратор сворачивает её в пункты (axis-тест, паритетные не-цели §8, освежение line-refs). Зона: спека в `backend/docs/`
(зона бэкенда); при лендинге оркестратор сворачивает её в
`docs/architecture/03-implementation-notes.md` новым разделом. `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: расщепить **Supersedes:** v2 (направление v1+v2 ратифицировано D-лог §Ратификации 09.07 п.2 и D20.1: расщепить
`snapshotID` на wire/вердикт-части; ключ чекпоинта — на wire-идентичности; вердикт-смену `snapshotID` на wire/вердикт-части; ключ чекпоинта — на wire-идентичности; вердикт-смену
re-classify-ить на resume бесплатно; три дыры v1 закрыты v2). **v3 закрывает три правки D20.1** re-classify-ить на resume бесплатно; три дыры v1 закрыты v2). **v3 закрывает три правки D20.1**
@ -146,7 +180,7 @@ jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wir
| Поле (сегодня) | Класс | Куда в v2 | Почему | | Поле (сегодня) | Класс | Куда в 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, гранулярно. | | `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). | | `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-у. | | `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.glossary_token_budget` | wire | **dropped**`content_hash` | Бюджет → eviction → инъектнутые строки → `msgs` (§10). |
| `context_assembly.stm_depth` | wire | **dropped**`content_hash` | STM входит в `msgs`. | | `context_assembly.stm_depth` | wire | **dropped**`content_hash` | STM входит в `msgs`. |
| `context_assembly.overlap_tokens` | wire | **dropped**`content_hash` | Overlap меняет `ch.Text``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` вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность. | | `model` (пер-стадийный) | wire | **dropped из снапшота** → прямое поле `RequestHash` + `guard_hash` | `RequestHash` уже несёт `model` вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность. |
| `temperature` (`st.Temperature`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же. Именно этого не хватало fast-path-у v1 (дыра a). | | `temperature` (`st.Temperature`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же. Именно этого не хватало fast-path-у v1 (дыра a). |
| `reasoning` (`st.Reasoning`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же (low→none — дыра a). | | `reasoning` (`st.Reasoning`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же (low→none — дыра a). |
@ -399,6 +435,20 @@ Reuse чекпоинта легален ⟺ **полная wire-идентичн
расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей
wire на новом конфиге был бы ДРУГИМ из-за temp/reasoning/maxTokens) — теперь исключён `guard_hash`. 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-ридеров ## 9. Судьба `chunk_status.snapshot_id` и status-ридеров
- **`chunk_status.snapshot_id`** — **сохраняется как advisory** (какой job-снапшот резолвнул строку; - **`chunk_status.snapshot_id`** — **сохраняется как advisory** (какой job-снапшот резолвнул строку;
@ -510,16 +560,19 @@ per chunk×stage, а не рассуждает аналитически):**
`ProjectedBookUSD=0`; флаг `--accept-rebill[=usd]` (опциональный потолок). `tmctl status`: заменить `ProjectedBookUSD=0`; флаг `--accept-rebill[=usd]` (опциональный потолок). `tmctl status`: заменить
булев `ConfigDrift` на проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф булев `ConfigDrift` на проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф
целей + проекция (§9). Adult: слой НЕ назначать — линт `CheckAdultChannel` (уже в пакете №3). целей + проекция (§9). Adult: слой НЕ назначать — линт `CheckAdultChannel` (уже в пакете №3).
9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**) 9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**/
→ $0 re-classify, вердикт обновлён; (б) append-термина → re-bill ТОЛЬКО фаерящих чанков + sticky+2 + **sanitizer**/**source_lang·target_lang** — v3.1-a) → $0 re-classify, вердикт обновлён; (б) append-термина
editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/модель/**temperature**/**reasoning**/ → re-bill ТОЛЬКО фаерящих чанков + sticky+2 + editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/
capability/estimator) → честный re-bill; (г) **F1/дыра-a мутация**: ослабить `guard_hash` (убрать модель/**temperature**/**reasoning**/capability/estimator) → честный re-bill; (г) **F1/дыра-a мутация**:
temp) → тест ловит stale-serve при temp 0.3→0.4; **(г-бис) role-флип на wire-нейтральном чанке ослабить `guard_hash` (убрать temp) → тест ловит stale-serve при temp 0.3→0.4; **(г-бис) role-флип на
(пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role` — re-classify под wire-нейтральном чанке (пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role`
coverage (§5-контрпример);** (д) dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис) re-classify под coverage (§5-контрпример);** **(г-тер, D22.2 axis-sensitivity) флип `role`/`stage` меняет
editor-каскад: смена temperature translator'а → проекция считает re-bill ОБЕИХ стадий чанка, не `RequestHash` — мутация «удалить role/stage из RequestHash» роняет тест (сейчас переживает сьют);** (д)
только translator (§7.1-бис; мутация «убрать правило каскада» роняет проекцию до ~½);** (е) dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис) editor-каскад: смена temperature translator'а
миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`. → проекция считает 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) ## 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 // TestBoevoyConfigEditorGlm5AndGrokReasoning pins two invariants. (1) The D30.1 flip: the
// resolves to an EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT // default editor of both boevoy pipelines is glm-5 BILINGUAL (mono-editor was passive and
// off-by-omission — grok's OMITTED default is "low" (it thinks, billed additively per xAI, so // blind, exp12; grok reasoning-off is REMOVED from editor roles — a no-op) and its reasoning
// control:none would silently cost ~444 reasoning tokens/edit). It must not trip the echo-mine // is off. (2) grok-4.3 (still the channel-B tier / 18+ judge in models.yaml) resolves to an
// guard (xAI is not echo-prone: the editor input is a Russian draft), and the edit stage of both // EXPLICIT reasoning off-switch (ReasoningEffortField + OffEffort "none"), NOT off-by-omission
// boevoy pipelines must be grok-4.3, while c2's select (judge) stage stays the glm-5 placeholder. // — grok's OMITTED default is "low" (it thinks, billed additively per xAI), and it must not
func TestBoevoyConfigGrokEditorExplicitNone(t *testing.T) { // 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")) m, err := LoadModels(filepath.Join("..", "..", "configs", "models.yaml"))
if err != nil { if err != nil {
t.Fatalf("load boevoy models.yaml: %v", err) t.Fatalf("load boevoy models.yaml: %v", err)
} }
const model = "grok-4.3" // (2) grok-4.3 reasoning invariant (channel B / 18+ judge; unchanged by the editor flip).
cap := m.ResolveCapability(model) const grok = "grok-4.3"
cap := m.ResolveCapability(grok)
if cap.Reasoning.Control != llm.ReasoningEffortField || cap.Reasoning.OffEffort != "none" { 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)", 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 != "" { 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", model, 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"} { for _, pf := range []string{"pipeline-c1.yaml", "pipeline-c2.yaml"} {
p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m) p, err := LoadPipeline(filepath.Join("..", "..", "configs", pf), m)
if err != nil { if err != nil {
t.Fatalf("load %s: %v", pf, err) t.Fatalf("load %s: %v", pf, err)
} }
var editModel, selectModel string var editModel, editReasoning, selectModel string
for _, st := range p.Stages { for _, st := range p.Stages {
switch st.Role { switch st.Role {
case "editor": case "editor":
editModel = st.Model editModel, editReasoning = st.Model, st.Reasoning
case "judge": case "judge":
selectModel = st.Model selectModel = st.Model
} }
} }
if editModel != "grok-4.3" { if editModel != "glm-5" {
t.Errorf("%s edit stage model = %q, want grok-4.3 (D1/D3 default; glm-5 was the placeholder)", pf, editModel) 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" { 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). // Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).
type Gates struct { type Gates struct {
Coverage CoverageGate `yaml:"coverage"` Coverage CoverageGate `yaml:"coverage"`
Glossary GlossaryGate `yaml:"glossary"` 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 // 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 // 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 // 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). // 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}) 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 return cls
} }
// The coverage gate compares the output against the ORIGINAL source, which is a // 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- // 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). // escalatable in v1 (the L3 targeted re-ask is a Phase-2 remedy, research/14 §9).
FlagGlossaryMiss FlagReason = "glossary_miss" 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 // 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 // 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. // returned length). 0 floor (reasoning-off models) leaves the budget untouched.
// Applied BEFORE the request_hash so it is wire-visible and snapshot-folded. // 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 { func (r *Runner) applyModelFloor(base int, model string) int {
if floor := r.Models.MinMaxTokens(model); floor > base { if floor := r.Models.MinMaxTokens(model); floor > base {
return floor return floor
@ -76,7 +84,7 @@ type escalationOutcome struct {
// (not a fresh retry budget): total calls/chunk = primary attempts + 1 hop, never // (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 // reset on the fallback (the LiteLLM #19985 retry×fallback blow-up guard). primary is
// the attempt loop's terminal (flagged) attempt. // 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 var out escalationOutcome
if !primary.cls.Reason.escalatable() || st.EscalateTo == "" { if !primary.cls.Reason.escalatable() || st.EscalateTo == "" {
return out, nil return out, nil
@ -108,7 +116,7 @@ func (r *Runner) maybeEscalate(ctx context.Context, st config.Stage, snapID stri
if !mayHop { if !mayHop {
return out, nil 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 { if err != nil {
// An OPTIONAL hop that trips a USD ceiling must NOT abort the whole book // 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 // (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 // 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). // 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 // D28.2 fixture expansion (D30.9 diff-class, this package): three ratified behaviours now
// on every chunk (both keys are longest-match approved surfaces, not collision-prone // PINNED by golden, not only by the dedicated unit tests —
// downgrades). A cell with AMBIGUOUS>0 (an auto/draft candidate forcing a post-check) // • FLOOR: fake-model floors max_tokens to 4000, fake-fallback to 6000 (per-model
// is an OPTIONAL fixture extension, deliberately NOT added in this pass: it would be a // capabilities.min_max_tokens) → the wire max_tokens and the resolved capability move,
// fresh ratified golden capture of its own, and this package's re-capture is scoped to // and the escalation HOP takes ITS OWN model's floor (ch2 hop = 6000, primary = 4000);
// the D24.3/D24.4 diff-class (floor max_tokens + post-check verdicts). // • 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 // Update (ONLY on a deliberate, ratified behaviour change — never to "fix" a red
// refactor): TM_UPDATE_GOLDEN=1 go test ./internal/pipeline/ -run TestGolden // refactor): TM_UPDATE_GOLDEN=1 go test ./internal/pipeline/ -run TestGolden
const goldenFile = "testdata/golden/capture.golden" const goldenFile = "testdata/golden/capture.golden"
// goldenEchoMarker / goldenRefusalMarker are unique substrings of chapters 2/3 of the // goldenEchoMarker / goldenRefusalMarker / goldenUnionMarker / goldenSanitizerMarker are
// fixture source; the mock provider keys its echo/refusal behaviour off them. // unique substrings of chapters 2/3/5/6 of the fixture source; the mock keys its
// echo / refusal / union / sanitizer behaviour off them.
const ( const (
goldenEchoMarker = "響動計画" goldenEchoMarker = "響動計画"
goldenRefusalMarker = "拒絶計画" 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 // 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): case strings.Contains(body, goldenRefusalMarker):
// Both the primary and the fallback refuse → the chunk stays flagged (1 hop). // Both the primary and the fallback refuse → the chunk stays flagged (1 hop).
return "Не могу помочь с этим фрагментом.", "refusal" 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): case strings.Contains(body, goldenEchoMarker) && !isEditBody(body):
if req.Model == "fake-fallback" { if req.Model == "fake-fallback" {
// The escalation hop returns a clean translation → the re-gate passes it. // 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. // The primary draft echoes CJK instead of translating → cjk_artifact.
return "夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。", "stop" 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): case isEditBody(body):
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки шёл по коридорам Академии магии.", "stop" return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки шёл по коридорам Академии магии.", "stop"
default: default:
@ -107,9 +134,11 @@ models:
fake-model: fake-model:
provider: fake provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 } 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: fake-fallback:
provider: fake provider: fake
price: { input_per_m: 3.0, cached_per_m: 0.3, cache_write_per_m: 0, output_per_m: 6.0 } 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)) `, time.Now().UTC().Format("2006-01-02"), providerURL))
return filepath.Join(dir, "book.yaml") 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 // («караВАН», «диВАН», «ИВАН» — a different person). Decl-aware (the research/14
// requirement): the stored decl forms carry the inflections a boundary regexp would miss. // 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 // 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 // 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 // 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 // TestEditorPromptIsBilingual pins the D30.1 flip (D1 monolingual → BILINGUAL editor,
// draft (+ the approved glossary injected as dst constraints by the runner), never the // supersede D17.в): exp12 proved the monolingual editor is both passive and structurally
// source. A reintroduced {{text}} would silently re-anchor the editor to the source and // blind to draft distortions (it cannot know «ударил головой» is a 磕头 kowtow without the
// breed кальки (the main translationese disease D1 exists to avoid). // source). The default editor.md now sees BOTH the source ({{text}}) and the draft
func TestEditorPromptIsMonolingual(t *testing.T) { // ({{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")) raw, err := os.ReadFile(filepath.Join("..", "..", "prompts", "editor.md"))
if err != nil { if err != nil {
t.Fatalf("read editor.md: %v", err) t.Fatalf("read editor.md: %v", err)
} }
if strings.Contains(string(raw), "{{text}}") { if !strings.Contains(string(raw), "{{text}}") {
t.Error("editor.md must NOT reference {{text}} (D1: the editor is monolingual — the source is not injected)") t.Error("editor.md must reference {{text}} (D30.1: the editor is now BILINGUAL — it sees the source)")
} }
if !strings.Contains(string(raw), "{{draft}}") { if !strings.Contains(string(raw), "{{draft}}") {
t.Error("editor.md must reference {{draft}} (it edits the 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 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 // memoryVersion is the content-hash of the deterministically materialized injected
// memory (the frozen APPROVED glossary rows + the normalization/matcher algorithm // 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 // 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 // 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 // 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. // part of BriefHash (a book field), so a policy change already re-pins via brief_hash.
StyleCheckVersion string `json:"style_check_version"` StyleCheckVersion string `json:"style_check_version"`
Stages []stageSnap `json:"stages"` // 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(), BriefHash: r.Book.BriefHash(),
ChunkerVersion: chunkerVersion, ChunkerVersion: chunkerVersion,
@ -200,6 +223,7 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate, PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
Coverage: r.coverageSnapshot(), Coverage: r.coverageSnapshot(),
StyleCheckVersion: cheapGateVersion, StyleCheckVersion: cheapGateVersion,
Sanitizer: r.sanitizerSnapshot(),
} }
for _, st := range r.Pipeline.Stages { for _, st := range r.Pipeline.Stages {
ss := stageSnap{ ss := stageSnap{

View file

@ -102,13 +102,17 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
maxRegen = 0 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 cumCost, runCost float64
var last stageAttempt var last stageAttempt
anyFresh := false anyFresh := false
attemptsMade := 0 attemptsMade := 0
for attempt := 0; ; attempt++ { for attempt := 0; ; attempt++ {
maxTokens := maxTokensForAttempt(baseMaxTokens, 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 { if err != nil {
return nil, err // infra failure 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- // 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. // denied hop keeps the primary flag instead of aborting the book.
escalated, escModel := false, "" 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 { if err != nil {
return nil, err return nil, err
} }
@ -213,7 +217,7 @@ type stageAttempt struct {
// checkpoints — no re-billing), otherwise a fresh reserve → call → settle+ // checkpoints — no re-billing), otherwise a fresh reserve → call → settle+
// checkpoint. It returns an error only on an infra failure; a bad completion // checkpoint. It returns an error only on an infra failure; a bad completion
// comes back as a classification on the attempt. // 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, reqHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, attempt, st.Name, st.Role, model,
st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs) st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
att := stageAttempt{reqHash: reqHash, modelActual: model} 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.finish = cp.FinishReason
att.modelActual = cp.ModelActual att.modelActual = cp.ModelActual
att.cumCost = cp.CostUSD 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 := r.baseRequestLog(st, ch, model, reqHash)
rl.ModelActual = cp.ModelActual rl.ModelActual = cp.ModelActual
rl.TMHit, rl.OK, rl.FinishReason, rl.Degraded = true, att.cls.ok(), cp.FinishReason, degradedTag(att.cls) 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 // non-empty truncated length draft is now classified (flagged/retried), never
// silently passed downstream as OK; an empty completion is flagged too, not a // silently passed downstream as OK; an empty completion is flagged too, not a
// run-crash. // 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 := r.baseRequestLog(st, ch, model, reqHash)
rl.ModelActual = modelActual rl.ModelActual = modelActual

View file

@ -103,16 +103,20 @@ func flagReasonSeverity(reason string) int {
return 0 return 0
case FlagCJKArtifact, FlagExcisionSuspect, FlagCoverageFail: case FlagCJKArtifact, FlagExcisionSuspect, FlagCoverageFail:
return 1 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 return 2
case FlagDecodeError: case FlagLoopDegenerate:
return 3 return 3
case FlagGlossaryMiss: case FlagDecodeError:
return 4 return 4
case FlagLength, FlagEmpty: case FlagGlossaryMiss:
return 5 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 // 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) # Golden-fixture seed: exercises exact hits, decl forms, a spoiler window (since_ch: 2),
# and a nested key (紋章 inside 竜の紋章) for the collision/AMBIGUOUS path. # 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: terms:
- src: 鈴木 - src: 鈴木
dst: Судзуки dst: Судзуки
type: name type: name
gender: m gender: m
decl: { invariant: true } decl: { invariant: true }
- src: 老人
dst: старик
type: term
decl:
# Oblique-only ON PURPOSE (no «старик» nominative) — the union check must supply the base.
forms: [старика, старику, стариком, старике]
- src: 魔法学院 - src: 魔法学院
dst: Академия магии dst: Академия магии
type: org 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 core: C1
version: 1 version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 } defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
retries: { regenerate_before_escalate: 1 } retries: { regenerate_before_escalate: 1 }
context: { glossary_injection: selective, glossary_token_budget: 800 } context: { glossary_injection: selective, glossary_token_budget: 800 }
gates: { sanitizer: { enabled: true } }
escalation: { budget_usd: 0.5 } escalation: { budget_usd: 0.5 }
stages: 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 } - { 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}}». Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».
Твой мандат УЗКИЙ — художественная редактура черновика перевода: Твой мандат — художественная редактура черновика со сверкой по исходнику:
- Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции. - Сверяй смысл черновика с исходным текстом (язык «{{source_lang}}»): исправляй искажения смысла, неверно понятые реалии, кальки жестов и идиом — переводчик мог передать выражение буквально (напр. поклон как «ударил головой»). Опирайся на исходник, но пиши по-русски, а не подстрочником.
- Сохраняй СМЫСЛ и ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены. - Правь стиль: убирай кальки с языка «{{source_lang}}», канцелярит, повторы, неестественные конструкции; пиши живым литературным русским.
- Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи. - Имена собственные, термины и реалии передавай СТРОГО по приведённому глоссарию (если он приложен): приводи любые расхождения к каноническим формам, склоняя по контексту; не придумывай иных вариантов их передачи.
- Сохраняй разбивку на абзацы. - Вёрстка: собирай повествование в естественные русские абзацы, НЕ копируя построчную разбивку исходника (в оригинале часто одно предложение — одна строка). Реплики прямой речи начинай с нового абзаца через тире «—». Максимума длины абзаца нет.
- Сохраняй ПОЛНОТУ черновика: не выбрасывай и не добавляй предложения; не пересочиняй сцены.
- Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}. - Хонорифики: {{honorifics}}. Баланс форенизация/доместикация: {{venuti}}.
Выведи ТОЛЬКО отредактированный текст перевода, без комментариев и пояснений. Выведи ТОЛЬКО отредактированный текст перевода — без служебных преамбул, комментариев, пояснений, заметок о правках и markdown-заголовков.
---USER--- ---USER---
Исходный текст (язык «{{source_lang}}»):
{{text}}
Черновик перевода для редактуры: Черновик перевода для редактуры:
{{draft}} {{draft}}

View file

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