diff --git a/backend/PACK11_REPORT.md b/backend/PACK11_REPORT.md
new file mode 100644
index 0000000..b851960
--- /dev/null
+++ b/backend/PACK11_REPORT.md
@@ -0,0 +1,239 @@
+# PACK11_REPORT — бэкенд-сессия по плану 11 (2026-07-19)
+
+> Строил по `docs/architecture/11-implementation-plan.md` (ратифицирован D39.12). **НЕ коммитил** —
+> сдача на приёмку оркестратору. Верификация исполнением: `go build ./... && go vet ./... &&
+> go test -race ./...` — зелёное (логи ниже). Все несущие изменения сверены с живым HEAD-кодом.
+
+## 0. Честный статус-обзор (что сдано / что резидуал)
+
+Пак 11 — это ФАКТИЧЕСКИ четыре пакета работ (§7 плана: пак-2 слой-1 + пак-3 слой-4 + пак-4 слой-5 +
+конфиг-пак). За одну сессию доведено до **зелёного, оттестированного, лендабельного** состояния:
+
+| WS | Компонент | Статус |
+|---|---|---|
+| **WS2** | чанкер output-бюджет + edit-единицы + oversized-флаг + src→dst редактор + снапшот-фолды + golden re-capture | ✅ **СДАНО** (тесты + golden) |
+| **WS1a** | eager-build клиентов в W0 + read-only `client()` (гонка `runner.go` закрыта) | ✅ **СДАНО** (`-race`) |
+| **WS1b** | пер-модельный rate-guard (семафор конкуренции + пейсинг) | ✅ **СДАНО** (concurrency-тест) |
+| **WS1c-инфра** | base/enriched `MemoryVersion`-split (механика пер-волнового снапшота, «переоплата ОДНА») | ✅ **СДАНО** (unit-тест) |
+| **WS4-core** | банкнота: pure `splitBanknote`/`parseBanknote` (`⟦TM-BANK-v1⟧`, толерантный сплит, Han-в-src, finish-truncation) | ✅ **СДАНО** (7 инвариант-тестов; Go↔Python паритет к `banknote.py`, пин 95/65/0/0 воспроизведён рефересом) |
+| **WS6** | mistral rate_limit конфиг (свап-арм за rate-guard); арм-механика (editor-model = конфиг → снапшот per-арм) уже в коде | ✅ **частично** (конфиг-тай; арм-пайплайн-конфиги — резидуал) |
+| **WS1c-executor** | параллельный волновой ИСПОЛНИТЕЛЬ W1/W1.5/W2 + пер-волновой снапшот-wiring + golden-рерайт + resume-across-waves + kill-9 N-резерв | ⚠️ **РЕЗИДУАЛ** (дизайн ниже, §Резидуал) |
+| **WS4-integration** | 12 точек интеграции банкноты (срез ДО classify, derived-checkpoint, snapshot-фолд, телеметрия) | ⚠️ **РЕЗИДУАЛ** (ядро+дизайн готовы) |
+| **WS3** | Go-майнер V-A→V-C + алиас + консолидация + сид-дельта + `tmctl seed-lint` + `Source:mined` | ⚠️ **РЕЗИДУАЛ** (Source-split инфра готова; порт — резидуал) |
+| **WS5** | DC-чекеры + omission-бэкстоп + данные пакета пары + `tmctl export --pairs` | ⚠️ **РЕЗИДУАЛ** (дизайн ниже) |
+
+**Почему не весь объём:** волновой ИСПОЛНИТЕЛЬ (WS1c) — это ре-архитектура модели исполнения
+(последовательный per-chunk-all-stages → W1 draft-∥ / W1.5-стоп / W2 edit-∥-over-units), которая
+инвалидирует golden + ~10 последовательно-модельных тестов (runner_memory/export/status/cheapgates/
+quality/escalation_budget/maxtokens_floor) и добавляет двух-фазный CLI-флоу. Довести это до зелёного
+за сессию БЕЗ поломки суита нереалистично. Решение (мандат «честный резидуал > фейк-комплит, не
+сдавать сломанное»): сдать **несущую БЕЗОПАСНУЮ инфраструктуру волн** (eager-clients, rate-guard,
+base/enriched split, edit-units) как лендабельную и зелёную, а параллельный исполнитель —
+резидуалом с точным дизайном (§Резидуал). Ни один сданный кусок не оставляет суит красным.
+
+---
+
+## 1. WS2 — чанкер-бюджет + edit-единица (план §2) — ✅ СДАНО
+
+Per-буллет чек-лист (буллеты промта):
+
+- ✅ **Бюджет draft-чанков в ВЫХОДНЫХ токенах** `est_out = 1.1978·cjk + 0.3852·other`, конфиг
+ `config.Segmentation` (per-pair, снапшот-folded через `segmentationSnap`). Классы символов —
+ **РЕАЛЬНЫЕ `unicode.RangeTable`** (`Han|Hiragana|Katakana|Hangul`, `tokenClassCounts` = те же, что
+ `EstimateTokens`), НЕ ord-диапазоны (§0.1 общность). Пин `target_out=1797 → 56 чанков` —
+ воспроизведён на пере-прогонном корпусе (см. §Верификация ниже).
+- ✅ **Edit-единица = глава; дефолт `EditCeilingOut=3200`.** Реализовано как **greedy-группировка
+ ЦЕЛЫХ draft-чанков до потолка** (`assignEditUnits`). **ДЕВИАЦИЯ-с-обоснованием (эскалация-нота):**
+ план §2(б) описывает edit-единицы как paragraph-greedy-pack; я реализовал grouping-of-whole-chunks
+ потому что независимые нарезки (para-pack draft @1797 и para-pack units @3200) МОГУТ дать
+ straddle draft-чанка через границу единицы → сломанная реконструкция draft для W2. **Эмпирически
+ проверено на реальном 25-гл корпусе:** grouping-whole-chunks даёт ТЕ ЖЕ **37 единиц**, что
+ para-pack, при **0 straddle** (скрипт в scratchpad; вывод: `{draft:56, para_units:37,
+ grouped_units:37, straddle:0}`). Т.е. на этом корпусе оба определения СОВПАДАЮТ, а grouping —
+ робастный супермножество-инвариант (гарантирует чистую реконструкцию всегда). Пин 37 сохранён.
+- ✅ **8000-арм НЕ открыт** — дефолт 3200; `edit_ceiling_out` остаётся config-tunable (потолок —
+ конфиг, арм гейтнут §11), валидация `EditCeilingOut ≥ DraftBudgetOut`.
+- ✅ **`Chunk` аддитивно:** `EstOut float64`, `OversizedSentence bool`, `EditUnitID int`. Контракт
+ `Chapter/ChunkIdx/Text` цел (тесты `TestSplitChunks*` + fuzz lossless-tiling зелёные).
+- ✅ **Мега-предложение** = passthrough + `OversizedSentence=true` (БЕЗ клауза-сплита). Пин
+ `TestSplitChunksOversizeSentenceIsOwnChunk`.
+- ✅ **Снос мёртвых `STMDepth`/`OverlapTokens`** из `config.ContextAssembly` + `contextSnap` +
+ yaml-конфигов (carryover не строим). Структурная смена `contextSnap` → строка манифеста §8.
+- ✅ **src→dst-формат `renderEditorConstraintBlock`** (`- 源термин → «dst»`), CONFIRMED-only ЦЕЛА,
+ дедуп по `(src,dst)` (омоним-widening). Обоснование — билингв-редактор + омонимичный dst (§2в
+ ревью-1 F4). **Выделенный `render-format-version`** снапшот-компонент (НЕ `memoryMatchVersion`).
+ Пины `TestRenderEditorConstraintBlock` + `TestRunnerMemoryInjectionAndPostcheck` обновлены.
+- ✅ **Сайзинг max_tokens редактора** — НЕ трогал (F8: существующий `stagerun.go:87-90` сайзит от
+ черновика `prev`; новой формулы не вводил — как и предписано).
+- ⚠️ **Инъекция W2-единицы (СВЕЖИЙ Select над enriched-банком)** — часть волнового ИСПОЛНИТЕЛЯ
+ (резидуал WS1c). Механика src→dst-блока и enriched-версии готова; вызов свежего Select по тексту
+ единицы делается в W2-проходе (резидуал). **Whole-chapter Select-бюджет (§2е открытый)** — решать
+ в исполнителе (резидуал-нота).
+
+Снапшот-фолды WS2 (§8): `chunkerVersion` v4→v5, `segmentationSnap`, `render_format_version`, снос
+`stm_depth`/`overlap_tokens`. Golden re-captured — masked-дифф ниже (только versions/new-fields/
+editor-src2dst; НИ одной несвязанной content-правки; фикстура НЕ ре-чанкается — 20 чанк-строк до/после).
+
+## 2. WS1 — волновой раннер (план §1)
+
+- ✅ **WS1a: Eager-build ВСЕХ клиентов в W0** (`buildClients` над `reachableModels()` =
+ `Stages[].Model ∪ EscalateTo`), вызван в `TranslateBook` W0-прелюдии ДО любой горутины. `client()`
+ теперь **READ-ONLY** — громкий отказ на miss (закрывает гонку `runner.go:171-181` без лока;
+ трипваер на будущую 3-ю модель-ось). `BuildClient` не требует ключа → безопасно на key-less resume.
+ `-race` чист.
+- ✅ **WS1b: Пер-модельный rate-guard** (`ratelimit.go`): семафор конкуренции `models.yaml
+ rate_limit.max_concurrency` + опц. пейсинг `min_interval_ms`. **Транспорт-ось, НЕ снапшот** (wire
+ не трогает). Построен в W0 (`buildRateGuards`), read-only в волнах; обёрнут вокруг ОДНОГО
+ `client.Complete` в `runAttempt` (ctx-aware acquire/release, reservation освобождается при
+ ctx-cancel). Пины: `TestRateGuardLimitsConcurrency` (семафор РЕАЛЬНО ограничивает — cap=2/20
+ воркеров, observed-max ≤ 2), nil/unlimited no-op, ctx-cancel без leak, pacing. **`waves.workers`
+ конфиг** — вводится с исполнителем (резидуал).
+- ✅ **WS1c-инфра: base/enriched `MemoryVersion`-split.** `MemoryBank.BaseVersion()` = хеш approved
+ `Source∈{seed,ruby,auto}` (БЕЗ mined); `Version()` (enriched) = все approved вкл. `Source:mined`.
+ `computeMemoryVersionScoped(excludeMined)`; enriched-путь **байт-идентичен** прежнему
+ `computeMemoryVersion` (домен-сепаратор только в base) → текущий снапшот не двигается от split-а.
+ Пин `TestBaseEnrichedMemoryVersionSplit`: добавление mined-approved строки двигает ТОЛЬКО enriched,
+ base стабилен (= «переоплата ОДНА» механизм); base≠enriched (домен-разделены). **`Source:mined`**
+ как значение enum — аддитивно (колонка `source` — свободный TEXT, миграция store не нужна;
+ подтверждено чтением `migrate.go`).
+- ⚠️ **WS1c-executor (РЕЗИДУАЛ):** пер-волновой снапшот-wiring (`snapshot_W1`/`snapshot_W2` upsert),
+ W0 sticky-precompute, W1/W2 параллельный dispatch, W1.5-стоп + mined-write-путь, wave-sequencing
+ инвариант (W2 не пере-рендерит draft), money-под-∥ kill-9 N-резерв, scheduler-aware окна. Дизайн —
+ §Резидуал.
+
+## 3. WS4-core — банкнота-парсер (план §4б) — ✅ СДАНО
+
+`internal/pipeline/banknote.go` — байт-верный порт `exp16/banknote.py`:
+- `splitBanknote(output)` → срез блока `⟦TM-BANK-v1⟧` (clean = rstrip до сепаратора, block = strip("\n"));
+- `parseBanknote(block, truncated)` → толерантный сплит `\t| {2,}|\s*\|\s*`, тип∈{name,place,title,term,
+ nickname}+дефолт term, src ДОЛЖЕН нести Han `[㐀-鿿]` (точный диапазон U+3400–U+9FFF), truncation-толерантность
+ на ПОСЛЕДНЕЙ строке (флаг `Truncated`, не parse_fail);
+- `bankParserVersion="banknote-v1"` (для будущего `banknoteSnap{enabled,parser_version}`).
+- Тесты (`banknote_test.go`): no-SEP→всё clean; срез блока (утечки нет); толерантный сплит tab/≥2проб/pipe;
+ non-Han src=bad; truncation True→флаг/False→parse_fail; пустой блок; SEP≠note-слово. **Все PASS.**
+- **Паритет:** референс `ws4_banknote_verify.py` воспроизводит пин **95 строк / 65 distinct / 0 fail / 0
+ trunc** + truncation-поведение + инварианты — идентично моему Go-порту (сверено исполнением).
+
+**WS4-integration (12 точек) — резидуал** (§Резидуал R3): требует правок `chunkrun.go`/`stagerun.go`/
+`disposition.go`/`snapshot.go` (файлы под адверсариал-ревью этой сессии — не трогал во избежание дрейфа
+цитат; ядро + точный дизайн готовы).
+
+## 4–5. WS3 / WS5 — ⚠️ РЕЗИДУАЛ (дизайн в §Резидуал)
+
+Не начаты в коде (кроме `Source:mined`-инфра из WS1c, которая — предусловие WS3-mined-write). Причина
+— бюджет сессии ушёл в несущий каркас Block A + верификацию + WS4-ядро. Дизайны/пред-условия — §Резидуал.
+
+---
+
+## Багфиксы вне плана
+Пока нет (изменения строго по плану). Правки по ревью — см. §Адверсариал-ревью ниже (2 doc-фикса + 1 gofmt).
+
+## Структурные переносы
+- Новый файл `internal/pipeline/ratelimit.go` (rate-guard) + `banknote.go` (WS4-ядро) — новые оси, не рефактор.
+- `SegBudget` (resolved segmentation) живёт в `chunker.go`; резолвер `segBudget()` — в `runner.go`
+ (segmenter остаётся config-free). `renderFormatVersion` const — в `memory.go` рядом с рендерерами.
+
+---
+
+## Верификация (исполнением — логи, не слова)
+
+```
+$ go build ./... → OK
+$ go vet ./... → OK (чисто)
+$ go test -race ./... → ВСЁ ЗЕЛЁНОЕ:
+ ok cmd/tmctl 1.0s · config · ledger · llm · pipeline 19.4s · store
+$ gofmt -l <мои файлы> → пусто (все чисты; репо несёт ПРЕ-существующий gofmt-долг в ~13
+ не-моих файлах под go1.26.4 — их не трогал)
+```
+
+**Фикстуры плана (исполнены рефересами `eval/design11/*` под `eval/.venv`):**
+- WS2 фертильность/нарезка: `56 чанков @1797` / `37 edit-единиц @3200` / **`0 straddle`** —
+ подтверждено `ws2_straddle_check.py` (grouping-whole-chunks ≡ para-pack на корпусе).
+- WS4 банкнота: `95 строк / 65 distinct / 0 fail / 0 trunc` (`ws4_banknote_verify.py`) — мой Go-парсер
+ воспроизводит контракт (7 инвариант-тестов PASS, идентичны демо `banknote.py`).
+
+**Go-тесты, добавленные/обновлённые этой сессией (все PASS под `-race`):**
+`TestSplitChunks*` (+ EstOut/OversizedSentence/EditUnitID пины + `TestSplitChunksEditUnitGroupsWholeChunks`),
+`TestRateGuard*` (concurrency-cap реально ограничивает, ctx-cancel без leak, pacing, nil no-op),
+`TestBaseEnrichedMemoryVersionSplit`, `TestRenderEditorConstraintBlock` (src→dst), `TestRunnerMemoryInjectionAndPostcheck`,
+`TestSplitBanknote*`/`TestParseBanknote*`, `TestGoldenDeterminism` (re-captured).
+
+## Адверсариал-ревью Block A (мандат: author≠reviewer, refute-by-default, 3 линзы) — ИСПОЛНЕН
+9-агентный воркфлоу (3 ревью-линзы: алгоритм-vs-план / сеньор-код-ревью / функциональное, каждая
+находка адверсариально верифицирована). **6 сырых находок → 3 подтверждено, 3 отвергнуто** (верификатор
+refuted как not-a-bug/hypothetical):
+- **CONFIRMED-1/2 (minor, doc):** комменты base/enriched говорили «equal until mined», но код
+ ДОМЕН-РАЗДЕЛЯЕТ их (base≠enriched всегда). Код/дизайн ВЕРНЫ; комменты — нет. **ПОФИКШЕНО**
+ (`memory.go:135`,`:179`).
+- **CONFIRMED-3 (nit, style):** `subChunk` struct не gofmt-выровнен. **ПОФИКШЕНО** (`gofmt -w`).
+- **REFUTED (верифицировано как не-баг):** (a) «edit-unit grouping диверджит от плана» → НЕ баг:
+ grouping-whole-chunks ТРЕБУЕТСЯ для W2-реконструкции, ≤3200 by construction, а т.к.
+ `chapterDraftChunks` НИКОГДА не режет абзац — каждая draft-граница = абзац-граница → удовлетворяет
+ И §2б И §1б (сильнее, чем я аргументировал); 37 — описательная статистика, не решающая ветка кода.
+ (b) rate-guard money-путь «нет интеграц-теста» → гипотетический будущий регресс, не дефект сданного
+ кода (release-on-cancel корректен). (c) job-status «running» до acquire → корректная семантика,
+ лог `calling model` маркирует queued-vs-wire. Диспозиции — в отчёте, тихих девиаций нет.
+
+## Golden re-capture (WS2) — маскированный структурный дифф
+18 изменённых masked-строк, ВСЕ объяснимы: `chunker_version` v4→v5; снятые `stm_depth`/`overlap_tokens`;
+новое `segmentation:{1797,3200,1.1978,0.3852}`; новое `render_format_version:renderfmt-v1-editor-src2dst`;
+редактор-блок `- «dst»` → `- src → «dst»` (7 editor-body строк). Фикстура НЕ ре-чанкается. Полный
+masked-дифф — scratchpad `ws2_golden_masked_diff.txt`.
+
+---
+
+## РЕЗИДУАЛ — точные дизайны для следующей сессии
+
+### R1. Волновой ИСПОЛНИТЕЛЬ (WS1c-executor) — несущий, самый крупный
+Каркас готов (eager-clients, rate-guard, base/enriched split, edit-units на чанках). Осталось:
+1. `snapshotIDForWave(wave)` — рефактор `snapshot.go`: fold ТОЛЬКО стадии волны (draft=translator-роль
+ → W1; editor-роль → W2) + версию банка волны (`BaseVersion` для W1, `Version` для W2). Апсертить
+ ДВА снапшота.
+2. Партиция стадий по роли; edit-единица addressable как `(chapter, firstChunkIdxOfUnit, "edit")` в
+ `chunk_status` (без миграции — units per-chapter, group-of-whole-chunks).
+3. W0 sticky-precompute: реплицировать последовательную sticky-цепочку ($0, `Select` src-производна)
+ → инъекц-байты draft ≡ последовательным (golden-пин).
+4. W1 dispatch: N воркеров тянут draft-чанки, `runStage(draft)` под `snapshot_W1`. W2 dispatch: N
+ воркеров тянут edit-единицы, `prev` = конкатенация draft-выходов чанков единицы (из W1-чекпоинтов
+ по `final_hash`), СВЕЖИЙ `Select` по тексту ВСЕЙ единицы над enriched-банком, `runStage(edit)` под
+ `snapshot_W2`. **W2 НИКОГДА не пере-рендерит draft** (несущий инвариант §1в).
+5. W1.5: при пустой mined-дельте (WS3 не построен) — авто-продолжение; иначе стоп + карта подписи +
+ mined-write-путь (штамп `Source:mined`, зеркало `rubyToCandidates`, НЕ через `loadGlossarySeed`).
+6. Money-под-∥: воркеры-горутины через single-writer store (уже сериализует). kill-9 тест с N живыми
+ резервами (порт `store/kill9_test.go` на N). rate-guard уже интегрирован.
+7. Golden-рерайт под волновую модель + обновление ~10 последовательно-модельных тестов.
+
+### R2. WS3 майнер — референс `eval/exp16/{arms,alias,canon,patterns,spread,palladius,banknote}.py`
+Новый пакет `internal/pipeline/miner*.go`. Порт V-A→V-C по ТОЧНЫМ порогам §3(б); дефолт-лемматизатор
+**B** (дроп Палладий-подканала → `古月:21→22`, скрин PASS). Инвариантные гарантии (EXACT): 13618-канд-СЕТ,
+recall 0.9649/0.9318, катастроф-скрин `方源/蛊/蛊师` ранги 0/1/2 ∈top-50. Реюз `memnorm.normalizeSourceKey`.
+`tmctl seed-lint` (сухой прогон фейл-лаудов `loadGlossarySeed`). Go↔Python паритет (Python=референс).
+`Source:mined`-инфра (base/enriched split) — **готова** (WS1c).
+
+### R3. WS4 банкнота — референс `eval/exp16/banknote.py`, спека research/20 §B3
+Pure `split_banknote`/`parse_banknote` (`⟦TM-BANK-v1⟧`, ≤12 строк src\tdst\ttype, толерантный сплит,
+Han-в-src, finish=stop-only гейт). 12 точек: срез ДО `classifyOutput` (`chunkrun.go`); derived-checkpoint
+`sha256("tm-banknote-v1\x00"+reqHash+"\x00"+stripped)` через `PutDerivedCheckpoint` (прецедент
+`commitSanitizedExport` — **уже есть**); re-point `final_hash` OK-пути draft на derived ПОСЛЕ резолва
+эскалации (`last.stripped`); `banknoteSnap{enabled,parser_version}` в `snapshotID()` (фолд при enabled,
+зеркало `sanitizerSnap`); инструкция сноски В ФАЙЛ промпта; телеметрия `n_banknote_lines`/`parse_fail`/
+`truncated`. Пин: пере-парс 95 строк = 0 fail (`ws4_banknote_verify.py`).
+
+### R4. WS5 DC-чекеры + omission-бэкстоп + данные пакета пары + `tmctl export --pairs`
+Расширить `cheapgates.go` DC1/2/6/7 (детерм. флаггеры, target-gated, наблюдаемость не гейт); DC3
+gender-инъекция (male/female/hidden-мандат) из seed-поля `Gender` в `renderEditorConstraintBlock`
+(фолд через `render-format-version` + `MemoryVersion`); omission-бэкстоп (substring по
+`{dst,decl.forms,aliases}` + лёгкий Go-стеммер, БЕЗ pymorphy3); данные пакета zh-ru (версионируемый
+снапшот-folded пакет); `tmctl export --pairs` (source-join уже в `Export`) — пред-условие §5(д)-FP.
+DC5-детекцию НЕ строить (74%/42% FP); hidden-ЧЕКЕР НЕ строить (Ф2-кореф). Референс `ws5_checkers_verify.py`.
+
+### R5. WS6 арм-конфиги
+Арм-механика (editor-model = конфиг → `stageSnap.Model` → снапшот per-арм) **уже работает**. Осталось:
+арм-пайплайн-конфиги (editor=mistral-large-2512 / deepseek-v4-pro, `few_shot:false` для reasoning),
+атрибуция арма в export/report (editor-model уже в chunk_status), `echo_mine_test` под арм-варианты.
+mistral rate_limit — **сдан** (WS1b/WS6 тай).
+
+### Открытые эскалации владельцу/оркестратору
+- edit-единица grouping-of-whole-chunks vs para-pack — СОВПАДАЮТ на этом корпусе (0 straddle);
+ выбран grouping для гарантии реконструкции. Требует подтверждения при лендинге.
+- Реестр §10 (W1.5-UX, DeepSeek ×2 ре-чек 24.07, whole-chapter Select-бюджет) — открыты как в плане.
diff --git a/backend/configs/models.yaml b/backend/configs/models.yaml
index 8ae9196..f95e515 100644
--- a/backend/configs/models.yaml
+++ b/backend/configs/models.yaml
@@ -218,10 +218,16 @@ models:
# = $0.5/$1.5 (OpenRouter согласен) → разрешает D19-подозрение ($0.5/$1.5 при $2/$6 = 4×). cached=input
# консервативно (cache-read не моделируем; канал B — свежий вход/чанк, hit≈0, потолок не слепнет).
price: { input_per_m: 0.5, cached_per_m: 0.5, cache_write_per_m: 0, output_per_m: 1.5 }
+ # WS1 §1б / WS6 gate №4: mistral-large — агрессивный rate-limiter (~48% retry-fails под N-∥ @1.3s;
+ # токен-бакет/конкуренц-кап, тир-зависим — 00-provider-quirks §Транспорт). Пер-модельный семафор
+ # конкуренции волнового раннера ограничивает одновременные вызовы (транспорт-ось, НЕ снапшот).
+ # Значение — консервативный старт; пере-замерить на прод-тире (правило двух направлений). Свап-арм
+ # редактора mistral НЕ идёт через прод-путь без этого guard (ревью-2 F4).
+ rate_limit: { max_concurrency: 2, min_interval_ms: 0 }
note: >-
Переводчик-дефолт канала B (D19.1): 10/10 чисто на violence, policy 11.06.2026 без запрета adult, не
- reasoning-модель (эхо-мины нет). Полный wiring канала B — Ф2 (конфиг-слот). Полигон перепроверяет цену (D19.5б).
- Живьём 2026-07-10: /models ✓, chat 200.
+ reasoning-модель (эхо-мины нет). Свап-арм редактора (WS6, за rate-guard). Полный wiring канала B — Ф2.
+ Полигон перепроверяет цену (D19.5б). Живьём 2026-07-10: /models ✓, chat 200.
gpt-5-mini: # кандидат-редактор/second-opinion судья (D3) — НЕ в дефолтной цепочке Ф1
provider: openai
diff --git a/backend/configs/pipeline-c1.yaml b/backend/configs/pipeline-c1.yaml
index 7a8742c..4ffb965 100644
--- a/backend/configs/pipeline-c1.yaml
+++ b/backend/configs/pipeline-c1.yaml
@@ -22,9 +22,20 @@ context:
# DeepSeek $0.0028/M — решается расчётом на прототипе).
glossary_injection: selective
glossary_token_budget: 800
- stm_depth: 2 # глубина STM в чанках (Фаза 1)
- overlap_tokens: 200 # перекрытие чанков = read-only контекст (Фаза 1)
cache_ttl: "5m"
+ # STMDepth/OverlapTokens сняты (WS2 §2а): carryover/overlap НЕ строится (D39.7/8 — метрики
+ # под полом когезии 0.126); мёртвые кнобы в снапшоте приглашали тихий «re-activate».
+
+# WS2 (слой 1): бюджет нарезки в ВЫХОДНЫХ (ru) токенах через фертильность (est_out = cjk·CJK +
+# other·Other); снапшот-folded (segmentationSnap → громкий --resnapshot при правке). Пропуск блока =
+# ратифицированные zh-ru дефолты (см. LoadPipeline). Крупно-главный арм (edit_ceiling_out>3200)
+# гейтнут платными Q2a span-судьями (§11) — 3200 = консервативный дефолт.
+segmentation:
+ draft_budget_out: 1797 # мелкий DRAFT-чанк, ru-выход (→ 56 чанков на 25-гл пере-прогоне)
+ edit_ceiling_out: 3200 # крупная EDIT-единица = глава/группа целых чанков (→ 37 единиц)
+ fertility: # независимо пере-выведено на пере-прогоне: R²=0.9633
+ cjk: 1.1978
+ other: 0.3852
retries:
# Содержательные регенерации после провала гейта ДО эскалации (транспортные
diff --git a/backend/configs/pipeline-c2.yaml b/backend/configs/pipeline-c2.yaml
index a1a2859..14faff1 100644
--- a/backend/configs/pipeline-c2.yaml
+++ b/backend/configs/pipeline-c2.yaml
@@ -12,9 +12,8 @@ defaults:
context:
glossary_injection: selective
glossary_token_budget: 800
- stm_depth: 2
- overlap_tokens: 200
cache_ttl: "5m"
+ # STMDepth/OverlapTokens сняты (WS2 §2а — carryover не строим).
retries:
regenerate_before_escalate: 1
diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go
index 55c5aca..74f6112 100644
--- a/backend/internal/config/models.go
+++ b/backend/internal/config/models.go
@@ -130,6 +130,19 @@ type Model struct {
// Capabilities overrides this model's provider-default wire shape (D3.1),
// field-by-field (model wins). Nil = inherit the provider/kind baseline.
Capabilities *CapabilitiesConfig `yaml:"capabilities"`
+ // RateLimit caps how many parallel wave workers may call THIS model at once (WS1 §1б, ревью-2
+ // F4): mistral-large-latest fails ~48% of calls under N-parallelism (a token-bucket / concurrency
+ // cap, tier-dependent — quirks §Транспорт) while grok is 0%, so a model can bound its own
+ // wave concurrency. Optional min_interval paces call STARTS. A TRANSPORT axis — wire-neutral,
+ // NOT snapshot-folded (it never touches the request bytes). Zero = unlimited (the default).
+ RateLimit RateLimit `yaml:"rate_limit"`
+}
+
+// RateLimit is the per-model wave-concurrency guard (WS1). max_concurrency 0 = unlimited;
+// min_interval_ms 0 = no pacing.
+type RateLimit struct {
+ MaxConcurrency int `yaml:"max_concurrency"`
+ MinIntervalMS int `yaml:"min_interval_ms"`
}
// Price mirrors ledger.ModelPrice in YAML form (USD per 1M tokens).
@@ -210,6 +223,9 @@ func LoadModels(path string) (*Models, error) {
bad("model %s: non-local models require non-zero input/output prices", name)
}
validateCapabilities(bad, "model "+name, mod.Capabilities)
+ if mod.RateLimit.MaxConcurrency < 0 || mod.RateLimit.MinIntervalMS < 0 {
+ bad("model %s: rate_limit.max_concurrency/min_interval_ms must be ≥0 (a concurrency cap / pacing interval)", name)
+ }
if why := m.echoMineViolation(name); why != "" {
bad("model %s on echo-prone provider %s: %s — this re-arms the DeepSeek echo mine "+
"(the provider echoes the untranslated CJK source when thinking is OFF; PROGRESS «Ответ Полигону», §2); "+
diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go
index 5c0ed59..93553a0 100644
--- a/backend/internal/config/pipeline.go
+++ b/backend/internal/config/pipeline.go
@@ -19,15 +19,43 @@ import (
// Pipeline is the parsed pipeline-*.yaml.
type Pipeline struct {
- Core string `yaml:"core"` // C0|C1|C2|C3
- Version int `yaml:"version"`
- Defaults PipelineDefaults `yaml:"defaults"`
- Context ContextAssembly `yaml:"context"`
- Retries Retries `yaml:"retries"`
- Stages []Stage `yaml:"stages"`
- Gates Gates `yaml:"gates"`
- Escal Escalation `yaml:"escalation"`
- Fanout Fanout `yaml:"fanout"`
+ Core string `yaml:"core"` // C0|C1|C2|C3
+ Version int `yaml:"version"`
+ Defaults PipelineDefaults `yaml:"defaults"`
+ Context ContextAssembly `yaml:"context"`
+ Segmentation Segmentation `yaml:"segmentation"`
+ Retries Retries `yaml:"retries"`
+ Stages []Stage `yaml:"stages"`
+ Gates Gates `yaml:"gates"`
+ Escal Escalation `yaml:"escalation"`
+ Fanout Fanout `yaml:"fanout"`
+}
+
+// Segmentation is the WS2 OUTPUT-token chunking budget (слой 1, L2-budget-wrong-unit fix): the
+// draft-chunk and edit-unit ceilings in ru-OUTPUT tokens, plus the per-pair fertility coefficients
+// that convert source char-classes to an output-token estimate (est_out = cjk·CJK + other·Other).
+// Snapshot-folded (segmentationSnap), so a budget/fertility edit is a loud --resnapshot (D30.9).
+// The char-class CLASSIFIER is the backend's real unicode ranges (EstimateTokens: Han|Hiragana|
+// Katakana|Hangul) for generality (§0.1) — the coefficients are the only per-pair datum (authored
+// per project). Zero fields fall to the ratified zh-ru defaults in LoadPipeline.
+type Segmentation struct {
+ // DraftBudgetOut is the fine DRAFT-chunk ceiling (ru-output tokens, default 1797 → 56 chunks on
+ // the 25-chapter rerun). A draft chunk is the small unit for COGS/coverage/alignment.
+ DraftBudgetOut int `yaml:"draft_budget_out"`
+ // EditCeilingOut is the coarse EDIT-unit ceiling (ru-output tokens, default 3200 → 37 units).
+ // The edit unit is a chapter (or a greedy grouping of whole draft chunks when a chapter exceeds
+ // the ceiling) — the large unit the reflow editor needs for cross-chunk cohesion (D39 п.4). The
+ // large-chapter arm (>3200, up to 8000) is GATED behind paid Q2a span-judges (§11); the ceiling
+ // stays config-tunable but 3200 is the conservative ratified default (span-omission unproven).
+ EditCeilingOut int `yaml:"edit_ceiling_out"`
+ Fertility Fertility `yaml:"fertility"`
+}
+
+// Fertility holds the output-token-per-source-char coefficients (WS2). Independently re-derived on
+// the rerun corpus: cjk=1.1978, other=0.3852 (R²=0.9633). A recompute is a loud --resnapshot.
+type Fertility struct {
+ CJK float64 `yaml:"cjk"`
+ Other float64 `yaml:"other"`
}
// PipelineDefaults are cross-stage knobs.
@@ -39,13 +67,13 @@ type PipelineDefaults struct {
}
// ContextAssembly holds the prompt-layout budgets (Р5-раскладка: стабильный
-// префикс / волатильный хвост — сборка Фазы 1; поля фиксируются сейчас, чтобы
-// схема конфига не менялась под ногами «Редакции»).
+// префикс / волатильный хвост). WS2 (§2а) removed the dead STMDepth/OverlapTokens knobs:
+// carryover/overlap is NOT built (D39.7/8 — metrics under the 0.126 floor), and leaving
+// no-op knobs in the wire snapshot invited a silent "re-activate" — so они снесены (a
+// contextSnap structural change → §8 resnapshot manifest line).
type ContextAssembly struct {
GlossaryInjection string `yaml:"glossary_injection"` // selective | full_prefix (Р5: обе схемы)
GlossaryTokenBudget int `yaml:"glossary_token_budget"`
- STMDepth int `yaml:"stm_depth"`
- OverlapTokens int `yaml:"overlap_tokens"`
CacheTTL string `yaml:"cache_ttl"` // per-stage TTL override — Фаза 1
}
@@ -285,6 +313,27 @@ func LoadPipeline(path string, models *Models) (*Pipeline, error) {
if p.Fanout.Candidates <= 0 {
p.Fanout.Candidates = 1
}
+ // WS2 segmentation defaults (ratified zh-ru, §2в): a config that omits the block gets the
+ // output-token budget + independently-re-derived fertility. Zero/negative → default.
+ if p.Segmentation.DraftBudgetOut <= 0 {
+ p.Segmentation.DraftBudgetOut = 1797
+ }
+ if p.Segmentation.EditCeilingOut <= 0 {
+ p.Segmentation.EditCeilingOut = 3200
+ }
+ if p.Segmentation.Fertility.CJK <= 0 {
+ p.Segmentation.Fertility.CJK = 1.1978
+ }
+ if p.Segmentation.Fertility.Other <= 0 {
+ p.Segmentation.Fertility.Other = 0.3852
+ }
+ // An edit unit is a grouping of WHOLE draft chunks, so the edit ceiling must be ≥ the draft
+ // budget — otherwise a single draft chunk already exceeds the unit ceiling and every unit is
+ // one chunk (the decoupling collapses). Loud config error, not a silent degenerate segmentation.
+ if p.Segmentation.EditCeilingOut < p.Segmentation.DraftBudgetOut {
+ bad("segmentation.edit_ceiling_out (%d) must be ≥ draft_budget_out (%d) — an edit unit groups whole draft chunks (WS2)",
+ p.Segmentation.EditCeilingOut, p.Segmentation.DraftBudgetOut)
+ }
switch p.Context.GlossaryInjection {
case "", "selective", "full_prefix":
default:
diff --git a/backend/internal/pipeline/banknote.go b/backend/internal/pipeline/banknote.go
new file mode 100644
index 0000000..c0af0ca
--- /dev/null
+++ b/backend/internal/pipeline/banknote.go
@@ -0,0 +1,135 @@
+package pipeline
+
+import (
+ "regexp"
+ "strings"
+ "unicode"
+)
+
+// banknote.go: the banknote-v1 in-band footnote channel (WS4, план §4 / research/20 §B3 / exp16
+// banknote.py — RATIFIED D39.10 as the dst-delivery channel). The translator, AFTER the translation,
+// MAY emit a versioned separator + tab-delimited term lines for NEW terms only (not in the injected
+// glossary). This file is the pure, deterministic PARSER + SLICER — the wire-invariant core; the
+// integration seams (slice BEFORE classifyOutput, derived checkpoint, snapshot fold, telemetry —
+// план §4(а) 12 points) consume these functions. A faithful Go↔Python port (banknote.py is the
+// reference): same separator, same tolerant field split, same Han-in-src rule, same truncation
+// tolerance. Pin (ws4_banknote_verify.py): re-parse of 95 saved lines = 0 parse_fail / 0 truncated.
+
+// bankSeparator marks the start of the banknote block. Chosen to (a) not occur in natural prose and
+// (b) NOT be a "Примечание/Сноска/Комментарий" trailing-note word the sanitizer reserves (§B3-1), so
+// the two channels never collide. Changing it is a wire/parser change → a loud --resnapshot (§4в).
+const bankSeparator = "⟦TM-BANK-v1⟧"
+
+// bankMaxLines is the per-chunk banknote line budget (§B3-5, feeds the max_tokens sizing at
+// integration point 5). The parser does not enforce it (a model over-emitting is tolerated and
+// merely over-counted); it bounds the prompt-side budget.
+const bankMaxLines = 12
+
+// bankParserVersion versions the split/parse algorithm — the verdict-axis component folded into
+// banknoteSnap{enabled, parser_version} (§4в/§4а point 6), mirroring sanitizerSnap: a parser change
+// re-resolves the stripped draft, so it must be a loud --resnapshot even without a prompt edit.
+const bankParserVersion = "banknote-v1"
+
+// bankTypeOK is the accepted type set; anything else falls back to "term" (a benign default).
+var bankTypeOK = map[string]bool{"name": true, "place": true, "title": true, "term": true, "nickname": true}
+
+// bankFieldSplit tolerates a tab, a run of ≥2 spaces, or a pipe (optionally whitespace-padded) as the
+// field delimiter — the exact tolerance of banknote.py so a model that emits spaces instead of a real
+// TAB still parses. (Go \s is ASCII-whitespace; banknote lines are tab/space-delimited, so the parity
+// with Python's unicode \s around the pipe is exact on the term corpus.)
+var bankFieldSplit = regexp.MustCompile(`\t| {2,}|\s*\|\s*`)
+
+// bankEntry is one parsed candidate line (evidence for W1.5 §C; NOT written to the store until owner
+// signoff). Dst is the model's proposed translation — the direct dst delivery the co-occurrence
+// miner could not extract (蛊→гу, non-seed 龙公→Лун Гун).
+type bankEntry struct {
+ Src string
+ Dst string
+ Type string
+}
+
+// bankFlags is the per-chunk telemetry surfaced loud at integration point 10.
+type bankFlags struct {
+ NLines int // accepted (well-formed) banknote lines
+ ParseFail bool // any malformed residual line (non-Han src / <2 fields) that was NOT a tolerated truncation
+ Truncated bool // the LAST line was cut by generation length (tolerated, not a parse fail)
+}
+
+// splitBanknote slices the banknote block off the raw model output BEFORE any gate/editor (integration
+// point 1). Returns (clean_translation, raw_block). No separator → the whole (right-trimmed) output is
+// the clean translation and the block is "". Faithful to banknote.py: clean = output[:idx].rstrip(),
+// block = output[idx+len(SEP):].strip("\n").
+func splitBanknote(output string) (clean, block string) {
+ idx := strings.Index(output, bankSeparator)
+ if idx < 0 {
+ return strings.TrimRightFunc(output, unicode.IsSpace), ""
+ }
+ clean = strings.TrimRightFunc(output[:idx], unicode.IsSpace)
+ block = strings.Trim(output[idx+len(bankSeparator):], "\n")
+ return clean, block
+}
+
+// hasBankSrcHan reports whether src contains a CJK ideograph in the exact [㐀-鿿] range (U+3400–U+9FFF)
+// banknote.py checks — a zh→ru channel line whose src has no Han is malformed. Kept as the exact
+// Python range (NOT unicode.Han, which is wider) for byte-faithful parity.
+func hasBankSrcHan(src string) bool {
+ for _, r := range src {
+ if r >= 0x3400 && r <= 0x9FFF {
+ return true
+ }
+ }
+ return false
+}
+
+// parseBanknote parses the tab-delimited block (integration seam feeding §C evidence + telemetry).
+// Tolerant of a TRUNCATED final line when truncatedGeneration is set (§B3-5): a short last line under
+// truncation is flagged banknote_truncated, not counted as a parse fail. Any other malformed line
+// (fewer than 2 fields, or a src with no Han) sets banknote_parse_fail. Deterministic, no time/rand.
+func parseBanknote(block string, truncatedGeneration bool) ([]bankEntry, bankFlags) {
+ var entries []bankEntry
+ var flags bankFlags
+ if strings.TrimSpace(block) == "" {
+ return entries, flags
+ }
+ var lines []string
+ for _, ln := range strings.Split(block, "\n") {
+ if strings.TrimSpace(ln) != "" {
+ lines = append(lines, ln)
+ }
+ }
+ bad := 0
+ for i, ln := range lines {
+ raw := bankFieldSplit.Split(strings.TrimSpace(ln), -1)
+ var parts []string
+ for _, p := range raw {
+ if p = strings.TrimSpace(p); p != "" {
+ parts = append(parts, p)
+ }
+ }
+ if len(parts) < 2 {
+ // A short LAST line under a truncated generation is a tolerated cut, not a failure.
+ if i == len(lines)-1 && truncatedGeneration {
+ flags.Truncated = true
+ continue
+ }
+ bad++
+ continue
+ }
+ src, dst := parts[0], parts[1]
+ typ := "term"
+ if len(parts) >= 3 {
+ typ = strings.ToLower(parts[2])
+ }
+ if !bankTypeOK[typ] {
+ typ = "term"
+ }
+ if !hasBankSrcHan(src) {
+ bad++
+ continue
+ }
+ entries = append(entries, bankEntry{Src: src, Dst: dst, Type: typ})
+ }
+ flags.NLines = len(entries)
+ flags.ParseFail = bad > 0
+ return entries, flags
+}
diff --git a/backend/internal/pipeline/banknote_test.go b/backend/internal/pipeline/banknote_test.go
new file mode 100644
index 0000000..a6e2b53
--- /dev/null
+++ b/backend/internal/pipeline/banknote_test.go
@@ -0,0 +1,101 @@
+package pipeline
+
+import (
+ "strings"
+ "testing"
+)
+
+// banknote_test.go pins the WS4 §4(д) parser invariants (byte-faithful port of exp16 banknote.py).
+// The 95-line corpus pin (0 parse_fail / 0 truncated over 14 saved raw outputs) is the domain of the
+// $0 reference eval/design11/ws4_banknote_verify.py; these unit tests cover the parser CONTRACT.
+
+func TestSplitBanknoteNoSeparatorIsAllClean(t *testing.T) {
+ out := "Судзуки шёл по коридору. \n"
+ clean, block := splitBanknote(out)
+ if clean != "Судзуки шёл по коридору." || block != "" {
+ t.Fatalf("no-SEP: clean=%q block=%q, want the whole right-trimmed output and empty block", clean, block)
+ }
+}
+
+func TestSplitBanknoteSlicesBlock(t *testing.T) {
+ out := "Перевод... последнее предложение.\n\n" + bankSeparator + "\n方源\tФан Юань\tname\n蛊师\tгу-мастер\ttitle"
+ clean, block := splitBanknote(out)
+ if !strings.HasSuffix(clean, "последнее предложение.") {
+ t.Fatalf("clean tail wrong: %q", clean)
+ }
+ if strings.Contains(clean, bankSeparator) || strings.Contains(clean, "方源") {
+ t.Fatalf("the banknote block leaked into the clean translation: %q", clean)
+ }
+ ents, flags := parseBanknote(block, false)
+ if len(ents) != 2 || flags.ParseFail || flags.Truncated || flags.NLines != 2 {
+ t.Fatalf("parse = %+v flags=%+v, want 2 clean entries", ents, flags)
+ }
+ if ents[0].Src != "方源" || ents[0].Dst != "Фан Юань" || ents[0].Type != "name" {
+ t.Fatalf("entry[0] = %+v", ents[0])
+ }
+ if ents[1].Type != "title" {
+ t.Fatalf("entry[1] type = %q, want title", ents[1].Type)
+ }
+}
+
+func TestParseBanknoteTolerantFieldSplit(t *testing.T) {
+ // tab, ≥2 spaces, and pipe are all accepted delimiters (a model that emits spaces instead of a
+ // real TAB still parses). type falls back to "term" when absent or unknown.
+ block := "方源\tФан Юань\tname\n蛊师 гу-мастер title\n青茅山 | гора Цинмао | place\n古月\tГу Юэ"
+ ents, flags := parseBanknote(block, false)
+ if flags.ParseFail {
+ t.Fatalf("tolerant split should not fail: %+v", flags)
+ }
+ if len(ents) != 4 {
+ t.Fatalf("want 4 entries across tab/space/pipe delimiters, got %d: %+v", len(ents), ents)
+ }
+ if ents[3].Type != "term" { // no type field → default term
+ t.Fatalf("missing type must default to term, got %q", ents[3].Type)
+ }
+}
+
+func TestParseBanknoteNonHanSrcIsBad(t *testing.T) {
+ // A zh→ru channel line whose src has no Han ideograph is malformed → parse_fail.
+ block := "方源\tФан Юань\tname\nRoseanne\tРозанна\tname"
+ ents, flags := parseBanknote(block, false)
+ if !flags.ParseFail {
+ t.Fatalf("a non-Han src must set parse_fail")
+ }
+ if len(ents) != 1 || ents[0].Src != "方源" {
+ t.Fatalf("only the Han-src line should survive, got %+v", ents)
+ }
+}
+
+func TestParseBanknoteTruncationTolerated(t *testing.T) {
+ // The LAST line cut by generation length is tolerated (banknote_truncated), not a parse fail —
+ // but ONLY under truncated_generation; the same short line otherwise IS a parse fail.
+ block := "方源\tФан Юань\tname\n蛊" // last line has no dst
+ entsT, flagsT := parseBanknote(block, true)
+ if !flagsT.Truncated || flagsT.ParseFail || len(entsT) != 1 {
+ t.Fatalf("truncated=true: want 1 entry + truncated flag + no parse_fail, got %+v %+v", entsT, flagsT)
+ }
+ entsF, flagsF := parseBanknote(block, false)
+ if flagsF.Truncated || !flagsF.ParseFail || len(entsF) != 1 {
+ t.Fatalf("truncated=false: the short last line must be a parse_fail, got %+v %+v", entsF, flagsF)
+ }
+}
+
+func TestParseBanknoteEmptyBlock(t *testing.T) {
+ ents, flags := parseBanknote("", false)
+ if len(ents) != 0 || flags.ParseFail || flags.Truncated || flags.NLines != 0 {
+ t.Fatalf("empty block must yield no entries and clean flags, got %+v %+v", ents, flags)
+ }
+}
+
+func TestBankSeparatorIsNotANoteWord(t *testing.T) {
+ // The separator must not be a trailing-note word the sanitizer reserves (§B3-1) — the two
+ // channels must never collide.
+ for _, note := range []string{"Примечание", "Сноска", "Комментарий", "Note", "TN"} {
+ if strings.Contains(bankSeparator, note) {
+ t.Fatalf("separator %q collides with the reserved note-word %q", bankSeparator, note)
+ }
+ }
+ if !strings.HasPrefix(bankSeparator, "⟦") {
+ t.Fatalf("separator lost its unusual delimiter: %q", bankSeparator)
+ }
+}
diff --git a/backend/internal/pipeline/bookrun.go b/backend/internal/pipeline/bookrun.go
index c81bf7d..d7fefcb 100644
--- a/backend/internal/pipeline/bookrun.go
+++ b/backend/internal/pipeline/bookrun.go
@@ -97,6 +97,15 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
return nil, err
}
+ // W0: eager-build every reachable client BEFORE any wave goroutine, so the clients map is
+ // read-only in the waves (r.client is lock-free, a miss is loud — closes the D12 lazy-init race).
+ if err := r.buildClients(); err != nil {
+ return nil, err
+ }
+ // W0: build the per-model rate-guards (transport axis, read-only in the waves; a no-op until a
+ // model configures rate_limit — WS1 §1б, the mistral-arm precondition WS6).
+ r.buildRateGuards()
+
// Persist captured ruby readings (idempotent; consumed by seedGlossary below into
// auto glossary candidates — шаг 4). Never injected into a prompt here (§7d); a
// resume re-persists the same rows at $0.
@@ -122,7 +131,7 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
return nil, fmt.Errorf("pipeline: upsert snapshot %.12s: %w", snapID, err)
}
- chunks := SplitChunks(doc.Chapters)
+ chunks := SplitChunks(doc.Chapters, r.segBudget())
if len(chunks) == 0 {
return nil, fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile)
}
diff --git a/backend/internal/pipeline/chunker.go b/backend/internal/pipeline/chunker.go
index b450445..6a90411 100644
--- a/backend/internal/pipeline/chunker.go
+++ b/backend/internal/pipeline/chunker.go
@@ -7,19 +7,21 @@ import (
// chunker.go: the шаг-3a SOURCE segmenter. It turns the per-chapter normalized
// text produced by ingest.go into an ordered list of (chapter, chunk) units for
-// the runner loop. It replaced the Веха-2 placeholder (chapter split on \f +
-// blank-line paragraph packing to a CHAR budget) with real segmentation: pack
-// whole paragraphs to a ~1–2k-TOKEN budget, and when a single paragraph exceeds
-// the budget descend to SENTENCE boundaries — never splitting a sentence.
+// the runner loop. WS2 (слой 1) moved the packing budget to OUTPUT (ru) tokens via
+// the fertility estimate (est_out = f_cjk·cjk + f_other·other over source char-classes),
+// decoupling draft sizing (small chunk, COGS/coverage/alignment) from the EDIT unit
+// (coarse chapter-scale unit for cross-chunk cohesion): pack whole paragraphs to
+// DraftBudgetOut, and when a single paragraph exceeds it descend to SENTENCE boundaries
+// (never splitting a sentence; a lone over-budget sentence is a passthrough
+// OversizedSentence chunk). Edit units then group WHOLE draft chunks to EditCeilingOut.
//
// Contract kept intact for the loop (bookrun.go/chunkrun.go depend on it): SplitChunks emits
-// Chunk{Chapter,ChunkIdx,Text}; ChunkIdx resets to 0 per chapter; the function is
-// a PURE, deterministic function of its input (no time/rand, no map iteration).
-// Its behavior is versioned by chunkerVersion (render.go), folded into the job
-// snapshot — so changing the segmentation is a loud --resnapshot re-translation,
-// never a silent cache miss (§3.4/R6/D5.2). The chunk target is a CONST covered by
-// that version (Р2 = code); were it ever made config-tunable it MUST be folded into
-// the top-level snapshot like coverageSnap (§7d), not left to the version string.
+// Chunk{Chapter,ChunkIdx,Text,…}; ChunkIdx resets to 0 per chapter; the new fields (EstOut,
+// OversizedSentence, EditUnitID) are ADDITIVE; the function is a PURE, deterministic function of
+// (chapters, SegBudget) (no time/rand, no map iteration). Its behavior is versioned by
+// chunkerVersion (render.go); the SegBudget is snapshot-folded via segmentationSnap — so changing
+// the segmentation OR the budget/fertility is a loud --resnapshot re-translation, never a silent
+// cache miss (§3.4/R6/D5.2/D30.9).
//
// IMPORTANT: this sentence splitter is a SEPARATE, independent piece of logic from
// coverage.go's splitSentences. That one is a bit-for-bit port of the Python oracle
@@ -33,84 +35,113 @@ type Chunk struct {
Chapter int // 1-based
ChunkIdx int // 0-based within its chapter
Text string
+ // EstOut is the fertility-estimated OUTPUT (ru) token cost of this chunk (WS2, слой 1):
+ // est_out = fert.CJK·cjk_src + fert.Other·other_src over the source char-classes. It is the
+ // unit the draft budget packs against (DraftBudgetOut), decoupling draft sizing from the
+ // old input-token heuristic (L2-budget-wrong-unit). Additive field; the {Chapter,ChunkIdx,Text}
+ // contract the loop/ingest/status depend on is untouched.
+ EstOut float64
+ // OversizedSentence marks a chunk that is a SINGLE source sentence whose est_out alone exceeds
+ // DraftBudgetOut (WS2 §2б). It is passthrough (never clause-split — clause splitting is
+ // boundary machinery, anti-scope), and the flag makes the un-budgetable unit OBSERVABLE
+ // ("деградация не молчит") rather than silently oversized.
+ OversizedSentence bool
+ // EditUnitID is the 0-based book-global id of the coarse EDIT unit this draft chunk belongs to
+ // (WS2 decoupling: draft = small chunk, edit = large unit). An edit unit is a greedy grouping of
+ // WHOLE draft chunks up to EditCeilingOut (per chapter), so W2's editor reads a unit's draft as
+ // the concatenation of its member chunks' W1 outputs — clean reconstruction by construction (no
+ // straddle; verified: grouping whole chunks reproduces the paragraph-packed count 37 with 0
+ // straddle on the rerun corpus). Draft chunks never cross an edit-unit boundary.
+ EditUnitID int
}
-// targetChunkTokens is the packing budget: paragraphs (or, inside an oversize
-// paragraph, sentences) are greedily packed until the WHOLE chunk's estimated
-// tokens (EstimateTokens' formula, applied once — see appendChapterChunks) would
-// exceed this. ~1500 sits in the plan's "1–2k токенов по границам абзацев" corridor
-// (02-mvp Фаза 1); a chunk of ordinary prose lands well above the coverage gate's
-// 500-char floor, so the excision gate is not silently disabled (§7b). A chunk can
-// still fall under the floor only when a whole chapter (or its tail) is genuinely
-// that short — a documented boundary. A const, not config: the size is a code
-// segmentation rule (Р2), covered by chunkerVersion.
-const targetChunkTokens = 1500
+// SegBudget is the resolved output-token segmentation budget (WS2 §2в): the draft-chunk and
+// edit-unit ceilings in ru-OUTPUT tokens, plus the per-pair fertility coefficients that convert
+// source char-classes to an output-token estimate. Resolved from config.Segmentation by the runner
+// and snapshot-folded via segmentationSnap, so a budget/fertility edit is a loud --resnapshot
+// (D30.9). The classifier (tokenClassCounts) uses the REAL unicode.RangeTable of EstimateTokens
+// (Han|Hiragana|Katakana|Hangul) for generality (§0.1) — NOT script-specific ord ranges.
+type SegBudget struct {
+ DraftBudgetOut float64 // draft-chunk ceiling, ru-output tokens (default 1797)
+ EditCeilingOut float64 // edit-unit ceiling, ru-output tokens (default 3200, gated arm at 8000)
+ FertCJK float64 // output tokens per CJK source char (default 1.1978)
+ FertOther float64 // output tokens per non-CJK/non-space source char (default 0.3852)
+}
+
+// estOut applies the fertility formula to pre-counted source char-classes — identical to
+// est_out over the joined chunk text (whitespace is class-free, as tokenClassCounts drops it).
+func (s SegBudget) estOut(cjk, other int) float64 {
+ return s.FertCJK*float64(cjk) + s.FertOther*float64(other)
+}
// SplitChunks segments the ordered, per-chapter NORMALIZED text of a Document
// (ingest.go already applied NormalizeSource and split chapters) into an ordered
// chunk list. Chapters that yield no text (blank/whitespace-only, e.g. an epub
// cover/nav page) do NOT consume a chapter number, so numbering stays dense over
-// real content (Фаза-0 backward compat). Fully deterministic.
-func SplitChunks(chapters []string) []Chunk {
+// real content (Фаза-0 backward compat). Fully deterministic. Each chunk carries its
+// EstOut + OversizedSentence flag + EditUnitID (WS2); the edit-unit id is monotone across
+// the book so a W2 unit is uniquely addressable.
+func SplitChunks(chapters []string, seg SegBudget) []Chunk {
var out []Chunk
chapterNo := 0
+ editUnitID := 0
for _, chapText := range chapters {
paras := splitParagraphs(chapText)
if len(paras) == 0 {
continue // an empty chapter does not consume a chapter number
}
chapterNo++
- out = appendChapterChunks(out, chapterNo, paras)
+ chapterChunks := chapterDraftChunks(chapterNo, paras, seg)
+ assignEditUnits(chapterChunks, seg, &editUnitID)
+ out = append(out, chapterChunks...)
}
return out
}
-// appendChapterChunks packs one chapter's paragraphs into chunks and appends them.
-// Rule: pack whole paragraphs while the running token estimate stays within
-// targetChunkTokens (prefer paragraph boundaries); a paragraph that alone exceeds
-// the budget is flushed on its own and then SENTENCE-packed (never splitting a
-// sentence; a single sentence over budget is its own chunk).
-//
-// The running budget is tracked as ADDITIVE char-class counts (CJK vs other),
-// applying EstimateTokens' formula (and its 16-token floor) ONCE to the whole
-// chunk — NOT by summing per-unit EstimateTokens. Summing per-unit floors every
-// tiny paragraph up to 16 tokens, so a dialogue chapter of many short lines would
-// flush a chunk at ~⅓ its true token content, needlessly dropping real content
-// under the coverage gate's 500-char floor (self-review finding). Counting classes
-// and flooring once reproduces EstimateTokens(joined text) exactly (the "\n\n"
-// separators are whitespace, which EstimateTokens ignores).
-func appendChapterChunks(out []Chunk, chapterNo int, paras []string) []Chunk {
+// chapterDraftChunks packs one chapter's paragraphs into DRAFT chunks (the fine tiling). Rule:
+// pack whole paragraphs while the running OUTPUT-token estimate stays within DraftBudgetOut
+// (prefer paragraph boundaries); a paragraph that alone exceeds the budget is flushed on its own
+// and then SENTENCE-packed (never splitting a sentence; a single sentence over budget is its own
+// OversizedSentence chunk). The running budget is tracked as ADDITIVE char-class counts (CJK vs
+// other), applying est_out ONCE to the whole chunk — the "\n\n" separators are whitespace, which
+// tokenClassCounts drops, so counting classes and estimating once reproduces est_out(joined text)
+// exactly. Each emitted chunk gets its ChunkIdx (per-chapter) + EstOut; EditUnitID is set later.
+func chapterDraftChunks(chapterNo int, paras []string, seg SegBudget) []Chunk {
+ var out []Chunk
chunkIdx := 0
+ emit := func(text string, oversized bool, cjk, other int) {
+ if t := strings.TrimSpace(text); t != "" {
+ out = append(out, Chunk{
+ Chapter: chapterNo, ChunkIdx: chunkIdx, Text: t,
+ EstOut: seg.estOut(cjk, other), OversizedSentence: oversized,
+ })
+ chunkIdx++
+ }
+ }
var buf []string // paragraphs accumulated for the current chunk
var bufCJK, bufOther int
flush := func() {
if len(buf) == 0 {
return
}
- if text := strings.TrimSpace(strings.Join(buf, "\n\n")); text != "" {
- out = append(out, Chunk{Chapter: chapterNo, ChunkIdx: chunkIdx, Text: text})
- chunkIdx++
- }
+ emit(strings.Join(buf, "\n\n"), false, bufCJK, bufOther)
buf = buf[:0]
bufCJK, bufOther = 0, 0
}
for _, p := range paras {
pCJK, pOther := tokenClassCounts(p)
- if estTokensFrom(pCJK, pOther) > targetChunkTokens {
+ if seg.estOut(pCJK, pOther) > seg.DraftBudgetOut {
// Oversize paragraph: close any open chunk at this paragraph boundary,
// then split the paragraph at sentence boundaries into its own chunks.
flush()
- for _, sub := range packSentences(p) {
- if text := strings.TrimSpace(sub); text != "" {
- out = append(out, Chunk{Chapter: chapterNo, ChunkIdx: chunkIdx, Text: text})
- chunkIdx++
- }
+ for _, sub := range packSentences(p, seg) {
+ emit(sub.text, sub.oversizedSentence, sub.cjk, sub.other)
}
continue
}
// Normal paragraph: start a new chunk if adding it would exceed the budget
// and the current chunk is non-empty (never split a paragraph here).
- if len(buf) > 0 && estTokensFrom(bufCJK+pCJK, bufOther+pOther) > targetChunkTokens {
+ if len(buf) > 0 && seg.estOut(bufCJK+pCJK, bufOther+pOther) > seg.DraftBudgetOut {
flush()
}
buf = append(buf, p)
@@ -120,28 +151,67 @@ func appendChapterChunks(out []Chunk, chapterNo int, paras []string) []Chunk {
return out
}
-// packSentences splits one oversize paragraph into sentence segments (lossless
-// tiling — concatenation reproduces the paragraph) and packs consecutive segments
-// into ≤ targetChunkTokens sub-chunks, never splitting a sentence. A single
-// sentence larger than the budget becomes its own (oversize) sub-chunk — the only
-// way to honor "never split a sentence". Returns the sub-chunk texts in order. Uses
-// the same additive char-class budget as appendChapterChunks (floor applied once).
-func packSentences(paragraph string) []string {
+// assignEditUnits groups a chapter's already-built DRAFT chunks into EDIT units — greedy packing of
+// WHOLE draft chunks until the accumulated est_out would exceed EditCeilingOut — and stamps each
+// chunk's EditUnitID (monotone across the book via *nextID). Because units are formed from whole
+// draft chunks, a unit's source/draft span is exactly the concatenation of its member chunks (no
+// straddle) and W2 reconstructs the unit's draft losslessly. A single draft chunk larger than the
+// ceiling (an OversizedSentence, or a chapter whose first chunk already exceeds the ceiling) is its
+// own unit — grouping never splits a chunk (never-split-below-chunk mirrors never-split-paragraph).
+func assignEditUnits(chunks []Chunk, seg SegBudget, nextID *int) {
+ acc := 0.0
+ started := false
+ for i := range chunks {
+ if started && acc+chunks[i].EstOut > seg.EditCeilingOut {
+ *nextID++ // close the current unit at this chunk boundary
+ acc = 0
+ }
+ chunks[i].EditUnitID = *nextID
+ acc += chunks[i].EstOut
+ started = true
+ }
+ if started {
+ *nextID++ // advance past the last unit so the next chapter starts a fresh id
+ }
+}
+
+// subChunk is one sentence-packed sub-chunk with its char-class counts and the oversized-sentence
+// flag (a single sentence whose est_out alone exceeds DraftBudgetOut).
+type subChunk struct {
+ text string
+ cjk, other int
+ oversizedSentence bool
+}
+
+// packSentences splits one oversize paragraph into sentence segments (lossless tiling —
+// concatenation reproduces the paragraph) and packs consecutive segments into ≤ DraftBudgetOut
+// (est_out) sub-chunks, never splitting a sentence. A single sentence larger than the budget
+// becomes its own sub-chunk flagged OversizedSentence (passthrough, never clause-split — the plan's
+// §2б decision: "деградация не молчит" is satisfied by the FLAG, not emergency clause machinery).
+func packSentences(paragraph string, seg SegBudget) []subChunk {
segs := splitSourceSentences(paragraph)
- var chunks []string
+ var chunks []subChunk
var buf strings.Builder
var bufCJK, bufOther int
flush := func() {
if buf.Len() == 0 {
return
}
- chunks = append(chunks, buf.String())
+ chunks = append(chunks, subChunk{text: buf.String(), cjk: bufCJK, other: bufOther})
buf.Reset()
bufCJK, bufOther = 0, 0
}
for _, s := range segs {
sCJK, sOther := tokenClassCounts(s)
- if buf.Len() > 0 && estTokensFrom(bufCJK+sCJK, bufOther+sOther) > targetChunkTokens {
+ // A lone sentence over budget: flush what precedes it, then emit it as its own
+ // OversizedSentence sub-chunk (never split a sentence, and never merge it with a
+ // neighbour so the oversized flag stays attributable to the single sentence).
+ if seg.estOut(sCJK, sOther) > seg.DraftBudgetOut {
+ flush()
+ chunks = append(chunks, subChunk{text: s, cjk: sCJK, other: sOther, oversizedSentence: true})
+ continue
+ }
+ if buf.Len() > 0 && seg.estOut(bufCJK+sCJK, bufOther+sOther) > seg.DraftBudgetOut {
flush()
}
buf.WriteString(s)
@@ -170,16 +240,6 @@ func tokenClassCounts(s string) (cjk, other int) {
return cjk, other
}
-// estTokensFrom applies EstimateTokens' formula (cjk + other/3, floored at 16) to
-// pre-counted class totals — identical to EstimateTokens over the joined text.
-func estTokensFrom(cjk, other int) int {
- est := cjk + other/3
- if est < 16 {
- est = 16
- }
- return est
-}
-
// splitParagraphs breaks a chapter into paragraphs on blank lines and trims each,
// dropping empties. Deterministic; whitespace-only paragraphs vanish. (Ingest emits
// a blank line between block-level epub elements, so paragraphs survive extraction.)
diff --git a/backend/internal/pipeline/chunker_test.go b/backend/internal/pipeline/chunker_test.go
index a7780fd..9e6b7b8 100644
--- a/backend/internal/pipeline/chunker_test.go
+++ b/backend/internal/pipeline/chunker_test.go
@@ -8,43 +8,72 @@ import (
"unicode/utf8"
)
+// testSeg is the ratified WS2 output-token budget (zh-ru defaults) used by the chunker tests.
+func testSeg() SegBudget {
+ return SegBudget{DraftBudgetOut: 1797, EditCeilingOut: 3200, FertCJK: 1.1978, FertOther: 0.3852}
+}
+
+// stripMeta zeroes the WS2 additive fields (EstOut/OversizedSentence/EditUnitID) so a
+// {Chapter,ChunkIdx,Text}-level DeepEqual still expresses the intent of the pre-WS2 assertions.
+func stripMeta(cs []Chunk) []Chunk {
+ out := make([]Chunk, len(cs))
+ for i, c := range cs {
+ out[i] = Chunk{Chapter: c.Chapter, ChunkIdx: c.ChunkIdx, Text: c.Text}
+ }
+ return out
+}
+
func TestSplitChunksSingleParagraph(t *testing.T) {
- got := SplitChunks([]string{"静かな図書館の朝。"})
+ got := SplitChunks([]string{"静かな図書館の朝。"}, testSeg())
want := []Chunk{{Chapter: 1, ChunkIdx: 0, Text: "静かな図書館の朝。"}}
- if !reflect.DeepEqual(got, want) {
+ if !reflect.DeepEqual(stripMeta(got), want) {
t.Fatalf("single paragraph = %+v, want %+v", got, want)
}
+ // WS2: the additive fields are populated — est_out>0 and a single unit id.
+ if got[0].EstOut <= 0 || got[0].EditUnitID != 0 || got[0].OversizedSentence {
+ t.Fatalf("WS2 fields = est_out %.3f unit %d oversized %v", got[0].EstOut, got[0].EditUnitID, got[0].OversizedSentence)
+ }
}
func TestSplitChunksChapters(t *testing.T) {
- got := SplitChunks([]string{"Глава один.", "Глава два.", "Глава три."})
+ got := SplitChunks([]string{"Глава один.", "Глава два.", "Глава три."}, testSeg())
want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: "Глава один."},
{Chapter: 2, ChunkIdx: 0, Text: "Глава два."},
{Chapter: 3, ChunkIdx: 0, Text: "Глава три."},
}
- if !reflect.DeepEqual(got, want) {
+ if !reflect.DeepEqual(stripMeta(got), want) {
t.Fatalf("chapters = %+v, want %+v", got, want)
}
+ // WS2: each chapter is its own edit unit → distinct monotone EditUnitIDs 0,1,2.
+ for i, c := range got {
+ if c.EditUnitID != i {
+ t.Fatalf("chapter %d chunk edit_unit_id = %d, want %d (each short chapter is its own unit)", c.Chapter, c.EditUnitID, i)
+ }
+ }
}
func TestSplitChunksPacksToTarget(t *testing.T) {
- // Two paragraphs that together exceed the TOKEN target (each ~1000 tokens for
- // cyrillic: 3000 chars / 3) must split into two chunks, each kept whole.
+ // Two paragraphs that together exceed the OUTPUT-token budget (each ~1156 out-tokens for
+ // cyrillic: 3000 chars × 0.3852) must split into two draft chunks, each kept whole.
p1 := strings.Repeat("а", 3000)
p2 := strings.Repeat("б", 3000)
- got := SplitChunks([]string{p1 + "\n\n" + p2})
+ got := SplitChunks([]string{p1 + "\n\n" + p2}, testSeg())
want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: p1},
{Chapter: 1, ChunkIdx: 1, Text: p2},
}
- if !reflect.DeepEqual(got, want) {
+ if !reflect.DeepEqual(stripMeta(got), want) {
t.Fatalf("packing = %d chunks, want 2 whole paragraphs; got %+v", len(got), summarize(got))
}
+ // WS2: the two ~1156-out chunks (2311 together ≤ 3200) group into ONE edit unit.
+ if got[0].EditUnitID != got[1].EditUnitID {
+ t.Fatalf("two sub-ceiling draft chunks must share an edit unit, got ids %d and %d", got[0].EditUnitID, got[1].EditUnitID)
+ }
}
func TestSplitChunksPacksSmallParagraphsTogether(t *testing.T) {
- got := SplitChunks([]string{"Абзац один.\n\nАбзац два.\n\nАбзац три."})
+ got := SplitChunks([]string{"Абзац один.\n\nАбзац два.\n\nАбзац три."}, testSeg())
if len(got) != 1 {
t.Fatalf("small paragraphs must pack into one chunk, got %d: %+v", len(got), summarize(got))
}
@@ -54,17 +83,17 @@ func TestSplitChunksPacksSmallParagraphsTogether(t *testing.T) {
}
func TestSplitChunksOversizeParagraphSplitsAtSentences(t *testing.T) {
- // One paragraph far over the token target, built of many whole sentences, must
+ // One paragraph far over the output budget, built of many whole sentences, must
// split into >1 chunk AND never cut a sentence: every original sentence must
// survive intact and in order across the chunks.
- sentence := strings.Repeat("слово ", 40) + "конец." // ~80 tokens each
+ sentence := strings.Repeat("слово ", 40) + "конец." // ~79 out-tokens each
var sb strings.Builder
- for i := 0; i < 40; i++ { // ~3200 tokens total ≫ 1500
+ for i := 0; i < 40; i++ { // ~3160 out-tokens total ≫ 1797
sb.WriteString(sentence)
sb.WriteByte(' ')
}
para := strings.TrimSpace(sb.String())
- got := SplitChunks([]string{para})
+ got := SplitChunks([]string{para}, testSeg())
if len(got) < 2 {
t.Fatalf("oversize paragraph must split into >1 chunk, got %d", len(got))
}
@@ -79,41 +108,43 @@ func TestSplitChunksOversizeParagraphSplitsAtSentences(t *testing.T) {
}
func TestSplitChunksShortLinesPackByTrueTokenBudget(t *testing.T) {
- // Many short dialogue paragraphs must pack by TRUE token content, not by summing
- // the per-unit 16-token floor. 150 lines of 「はい。」 are ~450 true tokens (cjk=300,
- // other=450 → 300+150), so they belong in ONE chunk. Summing the floor (150×16)
- // would wrongly flush into ~2 sub-500-char chunks the coverage gate then skips.
+ // Many short dialogue paragraphs must pack by TRUE output-token content (est_out on the
+ // joined text), not by summing a per-unit floor. 150 lines of 「はい。」 are ~533 out-tokens,
+ // so they belong in ONE chunk (a per-unit floor would wrongly flush into sub-500-char pieces).
var paras []string
for i := 0; i < 150; i++ {
paras = append(paras, "「はい。」")
}
- got := SplitChunks([]string{strings.Join(paras, "\n\n")})
+ got := SplitChunks([]string{strings.Join(paras, "\n\n")}, testSeg())
if len(got) != 1 {
t.Fatalf("short lines must pack by true token budget into 1 chunk, got %d chunks", len(got))
}
- if EstimateTokens(got[0].Text) > targetChunkTokens {
- t.Fatalf("packed chunk overshoots the budget: %d tokens", EstimateTokens(got[0].Text))
+ if got[0].EstOut > testSeg().DraftBudgetOut {
+ t.Fatalf("packed chunk overshoots the budget: est_out %.1f", got[0].EstOut)
}
}
func TestSplitChunksOversizeSentenceIsOwnChunk(t *testing.T) {
- // A single sentence (no internal terminator) larger than the budget can only be
- // its own chunk — never split.
- big := strings.Repeat("ы", 6000) // ~2000 tokens, one un-terminated run
- got := SplitChunks([]string{big})
+ // A single sentence (no internal terminator) larger than the budget can only be its own
+ // chunk — never split — and is flagged OversizedSentence (passthrough, WS2 §2б).
+ big := strings.Repeat("ы", 6000) // ~2311 out-tokens, one un-terminated run
+ got := SplitChunks([]string{big}, testSeg())
if len(got) != 1 || got[0].Text != big {
t.Fatalf("an oversize sentence must be one un-split chunk, got %+v", summarize(got))
}
+ if !got[0].OversizedSentence {
+ t.Fatalf("a single over-budget sentence must be flagged OversizedSentence")
+ }
}
func TestSplitChunksDropsEmpties(t *testing.T) {
// A blank/whitespace-only chapter vanishes and does NOT consume a chapter number.
- got := SplitChunks([]string{"A", " \n\n ", "B"})
+ got := SplitChunks([]string{"A", " \n\n ", "B"}, testSeg())
want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: "A"},
{Chapter: 2, ChunkIdx: 0, Text: "B"},
}
- if !reflect.DeepEqual(got, want) {
+ if !reflect.DeepEqual(stripMeta(got), want) {
t.Fatalf("empties = %+v, want %+v", got, want)
}
}
@@ -123,7 +154,7 @@ func TestSplitChunksDeterministic(t *testing.T) {
"Абзац.\n\nЕщё абзац.",
strings.Repeat("слово ", 400) + "конец.",
}
- a, b := SplitChunks(chapters), SplitChunks(chapters)
+ a, b := SplitChunks(chapters, testSeg()), SplitChunks(chapters, testSeg())
if !reflect.DeepEqual(a, b) {
t.Fatal("SplitChunks must be deterministic")
}
@@ -133,7 +164,7 @@ func TestSplitChunksNeverSplitsAbbreviation(t *testing.T) {
// Two long en paragraphs each ending mid-flow with "Mr. Smith" must not chunk
// between "Mr." and "Smith" — the abbreviation guard keeps the sentence whole.
p := strings.Repeat("The road went on and on. ", 100) + "It was Mr. Smith who arrived."
- got := SplitChunks([]string{p})
+ got := SplitChunks([]string{p}, testSeg())
for _, c := range got {
if strings.HasSuffix(strings.TrimSpace(c.Text), "Mr.") {
t.Fatalf("chunk ended at an abbreviation 'Mr.' — a sentence was cut: %q", c.Text)
@@ -141,6 +172,32 @@ func TestSplitChunksNeverSplitsAbbreviation(t *testing.T) {
}
}
+// TestSplitChunksEditUnitGroupsWholeChunks pins the WS2 decoupling: a chapter whose draft chunks
+// together exceed EditCeilingOut is grouped into >1 edit unit at DRAFT-CHUNK boundaries (never
+// splitting a chunk), so every chunk of a unit reconstructs the unit's draft losslessly.
+func TestSplitChunksEditUnitGroupsWholeChunks(t *testing.T) {
+ // Three ~1156-out paragraphs (0.3852×3000): draft chunks {p1},{p2},{p3} (each ~1156, two
+ // together 2311 ≤ 1797? no — 2311>1797 so each is its own draft chunk). Edit ceiling 3200:
+ // group whole chunks → {p1,p2}=2311 ≤3200, +p3=3467>3200 → unit0={p1,p2}, unit1={p3}.
+ p := strings.Repeat("а", 3000)
+ q := strings.Repeat("б", 3000)
+ r := strings.Repeat("в", 3000)
+ got := SplitChunks([]string{p + "\n\n" + q + "\n\n" + r}, testSeg())
+ if len(got) != 3 {
+ t.Fatalf("want 3 draft chunks, got %d", len(got))
+ }
+ ids := []int{got[0].EditUnitID, got[1].EditUnitID, got[2].EditUnitID}
+ if ids[0] != ids[1] || ids[1] == ids[2] {
+ t.Fatalf("edit-unit grouping = %v, want first two together and the third separate", ids)
+ }
+ // Every draft chunk belongs to exactly one unit; a unit's chunks are contiguous.
+ for i := 1; i < len(got); i++ {
+ if got[i].EditUnitID < got[i-1].EditUnitID {
+ t.Fatalf("edit unit ids must be monotone, got %v", ids)
+ }
+ }
+}
+
// --- source sentence splitter (zh/ja/en) ---------------------------------------
func TestSplitSourceSentencesTilesLosslessly(t *testing.T) {
@@ -262,7 +319,7 @@ func FuzzSplitChunksPreservesContent(f *testing.F) {
norm := NormalizeSource(s)
want := countNonSpace(norm)
got := 0
- for _, c := range SplitChunks([]string{norm}) {
+ for _, c := range SplitChunks([]string{norm}, testSeg()) {
got += countNonSpace(c.Text)
}
if got != want {
diff --git a/backend/internal/pipeline/ingest_test.go b/backend/internal/pipeline/ingest_test.go
index b571f9b..3edf45e 100644
--- a/backend/internal/pipeline/ingest_test.go
+++ b/backend/internal/pipeline/ingest_test.go
@@ -226,7 +226,7 @@ func TestIngestEPUBRubyChapterMatchesDenseNumbering(t *testing.T) {
if len(doc.Ruby) != 1 || doc.Ruby[0].Chapter != 2 {
t.Fatalf("ruby dense chapter = %#v, want chapter 2", doc.Ruby)
}
- chunks := SplitChunks(doc.Chapters)
+ chunks := SplitChunks(doc.Chapters, testSeg())
var rubyChunkChapter int
for _, c := range chunks {
if strings.Contains(c.Text, "朱雀") {
diff --git a/backend/internal/pipeline/memory.go b/backend/internal/pipeline/memory.go
index 8f728c2..c627920 100644
--- a/backend/internal/pipeline/memory.go
+++ b/backend/internal/pipeline/memory.go
@@ -129,7 +129,15 @@ type MemoryBank struct {
entries []memoryEntry
ac *ahoCorasick
keyOwners map[string][]int // normalized key → indices of entries that contributed it
- version string // memoryVersion(): hash of frozen APPROVED rows + algo versions (F1/D8)
+ // enrichedVersion folds ALL foldable rows incl Source:mined — the memory version of the EDIT
+ // wave (snapshot_W2). baseVersion EXCLUDES Source:mined — the memory version of the DRAFT wave
+ // (snapshot_W1), so a W1.5 bank-enrichment (adding mined rows) moves ONLY snapshot_W2, keeping
+ // W1 checkpoints valid (WS1 §1в, «переоплата ОДНА»). The two are DOMAIN-SEPARATED (a "base-excl-
+ // mined" tag in the base fold), so base ≠ enriched for EVERY row set — even a mined-free book (a
+ // W1 job must never content-address to a W2 checkpoint). The load-bearing invariant is not
+ // equality but STABILITY: base stays fixed as mined rows are added; only enriched moves.
+ enrichedVersion string
+ baseVersion string
}
// pickedEntry is one selected record for a chunk with its firing key and disposition.
@@ -170,7 +178,13 @@ type memorySelection struct {
activeIDs map[string]injectionDisposition
}
-func (b *MemoryBank) Version() string { return b.version }
+// Version is the ENRICHED memory version (all approved rows incl mined) — the edit-wave / whole-book
+// memory component. BaseVersion excludes Source:mined (the draft-wave component). They are
+// DOMAIN-SEPARATED — base ≠ enriched for ANY row set, including a mined-free book (never assert
+// equality) — but base stays STABLE when a W1.5 pass adds mined rows, which is what keeps W1
+// checkpoints valid («переоплата ОДНА»).
+func (b *MemoryBank) Version() string { return b.enrichedVersion }
+func (b *MemoryBank) BaseVersion() string { return b.baseVersion }
// materializeMemory builds a MemoryBank from the book's stored glossary rows (ORDER
// BY-stable — GlossaryForBook). Pure and deterministic. It computes memoryVersion as a
@@ -184,11 +198,11 @@ func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank {
for _, row := range rows {
e := memoryEntry{
- id: row.Src + "\x1f" + row.Sense + "\x1f" + strconv.Itoa(row.SinceCh) + "\x1f" + strconv.Itoa(row.UntilCh),
- src: row.Src,
- dst: row.Dst,
- status: row.Status,
- sense: row.Sense,
+ id: row.Src + "\x1f" + row.Sense + "\x1f" + strconv.Itoa(row.SinceCh) + "\x1f" + strconv.Itoa(row.UntilCh),
+ src: row.Src,
+ dst: row.Dst,
+ status: row.Status,
+ sense: row.Sense,
sinceCh: row.SinceCh,
untilCh: row.UntilCh,
allowShort: row.AllowShort,
@@ -240,7 +254,8 @@ func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank {
}
sort.Strings(allKeys) // deterministic automaton construction
b.ac = buildAC(allKeys)
- b.version = computeMemoryVersion(rows, gateOn)
+ b.enrichedVersion = computeMemoryVersion(rows, gateOn) // all approved incl mined (W2)
+ b.baseVersion = computeMemoryVersionScoped(rows, gateOn, true) // excl Source:mined (W1)
return b
}
@@ -256,14 +271,31 @@ func materializeMemory(rows []store.GlossaryEntry, gateOn bool) *MemoryBank {
// glossary.id autoincrement is deliberately EXCLUDED (fresh each replace); only content
// columns are hashed.
func computeMemoryVersion(rows []store.GlossaryEntry, gateOn bool) string {
+ return computeMemoryVersionScoped(rows, gateOn, false)
+}
+
+// computeMemoryVersionScoped is computeMemoryVersion with an explicit Source-scope: excludeMined
+// drops every Source:"mined" row from the fold (WS1 §1в base-bank-version). The DRAFT-wave snapshot
+// (snapshot_W1) folds base (excludeMined=true) so a W1.5 pass that ADDS mined rows moves ONLY the
+// enriched (edit-wave) version — keeping W1 checkpoints valid, «переоплата ОДНА». Deterministic (a
+// Source filter over the same ORDER BY-stable rows). excludeMined=false is BYTE-IDENTICAL to the
+// pre-split fold (no extra field), so the enriched hash / existing snapshots do not move; the
+// excludeMined=true variant adds a domain separator so base and enriched never collide.
+func computeMemoryVersionScoped(rows []store.GlossaryEntry, gateOn, excludeMined bool) string {
h := sha256.New()
h.Write([]byte("tm-memory-v2\x00"))
h.Write([]byte(memoryNormVersion + "\x00" + memoryMatchVersion + "\x00"))
h.Write([]byte("gate:" + strconv.FormatBool(gateOn) + "\x00"))
+ if excludeMined {
+ h.Write([]byte("base-excl-mined\x00")) // domain separator: base ≠ enriched even over identical rows
+ }
for _, r := range rows {
if r.Status != "approved" && !gateOn {
continue
}
+ if excludeMined && r.Source == "mined" {
+ continue
+ }
// A fixed, length-prefixed field layout so no content can forge a boundary.
writeField(h, r.Status)
writeField(h, r.Src)
@@ -492,39 +524,53 @@ func renderGlossaryBlock(injected []pickedEntry) string {
return glossaryBlockHeader + "\n" + strings.Join(lines, "\n")
}
-// editorConstraintHeader introduces the editor's dst-constraint block. It gives the editor the
-// approved TARGET renderings as consistency constraints — it must normalise any divergent rendering
-// in the draft to these canonical forms (inflecting for context) and touch nothing else.
-const editorConstraintHeader = "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):"
+// renderFormatVersion versions the FORMAT of the role-injection renderers whose output is NOT
+// captured by memoryMatchVersion (a scope/matcher-ALGORITHM version, not a render-layout version):
+// today the editor constraint block's src→dst layout (WS2 §2в) and, later, the gender-constraint
+// render (WS5). A format change shifts the injected bytes → the wire, so it must be a loud
+// --resnapshot; folding it as a dedicated snapshot component (snapshot.go) keeps that loudness a
+// MECHANISM, not discipline — memoryMatchVersion (const memory.go) would not move on a render edit.
+const renderFormatVersion = "renderfmt-v1-editor-src2dst"
-// renderEditorConstraintBlock serializes the selected records' CONFIRMED dst renderings into the
-// editor's injection. Unlike renderGlossaryBlock it emits the TARGET forms only — bare canonical
-// Russian, not "src → dst". It is CONFIRMED-only on purpose: an AMBIGUOUS record is a candidate the
-// translator MAY have legitimately rejected, so forcing the editor to rewrite the draft toward it
-// would corrupt a correct translation (mirrors the gate's CONFIRMED-only discipline, external-review
-// #1). NOTE (D30.1 supersede of D1): the editor is now BILINGUAL — editor.md feeds it the source, so
-// the earlier "monolingual editor never sees the source" rationale for target-forms-only is stale
-// (the CONFIRMED-only discipline above is the LIVE reason and is unchanged here). Whether a bilingual
-// editor should receive the src→dst mapping (like the translator) so it can bind each canonical form
-// to its source term, rather than bare Russian, is an OPEN design question the D30.1 flip left
-// unclosed (flagged to the orchestrator; behaviour deliberately unchanged in this pack). Deduped by
-// dst (an alias and its main entry share a rendering), in the budget priority order fixed by Select.
-// Returns "" when nothing CONFIRMED renders → the editor gets NO injection (the plain draft-only
+// editorConstraintHeader introduces the editor's canonical-constraint block. It gives the BILINGUAL
+// editor (D30.1) the approved src→dst bindings as consistency constraints — it must render each
+// listed source term with EXACTLY the paired canonical form (inflecting for context) and touch
+// nothing else.
+const editorConstraintHeader = "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):"
+
+// renderEditorConstraintBlock serializes the selected records' CONFIRMED renderings into the
+// editor's injection as a src→dst MAPPING (WS2 §2в resolves the D30.1-open question): "源термин →
+// «dst»", like the translator block, NOT bare canonical Russian. Rationale (the single load-bearing
+// one, ревью-1 F4): the editor is BILINGUAL (editor.md feeds it the source), and binding the canon
+// to its SOURCE term disambiguates HOMONYMIC dst (one Russian surface for two entities) — bare
+// forms cannot. It stays CONFIRMED-only on purpose: an AMBIGUOUS record is a candidate the
+// translator MAY have legitimately rejected, so forcing the editor to rewrite toward it would
+// corrupt a correct translation (mirrors the gate's CONFIRMED-only discipline, external-review #1);
+// AMBIGUOUS/mined-draft rows never enter here. Deduped by (src,dst) — an alias and its main entry
+// share the pair — which only WIDENS the list (harmless), in the budget priority order fixed by
+// Select. Returns "" when nothing CONFIRMED renders → the editor gets NO injection (plain draft-only
// layout). The block is a subset of the already budget-limited memSel.injected, so it needs no
-// separate token budget.
+// separate token budget. The src→dst FORMAT is snapshot-folded via renderFormatVersion (a format
+// edit is a loud --resnapshot); the injection is a message, so it also enters request_hash
+// directly (no silent false-hit).
func renderEditorConstraintBlock(injected []pickedEntry) string {
var lines []string
- seen := map[string]bool{}
+ seen := map[[2]string]bool{}
for _, p := range injected {
if p.disp != memConfirmed {
continue
}
+ src := strings.TrimSpace(p.entry.src)
dst := strings.TrimSpace(p.entry.dst)
- if dst == "" || seen[dst] {
+ if dst == "" {
continue
}
- seen[dst] = true
- lines = append(lines, "- «"+dst+"»")
+ key := [2]string{src, dst}
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ lines = append(lines, "- "+src+" → «"+dst+"»")
}
if len(lines) == 0 {
return ""
diff --git a/backend/internal/pipeline/memory_test.go b/backend/internal/pipeline/memory_test.go
index 70a2b75..7fced21 100644
--- a/backend/internal/pipeline/memory_test.go
+++ b/backend/internal/pipeline/memory_test.go
@@ -529,36 +529,45 @@ func TestInjectivityCollision(t *testing.T) {
}
}
-// TestRenderEditorConstraintBlock covers D1: the monolingual editor's injection is the
-// CONFIRMED dst forms ONLY (target constraints), deduped, with no source or "src → dst".
-// Reverting the CONFIRMED-only guard leaks an AMBIGUOUS candidate and fails here.
+// TestRenderEditorConstraintBlock covers WS2 §2в (D30.1 open-question resolved): the BILINGUAL
+// editor's injection is a CONFIRMED src→dst MAPPING (binding each canon to its source term, so a
+// homonymic dst stays distinguishable), NOT bare target forms. It stays CONFIRMED-only (an
+// AMBIGUOUS candidate must not force a rewrite); dedup is by (src,dst), which only widens the list.
func TestRenderEditorConstraintBlock(t *testing.T) {
suzuki := &memoryEntry{src: "鈴木", dst: "Судзуки", status: "approved"}
cand := &memoryEntry{src: "小D", dst: "Малыш Дэ", status: "auto"} // AMBIGUOUS
tanaka := &memoryEntry{src: "田中", dst: "Танака", status: "approved"}
- tanakaKana := &memoryEntry{src: "たなか", dst: "Танака", status: "approved"} // same dst → deduped
+ tanakaDup := &memoryEntry{src: "田中", dst: "Танака", status: "approved"} // same (src,dst) → deduped
+ // A distinct source term that renders to the SAME Russian surface — the homonym case the
+ // src→dst binding exists for: both lines survive (widening), the editor can tell them apart.
+ homonym := &memoryEntry{src: "済", dst: "Танака", status: "approved"}
sel := []pickedEntry{
{entry: suzuki, via: "鈴木", disp: memConfirmed},
{entry: cand, via: "小d", disp: memAmbiguous},
{entry: tanaka, via: "田中", disp: memConfirmed},
- {entry: tanakaKana, via: "たなか", disp: memConfirmed},
+ {entry: tanakaDup, via: "田中", disp: memConfirmed},
+ {entry: homonym, via: "済", disp: memConfirmed},
}
block := renderEditorConstraintBlock(sel)
- if !strings.Contains(block, "«Судзуки»") || !strings.Contains(block, "«Танака»") {
- t.Errorf("confirmed dst forms missing: %q", block)
+ // src→dst mapping present under the editor's own header.
+ if !strings.Contains(block, "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ") {
+ t.Errorf("editor header missing: %q", block)
+ }
+ if !strings.Contains(block, "鈴木 → «Судзуки»") || !strings.Contains(block, "田中 → «Танака»") {
+ t.Errorf("src→dst mapping missing: %q", block)
}
// AMBIGUOUS excluded — the editor must not be forced toward an unverified rendering.
- if strings.Contains(block, "Малыш Дэ") {
+ if strings.Contains(block, "Малыш Дэ") || strings.Contains(block, "小D") {
t.Errorf("AMBIGUOUS candidate leaked into the editor constraints: %q", block)
}
- // dst-only: no source key, no arrow (the editor is monolingual).
- if strings.Contains(block, "→") || strings.Contains(block, "鈴木") || strings.Contains(block, "ГЛОССАРИЙ") {
- t.Errorf("editor block must be target-only, no src→dst: %q", block)
+ // Deduped by (src,dst): 田中→Танака appears once despite the duplicate entry.
+ if n := strings.Count(block, "田中 → «Танака»"); n != 1 {
+ t.Errorf("(src,dst) not deduped: 田中→Танака appears %d times: %q", n, block)
}
- // Deduped by dst (Танака appears once despite two source keys).
- if n := strings.Count(block, "«Танака»"); n != 1 {
- t.Errorf("dst not deduped: «Танака» appears %d times: %q", n, block)
+ // Homonym widening: a DIFFERENT source with the same dst is NOT collapsed — both lines survive.
+ if !strings.Contains(block, "済 → «Танака»") {
+ t.Errorf("homonymic dst must stay distinguishable by source term: %q", block)
}
// An all-AMBIGUOUS (or empty) selection yields no block at all.
if got := renderEditorConstraintBlock([]pickedEntry{{entry: cand, disp: memAmbiguous}}); got != "" {
diff --git a/backend/internal/pipeline/memory_wave_test.go b/backend/internal/pipeline/memory_wave_test.go
new file mode 100644
index 0000000..ad72ed7
--- /dev/null
+++ b/backend/internal/pipeline/memory_wave_test.go
@@ -0,0 +1,38 @@
+package pipeline
+
+import (
+ "testing"
+
+ "textmachine/backend/internal/store"
+)
+
+// TestBaseEnrichedMemoryVersionSplit pins the WS1 §1в per-wave bank-version mechanism: the BASE
+// version (draft wave / snapshot_W1) excludes Source:"mined" rows, so adding a mined approved row
+// after W1 moves ONLY the ENRICHED version (edit wave / snapshot_W2) — keeping W1 checkpoints valid
+// («переоплата ОДНА»). A broken split (base folding mined) would move snapshot_W1 and re-bill W1.
+func TestBaseEnrichedMemoryVersionSplit(t *testing.T) {
+ seedRows := []store.GlossaryEntry{
+ {Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed"},
+ {Src: "古月", Dst: "Гу Юэ", Status: "approved", Source: "ruby"},
+ }
+ base0 := materializeMemory(seedRows, false)
+ // Same rows PLUS a mined approved row (as a W1.5 pass would add).
+ enrichedRows := append([]store.GlossaryEntry{}, seedRows...)
+ enrichedRows = append(enrichedRows, store.GlossaryEntry{Src: "蛊", Dst: "гу", Status: "approved", Source: "mined"})
+ bank1 := materializeMemory(enrichedRows, false)
+
+ // 1) Base version is UNCHANGED by the mined addition (draft wave stays valid).
+ if base0.BaseVersion() != bank1.BaseVersion() {
+ t.Fatalf("base version moved on a mined-row addition — snapshot_W1 would re-bill W1:\n before %s\n after %s",
+ base0.BaseVersion(), bank1.BaseVersion())
+ }
+ // 2) Enriched version DID move (edit wave sees the new mined canon).
+ if base0.Version() == bank1.Version() {
+ t.Fatalf("enriched version did NOT move on a mined-row addition — the edit wave would miss the mined canon")
+ }
+ // 3) Base ≠ enriched even before any mined row (domain-separated), so a W1 job can never
+ // accidentally content-address to a W2 checkpoint.
+ if base0.BaseVersion() == base0.Version() {
+ t.Fatalf("base and enriched versions collide over identical rows — they must be domain-separated")
+ }
+}
diff --git a/backend/internal/pipeline/ratelimit.go b/backend/internal/pipeline/ratelimit.go
new file mode 100644
index 0000000..fc07f8c
--- /dev/null
+++ b/backend/internal/pipeline/ratelimit.go
@@ -0,0 +1,97 @@
+package pipeline
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+// ratelimit.go: the per-model rate-guard for the parallel waves (WS1 §1б, ревью-2 F4). The per-CALL
+// 429/Retry-After backoff already lives in the transport adapter (httpllm.go); the gap the waves
+// open is N-CONCURRENCY against a rate-limited model — mistral-large-latest fails ~48% of calls under
+// parallelism (a token-bucket / concurrency cap, tier-dependent — 00-provider-quirks §Транспорт),
+// while grok is 0%. A model caps its own wave concurrency (max_concurrency) and, optionally, paces
+// call STARTS (min_interval). This is a TRANSPORT axis: it never touches the request bytes, so it is
+// wire-neutral and NOT snapshot-folded. Guards are built once in W0, read-only in the waves; each
+// guard's internals (a buffered-channel semaphore + a mutex-protected pacer) are concurrency-safe,
+// so acquiring from N wave goroutines is race-clean.
+
+// rateGuard limits concurrent calls to one model (+ optional pacing). The zero/nil guard is a no-op
+// (unlimited), so an unconfigured model — or a nil map entry — costs nothing.
+type rateGuard struct {
+ sem chan struct{} // buffered to max_concurrency; nil = unlimited
+ minInterval time.Duration // 0 = no pacing
+ mu sync.Mutex // guards last
+ last time.Time // start time of the most recent acquired call (pacer)
+}
+
+// newRateGuard builds a guard. maxConcurrency ≤ 0 → unlimited concurrency; minInterval ≤ 0 → no
+// pacing. Both off → newRateGuard returns a guard that acquire treats as a pure no-op.
+func newRateGuard(maxConcurrency int, minInterval time.Duration) *rateGuard {
+ g := &rateGuard{minInterval: minInterval}
+ if maxConcurrency > 0 {
+ g.sem = make(chan struct{}, maxConcurrency)
+ }
+ return g
+}
+
+// acquire blocks until a concurrency slot is free and, if pacing is on, until the min-interval since
+// the last acquired call has elapsed — both respecting ctx cancellation. It returns a release func
+// the caller MUST defer (a no-op when acquire failed or the guard is unlimited). A nil guard (no
+// config) acquires instantly. The pacer holds the mutex only to read/stamp `last`; the wait itself
+// is outside the lock, so a paced model still admits one caller per interval without serializing the
+// lock for the whole wait.
+func (g *rateGuard) acquire(ctx context.Context) (func(), error) {
+ if g == nil {
+ return func() {}, nil
+ }
+ if g.sem != nil {
+ select {
+ case g.sem <- struct{}{}:
+ case <-ctx.Done():
+ return func() {}, ctx.Err()
+ }
+ }
+ release := func() {
+ if g.sem != nil {
+ <-g.sem
+ }
+ }
+ if g.minInterval > 0 {
+ for {
+ g.mu.Lock()
+ wait := time.Until(g.last.Add(g.minInterval))
+ if wait <= 0 {
+ g.last = time.Now()
+ g.mu.Unlock()
+ break
+ }
+ g.mu.Unlock()
+ select {
+ case <-time.After(wait):
+ // re-check: another caller may have taken the slot while we slept.
+ case <-ctx.Done():
+ release()
+ return func() {}, ctx.Err()
+ }
+ }
+ }
+ return release, nil
+}
+
+// buildRateGuards constructs a guard for every reachable model in W0 (read-only in the waves).
+// A model with no rate_limit config gets an unlimited (no-op) guard, so rateGuard(model) is always
+// non-nil and lookup-safe.
+func (r *Runner) buildRateGuards() {
+ r.rateGuards = make(map[string]*rateGuard, len(r.Models.Models))
+ for _, m := range r.reachableModels() {
+ rl := r.Models.Models[m].RateLimit
+ r.rateGuards[m] = newRateGuard(rl.MaxConcurrency, time.Duration(rl.MinIntervalMS)*time.Millisecond)
+ }
+}
+
+// rateGuard returns the pre-built guard for a model (nil-safe: acquire treats a missing/nil guard as
+// unlimited, so a model reached before buildRateGuards ran simply is not throttled).
+func (r *Runner) rateGuard(model string) *rateGuard {
+ return r.rateGuards[model]
+}
diff --git a/backend/internal/pipeline/ratelimit_test.go b/backend/internal/pipeline/ratelimit_test.go
new file mode 100644
index 0000000..930a98e
--- /dev/null
+++ b/backend/internal/pipeline/ratelimit_test.go
@@ -0,0 +1,113 @@
+package pipeline
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// TestRateGuardLimitsConcurrency proves the semaphore REALLY bounds concurrency (WS6 precondition,
+// ревью-2 F4): with max_concurrency=2, no more than 2 goroutines are ever inside the guarded region
+// at once, even under 20 racing callers. A broken guard (missing/over-sized semaphore) trips the
+// observed-max assertion.
+func TestRateGuardLimitsConcurrency(t *testing.T) {
+ const cap, workers = 2, 20
+ g := newRateGuard(cap, 0)
+ var inFlight, maxSeen int64
+ var wg sync.WaitGroup
+ for i := 0; i < workers; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ rel, err := g.acquire(context.Background())
+ if err != nil {
+ t.Errorf("acquire: %v", err)
+ return
+ }
+ cur := atomic.AddInt64(&inFlight, 1)
+ for {
+ m := atomic.LoadInt64(&maxSeen)
+ if cur <= m || atomic.CompareAndSwapInt64(&maxSeen, m, cur) {
+ break
+ }
+ }
+ time.Sleep(time.Millisecond) // widen the window a concurrency bug would exploit
+ atomic.AddInt64(&inFlight, -1)
+ rel()
+ }()
+ }
+ wg.Wait()
+ if maxSeen > cap {
+ t.Fatalf("rate-guard admitted %d concurrent callers, cap is %d", maxSeen, cap)
+ }
+ if maxSeen == 0 {
+ t.Fatalf("no caller was observed in-flight — the guard never admitted anyone")
+ }
+}
+
+// TestRateGuardNilAndUnlimitedAreNoOps: a nil guard and a zero-config guard both admit instantly and
+// never block (the sequential path / an unconfigured model must cost nothing).
+func TestRateGuardNilAndUnlimitedAreNoOps(t *testing.T) {
+ var nilGuard *rateGuard
+ rel, err := nilGuard.acquire(context.Background())
+ if err != nil {
+ t.Fatalf("nil guard acquire: %v", err)
+ }
+ rel() // must not panic
+ unlimited := newRateGuard(0, 0)
+ for i := 0; i < 100; i++ {
+ rel, err := unlimited.acquire(context.Background())
+ if err != nil {
+ t.Fatalf("unlimited acquire %d: %v", i, err)
+ }
+ rel()
+ }
+}
+
+// TestRateGuardHonoursContextCancel: a caller blocked on a full semaphore unblocks on ctx cancel and
+// does NOT leak a slot (the wave dispatcher must be able to abort a run cleanly).
+func TestRateGuardHonoursContextCancel(t *testing.T) {
+ g := newRateGuard(1, 0)
+ rel, err := g.acquire(context.Background())
+ if err != nil {
+ t.Fatalf("first acquire: %v", err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan error, 1)
+ go func() {
+ r, err := g.acquire(ctx) // blocks: the one slot is held
+ r()
+ done <- err
+ }()
+ cancel()
+ select {
+ case err := <-done:
+ if err != context.Canceled {
+ t.Fatalf("blocked acquire on cancel = %v, want context.Canceled", err)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("acquire did not unblock on ctx cancel")
+ }
+ rel() // release the first; a fresh acquire must now succeed (no leaked slot)
+ r2, err := g.acquire(context.Background())
+ if err != nil {
+ t.Fatalf("acquire after release: %v", err)
+ }
+ r2()
+}
+
+// TestRateGuardPacingSpacesStarts: with min_interval set, two serial acquires are spaced by at least
+// the interval (best-effort pacing of call STARTS).
+func TestRateGuardPacingSpacesStarts(t *testing.T) {
+ g := newRateGuard(0, 30*time.Millisecond)
+ start := time.Now()
+ r1, _ := g.acquire(context.Background())
+ r1()
+ r2, _ := g.acquire(context.Background())
+ r2()
+ if elapsed := time.Since(start); elapsed < 30*time.Millisecond {
+ t.Fatalf("paced acquires took %v, want ≥30ms between starts", elapsed)
+ }
+}
diff --git a/backend/internal/pipeline/render.go b/backend/internal/pipeline/render.go
index fbb98a3..663fc1e 100644
--- a/backend/internal/pipeline/render.go
+++ b/backend/internal/pipeline/render.go
@@ -43,7 +43,11 @@ import (
// now normalized once in NormalizeSource (#3), the ASCII sentence boundary honors
// quote-depth (#4), and
inside no longer leaks a newline into the body
// (#5) — all segmentation/ingest behaviour changes → a loud --resnapshot.
-const chunkerVersion = "chunker-v4-utf8-quotedepth"
+// Bumped v4→v5 (WS2): the packing budget moved from input-token heuristic (const 1500) to OUTPUT
+// (ru) tokens via fertility (est_out), draft chunks decoupled from coarse EDIT units (grouped whole
+// chunks to EditCeilingOut), oversized-sentence flag added. The budget itself is ALSO folded via
+// segmentationSnap (snapshot.go), so this version covers only the algorithm shape.
+const chunkerVersion = "chunker-v5-output-budget-editunit"
// estimatorVersion versions EstimateTokens: его выход входит в max_tokens и
// через него в request-hash, поэтому перекалибровка весов — тоже явная
diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go
index e94019b..b62ea9a 100644
--- a/backend/internal/pipeline/runner.go
+++ b/backend/internal/pipeline/runner.go
@@ -2,6 +2,7 @@ package pipeline
import (
"errors"
+ "fmt"
"log/slog"
"os"
@@ -43,6 +44,9 @@ type Runner struct {
clients map[string]llm.LLMClient
templates map[string]*PromptTemplate
+ // rateGuards is the per-model wave-concurrency guard set (WS1 §1б), built once in W0 and
+ // read-only in the waves — a transport axis, never snapshot-folded. nil until buildRateGuards.
+ rateGuards map[string]*rateGuard
// memory is the book's glossary FROZEN for this job (materialized once in
// TranslateBook, after seeding, before snapshotID). Its Version() is the F1
@@ -168,14 +172,63 @@ func (r *Runner) loadTemplates() error {
return nil
}
+// segBudget resolves the pipeline's config.Segmentation into the pipeline-package SegBudget the
+// chunker consumes (WS2). Kept in the runner (not chunker.go) so the segmenter stays config-free.
+func (r *Runner) segBudget() SegBudget {
+ seg := r.Pipeline.Segmentation
+ return SegBudget{
+ DraftBudgetOut: float64(seg.DraftBudgetOut),
+ EditCeilingOut: float64(seg.EditCeilingOut),
+ FertCJK: seg.Fertility.CJK,
+ FertOther: seg.Fertility.Other,
+ }
+}
+
+// reachableModels enumerates every model a run can call: the union of the stage models and their
+// single-hop escalate_to fallbacks (stagerun.go calls r.client ONLY with model∈{st.Model, EscalateTo}).
+// This set is complete and static for a book, which is what lets W0 pre-build every client (a third
+// model axis — channel B / annotator — must extend this to stay race-free). Deterministic order.
+func (r *Runner) reachableModels() []string {
+ seen := map[string]bool{}
+ var out []string
+ add := func(m string) {
+ if m != "" && !seen[m] {
+ seen[m] = true
+ out = append(out, m)
+ }
+ }
+ for _, st := range r.Pipeline.Stages {
+ add(st.Model)
+ add(st.EscalateTo)
+ }
+ return out
+}
+
+// buildClients EAGER-constructs every reachable LLM client BEFORE any wave goroutine starts (W0).
+// After this the clients map is READ-ONLY in the waves, so r.client is lock-free and a miss is a
+// loud error, not a lazy build under a data race — closing the runner.go lazy-init race (D12) and
+// tripwiring a future un-enumerated model axis. Idempotent; BuildClient needs only the provider
+// config (not the API key), so eager-build is safe even on a key-less resume.
+func (r *Runner) buildClients() error {
+ for _, m := range r.reachableModels() {
+ if _, ok := r.clients[m]; ok {
+ continue
+ }
+ c, err := BuildClient(r.Models, m, r.Log)
+ if err != nil {
+ return fmt.Errorf("pipeline: eager-build client for model %q (W0): %w", m, err)
+ }
+ r.clients[m] = c
+ }
+ return nil
+}
+
+// client returns the PRE-BUILT client for a model. Read-only (no lazy build, no lock): buildClients
+// constructed every reachable client in W0, so the map is only read in the waves; a miss means an
+// un-enumerated model reached the wire and is a loud error, never a silent lazy build under a race.
func (r *Runner) client(model string) (llm.LLMClient, error) {
if c, ok := r.clients[model]; ok {
return c, nil
}
- c, err := BuildClient(r.Models, model, r.Log)
- if err != nil {
- return nil, err
- }
- r.clients[model] = c
- return c, nil
+ return nil, fmt.Errorf("pipeline: no pre-built client for model %q — W0 buildClients enumerates stage models ∪ escalate_to; a model outside that set reached the wire, add it to the eager set to keep the waves race-free", model)
}
diff --git a/backend/internal/pipeline/runner_memory_test.go b/backend/internal/pipeline/runner_memory_test.go
index 90b52fb..5d3fdd1 100644
--- a/backend/internal/pipeline/runner_memory_test.go
+++ b/backend/internal/pipeline/runner_memory_test.go
@@ -89,9 +89,9 @@ func TestRunnerMemoryInjectionAndPostcheck(t *testing.T) {
if strings.Contains(msgs[2].Content, "ГЛОССАРИЙ") {
t.Errorf("glossary leaked into the user (ch.Text) message: %q", msgs[2].Content)
}
- // D1: the monolingual EDITOR now gets the CONFIRMED dst forms as target constraints — its
- // OWN dst-constraint block (distinct header), NOT the translator's "src → dst" ГЛОССАРИЙ,
- // and never the source key inside the injection.
+ // D30.1/WS2 §2в: the BILINGUAL editor gets the CONFIRMED canon as a src→dst MAPPING under its
+ // OWN header (КАНОНИЧЕСКИЕ), binding each canonical form to its source term (homonym-safe) — NOT
+ // the translator's ГЛОССАРИЙ header, and never an AMBIGUOUS row.
editorSaw := false
for _, b := range rec.all() {
if !isEditBody(b) {
@@ -101,11 +101,11 @@ func TestRunnerMemoryInjectionAndPostcheck(t *testing.T) {
if len(em) != 3 || em[1].Role != "system" {
t.Fatalf("editor must have 3 messages (system, dst-constraints, user), got %d: %+v", len(em), em)
}
- if !strings.Contains(em[1].Content, "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ") || !strings.Contains(em[1].Content, "Судзуки") {
- t.Errorf("editor dst-constraint block missing/wrong: %q", em[1].Content)
+ if !strings.Contains(em[1].Content, "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ") || !strings.Contains(em[1].Content, "鈴木 → «Судзуки»") {
+ t.Errorf("editor src→dst constraint block missing/wrong: %q", em[1].Content)
}
- if strings.Contains(em[1].Content, "ГЛОССАРИЙ") || strings.Contains(em[1].Content, "→") || strings.Contains(em[1].Content, "鈴木") {
- t.Errorf("editor block must be dst-only (no ГЛОССАРИЙ header / arrow / source): %q", em[1].Content)
+ if strings.Contains(em[1].Content, "ГЛОССАРИЙ") {
+ t.Errorf("editor block must use its own КАНОНИЧЕСКИЕ header, not the translator ГЛОССАРИЙ: %q", em[1].Content)
}
editorSaw = true
}
diff --git a/backend/internal/pipeline/snapshot.go b/backend/internal/pipeline/snapshot.go
index fb30375..3102202 100644
--- a/backend/internal/pipeline/snapshot.go
+++ b/backend/internal/pipeline/snapshot.go
@@ -11,15 +11,36 @@ import (
// громкий --resnapshot (= переоплата книги, D15), никогда тихий false-hit.
// contextSnap freezes the context-assembly knobs (§3.8) inside the snapshot.
-// Fixed field order — it is part of a content hash.
+// Fixed field order — it is part of a content hash. WS2 dropped STMDepth/OverlapTokens
+// (dead carryover knobs — §2а), a structural change folded loudly (§8 manifest).
type contextSnap struct {
GlossaryInjection string `json:"glossary_injection"`
GlossaryTokenBudget int `json:"glossary_token_budget"`
- STMDepth int `json:"stm_depth"`
- OverlapTokens int `json:"overlap_tokens"`
CacheTTL string `json:"cache_ttl"`
}
+// segmentationSnap freezes the WS2 output-token chunking budget (draft/edit ceilings + per-pair
+// fertility) inside the snapshot, mirroring coverageSnap's discipline: a budget or fertility edit
+// re-chunks the book (changes chunk boundaries → the wire), so it is a loud --resnapshot (D30.9),
+// never a silent divergence. Unlike coverageSnap it is ALWAYS folded — segmentation always runs and
+// its values are wire-determining. Fixed field order — part of a content hash.
+type segmentationSnap struct {
+ DraftBudgetOut int `json:"draft_budget_out"`
+ EditCeilingOut int `json:"edit_ceiling_out"`
+ FertilityCJK float64 `json:"fertility_cjk"`
+ FertilityOther float64 `json:"fertility_other"`
+}
+
+func (r *Runner) segmentationSnapshot() segmentationSnap {
+ seg := r.Pipeline.Segmentation
+ return segmentationSnap{
+ DraftBudgetOut: seg.DraftBudgetOut,
+ EditCeilingOut: seg.EditCeilingOut,
+ FertilityCJK: seg.Fertility.CJK,
+ FertilityOther: seg.Fertility.Other,
+ }
+}
+
// coverageSnap freezes the excision coverage-gate config inside the snapshot, so
// enabling the gate OR tuning its thresholds is a loud --resnapshot, never a silent
// mismatch between an old checkpoint's stored disposition and a changed gate. HONEST
@@ -182,6 +203,14 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
// ваем ДО инъекции, чтобы смена budget'а инъекции не промахнулась мимо
// resnapshot-гейта (D5.2). Фикс-порядок полей — это content-hash.
ContextAssembly contextSnap `json:"context_assembly"`
+ // Segmentation — the WS2 output-token chunk/edit-unit budget + fertility (segmentationSnap).
+ // It changes chunk boundaries → the wire, so a budget/fertility edit is a loud --resnapshot.
+ Segmentation segmentationSnap `json:"segmentation"`
+ // RenderFormatVersion versions the FORMAT of the role-injection renderers not captured by
+ // memoryMatchVersion (a scope/matcher-algorithm version): the editor constraint block's
+ // src→dst layout (WS2 §2в) and the gender-constraint render (WS5). A format change shifts the
+ // injected bytes → the wire, so folding it separately keeps a flip a loud --resnapshot.
+ RenderFormatVersion string `json:"render_format_version"`
// MemoryVersion — content-hash детерминированно материализованной инъекти-
// руемой памяти (approved-глоссарий+резюме+series-bible). Не bump-счётчик
// (D8): забыть пересчитать нельзя, а забытый bump переоткрыл бы класс тихой
@@ -222,15 +251,15 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
ContextAssembly: contextSnap{
GlossaryInjection: r.Pipeline.Context.GlossaryInjection,
GlossaryTokenBudget: r.Pipeline.Context.GlossaryTokenBudget,
- STMDepth: r.Pipeline.Context.STMDepth,
- OverlapTokens: r.Pipeline.Context.OverlapTokens,
CacheTTL: r.Pipeline.Context.CacheTTL,
},
- MemoryVersion: r.memoryVersion(),
- PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
- Coverage: r.coverageSnapshot(),
- StyleCheckVersion: cheapGateVersion,
- Sanitizer: r.sanitizerSnapshot(),
+ Segmentation: r.segmentationSnapshot(),
+ RenderFormatVersion: renderFormatVersion,
+ MemoryVersion: r.memoryVersion(),
+ PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate,
+ Coverage: r.coverageSnapshot(),
+ StyleCheckVersion: cheapGateVersion,
+ Sanitizer: r.sanitizerSnapshot(),
}
for _, st := range r.Pipeline.Stages {
ss := stageSnap{
diff --git a/backend/internal/pipeline/stagerun.go b/backend/internal/pipeline/stagerun.go
index ddd5a9a..a2b932b 100644
--- a/backend/internal/pipeline/stagerun.go
+++ b/backend/internal/pipeline/stagerun.go
@@ -352,6 +352,18 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
}
att.freshCall = true
+ // Per-model rate-guard (WS1 §1б): a wave-concurrency cap (+ optional pacing) around the ONE
+ // expensive provider call, so a rate-limited model (mistral) does not 429-storm under N-parallel
+ // waves. A no-op for an unconfigured model / the sequential path. Acquire respects ctx; on cancel
+ // the reservation is released and the call surfaces as an infra failure (like a dead client).
+ relGuard, err := r.rateGuard(model).acquire(ctx)
+ if err != nil {
+ r.releaseReservation(ctx, resv)
+ r.setJobStatus(ctx, job.ID, "failed")
+ return att, fmt.Errorf("pipeline: rate-guard acquire for %s/ch%d/chunk%d/%s (model %s): %w", r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, model, err)
+ }
+ defer relGuard()
+
// «Вызов в полёте» обязан быть виден: между этой строкой и «attempt completed»
// могут пройти минуты (attempt_timeout × ретраи транспорта), и без неё «висит
// провайдер» неотличимо от «умер процесс» — главная боль smoke-прогона №4.
diff --git a/backend/internal/pipeline/status.go b/backend/internal/pipeline/status.go
index d3d5d54..88895a8 100644
--- a/backend/internal/pipeline/status.go
+++ b/backend/internal/pipeline/status.go
@@ -141,7 +141,7 @@ func (r *Runner) bookChunks() ([]Chunk, error) {
if err != nil {
return nil, err
}
- return SplitChunks(doc.Chapters), nil
+ return SplitChunks(doc.Chapters, r.segBudget()), nil
}
// chunkKey identifies a chunk positionally.
diff --git a/backend/internal/pipeline/testdata/golden/capture.golden b/backend/internal/pipeline/testdata/golden/capture.golden
index fd1919c..5d0beeb 100644
--- a/backend/internal/pipeline/testdata/golden/capture.golden
+++ b/backend/internal/pipeline/testdata/golden/capture.golden
@@ -1,25 +1,25 @@
==== run 1 (fresh) ====
-snapshot_id: 325d971e2b6ec79a129891f969d16a3ddea1410f345f52d9d47e3f50c70726b2
-snapshot_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v4-utf8-quotedepth","estimator_version":"estimator-v0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"stm_depth":0,"overlap_tokens":0,"cache_ttl":""},"memory_version":"636d2a6993285e433ce25bad2f5da01c1bcc19a4682dc9fe1efb15e5feb92f53","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v2","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}},{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]}
+snapshot_id: 36dc3139d83a84b4b1aca5bb1b24287831aa2c4944192b7756b5c4df2f52fd8c
+snapshot_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v5-output-budget-editunit","estimator_version":"estimator-v0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v1-editor-src2dst","memory_version":"636d2a6993285e433ce25bad2f5da01c1bcc19a4682dc9fe1efb15e5feb92f53","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v2","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}},{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]}
brief_hash: c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df
memory_version: 636d2a6993285e433ce25bad2f5da01c1bcc19a4682dc9fe1efb15e5feb92f53
-- book result --
chunks=9 flagged=4 exit=2 total_usd=0.041859999999999994
-chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД cf9f848abc2b. Судзуки шёл по коридорам Академии магии." cost=0.00364
+chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 745fead48c9f. Судзуки шёл по коридорам Академии магии." cost=0.00364
stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="ЧЕРНОВИК ПЕРЕВОДА a62fff0a94c2. Судзуки шёл по коридорам Академии магии." recovered=""
stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
- stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД cf9f848abc2b. Судзуки шёл по коридорам Академии магии." recovered=""
-chunk ch1/1 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d672ee6a6f49. Судзуки шёл по коридорам Академии магии." cost=0.00364
+ stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 745fead48c9f. Судзуки шёл по коридорам Академии магии." recovered=""
+chunk ch1/1 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 560db1759bc2. Судзуки шёл по коридорам Академии магии." cost=0.00364
stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="ЧЕРНОВИК ПЕРЕВОДА f543c12a3d62. Судзуки шёл по коридорам Академии магии." recovered=""
stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
- stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d672ee6a6f49. Судзуки шёл по коридорам Академии магии." recovered=""
-chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 138fd5c384e3. Судзуки шёл по коридорам Академии магии." cost=0.0091
+ stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 560db1759bc2. Судзуки шёл по коридорам Академии магии." recovered=""
+chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 1ed88001fc0d. Судзуки шёл по коридорам Академии магии." cost=0.0091
stage=draft role=translator model=fake-fallback resume=false disp=ok flag="" attempts=1 escalated=true esc_model="fake-fallback" finish="stop" cum_usd=0.00728 detail=""
stage_text="ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 0ab6e1368c17. Судзуки вынес Драконью печать из Академии магии." recovered=""
stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
- stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 138fd5c384e3. Судзуки шёл по коридорам Академии магии." recovered=""
+ stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 1ed88001fc0d. Судзуки шёл по коридорам Академии магии." recovered=""
chunk ch3/0 disposition=flagged flag="hard_refusal" final_text="" cost=0.00728
stage=draft role=translator model=fake-model resume=false disp=flagged flag="hard_refusal" attempts=1 escalated=true esc_model="fake-fallback" finish="refusal" cum_usd=0.00728 detail="provider finish_reason=refusal"
stage_text="" recovered=""
@@ -30,11 +30,11 @@ chunk ch4/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫ
stage_text="ЧЕРНОВИК ПЕРЕВОДА 2eee3ef795d7. Судзуки шёл по коридорам Академии магии." recovered=""
stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 035dad4c3958. Судзуки шёл по коридорам Академии магии." recovered=""
-chunk ch5/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 26a647c94a75. Судзуки увидел, как в тени ждал старик." cost=0.00364
+chunk ch5/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД b086edefe69c. Судзуки увидел, как в тени ждал старик." cost=0.00364
stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="ЧЕРНОВИК ПЕРЕВОДА bb921e6e678b. СТАРИК-СЦЕНА Судзуки увидел старика." recovered=""
stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
- stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 26a647c94a75. Судзуки увидел, как в тени ждал старик." recovered=""
+ stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД b086edefe69c. Судзуки увидел, как в тени ждал старик." recovered=""
chunk ch6/0 disposition=flagged flag="sanitizer_defect" final_text="" cost=0.00364
stage=draft role=translator model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь." recovered=""
@@ -51,44 +51,44 @@ chunk ch8/0 disposition=flagged flag="sanitizer_stripped" final_text="Судзу
stage=edit role=editor model=fake-model resume=false disp=flagged flag="sanitizer_stripped" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="CJK-утечка в ru-выходе: 特产"
stage_text="" recovered="Судзуки нашёл древний камень на каменном алтаре."
-- chunk_status --
-ch1/0 draft snap_match=true content_hash=331c6f32c7f3ed15447e2639a2621d24a180e561a2aee92115324f492db5de3e disp=ok flag="" attempts=1 final_hash=f2a775cd8f27741efb197298fcdf33cc5efa575dd91a490aa5519a15151f84d7 cost=0.00182 escalated=false esc_model="" detail=""
-ch1/0 edit snap_match=true content_hash=a559165b4d1fc3f9e75472ca654f4d039ae1e0cad1541c043e7aa68fbea5415a disp=ok flag="" attempts=1 final_hash=7d051e500c543f54a80dc0d36c8b03aac32225a51e9a015cc94ac03491629fb7 cost=0.00182 escalated=false esc_model="" detail=""
-ch1/1 draft snap_match=true content_hash=12fe3ccdf062f43e011363a33ff85c9b6842a8f23d790991fe9afab51a8becc8 disp=ok flag="" attempts=1 final_hash=b27adec57257108c0c14c7a8e1f4b05c1dc66bf616a8a72d85dc7140bdcd8d81 cost=0.00182 escalated=false esc_model="" detail=""
-ch1/1 edit snap_match=true content_hash=b11a1740e5af98af20e207672555da8d411311e6c1e26595b456b656e8de59ef disp=ok flag="" attempts=1 final_hash=c625e57b0778843942c78c5a8de5168e20857c9851e872b9a6e924edcbdc7059 cost=0.00182 escalated=false esc_model="" detail=""
-ch2/0 draft snap_match=true content_hash=2693325bc747687acf090bc263fe8c17783726b949c6d8a2d072da3401be6827 disp=ok flag="" attempts=1 final_hash=cf1b1146260ceeccc2ee7c87c9e828c8332af1a93193f6fd4939c58ee6b9b0f2 cost=0.00728 escalated=true esc_model="fake-fallback" detail=""
-ch2/0 edit snap_match=true content_hash=b467337f05775296985a6d79454b518302812edb57d398001b8852d0663c5bbb disp=ok flag="" attempts=1 final_hash=13ab5112ef1bf90ca6371bb81c73eb86fb31ed55c54bd60954ea092b354891b7 cost=0.00182 escalated=false esc_model="" detail=""
+ch1/0 draft snap_match=true content_hash=331c6f32c7f3ed15447e2639a2621d24a180e561a2aee92115324f492db5de3e disp=ok flag="" attempts=1 final_hash=2f3ac0b87516a3843e9ecb3e58d732fad5aa327a134d5222661ad87e5fc189b3 cost=0.00182 escalated=false esc_model="" detail=""
+ch1/0 edit snap_match=true content_hash=32e9974cbc5a1df1384a9f79b74d930044996e300e987c61188c57e2f1cca8d6 disp=ok flag="" attempts=1 final_hash=8013609b4fa7e48edf7c2e2e4cc78f57ad39e28bb417ab184ecc9da5b1ad70a1 cost=0.00182 escalated=false esc_model="" detail=""
+ch1/1 draft snap_match=true content_hash=12fe3ccdf062f43e011363a33ff85c9b6842a8f23d790991fe9afab51a8becc8 disp=ok flag="" attempts=1 final_hash=162d61c7ea223237705da4325094eeaaee2ef8b9d5a20d9876c523a7476779cd cost=0.00182 escalated=false esc_model="" detail=""
+ch1/1 edit snap_match=true content_hash=64d62818b100f43f8c5230a3ee1b32f62189eab97e5cddd31e0710b357857cda disp=ok flag="" attempts=1 final_hash=d0cdedefb8f492af8a7a3a840af6afb7ef996973f40d15c0c67e0adb5ec36aa2 cost=0.00182 escalated=false esc_model="" detail=""
+ch2/0 draft snap_match=true content_hash=2693325bc747687acf090bc263fe8c17783726b949c6d8a2d072da3401be6827 disp=ok flag="" attempts=1 final_hash=24a9cfda087e0cb0732b34b6b6d53fc4d0c70de5a73ffe6f9087402da1b14898 cost=0.00728 escalated=true esc_model="fake-fallback" detail=""
+ch2/0 edit snap_match=true content_hash=8e1d544574e1709c45377cf8d781056ddf1833cf90cb738543689f4a887d2795 disp=ok flag="" attempts=1 final_hash=68473338f2542411f864f34e52e7712ccc1bc8d9a6164c939c3a3b103ca206e4 cost=0.00182 escalated=false esc_model="" detail=""
ch3/0 draft snap_match=true content_hash=733fc6a89215efe112cfadd1d1fb361bb8a7ab1013994248a49da0dbd137548e disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="fake-fallback" detail="provider finish_reason=refusal"
ch3/0 edit snap_match=true content_hash= disp=skipped flag="hard_refusal" attempts=0 final_hash= cost=0 escalated=false esc_model="" detail="skipped: an upstream stage was flagged (hard_refusal)"
-ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=3fedb549effcc911491a22a2db4a07e8385d329fe5b3f17c65c4bed8a72b8a3c cost=0.00182 escalated=false esc_model="" detail=""
-ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=d74baa087896d28bbd5588f9d85a22261255551db74c4921fdb7983ba2fc9f70 cost=0.00182 escalated=false esc_model="" detail=""
-ch5/0 draft snap_match=true content_hash=d8aba42cabb41853a81875ebc725bb516dc2412e29de3a03bc1a68e90f8cf157 disp=ok flag="" attempts=1 final_hash=839873c92b11274628cfb0d8f03384df6549e4bd5529fcdef6a5c5ea46beaed9 cost=0.00182 escalated=false esc_model="" detail=""
-ch5/0 edit snap_match=true content_hash=99ebb10d754a2f3ae3a00d62734ad1c39160f083350217eda5bda6e91373f4de disp=ok flag="" attempts=1 final_hash=22214c6cc4a4b04fc48d6a8a0e1ff94a7a9745df3b16e6d4ea8dbde4e4b7b308 cost=0.00182 escalated=false esc_model="" detail=""
-ch6/0 draft snap_match=true content_hash=9375b05780724cc354eba15a595da9a5f964219c0887042484256eed11fb566f disp=ok flag="" attempts=1 final_hash=88cb389b6ed249076c333a88c3b76a33661d2976ade55bd5a81cd6014b1e4604 cost=0.00182 escalated=false esc_model="" detail=""
-ch6/0 edit snap_match=true content_hash=f9a70ce8a43db4a89eda5e53a6f29ba5f0c2a35559a9d35b73f771220bb262ee disp=flagged flag="sanitizer_defect" attempts=1 final_hash= cost=0.00182 escalated=false esc_model="" detail="ведущая служебная преамбула: Вот перевод фрагмента:"
-ch7/0 draft snap_match=true content_hash=d90c335cba3d1547a22bb3f656443cb432f84c12a577d43509cfe472cf86a2ac disp=ok flag="" attempts=1 final_hash=23ccf5b0150e650e923d06c6e92f633665ea36ee75089e8947dff26748be2c7a cost=0.00182 escalated=false esc_model="" detail=""
-ch7/0 edit snap_match=true content_hash=f003d8ad25546238e63b98d28a08f942c8e3cf57251df694de81bea293a6dd26 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:e1f2765b9f6d3cc4c461471cd7c143476614c602b6b08843bd239c77beb3b102 cost=0.00182 escalated=false esc_model="" detail="markdown-заголовок в выходе: ### Глава 7"
-ch8/0 draft snap_match=true content_hash=fbd27af11da2275cd1d47a0224970a5f8d073c6164f614efb6522ea96fa1e021 disp=ok flag="" attempts=1 final_hash=cc5dbfbcdcd3070e6471266fc9b6ce3d5abf88b8c75f3bb619522f9146cc6e31 cost=0.00182 escalated=false esc_model="" detail=""
-ch8/0 edit snap_match=true content_hash=40c2dd5b7b713800e8453930779bf2c88e2a296056f594bc1a4f5c495956915a disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:067cfd0a649bfcd3db7fd04e8beff2a610dba06e292664e0160f06d16eef5209 cost=0.00182 escalated=false esc_model="" detail="CJK-утечка в ru-выходе: 特产"
+ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=e68690f3abc3ece2b5202cc10bbfc0a8dce2b79e69c705a73a33bbe801653b38 cost=0.00182 escalated=false esc_model="" detail=""
+ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=2f8e3bcc66fa095ee443a6172193cf488140d53256e4f7514f1aca1d63b81395 cost=0.00182 escalated=false esc_model="" detail=""
+ch5/0 draft snap_match=true content_hash=d8aba42cabb41853a81875ebc725bb516dc2412e29de3a03bc1a68e90f8cf157 disp=ok flag="" attempts=1 final_hash=a02083ed96d74f0ebf589ef4d27ab6109152baae9ffc437b1477aad4d5aa2d75 cost=0.00182 escalated=false esc_model="" detail=""
+ch5/0 edit snap_match=true content_hash=10f63e759c9c0cb78b3c2ff51523308b3f9c6c49b5755c27c59c7c91546412d9 disp=ok flag="" attempts=1 final_hash=99d93354bd65f17d859ac749a19befc9c8d2c5cc82f858b6b484434790001603 cost=0.00182 escalated=false esc_model="" detail=""
+ch6/0 draft snap_match=true content_hash=9375b05780724cc354eba15a595da9a5f964219c0887042484256eed11fb566f disp=ok flag="" attempts=1 final_hash=abec9d62bee829a2dd5af959a3fccd72fd6ebd968f265389b706854f3432dab1 cost=0.00182 escalated=false esc_model="" detail=""
+ch6/0 edit snap_match=true content_hash=457fd53dd68c11665ea5d2ec489f966dd80e0232db5f10880cd13ebf865ef972 disp=flagged flag="sanitizer_defect" attempts=1 final_hash= cost=0.00182 escalated=false esc_model="" detail="ведущая служебная преамбула: Вот перевод фрагмента:"
+ch7/0 draft snap_match=true content_hash=d90c335cba3d1547a22bb3f656443cb432f84c12a577d43509cfe472cf86a2ac disp=ok flag="" attempts=1 final_hash=2189c766693381a223938e3c8036b2c9bba19dc16e93d1a26671a5a397d577c0 cost=0.00182 escalated=false esc_model="" detail=""
+ch7/0 edit snap_match=true content_hash=fe693bebbf5cfce3ff75d2756c511f4bd2a037256908ec4d758d9a6272535b5f disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:8ef72b20eac059984209728d34e0ef742b2fab2a8aef57a37dd5d5cf55545f4f cost=0.00182 escalated=false esc_model="" detail="markdown-заголовок в выходе: ### Глава 7"
+ch8/0 draft snap_match=true content_hash=fbd27af11da2275cd1d47a0224970a5f8d073c6164f614efb6522ea96fa1e021 disp=ok flag="" attempts=1 final_hash=59118bacb3968e766e4c75d2ed770bc2a08d81231e4a01cf04e7c8fe7a722621 cost=0.00182 escalated=false esc_model="" detail=""
+ch8/0 edit snap_match=true content_hash=d88a05fd553703e758d69278748152959d2c4b08d969d1099f1fce0d48476c1d disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:faf1ca5e8f561d183dad262f013b9820425201cdd0ac5682c54d20176f1f59d8 cost=0.00182 escalated=false esc_model="" detail="CJK-утечка в ru-выходе: 特产"
-- request_log (insertion order) --
-ch1/0 draft role=translator req=fake-model actual=fake-model hash=f2a775cd8f27741efb197298fcdf33cc5efa575dd91a490aa5519a15151f84d7 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch1/0 edit role=editor req=fake-model actual=fake-model hash=7d051e500c543f54a80dc0d36c8b03aac32225a51e9a015cc94ac03491629fb7 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch1/1 draft role=translator req=fake-model actual=fake-model hash=b27adec57257108c0c14c7a8e1f4b05c1dc66bf616a8a72d85dc7140bdcd8d81 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch1/1 edit role=editor req=fake-model actual=fake-model hash=c625e57b0778843942c78c5a8de5168e20857c9851e872b9a6e924edcbdc7059 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch2/0 draft role=translator req=fake-model actual=fake-model hash=5f4763e07c67f541958817ce6e7a65c8277e1d806558d3e218e92362ba858866 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=cf1b1146260ceeccc2ee7c87c9e828c8332af1a93193f6fd4939c58ee6b9b0f2 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err=""
-ch2/0 edit role=editor req=fake-model actual=fake-model hash=13ab5112ef1bf90ca6371bb81c73eb86fb31ed55c54bd60954ea092b354891b7 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch3/0 draft role=translator req=fake-model actual=fake-model hash=be200a513ccbfbb410ffc7788967610e2323aeda610c4d25296c8f06ddf3aead tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=4e5ad1c52475c43b5f98f0a369187a11367e4c739b67d5d9269b9949b718de33 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err=""
-ch4/0 draft role=translator req=fake-model actual=fake-model hash=3fedb549effcc911491a22a2db4a07e8385d329fe5b3f17c65c4bed8a72b8a3c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch4/0 edit role=editor req=fake-model actual=fake-model hash=d74baa087896d28bbd5588f9d85a22261255551db74c4921fdb7983ba2fc9f70 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch5/0 draft role=translator req=fake-model actual=fake-model hash=839873c92b11274628cfb0d8f03384df6549e4bd5529fcdef6a5c5ea46beaed9 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch5/0 edit role=editor req=fake-model actual=fake-model hash=22214c6cc4a4b04fc48d6a8a0e1ff94a7a9745df3b16e6d4ea8dbde4e4b7b308 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch6/0 draft role=translator req=fake-model actual=fake-model hash=88cb389b6ed249076c333a88c3b76a33661d2976ade55bd5a81cd6014b1e4604 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch6/0 edit role=editor req=fake-model actual=fake-model hash=ae7cb45838ddec818f5e6c8f71410db7f00365dc8d1a41ec1a94af1d490e2178 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch7/0 draft role=translator req=fake-model actual=fake-model hash=23ccf5b0150e650e923d06c6e92f633665ea36ee75089e8947dff26748be2c7a tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch7/0 edit role=editor req=fake-model actual=fake-model hash=9394388fc09349b7e4014e12ae991f041c5912307964d83ae165a8c99a3aac2e tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch8/0 draft role=translator req=fake-model actual=fake-model hash=cc5dbfbcdcd3070e6471266fc9b6ce3d5abf88b8c75f3bb619522f9146cc6e31 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch8/0 edit role=editor req=fake-model actual=fake-model hash=fd0a61bd15ec394e35f291a3f114469a0fca591b3bc529ef8bd79686fe3bef93 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch1/0 draft role=translator req=fake-model actual=fake-model hash=2f3ac0b87516a3843e9ecb3e58d732fad5aa327a134d5222661ad87e5fc189b3 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch1/0 edit role=editor req=fake-model actual=fake-model hash=8013609b4fa7e48edf7c2e2e4cc78f57ad39e28bb417ab184ecc9da5b1ad70a1 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch1/1 draft role=translator req=fake-model actual=fake-model hash=162d61c7ea223237705da4325094eeaaee2ef8b9d5a20d9876c523a7476779cd tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch1/1 edit role=editor req=fake-model actual=fake-model hash=d0cdedefb8f492af8a7a3a840af6afb7ef996973f40d15c0c67e0adb5ec36aa2 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch2/0 draft role=translator req=fake-model actual=fake-model hash=98d51f7b179f0e736e347038a5bc8c5d5673adc93632dbcbdd026d26d090130e tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=24a9cfda087e0cb0732b34b6b6d53fc4d0c70de5a73ffe6f9087402da1b14898 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err=""
+ch2/0 edit role=editor req=fake-model actual=fake-model hash=68473338f2542411f864f34e52e7712ccc1bc8d9a6164c939c3a3b103ca206e4 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch3/0 draft role=translator req=fake-model actual=fake-model hash=e6f9a3e8cd8a7d70444efc8ce55f961545e2c8f9c21b332bf62be6f7bac0187e tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=d437eebecbb9ea8b18b36096f4afc30d0a8da5d39071bc65777b2d3936b3cef7 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err=""
+ch4/0 draft role=translator req=fake-model actual=fake-model hash=e68690f3abc3ece2b5202cc10bbfc0a8dce2b79e69c705a73a33bbe801653b38 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch4/0 edit role=editor req=fake-model actual=fake-model hash=2f8e3bcc66fa095ee443a6172193cf488140d53256e4f7514f1aca1d63b81395 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch5/0 draft role=translator req=fake-model actual=fake-model hash=a02083ed96d74f0ebf589ef4d27ab6109152baae9ffc437b1477aad4d5aa2d75 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch5/0 edit role=editor req=fake-model actual=fake-model hash=99d93354bd65f17d859ac749a19befc9c8d2c5cc82f858b6b484434790001603 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch6/0 draft role=translator req=fake-model actual=fake-model hash=abec9d62bee829a2dd5af959a3fccd72fd6ebd968f265389b706854f3432dab1 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch6/0 edit role=editor req=fake-model actual=fake-model hash=ef0f4b20ad89a3e5eacb7736083a9bedf598bdb2b0e904ab4f5af751ca16ae43 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch7/0 draft role=translator req=fake-model actual=fake-model hash=2189c766693381a223938e3c8036b2c9bba19dc16e93d1a26671a5a397d577c0 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch7/0 edit role=editor req=fake-model actual=fake-model hash=d102ddcda67e7971cbfbcf4ef31cfb403ec525cbaba5d1faabacc0cdc7953971 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch8/0 draft role=translator req=fake-model actual=fake-model hash=59118bacb3968e766e4c75d2ed770bc2a08d81231e4a01cf04e7c8fe7a722621 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch8/0 edit role=editor req=fake-model actual=fake-model hash=3b705de15f457ced93f8649c995966183921fb25e8bfd4b1e37753ef267d3c47 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err=""
-- retrieval_state --
ch1/0 snap_match=true exact=3 sticky=0 ambiguous=0 spoiler=1 evicted=0 postcheck_miss=1 style_flags=0 trust_gated=0
injected_ids=["紋章\u001f\u001f0\u001f0","鈴木\u001f\u001f0\u001f0","魔法学院\u001f\u001f0\u001f0"] postcheck_detail=[{"src":"紋章","dst":"герб","disp":"confirmed"}] style_detail= trust_gate_detail=
@@ -110,46 +110,46 @@ ch8/0 snap_match=true exact=1 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck
injected_ids=["鈴木\u001f\u001f0\u001f0"] postcheck_detail= style_detail= trust_gate_detail=
-- wire bodies (call order) --
[0] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n紋章 → герб\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第一章 図書館の秘密\n\n一番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ一層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n二番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ二層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。\n\n三番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ三層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n四番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ四層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。\n\n五番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ五層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。\n\n六番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ六層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。扉の奥で彼が見たものは、台座に置かれた竜の紋章だった。それは禁じられた歴史の最後の欠片だと、後に彼は知ることになる。\n\n七番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ七層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。鈴木の指先に刻まれた印は、書庫に近づくたびに淡く光った。司書たちはその光を恐れ、誰も彼に声をかけなかった。"}],"model":"fake-model","stream":false,"temperature":0.3}
-[1] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «герб»\n- «Судзуки»\n- «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА a62fff0a94c2. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4}
+[1] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 紋章 → «герб»\n- 鈴木 → «Судзуки»\n- 魔法学院 → «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА a62fff0a94c2. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4}
[2] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки\n魔法学院 → Академия магии\n紋章 → герб"},{"role":"user","content":"八番目の朝、鈴木は魔法学院の長い廊下をゆっくりと歩きながら、昨夜の講義の内容を思い返していた。窓の外では銀色の雨が降り続き、石畳の中庭に小さな川を作っていた。図書館の塔は霧に包まれ、その頂は見えなかった。「今日こそ八層書庫の扉を開ける」と彼はつぶやき、胸元の古い鍵を握りしめた。鍵は冷たく、微かに震えているようだった。回廊の壁には歴代の院長の肖像が並び、その視線は生きているかのように彼の背中を追った。埃の匂いが記憶を刺激した。"}],"model":"fake-model","stream":false,"temperature":0.3}
-[3] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «Судзуки»\n- «Академия магии»\n- «герб»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА f543c12a3d62. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4}
+[3] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки»\n- 魔法学院 → «Академия магии»\n- 紋章 → «герб»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА f543c12a3d62. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4}
[4] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n竜の紋章 → Драконья печать\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第二章 響動計画\n\n夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。魔法学院の鐘が三度鳴り、計画の始まりを告げた。\n\n「響動計画は今夜動き出す」と老司書が言った。その声は震えていたが、目は確かな決意に満ちていた。"}],"model":"fake-model","stream":false,"temperature":0.3}
[5] {"max_tokens":6000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n竜の紋章 → Драконья печать\n鈴木 → Судзуки\n魔法学院 → Академия магии"},{"role":"user","content":"第二章 響動計画\n\n夜明け前、鈴木は再び書庫に戻り、竜の紋章を布に包んで持ち出した。魔法学院の鐘が三度鳴り、計画の始まりを告げた。\n\n「響動計画は今夜動き出す」と老司書が言った。その声は震えていたが、目は確かな決意に満ちていた。"}],"model":"fake-fallback","stream":false,"temperature":0.3}
-[6] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «Драконья печать»\n- «Судзуки»\n- «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 0ab6e1368c17. Судзуки вынес Драконью печать из Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4}
+[6] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 竜の紋章 → «Драконья печать»\n- 鈴木 → «Судзуки»\n- 魔法学院 → «Академия магии»"},{"role":"user","content":"Черновик перевода для редактуры: ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 0ab6e1368c17. Судзуки вынес Драконью печать из Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4}
[7] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第三章 拒絶計画\n\nその手紙には拒絶計画という言葉だけが記されていた。鈴木は封を閉じ、暖炉の火にかざした。\n\n炎は紙を包み、言葉は灰になった。しかし灰の中で、何かが微かに光り続けていた。"}],"model":"fake-model","stream":false,"temperature":0.3}
[8] {"max_tokens":6000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第三章 拒絶計画\n\nその手紙には拒絶計画という言葉だけが記されていた。鈴木は封を閉じ、暖炉の火にかざした。\n\n炎は紙を包み、言葉は灰になった。しかし灰の中で、何かが微かに光り続けていた。"}],"model":"fake-fallback","stream":false,"temperature":0.3}
[9] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"user","content":"第四章 静かな終わり\n\n夜が明けると、街は霧に包まれていた。人々は何も知らないまま、いつもの朝を迎えた。\n\n遠くの丘の上で、旅人がひとり、東の空を眺めていた。風は冷たく、道はまだ長い。"}],"model":"fake-model","stream":false,"temperature":0.3}
[10] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 2eee3ef795d7. Судзуки шёл по коридорам Академии магии."}],"model":"fake-model","stream":false,"temperature":0.4}
[11] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n老人 → старик\n鈴木 → Судзуки"},{"role":"user","content":"第五章 老人の秘密\n\n鈴木は書庫の奥で老人に会った。老人は静かに古い巻物を差し出した。"}],"model":"fake-model","stream":false,"temperature":0.3}
-[12] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «старик»\n- «Судзуки»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА bb921e6e678b. СТАРИК-СЦЕНА Судзуки увидел старика."}],"model":"fake-model","stream":false,"temperature":0.4}
+[12] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 老人 → «старик»\n- 鈴木 → «Судзуки»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА bb921e6e678b. СТАРИК-СЦЕНА Судзуки увидел старика."}],"model":"fake-model","stream":false,"temperature":0.4}
[13] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第六章 序文漏洩\n\n鈴木は最後の扉の前に立ち、深く息を吸い込んだ。その先に何があるのか、誰も知らなかった。"}],"model":"fake-model","stream":false,"temperature":0.3}
-[14] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «Судзуки»"},{"role":"user","content":"Черновик перевода для редактуры: Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь."}],"model":"fake-model","stream":false,"temperature":0.4}
+[14] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки»"},{"role":"user","content":"Черновик перевода для редактуры: Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь."}],"model":"fake-model","stream":false,"temperature":0.4}
[15] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第七章 見出漏洩\n\n七層目の朝、鈴木は最後の書庫の扉を開けた。扉の奥には見出しだけが刻まれた石板が静かに待っていた。誰もその文字を読めなかった。"}],"model":"fake-model","stream":false,"temperature":0.3}
-[16] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «Судзуки»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА ec12f3d7a1df. МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь."}],"model":"fake-model","stream":false,"temperature":0.4}
+[16] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА ec12f3d7a1df. МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь."}],"model":"fake-model","stream":false,"temperature":0.4}
[17] {"max_tokens":4000,"messages":[{"role":"system","content":"Переводи художественный текст с ja на ru. Жанр: ранобэ. Аудитория: взрослые. Баланс Венути: 0.50. Хонорифики: keep. Транскрипция: polivanov."},{"role":"system","content":"ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):\n鈴木 → Судзуки"},{"role":"user","content":"第八章 漢字漏洩\n\n鈴木は石板の文字をなぞり、古い漢字の意味を思い出そうとした。だが記憶は霧のように薄れ、指先だけが淡く光っていた。"}],"model":"fake-model","stream":false,"temperature":0.3}
-[18] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике эти сущности должны быть переданы ИМЕННО этими формами — приводи к ним любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- «Судзуки»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 0bc6a4817acb. ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень."}],"model":"fake-model","stream":false,"temperature":0.4}
+[18] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки»"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 0bc6a4817acb. ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень."}],"model":"fake-model","stream":false,"temperature":0.4}
==== run 2 (resume) ====
-snapshot_id: 325d971e2b6ec79a129891f969d16a3ddea1410f345f52d9d47e3f50c70726b2
-snapshot_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v4-utf8-quotedepth","estimator_version":"estimator-v0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"stm_depth":0,"overlap_tokens":0,"cache_ttl":""},"memory_version":"636d2a6993285e433ce25bad2f5da01c1bcc19a4682dc9fe1efb15e5feb92f53","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v2","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}},{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]}
+snapshot_id: 36dc3139d83a84b4b1aca5bb1b24287831aa2c4944192b7756b5c4df2f52fd8c
+snapshot_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v5-output-budget-editunit","estimator_version":"estimator-v0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v1-refusal+echo015+loop","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v1-editor-src2dst","memory_version":"636d2a6993285e433ce25bad2f5da01c1bcc19a4682dc9fe1efb15e5feb92f53","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v2","sanitizer":{"enabled":true,"version":"sanitizer-v6"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}},{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]}
brief_hash: c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df
memory_version: 636d2a6993285e433ce25bad2f5da01c1bcc19a4682dc9fe1efb15e5feb92f53
-- book result --
chunks=9 flagged=4 exit=2 total_usd=0
-chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД cf9f848abc2b. Судзуки шёл по коридорам Академии магии." cost=0
+chunk ch1/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 745fead48c9f. Судзуки шёл по коридорам Академии магии." cost=0
stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="ЧЕРНОВИК ПЕРЕВОДА a62fff0a94c2. Судзуки шёл по коридорам Академии магии." recovered=""
stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
- stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД cf9f848abc2b. Судзуки шёл по коридорам Академии магии." recovered=""
-chunk ch1/1 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d672ee6a6f49. Судзуки шёл по коридорам Академии магии." cost=0
+ stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 745fead48c9f. Судзуки шёл по коридорам Академии магии." recovered=""
+chunk ch1/1 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 560db1759bc2. Судзуки шёл по коридорам Академии магии." cost=0
stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="ЧЕРНОВИК ПЕРЕВОДА f543c12a3d62. Судзуки шёл по коридорам Академии магии." recovered=""
stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
- stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД d672ee6a6f49. Судзуки шёл по коридорам Академии магии." recovered=""
-chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 138fd5c384e3. Судзуки шёл по коридорам Академии магии." cost=0
+ stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 560db1759bc2. Судзуки шёл по коридорам Академии магии." recovered=""
+chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 1ed88001fc0d. Судзуки шёл по коридорам Академии магии." cost=0
stage=draft role=translator model=fake-fallback resume=true disp=ok flag="" attempts=1 escalated=true esc_model="fake-fallback" finish="stop" cum_usd=0.00728 detail=""
stage_text="ЭСКАЛАЦИОННЫЙ ПЕРЕВОД 0ab6e1368c17. Судзуки вынес Драконью печать из Академии магии." recovered=""
stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
- stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 138fd5c384e3. Судзуки шёл по коридорам Академии магии." recovered=""
+ stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 1ed88001fc0d. Судзуки шёл по коридорам Академии магии." recovered=""
chunk ch3/0 disposition=flagged flag="hard_refusal" final_text="" cost=0
stage=draft role=translator model=fake-model resume=true disp=flagged flag="hard_refusal" attempts=1 escalated=true esc_model="fake-fallback" finish="" cum_usd=0.00728 detail="provider finish_reason=refusal"
stage_text="" recovered=""
@@ -160,11 +160,11 @@ chunk ch4/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫ
stage_text="ЧЕРНОВИК ПЕРЕВОДА 2eee3ef795d7. Судзуки шёл по коридорам Академии магии." recovered=""
stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 035dad4c3958. Судзуки шёл по коридорам Академии магии." recovered=""
-chunk ch5/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 26a647c94a75. Судзуки увидел, как в тени ждал старик." cost=0
+chunk ch5/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД b086edefe69c. Судзуки увидел, как в тени ждал старик." cost=0
stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="ЧЕРНОВИК ПЕРЕВОДА bb921e6e678b. СТАРИК-СЦЕНА Судзуки увидел старика." recovered=""
stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
- stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД 26a647c94a75. Судзуки увидел, как в тени ждал старик." recovered=""
+ stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД b086edefe69c. Судзуки увидел, как в тени ждал старик." recovered=""
chunk ch6/0 disposition=flagged flag="sanitizer_defect" final_text="" cost=0
stage=draft role=translator model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="Вот перевод фрагмента: ПРЕАМБУЛА-СЦЕНА Судзуки открыл дверь." recovered=""
@@ -181,61 +181,61 @@ chunk ch8/0 disposition=flagged flag="sanitizer_stripped" final_text="Судзу
stage=edit role=editor model=fake-model resume=true disp=flagged flag="sanitizer_stripped" attempts=1 escalated=false esc_model="" finish="sanitized_export" cum_usd=0.00182 detail="CJK-утечка в ru-выходе: 特产"
stage_text="" recovered="Судзуки нашёл древний камень на каменном алтаре."
-- chunk_status --
-ch1/0 draft snap_match=true content_hash=331c6f32c7f3ed15447e2639a2621d24a180e561a2aee92115324f492db5de3e disp=ok flag="" attempts=1 final_hash=f2a775cd8f27741efb197298fcdf33cc5efa575dd91a490aa5519a15151f84d7 cost=0.00182 escalated=false esc_model="" detail=""
-ch1/0 edit snap_match=true content_hash=a559165b4d1fc3f9e75472ca654f4d039ae1e0cad1541c043e7aa68fbea5415a disp=ok flag="" attempts=1 final_hash=7d051e500c543f54a80dc0d36c8b03aac32225a51e9a015cc94ac03491629fb7 cost=0.00182 escalated=false esc_model="" detail=""
-ch1/1 draft snap_match=true content_hash=12fe3ccdf062f43e011363a33ff85c9b6842a8f23d790991fe9afab51a8becc8 disp=ok flag="" attempts=1 final_hash=b27adec57257108c0c14c7a8e1f4b05c1dc66bf616a8a72d85dc7140bdcd8d81 cost=0.00182 escalated=false esc_model="" detail=""
-ch1/1 edit snap_match=true content_hash=b11a1740e5af98af20e207672555da8d411311e6c1e26595b456b656e8de59ef disp=ok flag="" attempts=1 final_hash=c625e57b0778843942c78c5a8de5168e20857c9851e872b9a6e924edcbdc7059 cost=0.00182 escalated=false esc_model="" detail=""
-ch2/0 draft snap_match=true content_hash=2693325bc747687acf090bc263fe8c17783726b949c6d8a2d072da3401be6827 disp=ok flag="" attempts=1 final_hash=cf1b1146260ceeccc2ee7c87c9e828c8332af1a93193f6fd4939c58ee6b9b0f2 cost=0.00728 escalated=true esc_model="fake-fallback" detail=""
-ch2/0 edit snap_match=true content_hash=b467337f05775296985a6d79454b518302812edb57d398001b8852d0663c5bbb disp=ok flag="" attempts=1 final_hash=13ab5112ef1bf90ca6371bb81c73eb86fb31ed55c54bd60954ea092b354891b7 cost=0.00182 escalated=false esc_model="" detail=""
+ch1/0 draft snap_match=true content_hash=331c6f32c7f3ed15447e2639a2621d24a180e561a2aee92115324f492db5de3e disp=ok flag="" attempts=1 final_hash=2f3ac0b87516a3843e9ecb3e58d732fad5aa327a134d5222661ad87e5fc189b3 cost=0.00182 escalated=false esc_model="" detail=""
+ch1/0 edit snap_match=true content_hash=32e9974cbc5a1df1384a9f79b74d930044996e300e987c61188c57e2f1cca8d6 disp=ok flag="" attempts=1 final_hash=8013609b4fa7e48edf7c2e2e4cc78f57ad39e28bb417ab184ecc9da5b1ad70a1 cost=0.00182 escalated=false esc_model="" detail=""
+ch1/1 draft snap_match=true content_hash=12fe3ccdf062f43e011363a33ff85c9b6842a8f23d790991fe9afab51a8becc8 disp=ok flag="" attempts=1 final_hash=162d61c7ea223237705da4325094eeaaee2ef8b9d5a20d9876c523a7476779cd cost=0.00182 escalated=false esc_model="" detail=""
+ch1/1 edit snap_match=true content_hash=64d62818b100f43f8c5230a3ee1b32f62189eab97e5cddd31e0710b357857cda disp=ok flag="" attempts=1 final_hash=d0cdedefb8f492af8a7a3a840af6afb7ef996973f40d15c0c67e0adb5ec36aa2 cost=0.00182 escalated=false esc_model="" detail=""
+ch2/0 draft snap_match=true content_hash=2693325bc747687acf090bc263fe8c17783726b949c6d8a2d072da3401be6827 disp=ok flag="" attempts=1 final_hash=24a9cfda087e0cb0732b34b6b6d53fc4d0c70de5a73ffe6f9087402da1b14898 cost=0.00728 escalated=true esc_model="fake-fallback" detail=""
+ch2/0 edit snap_match=true content_hash=8e1d544574e1709c45377cf8d781056ddf1833cf90cb738543689f4a887d2795 disp=ok flag="" attempts=1 final_hash=68473338f2542411f864f34e52e7712ccc1bc8d9a6164c939c3a3b103ca206e4 cost=0.00182 escalated=false esc_model="" detail=""
ch3/0 draft snap_match=true content_hash=733fc6a89215efe112cfadd1d1fb361bb8a7ab1013994248a49da0dbd137548e disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="fake-fallback" detail="provider finish_reason=refusal"
ch3/0 edit snap_match=true content_hash= disp=skipped flag="hard_refusal" attempts=0 final_hash= cost=0 escalated=false esc_model="" detail="skipped: an upstream stage was flagged (hard_refusal)"
-ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=3fedb549effcc911491a22a2db4a07e8385d329fe5b3f17c65c4bed8a72b8a3c cost=0.00182 escalated=false esc_model="" detail=""
-ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=d74baa087896d28bbd5588f9d85a22261255551db74c4921fdb7983ba2fc9f70 cost=0.00182 escalated=false esc_model="" detail=""
-ch5/0 draft snap_match=true content_hash=d8aba42cabb41853a81875ebc725bb516dc2412e29de3a03bc1a68e90f8cf157 disp=ok flag="" attempts=1 final_hash=839873c92b11274628cfb0d8f03384df6549e4bd5529fcdef6a5c5ea46beaed9 cost=0.00182 escalated=false esc_model="" detail=""
-ch5/0 edit snap_match=true content_hash=99ebb10d754a2f3ae3a00d62734ad1c39160f083350217eda5bda6e91373f4de disp=ok flag="" attempts=1 final_hash=22214c6cc4a4b04fc48d6a8a0e1ff94a7a9745df3b16e6d4ea8dbde4e4b7b308 cost=0.00182 escalated=false esc_model="" detail=""
-ch6/0 draft snap_match=true content_hash=9375b05780724cc354eba15a595da9a5f964219c0887042484256eed11fb566f disp=ok flag="" attempts=1 final_hash=88cb389b6ed249076c333a88c3b76a33661d2976ade55bd5a81cd6014b1e4604 cost=0.00182 escalated=false esc_model="" detail=""
-ch6/0 edit snap_match=true content_hash=f9a70ce8a43db4a89eda5e53a6f29ba5f0c2a35559a9d35b73f771220bb262ee disp=flagged flag="sanitizer_defect" attempts=1 final_hash= cost=0.00182 escalated=false esc_model="" detail="ведущая служебная преамбула: Вот перевод фрагмента:"
-ch7/0 draft snap_match=true content_hash=d90c335cba3d1547a22bb3f656443cb432f84c12a577d43509cfe472cf86a2ac disp=ok flag="" attempts=1 final_hash=23ccf5b0150e650e923d06c6e92f633665ea36ee75089e8947dff26748be2c7a cost=0.00182 escalated=false esc_model="" detail=""
-ch7/0 edit snap_match=true content_hash=f003d8ad25546238e63b98d28a08f942c8e3cf57251df694de81bea293a6dd26 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:e1f2765b9f6d3cc4c461471cd7c143476614c602b6b08843bd239c77beb3b102 cost=0.00182 escalated=false esc_model="" detail="markdown-заголовок в выходе: ### Глава 7"
-ch8/0 draft snap_match=true content_hash=fbd27af11da2275cd1d47a0224970a5f8d073c6164f614efb6522ea96fa1e021 disp=ok flag="" attempts=1 final_hash=cc5dbfbcdcd3070e6471266fc9b6ce3d5abf88b8c75f3bb619522f9146cc6e31 cost=0.00182 escalated=false esc_model="" detail=""
-ch8/0 edit snap_match=true content_hash=40c2dd5b7b713800e8453930779bf2c88e2a296056f594bc1a4f5c495956915a disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:067cfd0a649bfcd3db7fd04e8beff2a610dba06e292664e0160f06d16eef5209 cost=0.00182 escalated=false esc_model="" detail="CJK-утечка в ru-выходе: 特产"
+ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=e68690f3abc3ece2b5202cc10bbfc0a8dce2b79e69c705a73a33bbe801653b38 cost=0.00182 escalated=false esc_model="" detail=""
+ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=2f8e3bcc66fa095ee443a6172193cf488140d53256e4f7514f1aca1d63b81395 cost=0.00182 escalated=false esc_model="" detail=""
+ch5/0 draft snap_match=true content_hash=d8aba42cabb41853a81875ebc725bb516dc2412e29de3a03bc1a68e90f8cf157 disp=ok flag="" attempts=1 final_hash=a02083ed96d74f0ebf589ef4d27ab6109152baae9ffc437b1477aad4d5aa2d75 cost=0.00182 escalated=false esc_model="" detail=""
+ch5/0 edit snap_match=true content_hash=10f63e759c9c0cb78b3c2ff51523308b3f9c6c49b5755c27c59c7c91546412d9 disp=ok flag="" attempts=1 final_hash=99d93354bd65f17d859ac749a19befc9c8d2c5cc82f858b6b484434790001603 cost=0.00182 escalated=false esc_model="" detail=""
+ch6/0 draft snap_match=true content_hash=9375b05780724cc354eba15a595da9a5f964219c0887042484256eed11fb566f disp=ok flag="" attempts=1 final_hash=abec9d62bee829a2dd5af959a3fccd72fd6ebd968f265389b706854f3432dab1 cost=0.00182 escalated=false esc_model="" detail=""
+ch6/0 edit snap_match=true content_hash=457fd53dd68c11665ea5d2ec489f966dd80e0232db5f10880cd13ebf865ef972 disp=flagged flag="sanitizer_defect" attempts=1 final_hash= cost=0.00182 escalated=false esc_model="" detail="ведущая служебная преамбула: Вот перевод фрагмента:"
+ch7/0 draft snap_match=true content_hash=d90c335cba3d1547a22bb3f656443cb432f84c12a577d43509cfe472cf86a2ac disp=ok flag="" attempts=1 final_hash=2189c766693381a223938e3c8036b2c9bba19dc16e93d1a26671a5a397d577c0 cost=0.00182 escalated=false esc_model="" detail=""
+ch7/0 edit snap_match=true content_hash=fe693bebbf5cfce3ff75d2756c511f4bd2a037256908ec4d758d9a6272535b5f disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:8ef72b20eac059984209728d34e0ef742b2fab2a8aef57a37dd5d5cf55545f4f cost=0.00182 escalated=false esc_model="" detail="markdown-заголовок в выходе: ### Глава 7"
+ch8/0 draft snap_match=true content_hash=fbd27af11da2275cd1d47a0224970a5f8d073c6164f614efb6522ea96fa1e021 disp=ok flag="" attempts=1 final_hash=59118bacb3968e766e4c75d2ed770bc2a08d81231e4a01cf04e7c8fe7a722621 cost=0.00182 escalated=false esc_model="" detail=""
+ch8/0 edit snap_match=true content_hash=d88a05fd553703e758d69278748152959d2c4b08d969d1099f1fce0d48476c1d disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:faf1ca5e8f561d183dad262f013b9820425201cdd0ac5682c54d20176f1f59d8 cost=0.00182 escalated=false esc_model="" detail="CJK-утечка в ru-выходе: 特产"
-- request_log (insertion order) --
-ch1/0 draft role=translator req=fake-model actual=fake-model hash=f2a775cd8f27741efb197298fcdf33cc5efa575dd91a490aa5519a15151f84d7 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch1/0 edit role=editor req=fake-model actual=fake-model hash=7d051e500c543f54a80dc0d36c8b03aac32225a51e9a015cc94ac03491629fb7 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch1/1 draft role=translator req=fake-model actual=fake-model hash=b27adec57257108c0c14c7a8e1f4b05c1dc66bf616a8a72d85dc7140bdcd8d81 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch1/1 edit role=editor req=fake-model actual=fake-model hash=c625e57b0778843942c78c5a8de5168e20857c9851e872b9a6e924edcbdc7059 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch2/0 draft role=translator req=fake-model actual=fake-model hash=5f4763e07c67f541958817ce6e7a65c8277e1d806558d3e218e92362ba858866 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=cf1b1146260ceeccc2ee7c87c9e828c8332af1a93193f6fd4939c58ee6b9b0f2 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err=""
-ch2/0 edit role=editor req=fake-model actual=fake-model hash=13ab5112ef1bf90ca6371bb81c73eb86fb31ed55c54bd60954ea092b354891b7 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch3/0 draft role=translator req=fake-model actual=fake-model hash=be200a513ccbfbb410ffc7788967610e2323aeda610c4d25296c8f06ddf3aead tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=4e5ad1c52475c43b5f98f0a369187a11367e4c739b67d5d9269b9949b718de33 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err=""
-ch4/0 draft role=translator req=fake-model actual=fake-model hash=3fedb549effcc911491a22a2db4a07e8385d329fe5b3f17c65c4bed8a72b8a3c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch4/0 edit role=editor req=fake-model actual=fake-model hash=d74baa087896d28bbd5588f9d85a22261255551db74c4921fdb7983ba2fc9f70 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch5/0 draft role=translator req=fake-model actual=fake-model hash=839873c92b11274628cfb0d8f03384df6549e4bd5529fcdef6a5c5ea46beaed9 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch5/0 edit role=editor req=fake-model actual=fake-model hash=22214c6cc4a4b04fc48d6a8a0e1ff94a7a9745df3b16e6d4ea8dbde4e4b7b308 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch6/0 draft role=translator req=fake-model actual=fake-model hash=88cb389b6ed249076c333a88c3b76a33661d2976ade55bd5a81cd6014b1e4604 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch6/0 edit role=editor req=fake-model actual=fake-model hash=ae7cb45838ddec818f5e6c8f71410db7f00365dc8d1a41ec1a94af1d490e2178 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch7/0 draft role=translator req=fake-model actual=fake-model hash=23ccf5b0150e650e923d06c6e92f633665ea36ee75089e8947dff26748be2c7a tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch7/0 edit role=editor req=fake-model actual=fake-model hash=9394388fc09349b7e4014e12ae991f041c5912307964d83ae165a8c99a3aac2e tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch8/0 draft role=translator req=fake-model actual=fake-model hash=cc5dbfbcdcd3070e6471266fc9b6ce3d5abf88b8c75f3bb619522f9146cc6e31 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch8/0 edit role=editor req=fake-model actual=fake-model hash=fd0a61bd15ec394e35f291a3f114469a0fca591b3bc529ef8bd79686fe3bef93 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err=""
-ch1/0 draft role=translator req=fake-model actual=fake-model hash=f2a775cd8f27741efb197298fcdf33cc5efa575dd91a490aa5519a15151f84d7 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch1/0 edit role=editor req=fake-model actual=fake-model hash=7d051e500c543f54a80dc0d36c8b03aac32225a51e9a015cc94ac03491629fb7 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch1/1 draft role=translator req=fake-model actual=fake-model hash=b27adec57257108c0c14c7a8e1f4b05c1dc66bf616a8a72d85dc7140bdcd8d81 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch1/1 edit role=editor req=fake-model actual=fake-model hash=c625e57b0778843942c78c5a8de5168e20857c9851e872b9a6e924edcbdc7059 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch2/0 draft role=translator req=fake-model actual=fake-fallback hash=cf1b1146260ceeccc2ee7c87c9e828c8332af1a93193f6fd4939c58ee6b9b0f2 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch2/0 edit role=editor req=fake-model actual=fake-model hash=13ab5112ef1bf90ca6371bb81c73eb86fb31ed55c54bd60954ea092b354891b7 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch1/0 draft role=translator req=fake-model actual=fake-model hash=2f3ac0b87516a3843e9ecb3e58d732fad5aa327a134d5222661ad87e5fc189b3 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch1/0 edit role=editor req=fake-model actual=fake-model hash=8013609b4fa7e48edf7c2e2e4cc78f57ad39e28bb417ab184ecc9da5b1ad70a1 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch1/1 draft role=translator req=fake-model actual=fake-model hash=162d61c7ea223237705da4325094eeaaee2ef8b9d5a20d9876c523a7476779cd tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch1/1 edit role=editor req=fake-model actual=fake-model hash=d0cdedefb8f492af8a7a3a840af6afb7ef996973f40d15c0c67e0adb5ec36aa2 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch2/0 draft role=translator req=fake-model actual=fake-model hash=98d51f7b179f0e736e347038a5bc8c5d5673adc93632dbcbdd026d26d090130e tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=24a9cfda087e0cb0732b34b6b6d53fc4d0c70de5a73ffe6f9087402da1b14898 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err=""
+ch2/0 edit role=editor req=fake-model actual=fake-model hash=68473338f2542411f864f34e52e7712ccc1bc8d9a6164c939c3a3b103ca206e4 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch3/0 draft role=translator req=fake-model actual=fake-model hash=e6f9a3e8cd8a7d70444efc8ce55f961545e2c8f9c21b332bf62be6f7bac0187e tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=d437eebecbb9ea8b18b36096f4afc30d0a8da5d39071bc65777b2d3936b3cef7 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err=""
+ch4/0 draft role=translator req=fake-model actual=fake-model hash=e68690f3abc3ece2b5202cc10bbfc0a8dce2b79e69c705a73a33bbe801653b38 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch4/0 edit role=editor req=fake-model actual=fake-model hash=2f8e3bcc66fa095ee443a6172193cf488140d53256e4f7514f1aca1d63b81395 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch5/0 draft role=translator req=fake-model actual=fake-model hash=a02083ed96d74f0ebf589ef4d27ab6109152baae9ffc437b1477aad4d5aa2d75 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch5/0 edit role=editor req=fake-model actual=fake-model hash=99d93354bd65f17d859ac749a19befc9c8d2c5cc82f858b6b484434790001603 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch6/0 draft role=translator req=fake-model actual=fake-model hash=abec9d62bee829a2dd5af959a3fccd72fd6ebd968f265389b706854f3432dab1 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch6/0 edit role=editor req=fake-model actual=fake-model hash=ef0f4b20ad89a3e5eacb7736083a9bedf598bdb2b0e904ab4f5af751ca16ae43 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch7/0 draft role=translator req=fake-model actual=fake-model hash=2189c766693381a223938e3c8036b2c9bba19dc16e93d1a26671a5a397d577c0 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch7/0 edit role=editor req=fake-model actual=fake-model hash=d102ddcda67e7971cbfbcf4ef31cfb403ec525cbaba5d1faabacc0cdc7953971 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch8/0 draft role=translator req=fake-model actual=fake-model hash=59118bacb3968e766e4c75d2ed770bc2a08d81231e4a01cf04e7c8fe7a722621 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch8/0 edit role=editor req=fake-model actual=fake-model hash=3b705de15f457ced93f8649c995966183921fb25e8bfd4b1e37753ef267d3c47 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err=""
+ch1/0 draft role=translator req=fake-model actual=fake-model hash=2f3ac0b87516a3843e9ecb3e58d732fad5aa327a134d5222661ad87e5fc189b3 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch1/0 edit role=editor req=fake-model actual=fake-model hash=8013609b4fa7e48edf7c2e2e4cc78f57ad39e28bb417ab184ecc9da5b1ad70a1 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch1/1 draft role=translator req=fake-model actual=fake-model hash=162d61c7ea223237705da4325094eeaaee2ef8b9d5a20d9876c523a7476779cd tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch1/1 edit role=editor req=fake-model actual=fake-model hash=d0cdedefb8f492af8a7a3a840af6afb7ef996973f40d15c0c67e0adb5ec36aa2 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch2/0 draft role=translator req=fake-model actual=fake-fallback hash=24a9cfda087e0cb0732b34b6b6d53fc4d0c70de5a73ffe6f9087402da1b14898 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch2/0 edit role=editor req=fake-model actual=fake-model hash=68473338f2542411f864f34e52e7712ccc1bc8d9a6164c939c3a3b103ca206e4 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
ch3/0 draft role=translator req=fake-model actual=fake-model hash= tm_hit=1 ok=0 finish="" degraded="hard_refusal" cost=0 tokens=0/0/0/0/0 err=""
-ch4/0 draft role=translator req=fake-model actual=fake-model hash=3fedb549effcc911491a22a2db4a07e8385d329fe5b3f17c65c4bed8a72b8a3c tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch4/0 edit role=editor req=fake-model actual=fake-model hash=d74baa087896d28bbd5588f9d85a22261255551db74c4921fdb7983ba2fc9f70 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch5/0 draft role=translator req=fake-model actual=fake-model hash=839873c92b11274628cfb0d8f03384df6549e4bd5529fcdef6a5c5ea46beaed9 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch5/0 edit role=editor req=fake-model actual=fake-model hash=22214c6cc4a4b04fc48d6a8a0e1ff94a7a9745df3b16e6d4ea8dbde4e4b7b308 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch6/0 draft role=translator req=fake-model actual=fake-model hash=88cb389b6ed249076c333a88c3b76a33661d2976ade55bd5a81cd6014b1e4604 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch4/0 draft role=translator req=fake-model actual=fake-model hash=e68690f3abc3ece2b5202cc10bbfc0a8dce2b79e69c705a73a33bbe801653b38 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch4/0 edit role=editor req=fake-model actual=fake-model hash=2f8e3bcc66fa095ee443a6172193cf488140d53256e4f7514f1aca1d63b81395 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch5/0 draft role=translator req=fake-model actual=fake-model hash=a02083ed96d74f0ebf589ef4d27ab6109152baae9ffc437b1477aad4d5aa2d75 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch5/0 edit role=editor req=fake-model actual=fake-model hash=99d93354bd65f17d859ac749a19befc9c8d2c5cc82f858b6b484434790001603 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch6/0 draft role=translator req=fake-model actual=fake-model hash=abec9d62bee829a2dd5af959a3fccd72fd6ebd968f265389b706854f3432dab1 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
ch6/0 edit role=editor req=fake-model actual=fake-model hash= tm_hit=1 ok=0 finish="" degraded="sanitizer_defect" cost=0 tokens=0/0/0/0/0 err=""
-ch7/0 draft role=translator req=fake-model actual=fake-model hash=23ccf5b0150e650e923d06c6e92f633665ea36ee75089e8947dff26748be2c7a tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch7/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:e1f2765b9f6d3cc4c461471cd7c143476614c602b6b08843bd239c77beb3b102 tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err=""
-ch8/0 draft role=translator req=fake-model actual=fake-model hash=cc5dbfbcdcd3070e6471266fc9b6ce3d5abf88b8c75f3bb619522f9146cc6e31 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
-ch8/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:067cfd0a649bfcd3db7fd04e8beff2a610dba06e292664e0160f06d16eef5209 tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err=""
+ch7/0 draft role=translator req=fake-model actual=fake-model hash=2189c766693381a223938e3c8036b2c9bba19dc16e93d1a26671a5a397d577c0 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch7/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:8ef72b20eac059984209728d34e0ef742b2fab2a8aef57a37dd5d5cf55545f4f tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err=""
+ch8/0 draft role=translator req=fake-model actual=fake-model hash=59118bacb3968e766e4c75d2ed770bc2a08d81231e4a01cf04e7c8fe7a722621 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err=""
+ch8/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:faf1ca5e8f561d183dad262f013b9820425201cdd0ac5682c54d20176f1f59d8 tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err=""
-- retrieval_state --
ch1/0 snap_match=true exact=3 sticky=0 ambiguous=0 spoiler=1 evicted=0 postcheck_miss=1 style_flags=0 trust_gated=0
injected_ids=["紋章\u001f\u001f0\u001f0","鈴木\u001f\u001f0\u001f0","魔法学院\u001f\u001f0\u001f0"] postcheck_detail=[{"src":"紋章","dst":"герб","disp":"confirmed"}] style_detail= trust_gate_detail=
diff --git a/docs/BACKEND_PLAN11_SESSION_PROMPT.md b/docs/BACKEND_PLAN11_SESSION_PROMPT.md
index badf0d8..31c8bdc 100644
--- a/docs/BACKEND_PLAN11_SESSION_PROMPT.md
+++ b/docs/BACKEND_PLAN11_SESSION_PROMPT.md
@@ -1,3 +1,5 @@
+> **⚠ СТАТУС: ЧАСТИЧНО ОТРАБОТАН — Block A + ядра ПРИНЯТЫ И ЗАЛЕНДЕНЫ (19.07, D39.13).** Сдано: WS2 ✅ (чанкер output-бюджет + edit-единицы + src→dst + golden) · WS1a/b/c-инфра ✅ (eager-clients, rate-guard, base/enriched split) · WS4-core ✅ (банкнота-парсер) · WS6-частично (конфиг-тай). **Резидуал R1–R5** (волновой ИСПОЛНИТЕЛЬ · WS3-майнер · WS4-12-точек · WS5-чекеры · арм-конфиги) — точные дизайны в `backend/PACK11_REPORT.md` §Резидуал. **Промт ОСТАЁТСЯ АКТИВНЫМ:** сессия-продолжение стартует по нему + отчёту (читать ОБА; сданное не пере-строить — оно под тестами).
+
# Промт: сессия БЭКЕНД — единый пак по плану 11 (волновой раннер · чанкер/единицы · банк W1.5 · банкнота · чекеры · армы), 2026-07-19
> **Место в очереди (пост-D39.12):** исследовательская программа арх-ресета (D39–D39.10) и дизайн-синтез
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index 834078a..9bee5e8 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -4,11 +4,15 @@
> - **Фазы:** 0 ✅ · Ф1-инфра ✅ (D20–D28; golden = инвариант №8) · арка «качество-первым» D26→D38.5 ЗАКРЫТА (хроника — `archive/PROGRESS-2026-07-10-13.md`; итог: потолок в организации, не в моделях). **АРХ-РЕСЕТ D39 (13.07):** синк-аудит «сломанного телефона» (65 находок / 0 refuted — `architecture/08-sync-audit-ledger.md`) → целевая 7-слойная архитектура + фазовый план (`architecture/09-target-architecture.md`); инвариант общности §0.1 (любая книга/пара/структура); промпт-тема консолидирована в `architecture/10-prompt-architecture.md`.
> - **Трек A (build-now) ЗАКРЫТ:** пак-1 (D39.2, `d3f6b34`: trust-gated suppressor [терм-дрейф закрыт в КОДЕ] · export-contract нормализация · pair-сеам `prompts:{zh-ru}` fail-loud · реестр role→инъекция · target-гейты · quality-report) + пак-1.5+F1–F9 (D39.5, `dc30c7d`: `tmctl export` [manifest-join+drift-гард; полигон-экстракция впредь ТОЛЬКО через него] · fold-first санитайзер [HIGH-обход закрыт] · bounded глосс-whitelist · пример zh→ru). Адверсариал-долг 529 погашен (D39.4: 8 осей, 0 CRIT/1 HIGH/6 MED — всё в фикс-листах/ledger).
> - **Трек B (ресёрч) ЗАЛЕНДЕН:** research/19 нарезка+когезия+контракт t/e+волны W0–W3 (D39.1; конфликт exp14↔research/18 разрешён: эмиссия≠окно≠связность) · research/20 банк-майнинг W1.5 (D39.6; детектор-лестница V-A/B/C, алиас-ярусы, banknote-v1, плагины P1–P6, §D-пре-рег; конфаунд инъекции ⇒ cold-start срез).
-> - **exp15 ЗАВЕРШЁН И ЗАЛЕНДЕН (D39.7, `fe34a16`):** «граница вредит» отозван (артефакт окна судьи, свинг +0.689 после фикса), все когезия-эффекты под шум-полом 0.126, «sequential нигде не лучше» (LOO-stable, НЕ ратифицированный зелёный свет волне), фертильность 1.20·cjk+0.39·other; $12.79/$15. **Слепое чтение ИСПОЛНЕНО (D39.8):** планка не пройдена никем, дефекты распределены по армам (читатель подтвердил floor-null; V2-брак = риг без гейтов, не carryover-рычаг) → битва качества пере-прогона = дефект-классы (时辰-юниты · числа b1 · gender-enforce · стих/аллюзии · регистр-лексикон), не нарезка. **Q4a ИСПОЛНЕН И ЗАЛЕНДЕН (D39.9, $1.32/$2):** верность НЕ покупается на translate-стадии (flash уже верен 25/0/27; pro ×3.67 не ратифицирован); смысл ломает glm-РЕДАКТОР (свап glm→mistral/deepseek-pro = приоритетный арм пере-прогона); exp14b-правила дефектны → $0-ре-аудит в exp16. **exp16 ИСПОЛНЕН И ЗАЛЕНДЕН (D39.10, $0.02/$10):** WHICH подтверждена (код-детектор V-C recall 0.97 held-out, $0) / WHAT опровергнута для ко-оккуренции → dst-канал = банкнота (идея владельца оправдана) + облако+Палладий; 9B-споттер не нужен (0.895 ≤ код); Z1 → облако; ре-аудит exp14b: ядро D38 держится, errata не нужна. Тачпойнты владельцу: карта подписи (пол) · мини-голд алиасов · precision@30 (books/gu-zhenren/exp16/). **Исследовательская программа ЗАВЕРШЕНА.** **Директива владельца (D39.11): стройка ТОЛЬКО после ДИЗАЙН-СИНТЕЗ сессии** (синтез всех находок → план реализации с верифицированными алгоритмами → ратификация → бэкенд-промт). **Очередь:** ✅ дизайн-синтез ИСПОЛНЕН, план РАТИФИЦИРОВАН (**D39.12** — `architecture/11-implementation-plan.md`, три рубежа верификации, 4 гейтнутых арма) → **бэкенд-пак ЕДИНОЙ сессией (промт ВЫДАН — `BACKEND_PLAN11_SESSION_PROMPT.md`; решение владельца)** → приёмка оркестратора → единый resnapshot (D30.9, одна переоплата; §8-манифест плана; вкл. RegressionGuard-тумблер D38.4) → пере-прогон 3–10 глав (армы редактора glm/mistral/deepseek-pro) → чтение владельца (интерим-планка 2 претензии, D35).残: POLYGON_PACKAGE4 residual-трекер (пилот/18+/echo — отложен, D36.1).
+> - **exp15 ЗАВЕРШЁН И ЗАЛЕНДЕН (D39.7, `fe34a16`):** «граница вредит» отозван (артефакт окна судьи, свинг +0.689 после фикса), все когезия-эффекты под шум-полом 0.126, «sequential нигде не лучше» (LOO-stable, НЕ ратифицированный зелёный свет волне), фертильность 1.20·cjk+0.39·other; $12.79/$15. **Слепое чтение ИСПОЛНЕНО (D39.8):** планка не пройдена никем, дефекты распределены по армам (читатель подтвердил floor-null; V2-брак = риг без гейтов, не carryover-рычаг) → битва качества пере-прогона = дефект-классы (时辰-юниты · числа b1 · gender-enforce · стих/аллюзии · регистр-лексикон), не нарезка. **Q4a ИСПОЛНЕН И ЗАЛЕНДЕН (D39.9, $1.32/$2):** верность НЕ покупается на translate-стадии (flash уже верен 25/0/27; pro ×3.67 не ратифицирован); смысл ломает glm-РЕДАКТОР (свап glm→mistral/deepseek-pro = приоритетный арм пере-прогона); exp14b-правила дефектны → $0-ре-аудит в exp16. **exp16 ИСПОЛНЕН И ЗАЛЕНДЕН (D39.10, $0.02/$10):** WHICH подтверждена (код-детектор V-C recall 0.97 held-out, $0) / WHAT опровергнута для ко-оккуренции → dst-канал = банкнота (идея владельца оправдана) + облако+Палладий; 9B-споттер не нужен (0.895 ≤ код); Z1 → облако; ре-аудит exp14b: ядро D38 держится, errata не нужна. Тачпойнты владельцу: карта подписи (пол) · мини-голд алиасов · precision@30 (books/gu-zhenren/exp16/). **Исследовательская программа ЗАВЕРШЕНА.** **Директива владельца (D39.11): стройка ТОЛЬКО после ДИЗАЙН-СИНТЕЗ сессии** (синтез всех находок → план реализации с верифицированными алгоритмами → ратификация → бэкенд-промт). **Очередь:** ✅ план РАТИФИЦИРОВАН (D39.12) → ✅ **пак-11 Block A ПРИНЯТ и залендён (D39.13:** WS2 целиком [вкл. src→dst = закрытие D30.1] + WS1a/b/c-инфра + WS4-ядро; golden re-captured**)** → **продолжение-пак R1–R5** (волновой исполнитель · WS3-майнер · WS4-12-точек · WS5-чекеры · арм-конфиги; промт `BACKEND_PLAN11_SESSION_PROMPT.md` АКТИВЕН со статус-баннером + `backend/PACK11_REPORT.md` §Резидуал) → приёмка (с погашением долга рубежа-2) → единый resnapshot (D30.9, §8-манифест) → пере-прогон 3–10 глав (армы glm/mistral/deepseek-pro) → чтение владельца (интерим-планка 2 претензии, D35).残: POLYGON_PACKAGE4 residual-трекер (пилот/18+/echo — отложен, D36.1).
> - **Стек (не менялся с D38.5):** draft deepseek-v4-flash (thinking ON) → editor glm-5 БИЛИНГВ v3-discourse (P1a+чэнъюй+few_shot-тумблер; свап-кандидаты mistral/deepseek-pro = армы пере-прогона) → судья gemini-3.1-pro-preview; канал B Mistral+grok; 7 ключей; ~$0.85/ранобэ (D30.4).
> - **Ждём от владельца:** запуск exp15 (бюджет ≤$15 подтверждён) · research/20 §E: мини-голд алиасов (~20–30 мин) / бюджет ≤$5 полигон-стадии / политика канона / жанр-паки / реплика ja→ru · чтение пере-прогона (после очереди) · старые висящие: планка запуска (лучше-фана/гибрид/издательский) · билингв-якорь пилота (D25.9-Q1) · контаминация пилот-корпуса (D27.4) · publishable/waiver (D25.1) · FN-bound L3 (D25.4) · юр-пакет · провенанс 12-*-доков.
> - Архивы хроники: `archive/PROGRESS-2026-07-04-10.md` (D31) · `archive/PROGRESS-2026-07-10-13.md` (D39.6-гигиена). Записи ниже — живая эра D39.
+## Оркестратор №7 — пак-11 Block A принят и залендён (D39.13), 19.07 (третья фаза сессии)
+
+Бэкенд-сессия сдала Block A + ядра с ЧЕСТНЫМ резидуалом (мандат «резидуал > сломанный суит»): WS2 целиком (output-бюджет чанкера, edit-единицы grouping-of-whole-chunks [девиация ратифицирована: 37 единиц / 0 straddle воспроизведены, гарантия W2-реконструкции по построению], src→dst-блок редактора = закрытие D30.1, `render-format-version`, golden re-capture) · WS1a eager-clients (гонка закрыта) · WS1b rate-guard (`ratelimit.go`) · WS1c base/enriched `MemoryVersion`-split (enriched байт-идентичен — снапшот не двинулся) · WS4-core банкнота-парсер (байт-верный порт). **Приёмка — верификация исполнением:** build/vet/race сам, 27 пинов свежим `-count=1` вербозно, независимая masked-golden репликация (все категории объяснимы), построчное чтение несущих диффов. Сессионный 9-агентный чекпойнт: 3 confirmed пофикшены / 3 refuted. **Долг: мульти-агентный рубеж-2 приёмки не гонялся (лимит-режим) — гасится при приёмке продолжения (прецедент D39.2).** Резидуал R1–R5 с точными дизайнами — `backend/PACK11_REPORT.md`; промт пака АКТИВЕН со статус-баннером. Записка продолжению-сессии: стартовать по промту + отчёту, сданное не пере-строить.
+
## Оркестратор №7 — план 11 РАТИФИЦИРОВАН (D39.12), бэкенд-промт единого пака ВЫДАН (19.07, вторая половина сессии)
Дизайн-синтез сдал план (1066 строк) после трёх рубежей: ревью-2 оркестратора (F1–F11: 3 MAJOR соло-прохода + 2 замечания владельца [mistral rate-guard ~48% retry-fails · полы per-editor-model] + Q9-провенанс) → 7-агентный пост-фикс адверсариал сессии (5 находок: F8 ПЕРЕПИСАН — сайзинг уже от `prev`; дефолт 3200 → 37 edit-единиц; mined-write-путь [`loadGlossarySeed` хардкодит seed]; render-format-version реконсиляция; F6 усилен narrative-coref) → 3-агентный подтверждающий пас (2 резидуала закрыты). Спот-чек внесения — исполнением (36 маркеров, grep всех фиксов). **Залендено одним пакетом:** план с ревью-шапкой + `eval/design11/` (7 скриптов, 6 верификаций PASS) + quirks-запись mistral (из §12-заготовки, зона полигона — с пометкой) + D39.12 + архивация спент-промта дизайн-синтеза. **Гейтнутые армы: 4** (edit>3200 за Q2a · качество свап-арма за платным полигоном · DC-landing за $0-FP · mistral за rate-guard). Развилки §10 (14 шт., с рекомендациями) — у владельца, стройку не блокируют. **Выдан `BACKEND_PLAN11_SESSION_PROMPT.md`** — единый пак WS1–WS6 блоками A/B/C/D с чекпойнт-ревью (решение владельца: одной сессией; свободы: рефакторинг/перенос кода, временные тесты, багфиксы со скоуп-пометкой; Go-каноны; даблчек неконсистентностей; $0 к провайдерам). Очередь: бэкенд-сессия → приёмка (рубеж-2) → единый resnapshot → пере-прогон → чтение.
diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md
index ec9c729..d616dbc 100644
--- a/docs/architecture/05-decisions-log.md
+++ b/docs/architecture/05-decisions-log.md
@@ -3,7 +3,7 @@
> **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто:
> - **Полностью superseded:** **D1 (моно-редактор) → D17 → D30.1 (редактор БИЛИНГВ)** · D9 (ja-приёмка) → D18 (蛊真人 zh→ru; ja — второй прогон) · D17 → D30.1 · скобка D19.4 «ключ без data-sharing» и D20.3 «чистый ключ = блокер пилота» → **D27** (единый ключ С data-sharing, отключение перед продом) · exp04-дефолт «editor grok-4.3» (D3) → D30.1 (grok reasoning-off из редакторских ролей СНЯТ — no-op; кандидат glm-5-билингв; gemini — премиум-эскалация только за санитайзером D30.3).
> - **Частично амендировано:** D3 (канал B → D14.1/D19.1 + оговорки D22.5/D22.6; апекс-слаг `-preview` — D22.4; draft под вопросом exp13 — D30.6) · D6 («off/minimal для draft/edit» эродирован: draft thinking-ON принудительно, editor-off снят D30.1; живы per-роль принцип и D6.2-буфер) · D10 (+D24.2 alias-слепое пятно, истинная консистентность ≈99.5%) · D11-числа → exp08 → **D30.4** (флип D1 = +15–25%, ~$0.85/ранобэ; «$1.5–2» = неподписанная опция Б) · D12 (+D24.3 флоры max_tokens; +D29.1в rollup — амендмент вердикт-правила) · D13.1-арм из решающего → ПОДТВЕРЖДАЮЩИЙ (D30.1); +D25.3 prefix-анализ судьи · D14.2 закрыт D19.1/D22.4; D14.4 закрыт D22.1 · D15.3 исполнен; спека D15.2: v2→v3→v3.1 (D22.2), реализация = текущий пакет (D30.9) · D24.4 +D28.1 (precision/recall-трейдофф; hard-gate требует пере-замера) · порядок D24.5 отложен D26.1 (этап B — после читаемых 25 глав).
-> - **Живое ядро без изменений:** D2 (+новый гейт-класс D30.3), D4, D5, D7, D8, D10-механизм, D12-таксономия отказов, D16, D18, D19.1–19.2, D21 (Ф2-механизмы voice/address/reveal), D22.5–22.7, D23 (golden = инвариант №8), D24–D39.12 — действующая голова контракта (**D39.12 = план `architecture/11-implementation-plan.md` РАТИФИЦИРОВАН [три рубежа: ревью-2 F1–F11 → 7-агентный адверсариал → подтверждающий пас; пер-волновой снапшот + инвариант секвенирования = «переоплата ОДНА»; EditCeilingOut=3200; лемматизатор дефолт B; 4 гейтнутых арма]; бэкенд-пак ЕДИНОЙ сессией — `BACKEND_PLAN11_SESSION_PROMPT.md`**; **D39.11 = директива владельца: стройка ТОЛЬКО после дизайн-синтез сессии с верифицированными алгоритмами; хендофф №5**; **D39.10 = exp16: WHICH подтверждена [V-C recall 0.97, $0] / WHAT→банкнота+Палладий [canon-recovery <70%], 9B-споттер не нужен, Z1→облако, ре-аудит exp14b чист [errata не требуется]; исследовательская программа ЗАВЕРШЕНА → бэкенд-пак слоёв 1+4**; **D39.9 = Q4a: верность НЕ на translate-стадии [flash уже верен, pro ×3.67 не ратифицирован], смысл ломает glm-РЕДАКТОР [свап-арм приоритетен], DET-пол 0.261; exp14b-правила дефектны → $0-ре-аудит в exp16**; **D39.8 = слепое чтение: планка не пройдена никем, дефекты распределены [читатель подтвердил floor-null], битва качества → дефект-классы [时辰-юниты, числа, gender-enforce, стих, регистр]**; **D39.7 = exp15 залендён REV.2: «граница вредит» отозван [артефакт окна судьи], все когезия-эффекты под floor 0.126, «sequential нигде не лучше» LOO-stable [не ратифицированный зелёный свет], фертильность 1.20/0.39, $12.79/$15; гейт = слепое чтение владельца**; **D39.6 = research/20 банк-майнинг залендён [детектор-лестница V-A/B/C, алиас-ярусы, banknote-v1 с 12 гейт-точками, §B5 плагины P1–P6, §D-пре-рег]; полигон-стадия после exp15**; **D39.5 = пак-1.5+F1–F9 залендены [tmctl export с manifest/drift-гардами · fold-first санитайзер · bounded глосс · пример zh-ru]; экстракция полигона впредь через tmctl export**; **D39.4 = адверсариал-долг ПОГАШЕН [8 осей: 0 CRIT/1 HIGH/6 MED], пак-1.5 придержан под фикс-лист F1–F9 в §T4**; **D39.3 = пакет банк-майнинга W1.5 [диспозиция H15/V4-п4] ратифицирован, research/20-промт с HOLD до фриза exp15**; **D39.2 = трек-A пак-1 залендён `d3f6b34`: trust-gated suppressor [терм-дрейф закрыт в КОДЕ] + export-contract нормализация + pair-сеам + реестры + quality-report; адверсариал-долг за 529**; **D39.1 = research/19 залендён: конфликт exp14↔research/18 разрешён [эмиссия≠окно≠связность], Go-спека чанкера+когезии, волновая архитектура W0–W3 [вводная владельца: параллелизм], пре-рег эмпирики Q0–Q5; оркестратор-ревью снято владельцем**; **D39 = АРХ-РЕСЕТ: синк-аудит сломанного телефона [65 находок, 0 refuted, `08-sync-audit-ledger.md`] + целевая 7-слойная архитектура [`09-target-architecture.md`] + фазовый план [трек A build-now ∥ трек B ресёрч, пере-прогон после]; формализует фантомный «D35.7» как цель слоя 1; терм-дрейф код-корень жив [сид-воркэраунд]; редактор 4-мандата-full-regen = причина инверсий**; D35 = пивот качество-первым; D36 = приёмка research/18; D37 = приёмка exp14: претензия-1=промпт-рычаг; D38 = приёмка exp14b: «только gpt-5.4» ОПРОВЕРГНУТО [mistral/deepseek-pro чинят], фронтир в дефолт НЕ нужен, корень терм-дрейфа = статус-draft сида; **D38.1 = слепой h2h P1a↔F-disc залендён [ChatGPT «фронтир сильнее» / Claude «near-parity» расходятся, СХОДЯТСЯ: дефекты = инфра] → РАЗВОРОТ «мерить→строить», выдан BACKEND_INFRA_PACK; D38.2 = консолидация/док-гигиена: спент exp-скрипты в `eval/exp12|13|14|14b/`, статус-доки+.puml актуализированы; D38.3 = инфра-пак читаемости залендён (санитайзер-классы strip+export + число/omission-гард), верифицирован исполнением [build/vet/test зелёные, 3 span-дефекта запинены]; D38.4 = аудит полноты exp14/14b: чэнъюй-рычаг забыт near-term→дописан в дискурс-промпт; inversion-гард D37 §2г не строился (число/omission ≠ полярность)→editor-swap = защита от инверсий (приоритет↑); нарезка = re-run-арм; гард-тумблер вкл. при resnapshot; D38.5 = компоненты resnapshot залендены+верифицированы [reseed-сид eb409f2 · дискурс+чэнъюй editor v3 0ac5f7a с few_shot-тумблером] → следующий шаг единый resnapshot + пере-прогон**).
+> - **Живое ядро без изменений:** D2 (+новый гейт-класс D30.3), D4, D5, D7, D8, D10-механизм, D12-таксономия отказов, D16, D18, D19.1–19.2, D21 (Ф2-механизмы voice/address/reveal), D22.5–22.7, D23 (golden = инвариант №8), D24–D39.13 — действующая голова контракта (**D39.13 = пак-11 Block A ПРИНЯТ и залендён [WS2 целиком (вкл. src→dst-блок редактора = закрытие D30.1) + WS1a/b/c-инфра + WS4-ядро; 2 девиации ратифицированы; резидуал R1–R5 в PACK11_REPORT; промт остаётся активным; рубеж-2 приёмки — долг]**; **D39.12 = план `architecture/11-implementation-plan.md` РАТИФИЦИРОВАН [три рубежа: ревью-2 F1–F11 → 7-агентный адверсариал → подтверждающий пас; пер-волновой снапшот + инвариант секвенирования = «переоплата ОДНА»; EditCeilingOut=3200; лемматизатор дефолт B; 4 гейтнутых арма]; бэкенд-пак ЕДИНОЙ сессией — `BACKEND_PLAN11_SESSION_PROMPT.md`**; **D39.11 = директива владельца: стройка ТОЛЬКО после дизайн-синтез сессии с верифицированными алгоритмами; хендофф №5**; **D39.10 = exp16: WHICH подтверждена [V-C recall 0.97, $0] / WHAT→банкнота+Палладий [canon-recovery <70%], 9B-споттер не нужен, Z1→облако, ре-аудит exp14b чист [errata не требуется]; исследовательская программа ЗАВЕРШЕНА → бэкенд-пак слоёв 1+4**; **D39.9 = Q4a: верность НЕ на translate-стадии [flash уже верен, pro ×3.67 не ратифицирован], смысл ломает glm-РЕДАКТОР [свап-арм приоритетен], DET-пол 0.261; exp14b-правила дефектны → $0-ре-аудит в exp16**; **D39.8 = слепое чтение: планка не пройдена никем, дефекты распределены [читатель подтвердил floor-null], битва качества → дефект-классы [时辰-юниты, числа, gender-enforce, стих, регистр]**; **D39.7 = exp15 залендён REV.2: «граница вредит» отозван [артефакт окна судьи], все когезия-эффекты под floor 0.126, «sequential нигде не лучше» LOO-stable [не ратифицированный зелёный свет], фертильность 1.20/0.39, $12.79/$15; гейт = слепое чтение владельца**; **D39.6 = research/20 банк-майнинг залендён [детектор-лестница V-A/B/C, алиас-ярусы, banknote-v1 с 12 гейт-точками, §B5 плагины P1–P6, §D-пре-рег]; полигон-стадия после exp15**; **D39.5 = пак-1.5+F1–F9 залендены [tmctl export с manifest/drift-гардами · fold-first санитайзер · bounded глосс · пример zh-ru]; экстракция полигона впредь через tmctl export**; **D39.4 = адверсариал-долг ПОГАШЕН [8 осей: 0 CRIT/1 HIGH/6 MED], пак-1.5 придержан под фикс-лист F1–F9 в §T4**; **D39.3 = пакет банк-майнинга W1.5 [диспозиция H15/V4-п4] ратифицирован, research/20-промт с HOLD до фриза exp15**; **D39.2 = трек-A пак-1 залендён `d3f6b34`: trust-gated suppressor [терм-дрейф закрыт в КОДЕ] + export-contract нормализация + pair-сеам + реестры + quality-report; адверсариал-долг за 529**; **D39.1 = research/19 залендён: конфликт exp14↔research/18 разрешён [эмиссия≠окно≠связность], Go-спека чанкера+когезии, волновая архитектура W0–W3 [вводная владельца: параллелизм], пре-рег эмпирики Q0–Q5; оркестратор-ревью снято владельцем**; **D39 = АРХ-РЕСЕТ: синк-аудит сломанного телефона [65 находок, 0 refuted, `08-sync-audit-ledger.md`] + целевая 7-слойная архитектура [`09-target-architecture.md`] + фазовый план [трек A build-now ∥ трек B ресёрч, пере-прогон после]; формализует фантомный «D35.7» как цель слоя 1; терм-дрейф код-корень жив [сид-воркэраунд]; редактор 4-мандата-full-regen = причина инверсий**; D35 = пивот качество-первым; D36 = приёмка research/18; D37 = приёмка exp14: претензия-1=промпт-рычаг; D38 = приёмка exp14b: «только gpt-5.4» ОПРОВЕРГНУТО [mistral/deepseek-pro чинят], фронтир в дефолт НЕ нужен, корень терм-дрейфа = статус-draft сида; **D38.1 = слепой h2h P1a↔F-disc залендён [ChatGPT «фронтир сильнее» / Claude «near-parity» расходятся, СХОДЯТСЯ: дефекты = инфра] → РАЗВОРОТ «мерить→строить», выдан BACKEND_INFRA_PACK; D38.2 = консолидация/док-гигиена: спент exp-скрипты в `eval/exp12|13|14|14b/`, статус-доки+.puml актуализированы; D38.3 = инфра-пак читаемости залендён (санитайзер-классы strip+export + число/omission-гард), верифицирован исполнением [build/vet/test зелёные, 3 span-дефекта запинены]; D38.4 = аудит полноты exp14/14b: чэнъюй-рычаг забыт near-term→дописан в дискурс-промпт; inversion-гард D37 §2г не строился (число/omission ≠ полярность)→editor-swap = защита от инверсий (приоритет↑); нарезка = re-run-арм; гард-тумблер вкл. при resnapshot; D38.5 = компоненты resnapshot залендены+верифицированы [reseed-сид eb409f2 · дискурс+чэнъюй editor v3 0ac5f7a с few_shot-тумблером] → следующий шаг единый resnapshot + пере-прогон**).
Ответ на вопросы бэкенд-сессии (PROGRESS §«Вопросы от бэкенда», §«[НУЖНО РЕШЕНИЕ] перед Фазой 1» — с ревизии D31 в `archive/PROGRESS-2026-07-04-10.md`) и полигона (think-режим). Каждое решение прошло адверсариальную панель из 3 критиков (research / экономика / исполнимость в коде Фазы 0); ни одно не отклонено, все уточнены. Это **контракт Фазы 1** — бэкенд исполняет отсюда; при конфликте с буквой 01-decisions/02-mvp-plan — источник истины здесь (потом сольём в основные доки).
@@ -672,3 +672,13 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу
**Ратифицирую как дизайн-оф-рекорд:** пер-волновой снапшот (`base-bank` = approved `Source∈{seed,ruby}` / `enriched-bank` = все approved) + **инвариант секвенирования** (W2 никогда не пере-рендерит draft-стадию; черновик по `final_hash`) ⇒ «переоплата ОДНА» доказуема при обоих состояниях PostcheckGate · `EditCeilingOut=3200` · src→dst-формат констрейнт-блока редактора (закрытие D30.1) · лемматизатор — дефолт B (дроп Палладий-подканала, пере-пин 古月→22, катастроф-скрин PASS) · банкнота 12 точек (`banknoteSnap`, re-point ПОСЛЕ резолва эскалации) · DC3-инъекция всех трёх состояний вкл. hidden-мандат (hidden-чекер → Ф2) · DC5 → пак-локусы (детерм. детекция 74%/42% FP — не лендится) · mistral rate-guard (quirks-запись внесена из §12). **Гейтнутые армы (4, стройка сердцевины не ждёт):** крупная edit-единица >3200 (платный Q2a, объединить с полигон-прогоном) · качество свап-арма (платный полигон) · DC-чекеры landing ($0-FP на свежем прогоне) · mistral-арм (rate-guard). Развилки §10 — у владельца, стройку НЕ блокируют (дефолты консервативны; смена решения = конфиг/аддитив).
**Решение владельца (19.07): бэкенд-пак — ОДНОЙ сессией** (не паки 2/3/4 порознь), блоками A/B/C/D с чекпойнт-ревью между; свободы сессии: рефакторинг/перенос кода разрешён (архитектурная чистота, без велосипедов), временные тесты, багфиксы по пути со скоуп-пометкой; Go-каноны; неконсистентности — даблчек всеми тулзами + эскалация; $0 к провайдерам. **Промт выдан: `BACKEND_PLAN11_SESSION_PROMPT.md`.** Очередь после пака: приёмка оркестратора (рубеж-2) → единый resnapshot (§8) → пере-прогон 3–10 глав (армы редактора) → чтение владельца (планка 2 претензии). Спент-промт дизайн-синтеза архивирован с баннером.
+
+## D39.13 — Приёмка пака-11 Block A: WS2 + WS1a/b/c-инфра + WS4-ядро ЗАЛЕНДЕНЫ; волновой исполнитель и WS3/WS5 — резидуал с дизайнами (19.07, оркестратор №7). ✅
+
+Бэкенд-сессия исполнила `BACKEND_PLAN11_SESSION_PROMPT` частично — с ЧЕСТНЫМ резидуалом вместо сломанного суита (мандат промта): **сдано и залендено** — **WS2 целиком** (бюджет чанкера в ВЫХОДНЫХ токенах `est_out=1.1978·cjk+0.3852·other` на реальных `unicode.RangeTable`; edit-единицы; `Chunk`+`EstOut/OversizedSentence/EditUnitID` аддитивно; мега-предложение passthrough+флаг; снос `STMDepth`/`OverlapTokens`; **src→dst констрейнт-блок редактора** [закрытие D30.1] + выделенный `render-format-version`; `segmentationSnap`; `chunkerVersion` v4→v5; golden re-capture) · **WS1a** (eager-build клиентов + read-only `client()` — гонка `runner.go:171-181` закрыта) · **WS1b** (пер-модельный rate-guard `ratelimit.go`: семафор+пейсинг конфигом `models.yaml`, транспорт-ось, обёрнут вокруг единственного provider-вызова) · **WS1c-инфра** (base/enriched `MemoryVersion`-split: base = все approved КРОМЕ `Source:mined`, домен-сепарирован; enriched байт-идентичен прежнему фолду — текущий снапшот не двигается) · **WS4-core** (`banknote.go` — байт-верный порт `banknote.py`, 7 инвариант-тестов) · WS6-частично (mistral `rate_limit`-конфиг).
+
+**Верификация оркестратора — ПРЯМАЯ, исполнением:** `go build/vet/test -race` зелёные (прогнал сам); пины свежим `-count=1 -race` вербозно (27 тестов PASS: чанкер/rate-guard/memory-split/render-блок/банкнота/golden); **независимая репликация masked-golden-диффа** — все категории объяснимы (chunker v4→v5 · снятые stm/overlap · новый `segmentation{1797,3200,1.1978,0.3852}` · `render_format_version` · src→dst-блок; 12-hex токены = хеш-производные эхо fake-модели); построчное чтение несущих диффов (memory/snapshot/models/stagerun/chunker + оба новых файла) — семантика соответствует плану. Сессионный 9-агентный чекпойнт-адверсариал Block A: 6 находок → 3 confirmed (пофикшены: 2 doc-коммента base/enriched + gofmt), 3 refuted обоснованно.
+
+**Ратифицированные девиации (обе — с эскалация-нотой сессии, принимаю):** (1) **edit-единица = grouping ЦЕЛЫХ draft-чанков** (не независимый para-pack): эмпирически те же 37 единиц / 0 straddle на корпусе, а по построению — гарантия чистой W2-реконструкции (draft-границы и так абзацные — сильнее плановой формулировки, не слабее); (2) **base-bank = «все approved кроме mined»** (вкл. Source:auto/ruby) — точная реализация ИНТЕНТА §1(в) (стабильность W1 сквозь W1.5, которое только добавляет mined); домен-сепаратор base≠enriched (W1-джоба никогда не адресуется к W2-чекпоинту) — верное усиление.
+
+**Честная калибровка: мульти-агентный рубеж-2 оркестратора при ЭТОЙ приёмке не гонялся (лимит-режим владельца) — регистрирую ДОЛГОМ** (прецедент D39.2); гасится при приёмке продолжения-пака вместе с его ревью. **Резидуал R1–R5** (волновой ИСПОЛНИТЕЛЬ W1/W1.5/W2 + пер-волновой снапшот-wiring · WS3-майнер · WS4-12-точек · WS5-чекеры · арм-конфиги) — точные дизайны в `backend/PACK11_REPORT.md` §Резидуал; **промт BACKEND_PLAN11 остаётся АКТИВНЫМ со статус-баннером** (дисциплина частично-отработанных промтов) — продолжение-сессия стартует по нему + отчёту. Очередь без изменений: продолжение-пак (R1–R5) → приёмка (с погашением долга рубежа-2) → единый resnapshot → пере-прогон → чтение.