diff --git a/backend/README.md b/backend/README.md index c4caa7f..868b66b 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,6 +1,6 @@ # backend/ — Go-бэкенд TextMachine -Зона сессии «Бэкенд». Контракт — `docs/architecture/05-decisions-log.md` (D1–D17); контракты Фазы 0 — `03-implementation-notes.md`; квирки провайдеров — `docs/experiments/00-provider-quirks.md`. **`.env` не читать.** +Зона сессии «Бэкенд». Контракт — `docs/architecture/05-decisions-log.md` (D1–D18); контракты Фазы 0 — `03-implementation-notes.md`; квирки провайдеров — `docs/experiments/00-provider-quirks.md`. **`.env` не читать.** ## Карта пакетов @@ -9,7 +9,7 @@ | `cmd/tmctl` | CLI: `translate` / `report` / `status` (read-only проекция N/M+паспорта глав+деньги, `--json`) / `redrive` (переатака флагнутых: `--chapter/--chunk/--reason/--dry-run`, D15.3) | `main.go`, `internal/pipeline/status.go` | | `internal/llm` | OpenAI-совместимый транспорт + retry/backoff, **capability-слой** (budget_field/temperature/reasoning per-модель), failover (написан, НЕ подключён — D4: только local-роли), нативный Anthropic = DEPRECATED-референс под будущий Gemini | `httpllm.go`, `capability.go`, `failover.go` | | `internal/ledger` | Цены по usage (вкл. reasoning/cache-поля), `PriceForResponse` по фактической модели | `pricing.go` | -| `internal/store` | SQLite (modernc, CGO-free), идемпотентные миграции v1–v5, reserve/settle+checkpoint, chunk_status, глоссарий, ruby, retrieval_state, request_log | `ledger.go`, `migrate.go`, `glossary.go` | +| `internal/store` | SQLite (modernc, CGO-free), идемпотентные миграции v1–v6, reserve/settle+checkpoint, chunk_status, глоссарий, ruby, retrieval_state, request_log | `ledger.go`, `migrate.go`, `glossary.go` | | `internal/config` | fail-fast загрузка models/pipeline/book; **эхо-мина-гейт** `echoMineViolation`; `CheckRunnable` блокирует неисполнимое (C2/fanout/judge) | `models.go`, `pipeline.go` | | `internal/pipeline` | Раннер (циклы, disposition, single-hop эскалация с ре-гейтом, resume), чанкер v4, ingest txt/epub+ruby, classify/coverage, **банк памяти v2** (Aho-Corasick, спойлер-окна, disposition, post-check), рендер+инъекция | `runner.go`, `memory.go`, `disposition.go`, `coverage.go` | | `internal/obs` | trace_id, структурные логи, safego | | diff --git a/backend/cmd/tmctl/main.go b/backend/cmd/tmctl/main.go index 6591096..ea372c3 100644 --- a/backend/cmd/tmctl/main.go +++ b/backend/cmd/tmctl/main.go @@ -201,7 +201,7 @@ func report(cfgPath string) error { return err } if len(states) > 0 { - var injected, sticky, ambiguous, spoiler, evicted, misses int + var injected, sticky, ambiguous, spoiler, evicted, misses, style int for _, rs := range states { injected += rs.NExactHits sticky += rs.NSticky @@ -209,6 +209,7 @@ func report(cfgPath string) error { spoiler += rs.NSpoilerBlocked evicted += rs.NEvicted misses += rs.NPostcheckMiss + style += rs.NStyleFlags } fmt.Printf("\n=== ПАМЯТЬ (retrieval-state) ===\n") fmt.Printf("инъекций(точных)=%d sticky=%d ambiguous=%d спойлер-блок=%d вытеснено=%d post-check-промахов=%d\n", @@ -224,6 +225,19 @@ func report(cfgPath string) error { } fmt.Printf("%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NPostcheckMiss, rs.PostcheckDetail) } + // Cheap style/number flaggers (observability, not gates): total + per-chunk detail. + fmt.Printf("\n=== СТИЛЬ-ГЕЙТЫ (наблюдаемость: тире-диалоги, ё, транслит-междометия, разряды 万/億) — всего %d ===\n", style) + stylePrinted := false + for _, rs := range states { + if rs.NStyleFlags == 0 { + continue + } + if !stylePrinted { + fmt.Printf("%-4s %-6s %6s %s\n", "ch", "chunk", "flags", "detail") + stylePrinted = true + } + fmt.Printf("%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NStyleFlags, rs.StyleDetail) + } } committed, reserved, err := r.Store.SpentUSD(r.Book.BookID) @@ -236,7 +250,9 @@ func report(cfgPath string) error { // status prints the READ-ONLY progress projection (D15.3): 0 LLM, 0 replay. --json emits the // StatusReport with stable disposition/flag_reason enums for CI/IDE; the default is a human -// dashboard (unit counts, per-chapter quality passports, money, secondary ETA). +// dashboard (unit counts, per-chapter quality passports, money, secondary ETA). BOTH modes exit 2 +// (completed-with-flags) when flagged>0 (minor 1d — --json used to always exit 0), so a consumer +// never reads a clean 0 over a book that still needs attention. func status(ctx context.Context, cfgPath string, asJSON bool) error { r, err := pipeline.NewRunner(cfgPath, obs.NewLogger()) if err != nil { @@ -252,7 +268,17 @@ func status(ctx context.Context, cfgPath string, asJSON bool) error { if asJSON { enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") - return enc.Encode(rep) + if err := enc.Encode(rep); err != nil { + return err + } + // Symmetric with the human mode (minor 1d): flagged>0 is exit 2 (completed-with-flags), not + // exit 0. The full JSON is already on stdout; the sentinel only sets the shell disposition + // (its message goes to stderr in main), so a CI/IDE consumer both parses the projection AND + // sees "attention needed" in the exit code instead of silently reading 0. + if rep.Flagged > 0 { + return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalChunks} + } + return nil } snap := rep.Snapshot @@ -271,7 +297,8 @@ func status(ctx context.Context, cfgPath string, asJSON bool) error { fmt.Printf("=== СТАТУС: %s (snapshot %s)%s ===\n", rep.BookID, snap, drift) fmt.Printf("Прогресс: %d/%d чанков (%.1f%%) — done=%d in_progress=%d flagged=%d pending=%d\n", rep.Done, rep.TotalChunks, rep.PercentDone, rep.Done, rep.InProgress, rep.Flagged, rep.Pending) - fmt.Printf("Эскалаций: %d · post-check промахов (confirmed): %d\n", rep.Escalations, rep.PostcheckMisses) + fmt.Printf("Эскалаций: %d · post-check промахов (confirmed): %d · стиль-флагов (наблюдаемость): %d\n", + rep.Escalations, rep.PostcheckMisses, rep.StyleFlags) ceil := "" if rep.BookCeilingUSD > 0 { ceil = fmt.Sprintf(" (потолок $%.2f — %.1f%%)", rep.BookCeilingUSD, rep.CeilingPct) @@ -279,7 +306,7 @@ func status(ctx context.Context, cfgPath string, asJSON bool) error { fmt.Printf("Деньги: committed=$%.6f reserved=$%.6f%s · прогноз книги ~$%.6f\n", rep.CommittedUSD, rep.ReservedUSD, ceil, rep.ProjectedBookUSD) if rep.ETASeconds > 0 { - fmt.Printf("ETA: ~%.0fс (по throughput fresh-вызовов; вторично)\n", rep.ETASeconds) + fmt.Printf("ETA: ~%.0fс (средний throughput fresh-вызовов, НЕ EWMA — D12-отступление; вторично)\n", rep.ETASeconds) } if len(rep.Chapters) > 0 { @@ -290,11 +317,20 @@ func status(ctx context.Context, cfgPath string, asJSON bool) error { dashIfEmpty(p.WorstFlagReason), p.CostUSD) } } - // flagged≠failed (jobs.status.failed = infra-only, D12): flagged chunks are a - // "clean run, attention needed" signal, re-drivable with `tmctl redrive`. + // flagged≠failed (jobs.status.failed = infra-only, D12): flagged chunks are a "clean run, + // attention needed" signal. Two kinds need DIFFERENT actions (minor 1d): a chunk_status flag + // (soft_refusal, length, …) is re-drivable with `tmctl redrive`; a gate-promoted glossary_miss + // is NOT (redrive is a no-op — the miss re-derives from the unchanged glossary), so it needs a + // seed fix + `translate --resnapshot`. Advise each set separately instead of one blanket hint. if rep.Flagged > 0 { - fmt.Printf("\n%d флагнутых чанк(ов) ждут внимания — переатаковать: tmctl redrive --config %s [--chapter N --chunk M --reason R]\n", - rep.Flagged, cfgPath) + if redrivable := rep.Flagged - rep.GlossaryMissFlagged; redrivable > 0 { + fmt.Printf("\n%d флагнутых чанк(ов) ждут внимания — переатаковать: tmctl redrive --config %s [--chapter N --chunk M --reason R]\n", + redrivable, cfgPath) + } + if rep.GlossaryMissFlagged > 0 { + fmt.Printf("\n%d чанк(ов) флагнуты post-check-гейтом (glossary_miss) — redrive для них no-op: исправьте сид-глоссарий (термин/dst) и перезапустите `tmctl translate --config %s --resnapshot` (переоплата затронутых чанков)\n", + rep.GlossaryMissFlagged, cfgPath) + } return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalChunks} } return nil diff --git a/backend/docs/D15.2-content-addressed-resume-spec.md b/backend/docs/D15.2-content-addressed-resume-spec.md index 80c76ae..ba2f4d8 100644 --- a/backend/docs/D15.2-content-addressed-resume-spec.md +++ b/backend/docs/D15.2-content-addressed-resume-spec.md @@ -1,160 +1,455 @@ -# D15.2 — Спека: content-addressed переиспользование чекпоинтов (дизайн, НЕ код) +# D15.2 — Спека v2: content-addressed переиспользование чекпоинтов (дизайн, НЕ код) -**Статус:** проект спеки бэкенд-сессии, сдан на утверждение оркестратору. Реализация — Ф1.5, -**после** ратификации. Зона: спека написана в `backend/docs/` (зона бэкенда); при утверждении -оркестратор переносит/сворачивает в `docs/architecture/03-implementation-notes.md` новым разделом. +**Статус:** **v2, сдан на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до +ратификации v2** (D-лог §Ратификации 09.07 п.2: направление ратифицировано, реализация заперта до +доработки трёх дыр). Зона: спека в `backend/docs/` (зона бэкенда); при утверждении оркестратор +сворачивает её в `docs/architecture/03-implementation-notes.md` новым разделом. -**Гейтит:** онгоинг-режим (`add-chapters`, сегмент «ИИ-фабрик», Р9) — D15.2. Текущий -all-or-nothing resnapshot **приемлем для статичной приёмочной книги Ф1 (D15.1) — НЕ трогать до -приёмки**; эта спека — для онгоинга. +**Supersedes:** D15.2-content-addressed-resume-spec.md (v1). Направление v1 (расщепить `snapshotID` +на wire-часть и вердикт-часть; ключ чекпоинта держать на wire-идентичности; вердикт-смену +re-classify-ить на resume бесплатно) — **ратифицировано** и сохранено. v2 **закрывает три +дизайн-дыры внешнего ревью**, которые заблокировали реализацию v1: + +- **(a)** fast-path-гард v1 (content_hash + wireSnapshotID + verdict_snapshot) **слабее текущего + гарда**: не покрывает ПРЯМЫЕ wire-поля `RequestHash` (model / temperature / reasoning / maxTokens / + escalate_to). Смена `temperature` 0.3→0.4 или `reasoning` low→none тихо отдала бы stale-текст — + ре-открытие дефекта stale-serve F1. → §5, отдельная колонка `guard_hash`. +- **(b)** расщепление `memory_version` (wire vs verdict) в v1 было «(Опц.)». **Сделано ОБЯЗАТЕЛЬНЫМ** + (иначе главный сценарий онгоинга — append-термина/toggle гейта — не материализуется без переоплаты + книги). → §6. +- **(c)** loud-гейт согласия (`--resnapshot` fail-loud, Р6 «согласие на трату») content-addressed + resume **демонтирует**; v1 просто удалял его. **Заменён pre-flight dry-run** «N чанков, ~$X» с + порогом явного флага. → §7. + +**Гейтит:** онгоинг-режим (`add-chapters`, сегмент «ИИ-фабрик», Р9 / D15 п.2). Текущий +all-or-nothing resnapshot остаётся **приемлемым для статичной приёмочной книги Ф1 (D15.1 / D18 蛊真人) +— НЕ трогать до приёмки**; эта спека — для онгоинга (Ф1.5). --- ## 1. Проблема (верифицирована D15 / 07 §4.4) -`snapshotID` вшит в `RequestHash` (`render.go` — поле `snapshotID` в length-prefixed хеше). Значит -**любое** изменение снапшота (флип coverage-гейта, toggle `postcheck_gate`, бамп `classifierVersion`, -append approved-терминов через `memoryVersion`, правка инертной ручки) после `--resnapshot` -пере-оплачивает **всю книгу**, включая **байт-идентичные** запросы (воспроизведено: 2→4 оплаченных -вызова на wire-идентичных телах). Механизм честного reuse существует (`msgsContentHash`, -`render.go`), но заперт условием `cs.SnapshotID == snapID` (`runner.go`, resume fast-path). +`snapshotID` (`runner.go:263-431`) вшит в `RequestHash` как поле `snapshotID` (`render.go:198,212-214`, +length-prefixed хеш). Значит **любое** изменение снапшота — флип coverage-гейта, toggle +`postcheck_gate`, бамп `classifierVersion`, append approved-терминов через `memoryVersion`, правка +инертной ручки — после `--resnapshot` пере-оплачивает **всю книгу**, включая **байт-идентичные** +запросы (воспроизведено: 2→4 оплаченных вызова на wire-идентичных телах). Механизм честного reuse +существует (`msgsContentHash`, `render.go:233-249`), но заперт: fast-path в `runStage` +(`runner.go:899`) доверяет строке `chunk_status` только при `cs.SnapshotID == snapID`, поэтому +через смену снапшота не срабатывает. Лгущие комментарии `runner.go`/`coverage.go` про «re-classify from checkpoint for FREE» уже -исправлены в этом пакете (Задача 1a): сегодня это правда ТОЛЬКО внутри неизменного снапшота. -Цель спеки — **сделать это правдой и через смену снапшота**, без потери snapshot-гарантий. +исправлены (пакет 09.07): сегодня это правда ТОЛЬКО внутри неизменного снапшота. Цель — сделать это +правдой и через смену снапшота, без потери snapshot-гарантий и без ре-открытия stale-serve. -## 2. Ключевое наблюдение: снапшот смешивает ДВА разных класса входов +## 2. Ключевое наблюдение: снапшот смешивает ДВА класса входов + ТРЕТИЙ, избыточный -`snapshotID` сегодня сворачивает вместе: +`snapshotID` сегодня сворачивает вместе три категории, хотя ключ вызова нуждается только в одной: -**(A) Wire-определяющие входы** — меняют БАЙТЫ, отправленные провайдеру, значит меняют сам -ответ, значит требуют нового вызова: -- `brief_hash`, `chunker_version`, `memory_version`, `context_assembly` — **все они уже входят в - `msgsContentHash`**, потому что рендер (метаданные книги в шаблоне, нарезка → `ch.Text`, - инъекция глоссария как ОТДЕЛЬНОЕ сообщение) целиком материализуется в `msgs`. Это - belt-and-suspenders: фактический per-chunk рендер — источник истины. -- `model`, `temperature`, `reasoning`, `jsonOnly`, `maxTokens` — уже ОТДЕЛЬНЫЕ поля `RequestHash` - (не через снапшот). -- `capability` (budget-поле / temp-режим / reasoning-контроль), `model_extra`, - провайдер-оверрайды (local temp/max_tokens/model), эскалационные capability/оверрайды — **НЕ в - `msgs` и НЕ отдельные поля**: меняют тело запроса, но сегодня заходят в ключ только через - снапшот. Это единственная wire-релевантная часть снапшота, не покрытая иначе. -- `estimator_version`, `max_tokens_policy` — влияют на `maxTokens`, а он уже отдельное поле; сами - ВЕРСИИ избыточны (фактический `maxTokens` в ключе). +**(A) Wire-определяющие, УЖЕ покрытые иначе** (msgs/content_hash или прямые поля `RequestHash`). +`brief_hash`, `chunker_version`, `context_assembly` (инъекционные ручки), `memory_version` +(матчер+инъекция) — всё материализуется в `msgs` (метаданные книги через шаблон `render.go:108-121`, +нарезка → `ch.Text`, инъекция глоссария как ОТДЕЛЬНОЕ сообщение `render.go:174-190`) → +`msgsContentHash`. `model`, `temperature`, `reasoning`, `jsonOnly`, `maxTokens` — уже ПРЯМЫЕ поля +`RequestHash`. `estimator_version`, `max_tokens_policy`, `max_output_ratio`, `min_max_tokens` — лишь +ДЕРИВИРУЮТ `maxTokens`, а его фактическое значение уже прямое поле. -**(B) Вердикт-определяющие входы** — меняют, как ответ КЛАССИФИЦИРУЕТСЯ, но НЕ байты запроса: -- `classifier_version` (порядок/пороги intrinsic classify), `coverage` (enabled + пороги + - `coverage_gate_version`), `postcheck_gate` (on/off; сам АЛГОРИТМ post-check — в `memory_version`, - но он влияет на вердикт, не на wire… см. §6 нюанс). +**(B) Wire-определяющие, НЕ покрытые иначе.** Резолвнутая `capability`, `model_extra`, +провайдер-оверрайды (local `temp`/`max_tokens`/`model`), эскалационные `escalate_to` + +capability/extra/оверрайды, `pipeline_core` (C0…C3 — механика сборки), `cache_ttl` (cache-control на +проводе, не в `content_hash`). Меняют ТЕЛО запроса, но не заходят в ключ никак, кроме снапшота — это +единственная wire-часть снапшота, которую надо сохранить в ключе. -Сегодня (B) сидит в `RequestHash` → вердикт-only правка промахивается по всем чекпоинтам и -пере-оплачивает книгу. Это и есть дефект. +**(C) Вердикт-определяющие** — меняют, как ответ КЛАССИФИЦИРУЕТСЯ, но НЕ байты запроса: +`classifier_version` (`classifyOutput` → `classify`, `runner.go:201-219`), `coverage` +(enabled+пороги+`coverageGateVersion`), `postcheck_gate` (on/off) и алгоритм/данные post-check +(`memoryPostcheckVersion` + decl инъектнутых записей при gate ON — `computeMemoryVersion`, +`memory.go:225-229`). Все они пересчитываются ВЖИВУЮ на resume и **в `chunk_status` не хранятся** +(классификатор/coverage — в `classifyOutput`; post-check — в `translateChunk` над финальным `prev`, +`runner.go:774-788`). Значит из ключа вызова их можно убрать целиком. -## 3. Дизайн: расщепить снапшот на wire-часть и вердикт-часть +Сегодня (B) и (C) сидят в `RequestHash` → вердикт-only или book-global-wire правка промахивается по +ВСЕМ чекпоинтам и пере-оплачивает книгу. Это и есть дефект. -- **`wireSnapshotID`** = хеш ТОЛЬКО (A)-входов, НЕ покрытых `msgs`/прямыми полями: резолвнутая - `capability`, `model_extra`, провайдер-оверрайды (temp/max_tokens/model), эскалационные - capability/extra/оверрайды. (Всё остальное wire-определяющее уже в `msgsContentHash` или в - прямых полях `RequestHash`.) -- **`verdictSnapshotID`** = хеш (B)-входов: `classifier_version`, coverage-конфиг+версия, - `postcheck_gate`-состояние. +## 3. Дизайн: три хеша вместо одного снапшота-в-ключе -**`RequestHash` (ключ чекпоинта) = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temp, -reasoning, jsonOnly, maxTokens, `wireSnapshotID`, msgs).** — убираем `snapshotID`, добавляем -`wireSnapshotID`. Вердикт-версии из ключа чекпоинта **уходят**. +Расщепляем `snapshotID()` и вводим отдельный fast-path-гард. Появляются ТРИ независимых хеша, каждый +на своём слое: -Полный `snapshotID` (для `jobs.snapshot_id`, `snapshots`-таблицы, отчётности) остаётся как -`hash(wireSnapshotID, verdictSnapshotID)` — но перестаёт быть частью ключа вызова. +### 3.1 `wireSnapshotID` — ПЕР-СТАДИЙНЫЙ, идёт в `RequestHash` +Хеш ТОЛЬКО категории (B) для КОНКРЕТНОЙ стадии: резолвнутая `capability`, `model_extra`, +провайдер-оверрайды (`ProviderTemp`/`ProviderMaxTok`/`ProviderModel`), эскалационные +`EscalateTo`+`EscalateCapability`+`EscalateExtra`+`EscalateProvider*`, `pipeline_core`, `cache_ttl`. +Префикс версии `tm-wire-v1`. -### Что тогда re-bill-ится, а что бесплатно (операторский контракт) +**Пер-стадийность — часть выигрыша гранулярности.** Сегодня `snapshotID` book-global: правка +`capability` стадии-2 (редактор) меняет `RequestHash` стадии-1 (черновик). В v2 `RequestHash` +стадии-1 несёт `wireSnapshotID` ТОЛЬКО стадии-1 → правка редактора не пере-оплачивает черновик +(каскад на редактор идёт честно через изменённый `prev`, §10). Book-global входы (`pipeline_core`, +`cache_ttl`) кладём в `wireSnapshotID` каждой стадии одинаковым значением. -| Изменение | Класс | Эффект | -|---|---|---| -| Флип `coverage.enabled`, правка порогов coverage; бамп `classifier_version`; toggle/пороги `postcheck_gate` | (B) вердикт | **$0** — `wireSnapshotID`+`msgs` не меняются → чекпоинт ХИТ → только **re-classify** | -| Append approved-терминов | (A) через `msgs` | Re-bill **только тех чанков, где новый термин ФАЕРИТСЯ** (их инъекция → `msgs` меняются); чанки, где не фаерится — **$0** (идентичная инъекция → идентичный `msgs`) | -| Правка book-brief, меняющая метаданные части глав | (A) через `msgs` | Re-bill только затронутых чанков | -| Правка промпт-шаблона, смена модели/temp/reasoning, правка `capability`, ре-чанкинг, смена estimator/maxtok-политики (меняет фактический `maxTokens`) | (A) wire | Re-bill (честно: тело/ответ реально другие) | +Смена `wireSnapshotID` стадии → `RequestHash` этой стадии по всей книге меняется → честный re-bill +(эти входы реально меняют тело/ответ каждого вызова; v1 §3 это уже фиксировал). -**Онгоинг-выигрыш (флагман «ИИ-фабрик»):** подтверждение пачки терминов на границе тома больше не -пере-покупает готовые главы — только те чанки, где термин действительно матчится. Это прямо снимает -D15-мину онгоинга. +### 3.2 `verdictSnapshotID` — book-global, идёт в `chunk_status.verdict_snapshot` (НЕ в `RequestHash`) +Хеш категории (C): `classifier_version`, coverage-конфиг+`coverageGateVersion`, `postcheck_gate`, +`memoryPostcheckVersion` + decl-контент инъектируемых записей при gate ON (§6). Префикс +`tm-verdict-v1`. В ключ вызова НЕ входит. Хранится в `chunk_status` как триггер бесплатного +re-classify на fast-path (§5, §8). -## 4. Resume: когда байт-идентичный запрос легально переиспользуется +### 3.3 `guard_hash` — ПЕР-СТАДИЙНЫЙ, хранится в `chunk_status`, гейт fast-path (НЕ в `RequestHash`) +Хеш ВСЕГО wire stage-плана для чанка×стадии — суперсет того, что fast-path обязан проверить перед +доверием строке (закрывает дыру (a), §5): +`guard_hash = H(tm-guard-v1, content_hash, wireSnapshotID, model, temperature, reasoning, jsonOnly, +estimatorVersion, maxOutputRatio, minMaxTokens, maxTokensPolicyVersion)`. +Прямые wire-поля (`model`/`temperature`/`reasoning`/`jsonOnly`) и ДЕРИВАЦИЯ `maxTokens` +(версии+ratio+floor; сам текст для деривации уже в `content_hash` через `msgs`) — то, чего `content_hash` ++ `wireSnapshotID` не покрывают. `escalate_to` и эскалационная wire уже внутри `wireSnapshotID`. -Reuse чекпоинта легален ⟺ **полная wire-идентичность** совпадает: `msgsContentHash` И не-msgs -wire-параметры (`wireSnapshotID` + прямые поля model/temp/reasoning/maxTokens). Это ровно то, что -даёт новый `RequestHash` — совпал ключ ⇒ совпал wire ⇒ переиспользуем оплаченный ответ (то же -основание, что у сегодняшнего same-snapshot resume; провайдер недетерминирован при temp>0, но -чекпоинт — валидный ответ на ЭТОТ wire). +### 3.4 Итоговый `RequestHash` (ключ чекпоинта), бамп `tm-request-v2`→`tm-request-v3` +`RequestHash = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temperature, reasoning, +jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wireSnapshotID`; всё остальное +как в `render.go:198`. Вердикт-версии из ключа **уходят**. Прямые wire-поля остаются прямыми (были и +есть) — это и есть покрытие, которого не хватало fast-path-у. -**Вердикт-различие НИКОГДА не блокирует reuse** — доказуемо wire-нейтрально (см. §5). +Полный `snapshotID` (для `jobs.snapshot_id`, `snapshots`-таблицы, отчётности) сохраняется как +`H(все wireSnapshotID стадий, verdictSnapshotID)`, но перестаёт быть частью ключа вызова и +демотируется до advisory (§9). -## 5. Re-classify по новым порогам (и почему это безопасно, F1) +## 4. Полный полевой маппинг снапшота (обязательно, ревью п.2) -Машинерия re-classify **уже есть**: на checkpoint-hit `runAttempt` вызывает `classifyOutput` над -текстом чекпоинта (не доверяет старому вердикту). Единственная правка — чтобы чекпоинт ХИТИЛ при -вердикт-only смене (§3 убирает вердикт-версии из ключа). Тогда: +Каждое поле, сегодня сворачиваемое в `snapshotID()` (`runner.go:263-431`) + `memoryVersion` +(`memory.go:232-259`) + coverage/classifier/postcheck, классифицировано: +**wire** (влияет на байты запроса) / **verdict** (влияет на классификацию) / **dropped-as-redundant** +(уже покрыто иначе). Колонка «Куда в v2»: `content_hash` (msgs), прямое поле `RequestHash`, +`wireSnapshotID`, `verdictSnapshotID`, `guard_hash`, либо dropped. -- **Fast-path `resumeFromChunkStatus`** (короткое замыкание до `runAttempt`): доверять строке - `chunk_status` только когда совпали `content_hash` И `wireSnapshotID` И `verdictSnapshotID`. При - СМЕНЕ `verdictSnapshotID` — **не** отдавать старый вердикт: проваливаться в `runAttempt`, где - чекпоинт хитит по wire-идентичности → re-classify бесплатно → re-upsert `chunk_status` с новым - вердиктом. Нужна колонка `chunk_status.verdict_snapshot` (в дополнение к `content_hash`). -- **Вердикт-флип на re-classify:** `flagged→ok` (ослабили гейт) → текст чекпоинта отдаётся - бесплатно. `ok→flagged` (ужесточили) → чанк флагается; если это влечёт НОВЫЙ wire-запрос (retry - на attempt+1, или эскалация на другую модель) — он честно оплачивается (это реально новая работа, - не re-pay старой). Ретраи/эскалация уже сидят на своих осях ключа (`attempt`, модель эскалации). +| Поле (сегодня) | Класс | Куда в v2 | Почему | +|---|---|---|---| +| `brief_hash` | wire | **dropped** → `content_hash` | Метаданные книги рендерятся в шаблон (`render.go:108-121`) → в `msgs`; per-chunk `content_hash` ловит правку брифа гранулярно. Caveat: поле `Adult` (`book.go:151,156`) и НЕрендеримые плейсхолдеры не в `msgs` — они wire-нейтральны by construction (не уходят на провод); если `Adult` когда-либо станет wire/verdict-влияющим — вернуть в `wireSnapshotID` (или `verdictSnapshotID`). | +| `chunker_version` | wire | **dropped** → `content_hash` | Правила чанкера дают `ch.Text` → `msgs`. Ре-чанкинг меняет границы/число чанков → `content_hash` почти всех → честный broad re-bill, гранулярно. | +| `estimator_version` | wire (деривация) | **guard_hash** (не в `RequestHash`) | Деривирует `maxTokens`, а фактический `maxTokens` — уже прямое поле `RequestHash`. В ключе избыточен; fast-path нуждается — кладём в `guard_hash`. Бамп busts fast-path каждого чанка; платят только те, чей `maxTokens` реально сменился (остальные — checkpoint-hit, re-classify $0). | +| `max_tokens_policy` | wire (деривация) | **guard_hash** | Как `estimator_version`, для attempt≥1 (`maxTokensForAttempt`). Избыточен в ключе (фактический `maxTokens` там), нужен fast-path-у. | +| `max_output_ratio` | wire (деривация) | **guard_hash** | Множитель `maxTokens`. То же. | +| `min_max_tokens` | wire (деривация) | **guard_hash** | Пол `maxTokens`. То же. | +| `context_assembly.glossary_injection` | wire | **dropped** → `content_hash` | Режим инъекции меняет наличие/содержимое инъекц-сообщения → `msgs`. | +| `context_assembly.glossary_token_budget` | wire | **dropped** → `content_hash` | Бюджет → eviction → инъектнутые строки → `msgs` (§10). | +| `context_assembly.stm_depth` | wire | **dropped** → `content_hash` | STM входит в `msgs`. | +| `context_assembly.overlap_tokens` | wire | **dropped** → `content_hash` | Overlap меняет `ch.Text` → `msgs`. | +| `context_assembly.cache_ttl` | wire (cache-control) | **wireSnapshotID** | НЕ в `content_hash` (в `msgs` только `Role`+`Content`; `msgsContentHash` опускает `CacheBoundary`/TTL). Response-нейтрален → reuse безопасен; пиним консервативно, чтобы смена TTL честно триггерила свежий вызов, но fast-path на `content_hash` не рушится (см. примечание ниже таблицы). | +| `model` (пер-стадийный) | wire | **dropped из снапшота** → прямое поле `RequestHash` + `guard_hash` | `RequestHash` уже несёт `model` вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность. | +| `temperature` (`st.Temperature`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же. Именно этого не хватало fast-path-у v1 (дыра a). | +| `reasoning` (`st.Reasoning`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же (low→none — дыра a). | +| `jsonOnly` | wire | прямое поле `RequestHash` + `guard_hash` | Уже прямое (сейчас всегда `false`); держим в `guard_hash` для полноты. | +| `maxTokens` (фактический) | wire | прямое поле `RequestHash`; деривация → `guard_hash` | Значение — прямое поле; версии деривации — в `guard_hash` (выше). | +| `capability` (ResolveCapability) | wire | **wireSnapshotID** + `guard_hash` | Резолвнутая wire-форма (budget-ключ, temp-режим, thinking-выключатель); не в `msgs`, не прямое поле. | +| `model_extra` (`ExtraBody`) | wire | **wireSnapshotID** + `guard_hash` | Меняет тело (GLM thinking-off и т.п.); не в `msgs`. | +| `provider_temp` (local) | wire | **wireSnapshotID** + `guard_hash` | Оверрайд поверх wire ПОСЛЕ хеша; не в `msgs`. | +| `provider_max_tok` (local) | wire | **wireSnapshotID** + `guard_hash` | То же. | +| `provider_model` (local) | wire | **wireSnapshotID** + `guard_hash` | Фактически отвечающий local-бэкенд (8b→14b); самый импактный local-оверрайд. | +| `escalate_to` | wire | **wireSnapshotID** + `guard_hash` | Фолбэк-модель: меняет wire эскал-подвызова И резолвнутую диспозицию (случился ли хоп). | +| `escalate_capability` | wire | **wireSnapshotID** + `guard_hash` | Резолвнутая wire фолбэка. | +| `escalate_extra` | wire | **wireSnapshotID** + `guard_hash` | `extra_body` фолбэка. | +| `escalate_provider_temp/max_tok/model` | wire | **wireSnapshotID** + `guard_hash` | Провайдер-оверрайды фолбэка. | +| `pipeline_core` (C0…C3) | wire | **wireSnapshotID** + `guard_hash` | Механика сборки прохода (linear vs selection/fusion); консервативный catch-all wire-плана. | +| `prompt_version` (`st.PromptVersion`) | wire | **dropped** → `content_hash` | Человеческий лейбл; фактические байты промпта рендерятся в `msgs` (авторитет — рендер, не лейбл). | +| `prompt_sha256` | wire | **dropped** → `content_hash` | Шаблон рендерится в system/user сообщения → `msgs`. Рендер И ЕСТЬ `msgs`. | +| stage `name` | wire | **dropped из снапшота** → прямое поле `stage` + `content_hash` | `RequestHash` несёт `stage`; структура плана отражена в `msgs` через `prev`-цепочку. | +| stage `role` | wire | **dropped из снапшота** → прямое поле `role` + `content_hash` | `role` — прямое поле; он же выбирает инъекцию (translator/editor) → `msgs`. | +| `memory_version` (матчер+инъекция часть) = `memoryInjectVersion` | wire | **dropped** → `content_hash` | `memoryNormVersion` + матчер-часть `memoryMatchVersion` определяют, КАКИЕ записи инъектятся → инъекц-сообщение → `msgs`. §6. | +| `memory_version` (post-check часть) = `memoryPostcheckVersion` | verdict | **verdictSnapshotID** | Алгоритм/пороги post-check — вердикт, пересчитывается вживую (`translateChunk`). §6. | +| `postcheck_gate` (top-level + `gate:` в memory) | verdict | **verdictSnapshotID** | Toggle меняет резолвнутую диспозицию; пересчёт бесплатен. Сегодня двойной фолд (top-level `PostcheckGate` + `gate:bool` в `computeMemoryVersion`) → в v2 один раз. §6. | +| decl-контент инъектнутых записей | verdict (при gate ON) | **verdictSnapshotID** | Decl НЕ инъектится в `msgs`, но при gate ON влияет на post-check вердикт (`memory.go:225-229`). §6. | +| `classifier_version` | verdict | **verdictSnapshotID** | Пороги/порядок `classify`; пересчёт `classifyOutput` бесплатен на checkpoint-hit. | +| `coverage` (enabled+len_ratio+sent_cov_min+min_chunk_chars+`coverageGateVersion`) | verdict | **verdictSnapshotID** | Excision-гейт не трогает провод; `coverageCheck` пересчитывается вживую над текстом чекпоинта. | -**Ось безопасности (F1 — никакого тихого расходящегося re-pay):** reuse — precondition на ТОЧНОЕ -совпадение wire-идентичности (`msgsContentHash` — сильнейший гард: точные байты промпта+инъекции; -плюс не-msgs wire-параметры). Вердикт-смена меняет только post-settle классификацию, НИКОГДА не -запрос, поэтому re-classify матчнутого чекпоинта не может отдать расходящийся ПЕРЕВОД — только другой -ВЕРДИКТ на том же переводе (искомый бесплатный re-verdict). Опасный кейс — reuse чекпоинта, чей wire -на новом конфиге был бы ДРУГИМ — исключён требованием точного wire-match. +**Примечание к `cache_ttl`/`CacheBoundary`:** `RequestHash` включает `m.CacheBoundary` +(`render.go:219`), `msgsContentHash` — нет (`render.go:245-247`), а `guard_hash` строится из +`content_hash`. Следствие: смена cache-границы/TTL честно даёт свежий `RequestHash` (свежий вызов +если дошли до attempt-цикла), но fast-path на `content_hash`/`guard_hash` НЕ рушит reuse — это +корректно, т.к. cache-control response-нейтрален (валидный старый ответ переиспользуем). `cache_ttl` +в `wireSnapshotID` — консервативный пин для свежести ключа, не гард reuse. -## 6. Нюансы и открытые вопросы к оркестратору +## 5. HOLE (a): fast-path-гард обязан покрывать ВЕСЬ wire stage-план -1. **`memory_version` содержит `memoryMatchVersion` (алгоритм матчера).** Смена алгоритма матчера - меняет per-chunk ВЫБОР (какие записи инъектятся) → инъекция → `msgs` → корректно re-bill только - затронутых чанков. Значит `memory_version` из wire-ключа можно убрать (полагаться на `msgs`). - **НО:** post-check-алгоритм тоже версионируется `memoryMatchVersion`, а post-check — ВЕРДИКТ, не - wire. Дилемма: одна версия покрывает и wire-часть матчера, и вердикт-часть post-check. Развязка: - при реализации расщепить `memoryMatchVersion` на `memoryInjectVersion` (wire, покрыт `msgs`) и - `memoryPostcheckVersion` (вердикт, в `verdictSnapshotID`). До расщепления — держать - `memory_version` в wire-части (консервативно: лишний re-bill безопаснее тихого stale-вердикта). -2. **Гранулярность гейта.** Сегодня resnapshot-гейт — per-JOB (`jobs.snapshot_id`, глава×стадия). - Для онгоинга нужен per-CHUNK (`content_hash`) первичный гейт; job-снапшот демотируется до - грубого advisory. Раннер уже делает per-chunk `content_hash`-чек (`runStage`) — спека делает его - ПЕРВИЧНЫМ. Job-снапшот-mismatch перестаёт быть fail-loud стопом; заменяется на per-chunk решение. -3. **Экономика показа (D5 retro-patch).** Append-термин, фаерящийся в старых главах, ЗАКОННО их - re-bill-ит (термин должен примениться) — но оператору показать стоимость (`tmctl status` - projected-дельта или dry-run «сколько чанков затронет этот append»). Это UI-хвост, не F1. -4. **Миграция ключей.** Смена формулы `RequestHash` (убрать `snapshotID`, добавить `wireSnapshotID`) - — это смена ФОРМАТА ключа (как v1→v2 сейчас). Все существующие чекпоинты промахнутся ОДИН раз при - переходе на новую формулу. Приемлемо (одноразово, на границе Ф1.5); зафиксировать бампом - `tm-request-v2`→`v3` и задокументировать как осознанный one-time re-pin. -5. **`AMBIGUOUS-BILLED` / F3.** Ортогонально этой спеке (F3 — техдолг at-most-once); content-addressed - reuse не меняет F3-инвариант. +**Дефект v1.** Fast-path (`resumeFromChunkStatus`, короткое замыкание в `runStage:897-901` ДО +`runAttempt`) доверяет строке `chunk_status`, когда совпали `content_hash` + `wireSnapshotID` + +`verdict_snapshot`. Но `content_hash` (= `msgsContentHash`) НЕ содержит прямых wire-полей +`model`/`temperature`/`reasoning`/`maxTokens`, а `wireSnapshotID` (§3.1) их тоже не содержит (они — +прямые поля `RequestHash`, не категория B). Значит смена `temperature` 0.3→0.4, `reasoning` low→none +или деривации `maxTokens` НЕ меняет ни `content_hash`, ни `wireSnapshotID`, ни `verdict_snapshot` → +v1-fast-path ХИТИТ → отдаёт stale-текст, сгенерённый на СТАРОМ wire. Это ре-открытие stale-serve F1. +Настоящий `RequestHash` эти поля включает, но fast-path замыкается ДО attempt-цикла, где `RequestHash` +считается. -## 7. Минимальный план реализации (Ф1.5, после утверждения) +**Взвешены два варианта:** -1. Расщепить `snapshotID()` → `wireSnapshotID()` + `verdictSnapshotID()`; `snapshotID` = их хеш. -2. `RequestHash`: `snapshotID` → `wireSnapshotID` (бамп `tm-request-v3`). -3. `chunk_status`: добавить `verdict_snapshot` (миграция), писать/читать; fast-path доверяет строке - только при совпадении `content_hash`+`wireSnapshotID`+`verdict_snapshot`, иначе проваливается в - `runAttempt` (re-classify бесплатно). -4. Job-снапшот-mismatch: демотировать из fail-loud стопа в per-chunk `content_hash`-решение. -5. (Опц.) расщепить `memoryMatchVersion` на inject/postcheck версии (§6.1). -6. Тесты: (а) вердикт-only смена → $0 re-classify, вердикт обновлён; (б) append-термин → re-bill - ТОЛЬКО фаерящихся чанков, остальные $0; (в) wire-смена (промпт/модель/capability) → честный - re-bill; (г) F1: никакой матч не отдаёт расходящийся текст (mutation: ослабить wire-match → - тест ловит stale-serve). +- **(1) Отдельная колонка `guard_hash`** (§3.3): хеш ВСЕГО wire stage-плана (`content_hash` + + `wireSnapshotID` + `model`/`temp`/`reasoning`/`jsonOnly` + деривация `maxTokens` + `escalate_to` + внутри `wireSnapshotID`). НЕ часть `RequestHash` — только гейт доверия fast-path. На fast-path + пересчитываем `guard_hash` из ТЕКУЩЕГО конфига и сравниваем со строкой. +- **(2) Verify-by-recompute**: на fast-path пересчитать полные wire-параметры победившего attempt и + сравнить, храня в строке всё нужное для пересчёта (модель/temp/reasoning/фактический maxTokens + победителя, capability и т.д.). + +**Рекомендация — вариант (1), `guard_hash`.** Проще и строго-сильнее: +- Один детерминированный хеш вместо хранения и по-полевого сравнения ~десятка параметров (вар. 2 + раздувает `chunk_status` и дублирует логику резолва). +- **Строго сильнее текущего гарда**: сегодня fast-path проверяет `snapshot_id`+`content_hash`; + `guard_hash` покрывает `content_hash` (⊇) ПЛЮС весь wire-план, включая то, что `snapshot_id` терял + при пер-стадийном расщеплении. Смена `temperature`/`reasoning`/`maxTokens`-деривации/`escalate_to` + → `guard_hash` mismatch → fast-path проваливается в attempt-цикл → там `RequestHash` (с прямыми + полями) промахивается → **честный свежий вызов** (это реально другая работа, не re-pay). +- **Разделение слоёв**: `RequestHash` — ключ оплаченного ответа; `guard_hash` — гард доверия + материализованной строки-резолва. Держать их разными хешами (а не пихать всё в `RequestHash`) + сохраняет пер-стадийную гранулярность ключа и не смешивает деривац-версии (которые в ключе + избыточны) с ключом. + +**Fast-path v2** (`runStage`): доверять строке ⟺ +`cs.guard_hash == guard_hash(тек.) && cs.verdict_snapshot == verdictSnapshotID(тек.) && +cs.disposition != skipped`. `content_hash` продолжаем хранить (сигнатура msgs — нужна dry-run’у §7 и +отчётности), но гейтом служит `guard_hash` (он его суперсет). При mismatch `guard_hash` — +проваливаемся в attempt-цикл (свежий вызов, если wire реально другой); при mismatch только +`verdict_snapshot` (guard совпал) — проваливаемся в `runAttempt`, где checkpoint ХИТИТ по +wire-идентичности → **re-classify бесплатно** → re-upsert строки с новым `verdict_snapshot` и +диспозицией (§8). + +## 6. HOLE (b): расщепление `memory_version` — ОБЯЗАТЕЛЬНО + +`memoryMatchVersion` (`memory.go:31-39`) сегодня версионирует ОБА: матчер/селекцию инъекции И +алгоритм post-check (слаг буквально несёт `…+postcheck-declaware`). `computeMemoryVersion` +(`memory.go:232-259`) сворачивает `memoryNormVersion` + `memoryMatchVersion` + `gate:bool` + контент +строк (approved-only при gate OFF; ВСЕ строки, включая `decl`+`status`, при gate ON). Всё это сегодня +→ `MemoryVersion` → `snapshotID` → ключ. Правка → промах по книге. Ревью-инсайт: **post-check +пересчитывается вживую на каждом resume (`translateChunk:774-788` над финальным `prev`) и в +`chunk_status` НЕ хранится — значит `memory_version` можно убрать из ключа вызова целиком.** + +**Расщепление (обязательное, не опциональное):** + +- **`memoryInjectVersion`** (wire) = `memoryNormVersion` + матчер/селекция-часть `memoryMatchVersion` + + инъектируемый контент approved-записей (`src`/`dst`/aliases — то, что рендерится в + `renderGlossaryBlock`/`renderEditorConstraintBlock`). Определяет, ЧТО и КАК инъектится → + инъекц-сообщение → `msgs` → `content_hash`. **Из ключа УБИРАЕТСЯ полностью; отдельным полем ключа + НЕ является** — покрыт `content_hash` пер-чанково (это и даёт гранулярный re-bill append-а, §10). +- **`memoryPostcheckVersion`** (verdict) = алгоритм+пороги post-check + `gate:bool` (`postcheck_gate`) + + **`decl`-контент ВСЕХ инъектируемых записей при gate ON**. Уходит в **`verdictSnapshotID`** + (§3.2), пересчитывается на resume бесплатно. + +**Что именно ПОКИДАЕТ wire-ключ:** весь `memory_version`. Матчер/нормализация/инъект-контент — их +эффект целиком в `msgs`. **Что входит в `verdictSnapshotID`:** +1. `memoryPostcheckVersion` (алгоритм/пороги post-check); +2. `gate:bool` — судьба `postcheck_gate` **явно**: это ВЕРДИКТ-сторона (toggle меняет резолвнутую + диспозицию `glossary_miss`, `runner.go:782-787`), кладём в `verdictSnapshotID`. Сегодняшний + двойной фолд (top-level `PostcheckGate` в `snapshotID` + `gate:` в `computeMemoryVersion`) + схлопывается в один источник — `verdictSnapshotID`; +3. **decl-контент** инъектируемых записей — судьба **явно**: `decl` НЕ инъектится в `msgs`, но при + gate ON участвует в post-check вердикте (`memory.go:225-229` — «decl edit would silently flip a + resumed chunk's disposition»). Значит `decl` — ВЕРДИКТ-сторона → в `verdictSnapshotID` (только при + gate ON, зеркаля текущее условие `gateOn` в `computeMemoryVersion:238`). При gate OFF `decl` + не влияет ни на wire, ни на вердикт → не входит никуда (dropped). + +**Почему это корректно и бесплатно.** Post-check материализуется из банка (пере-материализуется +каждый прогон, `seedGlossary`→`materializeMemory`) и гоняется вживую в `translateChunk` над `prev` +независимо от того, пришёл `prev` из свежего вызова или из fast-path-резолва. Правка `decl`/порогов +→ новый банк → новый вердикт post-check → но **wire идентичен → $0**. `verdictSnapshotID` в +`chunk_status.verdict_snapshot` делает такую правку (а) видимой статусу (§9) и (б) триггером +пере-резолва per-stage fast-path (classifier/coverage тоже вердикт, но пересчитываются ТОЛЬКО в +`runAttempt`/`translateChunk`, не в `resumeFromChunkStatus` — потому `verdict_snapshot` busts +fast-path и заставляет пройти attempt-цикл, где всё пере-классифицируется бесплатно). Post-check-часть +в `verdict_snapshot` формально belt-and-suspenders (post-check и так live на chunk-level), но держит +`verdict_snapshot` полным отпечатком вердикт-конфига. + +## 7. HOLE (c): замена loud-гейта согласия на pre-flight dry-run + +**Что демонтируется.** Сегодня рассинхрон снапшота — fail-loud стоп (`runStage:865-869`; +`--resnapshot` = явное согласие, Р6-класс «согласие на трату»). Content-addressed resume демотирует +job-snapshot-mismatch из fail-loud в пер-чанковое `content_hash`/`guard_hash`-решение (§8, §9) — то +есть **удаляет этот гейт согласия**. Его нельзя просто выкинуть: Р6-класс согласия на трату должен +быть ЗАМЕНЁН, иначе тихая переоплата возвращается через заднюю дверь. + +**Замена — pre-flight dry-run.** Перед `translate`, который сменил бы конфиг (и всегда в +онгоинг-режиме) прогоняем СУХОЙ проход (детерминированный, $0, без LLM): + +1. **Проекция (источник — тот же пер-чанковый `content_hash`/`guard_hash`-диф, что и на fast-path).** + Для каждого чанка×стадии материализуем ТЕКУЩИЕ `msgs` (`MessagesWithInjection` — дёшево, только + стр__ковая подстановка) и считаем `content_hash` + `guard_hash`. Сравниваем со СТРОКОЙ + `chunk_status`. Единица «будет re-bill», если её `guard_hash` отличается от хранимого И хранимая + диспозиция была оплачена (ok/flagged). Этот диф **естественно ловит все эффекты второго порядка** + (sticky, eviction, каскад на редактор — §10), потому что он пере-материализует `msgs` per + chunk×stage, а не рассуждает о них аналитически. +2. **Стоимость.** Проецируемый $ = Σ по изменившимся единицам их хранимого `chunk_status.cost_usd` + (честная прошлая фактическая цена; для каскадных downstream-единиц без прошлой цены — оценка + `ledger.EstimateUSD` над новыми `msgs`). Выдаём «**N чанков будет пере-оплачено, ~$X**». +3. **Порог + флаг.** Порог `rebill_consent_usd` (конфиг книги; дефолт, напр., `$0.50` ИЛИ ≤5% от + `ProjectedBookUSD`, что меньше). Ниже порога — **проходим автоматически** (append-термина в 3 + чанках стоит центы — без трения согласия). Выше порога — **отказ без явного флага + `--accept-rebill`** (зеркалит старую семантику `--resnapshot`, но теперь content-scoped: гейт + срабатывает только на РЕАЛЬНУЮ проецируемую трату, а не на любой вердикт-only $0-change). + +Это сохраняет Р6-класс согласия на трату, убирая ложные тревоги (вердикт-only смена больше не просит +согласия — она $0). `--resnapshot` как флаг остаётся синонимом-алиасом для обратной совместимости, но +его семантика — «прими проецируемый re-bill», а не «пере-пинуй всё». + +## 8. Resume: легальность reuse и безопасность re-classify (F1) + +Reuse чекпоинта легален ⟺ **полная wire-идентичность**: `guard_hash` совпал (⊇ `content_hash` + +`wireSnapshotID` + прямые wire-поля + деривация `maxTokens`). Это ровно то, что даёт новый +`RequestHash` на attempt-оси: совпал `guard_hash` ⇒ совпал wire ⇒ `RequestHash` попадёт в оплаченный +ответ (то же основание, что у сегодняшнего same-snapshot resume). **Вердикт-различие +(`verdict_snapshot`) НИКОГДА не блокирует reuse перевода** — только заставляет пере-классифицировать. + +Машинерия re-classify уже есть: на checkpoint-hit `runAttempt` (`runner.go:1094`) зовёт +`classifyOutput` над текстом чекпоинта, не доверяя старому вердикту; post-check — `translateChunk` +над `prev`. Правки: +- **Fast-path `resumeFromChunkStatus`**: гейт §5 (`guard_hash` + `verdict_snapshot`). При смене + `verdict_snapshot` (guard совпал) — НЕ отдавать старую диспозицию: проваливаться в `runAttempt` → + checkpoint хитит по wire → re-classify бесплатно → re-upsert `chunk_status` с новыми + `verdict_snapshot`/диспозицией. Нужны колонки `chunk_status.guard_hash`, `chunk_status.verdict_snapshot`. +- **Вердикт-флип:** `flagged→ok` (ослабили гейт) → текст чекпоинта отдаётся бесплатно. `ok→flagged` + (ужесточили) → чанк флагается; если это влечёт НОВЫЙ wire-запрос (retry на `attempt+1` или + эскалация) — честно оплачивается (реально новая работа). Ретраи/эскалация уже на своих осях ключа + (`attempt`, модель эскалации). + +**Ось безопасности F1 (никакого тихого расходящегося re-pay).** Reuse — precondition на ТОЧНОЕ +совпадение wire-идентичности (`guard_hash` — сильнейший гард: точные байты промпта+инъекции ПЛЮС все +прямые wire-поля и деривация maxTokens, чего v1 не покрывал). Вердикт-смена меняет только post-settle +классификацию, НИКОГДА не запрос — потому re-classify матчнутого чекпоинта не может отдать +расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей +wire на новом конфиге был бы ДРУГИМ из-за temp/reasoning/maxTokens) — теперь исключён `guard_hash`. + +## 9. Судьба `chunk_status.snapshot_id` и status-ридеров + +- **`chunk_status.snapshot_id`** — **сохраняется как advisory** (какой job-снапшот резолвнул строку; + отчётность), но **перестаёт быть гейтом корректности**. Первичный resume-гейт — `guard_hash` + + `verdict_snapshot` + `content_hash` (§5). Условие `cs.SnapshotID == snapID` (`runStage:899`) + снимается. +- **`SnapshotDrift`** (`status.go:53-55,283-284`: >1 snapshot_id среди строк) — **демотируется до + информационного advisory**, не сигнала тревоги: при content-addressed resume сосуществование строк + под разными job-снапшотами НОРМАЛЬНО (wire-идентичные чанки, резолвнутые в разное время). Больше не + «loud». +- **`ConfigDrift`** (`status.go:56-61,295-299`: текущий конфиг рендерит иной снапшот, чем хранимый) — + **заменяется/дополняется** проекцией dry-run (§7): вместо булева «снапшот отличается» показываем + **«проецируемый re-bill: N чанков, ~$X»** из того же пер-чанкового `guard_hash`-дифа. Равенство + снапшота остаётся грубой подсказкой; действенное число — пер-чанковая проекция (`currentSnapshotProjected`, + `status.go:347`, расширяется до пер-чанкового дифа). +- **Redrive drift-guard** (`status.go:462-480`, добавлен этой сессией — рефузит redrive при + `cs.SnapshotID != curSnap`): его резон-детр (drift → переоплата всей книги) **растворяется** + расщеплением — drift больше не пере-оплачивает неизменные чанки. Guard **эволюционирует**: грубое + сравнение snapshot-равенства заменяется на `guard_hash`-диф ТОЛЬКО целевых (сбрасываемых) чанков. + Redrive и так пере-гоняет цели под текущим wire — это его цель; поэтому жёсткий рефуз снимается, + заменяясь на pre-flight проекцию §7 (`--accept-rebill` при превышении порога) плюс сохранённый + инвариант «DispOK-стадии не сбрасываются» (D-лог п.5в). Итог: redrive не рушит флаг-телеметрию + громким отказом, а показывает проецируемую цену целей. + +## 10. Blast-radius approved-term APPEND (со sticky + eviction + каскадом стадий) + +**Первый порядок (главная история):** append approved-термина T re-bill-ит **только чанки, где T +ФАЕРИТСЯ**. T фаерится в чанке, если его нормализованный ключ встречается в `ch.Text` (для translator +`memory.go` Select) → T инъектится → инъекц-сообщение меняется → `content_hash` меняется → translator +этого чанка re-bill. Чанки, где T не фаерится → идентичная инъекция → идентичный `content_hash` → $0 +(checkpoint-hit, re-classify free). Это FIRST-ORDER контракт. + +**Второй порядок — три поправки (ловятся dry-run’ом §7 автоматически, т.к. он диффит `content_hash` +per chunk×stage, а не рассуждает аналитически):** + +1. **Sticky-кэрри (`stickyDepth = 2`, `memory.go:70-73`).** T, фаернувший в чанке K, попадает в + `activeIDs[K]` → `unionSticky` (`runner.go:577-588`) несёт его в K+1 и K+2 (в пределах ГЛАВЫ; + сброс на границе главы, `runner.go:549-552`). В K+1/K+2 T инъектится через sticky, даже если его + ключ там не встречается → их translator-инъекция меняется → `content_hash` → **K+1, K+2 тоже + re-bill**. Радиус = {фаерящие чанки} ∪ {их следующие 2 внутри главы}. +2. **F2 eviction (`memory.go` бюджет `glossary_token_budget`).** Инъекция бюджет-лимитирована. В + бюджет-ПОЛНОМ чанке добавление T ВЫТЕСНЯЕТ более низкоприоритетную ранее-инъектнутую строку L. Ключевой + второпорядковый случай: T, принесённый в K+1 через sticky (где T НЕ фаерится), потребляет бюджет и + вытесняет строку L' — **меняя `msgs` K+1 сверх просто +T, хотя сам T там не фаерится**. Eviction + НЕ расширяет множество чанков за пределы {фаерящие ∪ sticky}, но увеличивает дельту внутри них + (не «+T», а «+T−L»). +3. **Каскад на редактор.** Инъектируемый констрейнт-блок редактора — ПОДМНОЖЕСТВО инъекции черновика + (`editorInjection = renderEditorConstraintBlock(memSel.injected)`, `runner.go:717-719` — тот же + `memSel`). Значит: (i) T-подтверждённый попадает и в констрейнт-блок редактора → `msgs` редактора + меняются; (ii) черновик translator’а меняется (T применён) → вход `{{draft}}` редактора меняется + → `content_hash` редактора меняется. **Любой re-billed translator-чанк каскадит на свою editor-стадию.** + Первопорядковое «re-bill фаерящих чанков» надо читать как «re-bill фаерящих чанков × ОБЕ стадии». + +**Итоговый радиус:** `{фаерящие T} ∪ {sticky +2/глава}` × `{translator, editor}`; всё прочее — $0 +(checkpoint-hit → re-classify free). Онгоинг-выигрыш D15: подтверждение пачки терминов на границе +тома не пере-покупает готовые главы — только реально затронутые чанки×стадии, а pre-flight dry-run +(§7) показывает их число и цену ДО траты. + +## 11. Sequencing миграции + одноразовый re-pin + +Два изменения, оба ломающие ключ/схему: +- **Формула `RequestHash`**: `snapshotID`→`wireSnapshotID`, бамп `tm-request-v2`→`tm-request-v3` + (`render.go:212`). Это смена ФОРМАТА ключа → **все существующие чекпоинты промахнутся ОДИН раз**. +- **Схема**: миграция **v8** (текущий максимум — **v7** после cheap-gate миграции этой сессии: + `migrate.go` v7 = `ALTER TABLE retrieval_state ADD COLUMN n_style_flags/style_detail`): + `ALTER TABLE chunk_status ADD COLUMN guard_hash TEXT NOT NULL DEFAULT '';` + `ALTER TABLE chunk_status ADD COLUMN verdict_snapshot TEXT NOT NULL DEFAULT '';` + (`snapshot_id`, `content_hash` — сохраняются). Существующие строки получают пустой `guard_hash` → + fast-path у них всегда busts → падают в attempt-цикл, где `RequestHash` под v3 не совпадёт с v2 → + свежий вызов (тот самый одноразовый re-pin). Строки самозалечиваются с новыми хешами при первом + прогоне. + +**Ограничение порядка (жёсткое).** Миграция+бамп ДОЛЖНЫ приземлиться **ДО первой онгоинг-книги +(`add-chapters`)**. Иначе: если книга уже несёт v2-чекпоинты, а миграция приходит В СЕРЕДИНЕ её +жизни, первый `add-chapters` после миграции **пере-оплатит ВЕСЬ бэклог** (все прежние главы +пере-пинятся под v3) — ровно та D15-мина, которую спека снимает. Поэтому: +- Статичную приёмочную книгу Ф1 (D15.1 / D18 蛊真人) либо завершаем под v2, либо принимаем её + **одноразовый re-pin** (громкий, расхождений нет — D15.1) на границе Ф1.5. +- **Первая онгоинг-книга РОЖДАЕТСЯ под `tm-request-v3`** (начальные главы кейятся v3), чтобы + последующие `add-chapters` находили v3-ключи и не пере-пинали бэклог. +- **Одноразовый re-pin осознан и задокументирован** (как v1→v2 сейчас): ни одна онгоинг-книга не + должна пересекать миграцию в середине жизненного цикла. + +## 12. Минимальный план реализации (Ф1.5, после ратификации v2) + +1. **Расщепить `snapshotID()`** → `wireSnapshotID(stage)` (пер-стадийный, §3.1) + `verdictSnapshotID()` + (book-global, §3.2). Полный `snapshotID` = их хеш (advisory). +2. **`RequestHash`**: `snapshotID`→`wireSnapshotID`, бамп `tm-request-v3` (§3.4). +3. **`guard_hash`** (§3.3): функция `guardHash(content_hash, wireSnapshotID, model, temp, reasoning, + jsonOnly, estimator/ratio/floor/policy)`, префикс `tm-guard-v1`. +4. **Расщепить `memoryMatchVersion`/`computeMemoryVersion` (ОБЯЗАТЕЛЬНО, §6)** на `memoryInjectVersion` + (→ `msgs`, из ключа убрать) и `memoryPostcheckVersion` (+`gate`+`decl`-при-gateON → `verdictSnapshotID`). + Снять двойной фолд `postcheck_gate`. +5. **Миграция v8** (§11): колонки `guard_hash`, `verdict_snapshot`; писать/читать в + `chunkstatus.go` (Upsert/Get/ForBook). `snapshot_id` оставить (advisory). +6. **Fast-path (`runStage`)**: гейт `cs.guard_hash == guardHash(тек.) && cs.verdict_snapshot == + verdictSnapshotID(тек.) && disposition != skipped`; иначе `runAttempt` (re-classify бесплатно, §8). +7. **Job-snapshot-mismatch**: демотировать fail-loud стоп (`runStage:865-869`) в пер-чанковое + `guard_hash`-решение. +8. **Pre-flight dry-run (§7)**: пер-чанковый `guard_hash`-диф → «N чанков, ~$X»; порог + `rebill_consent_usd` + флаг `--accept-rebill`. `tmctl status`: заменить булев `ConfigDrift` на + проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф целей + проекция (§9). +9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl) → $0 re-classify, + вердикт обновлён; (б) append-термина → re-bill ТОЛЬКО фаерящих чанков + sticky+2 + editor-каскад, + остальные $0 (§10); (в) wire-смена (промпт/модель/**temperature**/**reasoning**/capability/ + estimator) → честный re-bill; (г) **F1/дыра-a мутация**: ослабить `guard_hash` (убрать temp) → + тест ловит stale-serve при temp 0.3→0.4; (д) dry-run порог: ниже — авто, выше — отказ без флага; + (е) миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`. + +## 13. Открытые вопросы оркестратору (решения, гейтящие ратификацию v2) + +1. **Пер-стадийная гранулярность `wireSnapshotID`/`guard_hash`** — это v2-уточнение над book-global + `snapshotID` v1: правка `capability`/`model_extra` РЕДАКТОРА больше не пере-оплачивает ЧЕРНОВИК + (§3.1). Выигрыш реальный, но добавляет поверхность реализации (хеш на стадию, а не на книгу). + **Подтвердить: берём пер-стадийную гранулярность или держим book-global** (проще, но грубее)? +2. **Дефолт порога dry-run** `rebill_consent_usd` (§7) и **имя флага**. Предложение: порог = + `min($0.50, 5% × ProjectedBookUSD)`; флаг `--accept-rebill` (отдельный от `--resnapshot`, который + остаётся алиасом). **Нужно решение владельца/оркестратора** — это Р6-класс согласия на трату. +3. **Caveat `Adult`** (`book.go`): дроп `brief_hash` опирается на `content_hash`, но булев `Adult` + НЕ рендерится ни в один шаблон → wire-нейтрален by construction (§4, строка `brief_hash`). Если + `Adult` когда-либо должен инвалидировать работу (напр. смена канала/цензуры) — его надо ЯВНО + вернуть в `wireSnapshotID` или `verdictSnapshotID`. **Подтвердить, что `Adult` остаётся + wire/verdict-нейтральным**, либо назначить ему слой. --- -**Резюме для оркестратора:** дефект — `snapshotID` в ключе вызова смешивает wire- и вердикт-входы. -Фикс — расщепить; ключ чекпоинта держать на wire-идентичности (по факту это `msgsContentHash` + -не-msgs wire-параметры), вердикт-версии убрать из ключа и re-classify-ить на resume бесплатно. -Безопасность (F1) сохраняется: reuse — на точном wire-match, вердикт-смена доказуемо wire-нейтральна. -Онгоинг-мина D15 снимается: append-термин re-bill-ит только реально затронутые чанки. Реализация — -малая и хирургическая (машинерия re-classify уже есть), но гейтит онгоинг Ф1.5 и требует ратификации. +**Резюме для оркестратора.** Дефект — `snapshotID` в ключе вызова смешивает wire-, вердикт- и +избыточные входы. Фикс v2: три хеша на трёх слоях — `wireSnapshotID` (пер-стадийный, в `RequestHash`), +`verdictSnapshotID` (в `chunk_status`, не в ключе), `guard_hash` (пер-стадийный суперсет wire-плана, +гейт fast-path). Три дыры закрыты: **(a)** `guard_hash` покрывает прямые wire-поля, которых fast-path +v1 терял (temp/reasoning/maxTokens/escalate) → нет ре-открытия stale-serve; **(b)** `memory_version` +расщеплён ОБЯЗАТЕЛЬНО — inject-часть уходит в `content_hash` (из ключа вон), post-check+gate+decl → в +`verdictSnapshotID` (пересчёт бесплатен); **(c)** loud-гейт согласия заменён pre-flight dry-run «N +чанков, ~$X» с порогом `--accept-rebill` (Р6 сохранён). Безопасность F1 усилена (guard_hash — сильнее +прежнего гарда). Онгоинг-мина D15 снята: append re-bill-ит только реально затронутые чанки×стадии. +Реализация мала и хирургична, но **остаётся ЗАБЛОКИРОВАННОЙ до ратификации v2**. diff --git a/backend/internal/config/book.go b/backend/internal/config/book.go index 00f879e..599602f 100644 --- a/backend/internal/config/book.go +++ b/backend/internal/config/book.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "gopkg.in/yaml.v3" @@ -34,12 +35,24 @@ type Book struct { Honorifics string `yaml:"honorifics"` // keep | adapt Transcription string `yaml:"transcription"` // e.g. polivanov | palladius Footnotes string `yaml:"footnotes"` // none | minimal | rich + // YoPolicy is the brief's ё-policy for the cheap yofikator gate: auto (flag only inconsistency + // — the same word both with ё and е) | all-yo | all-e. "" defaults to auto. A brief field: it + // changes the style-flag verdict, so it is folded into BriefHash (a change re-pins the snapshot). + YoPolicy string `yaml:"yo_policy"` + // StyleAllowlist exempts surfaces from the translit-interjection blocklist (a per-project + // allowlist, 04-unhappy §6): a character actually named «Ара» is not an untranslated filler. + // Lower-cased on load. Verdict-affecting → also folded into BriefHash. + StyleAllowlist []string `yaml:"style_allowlist"` // Wiring: paths are resolved relative to the book.yaml location. Pipeline string `yaml:"pipeline"` ModelsFile string `yaml:"models"` SourceFile string `yaml:"source_file"` // Фаза 0: один файл = один чанк - ProjectDB string `yaml:"project_db"` // default: .db рядом с book.yaml + // Encoding of the txt source: auto|utf8|gb18030 ("" defaults to auto). Real zh .txt are often + // GB18030 (the 蛊真人 acceptance book is), which the auto-detect handles; declare it explicitly + // to skip detection. Ignored for epub (its documents carry their own charset). See ingest.go. + Encoding string `yaml:"encoding"` + ProjectDB string `yaml:"project_db"` // default: .db рядом с book.yaml // GlossarySeed is the optional path to the manual glossary seed YAML (memory v2, // шаг 4). Its curated approved/draft terms + the classified ruby readings are the // deterministic inputs the glossary is REPLACED from each run; editing it changes the @@ -110,6 +123,29 @@ func LoadBook(path string) (*Book, error) { bad("glossary_seed %s is not readable: %v", b.GlossarySeed, err) } } + if b.Encoding == "" { + b.Encoding = "auto" + } + switch strings.ToLower(b.Encoding) { + case "auto", "utf8", "utf-8", "gb18030", "gbk", "gb2312": + default: + bad("encoding %q is not supported (use auto|utf8|gb18030)", b.Encoding) + } + if b.YoPolicy == "" { + b.YoPolicy = "auto" + } + switch b.YoPolicy { + case "auto", "all-yo", "all-e": + default: + bad("yo_policy %q is not supported (use auto|all-yo|all-e)", b.YoPolicy) + } + for i, s := range b.StyleAllowlist { + b.StyleAllowlist[i] = strings.ToLower(strings.TrimSpace(s)) + } + // Canonicalize (sort) the allowlist so a cosmetic reorder in book.yaml does not change BriefHash + // → the snapshot → force an unnecessary re-pin (self-review: it is a set, consumed order- + // independently in cheapGateConfig). + sort.Strings(b.StyleAllowlist) if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 { bad("ceilings: at least one of book_usd/day_usd must be set (ledger без потолка запрещён Р7)") } @@ -130,18 +166,20 @@ func (b *Book) BriefHash() string { // Без него правка заголовка молча инвалидировала бы чекпоинты в обход // snapshot-гейта (находка ревью). canon := struct { - BookID string `json:"book_id"` - Title string `json:"title"` - SourceLang string `json:"source_lang"` - TargetLang string `json:"target_lang"` - Genre string `json:"genre"` - Audience string `json:"audience"` - Adult bool `json:"adult"` - Venuti float64 `json:"venuti"` - Honorifics string `json:"honorifics"` - Transcription string `json:"transcription"` - Footnotes string `json:"footnotes"` - }{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes} + BookID string `json:"book_id"` + Title string `json:"title"` + SourceLang string `json:"source_lang"` + TargetLang string `json:"target_lang"` + Genre string `json:"genre"` + Audience string `json:"audience"` + Adult bool `json:"adult"` + Venuti float64 `json:"venuti"` + Honorifics string `json:"honorifics"` + Transcription string `json:"transcription"` + Footnotes string `json:"footnotes"` + YoPolicy string `json:"yo_policy"` + StyleAllowlist []string `json:"style_allowlist"` + }{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, b.Adult, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes, b.YoPolicy, b.StyleAllowlist} data, err := json.Marshal(canon) if err != nil { // A struct of scalars cannot fail to marshal; keep the signature clean. diff --git a/backend/internal/pipeline/cheapgates.go b/backend/internal/pipeline/cheapgates.go new file mode 100644 index 0000000..3579109 --- /dev/null +++ b/backend/internal/pipeline/cheapgates.go @@ -0,0 +1,546 @@ +package pipeline + +import ( + "fmt" + "sort" + "strings" + "unicode" +) + +// cheapgates.go: four cheap, deterministic post-check flaggers on the FINAL chunk text (04-unhappy +// §6/§9, план v3). They are OBSERVABILITY, never hard gates (like the glossary post-check's default +// flagger mode): a hit is recorded in the retrieval-state and surfaced in the report / chapter +// passport, but it never changes a chunk disposition or costs an LLM call. All four are pure and +// deterministic (no time/rand, sorted detail) so a resume re-derives identical counts. +// +// 1. dialogue-dash linter (Rosenthal §47–52): direct speech marked with straight quotes or a +// hyphen/en-dash instead of the em-dash on a new line, or a chunk mixing the two styles. +// 2. yofikator: inconsistent ё — the SAME word spelled both with ё and with е (Пётр/Петр), or a +// brief ё-policy (all-ё / all-е) violated. +// 3. translit-interjection blocklist: untranslated JP/EN fillers («ара-ара», «маа», «хмф», +// «нани», «ауч», «упс») left in the Russian output (04-unhappy §9); per-project allowlist. +// 4. 万/億 magnitude gate: a CJK myriad/hundred-million magnitude in the source whose order is not +// represented on the Russian side (三万 → «три миллиона» is a 100× error, 04-unhappy §9). +// +// FALSE POSITIVES are the main risk (quotations, stylization, homographs), so each rule is tuned +// for PRECISION over recall: it fires only on a high-confidence signal and stays silent on the +// ambiguous middle. The unit tests assert both firing AND non-firing. + +// cheapGateVersion versions the rules above. It is folded into the job snapshot (like +// classifierVersion): editing any rule is a loud --resnapshot, so the reported style-flag counts +// never shift silently between runs under the same snapshot. [D15.2 note: this is a VERDICT +// version — once content-addressed resume lands it moves to verdictSnapshotID and a rule edit +// re-classifies free instead of re-paying the book.] +const cheapGateVersion = "cheapgate-v1" + +// cheapGateConfig carries the brief-derived knobs: the ё-policy and the per-project allowlist of +// surfaces that look like a blocklisted interjection but are legitimate here (e.g. a character +// named «Ара»). Both come from book.yaml. +type cheapGateConfig struct { + yoPolicy string // "auto" (inconsistency only) | "all-yo" | "all-e" + allowlist map[string]bool // lower-cased surfaces exempt from the interjection blocklist +} + +// cheapGateResult is the per-chunk outcome: a count per flagger plus human-readable detail lines +// (deterministic order) for the report. total() is what the passport surfaces. +type cheapGateResult struct { + DialogueDash int `json:"dialogue_dash,omitempty"` + YoInconsistent int `json:"yo,omitempty"` + TranslitInterj int `json:"translit_interj,omitempty"` + NumberMagnitude int `json:"number_magnitude,omitempty"` + Detail []string `json:"detail,omitempty"` +} + +func (c cheapGateResult) total() int { + return c.DialogueDash + c.YoInconsistent + c.TranslitInterj + c.NumberMagnitude +} + +// runCheapGates runs all four flaggers over one chunk's source and FINAL translated text. +func runCheapGates(source, final string, cfg cheapGateConfig) cheapGateResult { + var r cheapGateResult + n, det := lintDialogueDash(final) + r.DialogueDash, r.Detail = n, append(r.Detail, det...) + n, det = lintYofikation(final, cfg.yoPolicy) + r.YoInconsistent = n + r.Detail = append(r.Detail, det...) + n, det = lintTranslitInterjections(final, cfg.allowlist) + r.TranslitInterj = n + r.Detail = append(r.Detail, det...) + n, det = lintNumberMagnitude(source, final) + r.NumberMagnitude = n + r.Detail = append(r.Detail, det...) + return r +} + +// --- 1. dialogue-dash linter ---------------------------------------------------- + +// lintDialogueDash flags direct-speech lines opened with the wrong marker. Russian direct speech +// takes an EM-dash «—» at the line start; MTL output leaves a straight ASCII quote or a plain +// hyphen/en-dash. It fires per offending line, and additionally when a chunk MIXES em-dash speech +// with chevron-quote «…» speech (a within-chapter style clash). Precision guards: a hyphen only +// counts as a mis-set dash when followed by a space and a letter (so a hyphenated word wrap or a +// «- 1» list item does not fire); chevron lines count only under mixing (a chunk that uses «…» for +// speech throughout may be a deliberate style, but mixing it with dashes is an inconsistency). +func lintDialogueDash(text string) (int, []string) { + var emDash, hyphenLike, chevronLead int + var detail []string + for _, line := range strings.Split(text, "\n") { + t := strings.TrimLeft(line, " \t ") + rs := []rune(t) + if len(rs) == 0 { + continue + } + switch rs[0] { + case '—': // em-dash: the correct Russian dialogue marker — count only true speech shape + if dialogueShape(rs[1:]) { // «— 15 минут спустя» (dash+space+digit, a scene break) is NOT dialogue (self-review) + emDash++ + } + case '"': // straight ASCII quote leading a line → Russian typography never uses these → MTL artifact + if quoteShape(rs[1:]) { + detail = append(detail, "прямая речь открыта прямой кавычкой \" вместо тире: "+preview(t)) + hyphenLike++ // counted in the offending total + } + case '-', '–': // hyphen / en-dash where an em-dash belongs + if dialogueShape(rs[1:]) { + detail = append(detail, "реплика через дефис/короткое тире вместо длинного «—»: "+preview(t)) + hyphenLike++ + } + case '«': // chevron-led line: only an issue if the chunk ALSO uses em-dash speech (mixing) + chevronLead++ + } + } + n := hyphenLike + if emDash > 0 && chevronLead > 0 { + n += chevronLead + detail = append(detail, fmt.Sprintf("смешение стилей прямой речи в чанке: %d реплик через «—» и %d через «…»", emDash, chevronLead)) + } + return n, detail +} + +// dialogueShape reports whether the runes after a leading marker look like spoken text: an optional +// space then a letter. It filters out non-dialogue leading dashes (word wraps, «-1», bare marks). +func dialogueShape(after []rune) bool { + i := 0 + for i < len(after) && (after[i] == ' ' || after[i] == ' ') { + i++ + } + if i == 0 { + return false // a marker glued to the next glyph (a hyphenated fragment), not «— реплика» + } + return i < len(after) && unicode.IsLetter(after[i]) +} + +// quoteShape reports whether the runes after a leading quote look like spoken text: an OPTIONAL +// space then a letter («"Привет»). Unlike a dash, a quote sits directly on the first word, so no +// space is required. +func quoteShape(after []rune) bool { + i := 0 + for i < len(after) && (after[i] == ' ' || after[i] == ' ') { + i++ + } + return i < len(after) && unicode.IsLetter(after[i]) +} + +// --- 2. yofikator --------------------------------------------------------------- + +// yoHomographEForms are е-spellings that are DISTINCT words from their ё-counterpart (все≠всё, +// небо≠нёбо, берет≠берёт, осел≠осёл, …). The inconsistency rule below would otherwise false-flag +// «все»+«всё» as one word spelled two ways. Expanded (self-review major) to cover the frequent +// distinct-word pairs and their common inflections; still not exhaustive — full disambiguation +// needs a ё-dictionary (deferred, B-tier). Names — the primary target (Пётр/Петр) — are never +// homographs, so they are always caught regardless. +var yoHomographEForms = map[string]bool{ + "все": true, "всех": true, "всем": true, "всеми":true, + "небо": true, "небом": true, + "узнаем": true, "узнаете": true, "узнает": true, + "падеж": true, "падежа": true, + "совершенный": true, "совершенное": true, "совершенная": true, "совершенно": true, "совершенны": true, + "чем": true, "тем": true, "тема": true, "теме": true, "темы": true, + "берет": true, "берете": true, "берета": true, + "осел": true, "осла": true, "ослы": true, "ослов": true, + "мел": true, "мела": true, + "лен": true, "лена": true, + "нес": true, "несем": true, "несет": true, + "вел": true, "ведет": true, "ведем": true, +} + +// lintYofikation flags inconsistent ё. Default ("auto"): the same word appears BOTH with ё and, as +// a separate token, with its exact ё→е form (Пётр/Петр) — excluding the homograph traps above. +// Policy "all-e": any ё present is a violation (the project wants no ё). Policy "all-yo": an е-form +// of a word that ALSO appears somewhere with ё is flagged (the partial-ёфикация case), same signal +// as auto — full "every word that SHOULD have ё" enforcement needs a ё-dictionary (deferred, B-tier). +func lintYofikation(text, policy string) (int, []string) { + words := tokenizeCyrillic(text) + if policy == "all-e" { + seen := map[string]bool{} + var det []string + n := 0 + for _, w := range words { + if strings.ContainsRune(w, 'ё') && !seen[w] { + seen[w] = true + n++ + det = append(det, "ё при политике all-e: "+w) + } + } + sort.Strings(det) + return n, det + } + // auto / all-yo: detect a word present in BOTH its ё-form and its е-form. + present := map[string]bool{} + for _, w := range words { + present[w] = true + } + seenPair := map[string]bool{} + var det []string + n := 0 + for _, w := range words { + if !strings.ContainsRune(w, 'ё') { + continue + } + eForm := strings.ReplaceAll(w, "ё", "е") + if eForm == w || yoHomographEForms[eForm] { + continue // no ё, or a distinct-word homograph (все/всё) — not an inconsistency + } + if present[eForm] && !seenPair[w] { + seenPair[w] = true + n++ + det = append(det, fmt.Sprintf("непоследовательная ё: %q и %q", w, eForm)) + } + } + sort.Strings(det) + return n, det +} + +// tokenizeCyrillic lower-cases and splits text into Cyrillic word tokens (ё kept distinct from е). +func tokenizeCyrillic(text string) []string { + var words []string + var b strings.Builder + flush := func() { + if b.Len() > 0 { + words = append(words, b.String()) + b.Reset() + } + } + for _, r := range strings.ToLower(text) { + if unicode.Is(unicode.Cyrillic, r) { + b.WriteRune(r) + } else { + flush() + } + } + flush() + return words +} + +// --- 3. translit-interjection blocklist ----------------------------------------- + +// translitInterjections is the starter blocklist (04-unhappy §9): JP/EN fillers that must be +// translated/adapted, not transliterated. Only surfaces that are NOT ordinary Russian words are +// listed (self-review majors): «ара» (a macaw, also a name) and «уму» (dative of «ум») were removed +// as high-false-positive collisions; «ара-ара» (the reduplicated JP filler) stays. A residual name +// collision is handled by the per-project allowlist. Lower-cased. +var translitInterjections = []string{ + "ара-ара", "маа", "хмф", "нани", "ауч", "упс", "кья", "десу", +} + +// lintTranslitInterjections counts whole-word (letter-bounded, case-insensitive) occurrences of a +// blocklisted interjection in the output, minus any surface on the per-project allowlist. Whole-word +// matching keeps «ара» from firing inside «характер»; the allowlist exempts legitimate uses. +func lintTranslitInterjections(text string, allowlist map[string]bool) (int, []string) { + low := []rune(strings.ToLower(text)) + counts := map[string]int{} + for _, interj := range translitInterjections { + if allowlist[interj] { + continue + } + f := []rune(interj) + for i := 0; i+len(f) <= len(low); i++ { + if !runesEqual(low[i:i+len(f)], f) { + continue + } + if (i == 0 || !isWordRune(low[i-1])) && (i+len(f) == len(low) || !isWordRune(low[i+len(f)])) { + counts[interj]++ + } + } + } + n := 0 + surfaces := make([]string, 0, len(counts)) + for k, c := range counts { + n += c + surfaces = append(surfaces, k) + } + sort.Strings(surfaces) + var det []string + for _, s := range surfaces { + det = append(det, fmt.Sprintf("непереведённое междометие %q ×%d", s, counts[s])) + } + return n, det +} + +// isWordRune reports whether r is part of a word for boundary checks (letter or a hyphen inside a +// compound interjection like «ара-ара»). The hyphen inclusion means «ара-ара» matches as one unit +// and its inner «ара» does not double-count against a hyphen boundary. +func isWordRune(r rune) bool { + return unicode.IsLetter(r) || r == '-' +} + +// --- 4. 万/億 magnitude gate ----------------------------------------------------- + +// lintNumberMagnitude flags a source CJK myriad/hundred-million magnitude whose ORDER of magnitude +// is not represented on the Russian side (04-unhappy §9: 三万 → «три миллиона» is a 100× error). It +// parses each CJK numeral RUN that carries a big marker (万/萬/億/亿/兆) into an order of magnitude, +// then checks the output's magnitude coverage (Russian magnitude words expanded to a [base,base+2] +// range for their possible multiplier, plus Arabic-number orders). It fires only when the source's +// TOP order is outside every output range — a conservative, multiplier-tolerant signal that leaves +// 三億→«триста миллионов» (8 within миллион's [6,8]) silent while catching 三万→«три миллиона». +func lintNumberMagnitude(source, final string) (int, []string) { + srcOrders := cjkMagnitudeOrders(source) + if len(srcOrders) == 0 { + return 0, nil + } + maxSrc := 0 + for _, o := range srcOrders { + if o > maxSrc { + maxSrc = o + } + } + if maxSrc < 4 { // only gate on 万+ magnitudes (the 万/億 concern) + return 0, nil + } + ranges := outputMagnitudeRanges(final) + if len(ranges) == 0 { + return 0, nil // no numeric magnitude in the output to compare against — silent (could be rephrased prose) + } + for _, rg := range ranges { + if maxSrc >= rg[0] && maxSrc <= rg[1] { + return 0, nil // the top source magnitude IS represented — no mismatch + } + } + return 1, []string{fmt.Sprintf("разряд источника 10^%d (万/億) не отражён в порядках величин перевода — возможна ошибка разряда (напр. 三万→«три миллиона»)", maxSrc)} +} + +// cjkNumeralRunes are the characters that can form a CJK numeral expression (digits, small units, +// big markers). A maximal run of these is one candidate number. +var cjkNumeralRunes = map[rune]bool{} + +func init() { + for _, r := range "0123456789〇零一二三四五六七八九十百千两兩万萬億亿兆" { + cjkNumeralRunes[r] = true + } +} + +// cjkMagnitudeOrders returns the base-10 order of every CJK numeral run in text that contains a big +// marker (万/億/兆). Runs without a big marker are ignored (the gate is about myriad-scale разряды). +func cjkMagnitudeOrders(text string) []int { + var orders []int + rs := []rune(text) + for i := 0; i < len(rs); { + if !cjkNumeralRunes[rs[i]] { + i++ + continue + } + j := i + for j < len(rs) && cjkNumeralRunes[rs[j]] { + j++ + } + run := string(rs[i:j]) + // A run counts only when it carries a big marker AND has an explicit DIGIT coefficient before + // it (self-review major): this excludes the common web-novel IDIOMS that are not magnitudes — + // 万一 (in case), 万分 (extremely), 万物 (all things), 千万 (by all means), 亿万 (myriads) — where 万/億 + // is not preceded by a digit. It also drops bare-unit magnitudes (十万/百万) — an accepted recall + // trade for not false-flagging the far more frequent idioms. + if strings.ContainsAny(run, "万萬億亿兆") && hasDigitBeforeBigMarker(run) { + if v, ok := parseCJKNumber(run); ok && v > 0 { + orders = append(orders, orderOf(v)) + } + } + i = j + } + return orders +} + +// hasDigitBeforeBigMarker reports whether a digit (一-九 / 两 / 0-9) appears before the FIRST big +// marker (万/億/兆) in the run — the signature of a real magnitude expression (三万) vs an idiom (万一). +func hasDigitBeforeBigMarker(run string) bool { + for _, r := range run { + if strings.ContainsRune("万萬億亿兆", r) { + return false // hit a big marker with no digit before it → idiom / bare unit + } + if (r >= '0' && r <= '9') || strings.ContainsRune("一二三四五六七八九两兩", r) { + return true + } + } + return false +} + +// parseCJKNumber parses a CJK numeral expression (mixed with Arabic digits) into its integer value. +// Standard section algorithm: small units (十百千) scale the pending coefficient into the current +// <10^4 section; big units (万億兆) flush the section times the big unit into the total. Returns +// ok=false on a shape it cannot parse (conservative — an unparseable run does not flag). +func parseCJKNumber(s string) (int64, bool) { + var total, section, cur int64 + sawBig := false + for _, r := range s { + switch { + case r >= '0' && r <= '9': + cur = cur*10 + int64(r-'0') + case r == '〇' || r == '零': + cur = cur * 10 + default: + if d, ok := cjkDigit(r); ok { + cur = cur*10 + d // positional accumulation (一二→12), matching the Arabic-digit branch (self-review) + continue + } + if u, ok := cjkSmallUnit(r); ok { + if cur == 0 { + cur = 1 + } + section += cur * u + cur = 0 + continue + } + if u, ok := cjkBigUnit(r); ok { + sawBig = true + section += cur + if section == 0 { + section = 1 + } + total += section * u + section = 0 + cur = 0 + continue + } + return 0, false // an unexpected rune + } + } + if !sawBig { + return 0, false // no 万/億/兆 → not a magnitude expression this gate cares about + } + return total + section + cur, true +} + +func cjkDigit(r rune) (int64, bool) { + switch r { + case '一': + return 1, true + case '二', '两', '兩': + return 2, true + case '三': + return 3, true + case '四': + return 4, true + case '五': + return 5, true + case '六': + return 6, true + case '七': + return 7, true + case '八': + return 8, true + case '九': + return 9, true + } + return 0, false +} + +func cjkSmallUnit(r rune) (int64, bool) { + switch r { + case '十': + return 10, true + case '百': + return 100, true + case '千': + return 1000, true + } + return 0, false +} + +func cjkBigUnit(r rune) (int64, bool) { + switch r { + case '万', '萬': + return 10000, true + case '億', '亿': + return 100000000, true + case '兆': + return 1000000000000, true + } + return 0, false +} + +func orderOf(v int64) int { + o := 0 + for v >= 10 { + v /= 10 + o++ + } + return o +} + +// outputMagnitudeRanges returns the covered [minOrder,maxOrder] ranges implied by the Russian +// output. A magnitude WORD covers [base, base+2] because an unseen multiplier can lift it up to two +// orders (триста миллионов = 3·10^8, base 6 → order 8). An Arabic integer covers its own order +// exactly (grouping spaces/commas/NBSP between 3-digit groups are stitched first). +func outputMagnitudeRanges(text string) [][2]int { + low := strings.ToLower(text) + var ranges [][2]int + for stem, base := range map[string]int{"тысяч": 3, "миллион": 6, "миллиард": 9, "триллион": 12} { + if strings.Contains(low, stem) { + ranges = append(ranges, [2]int{base, base + 2}) + } + } + for _, o := range arabicNumberOrders(text) { + ranges = append(ranges, [2]int{o, o}) + } + return ranges +} + +// arabicNumberOrders returns the order of each Arabic integer in text, stitching grouping +// separators (space / NBSP / comma) between runs of exactly three digits so "30 000" reads as one +// 5-digit number, not "30" and "000". +func arabicNumberOrders(text string) []int { + rs := []rune(text) + var orders []int + for i := 0; i < len(rs); { + if !isASCIIDigit(rs[i]) { + i++ + continue + } + // Leading group. + j := i + for j < len(rs) && isASCIIDigit(rs[j]) { + j++ + } + digits := j - i + // Stitch " ddd" / ",ddd" groups. + for j < len(rs) { + if (rs[j] == ' ' || rs[j] == ' ' || rs[j] == ',') && j+3 < len(rs)+1 { + k := j + 1 + g := 0 + for k < len(rs) && isASCIIDigit(rs[k]) { + k++ + g++ + } + if g == 3 { + digits += 3 + j = k + continue + } + } + break + } + orders = append(orders, digits-1) + i = j + } + return orders +} + +func isASCIIDigit(r rune) bool { return r >= '0' && r <= '9' } + +// preview trims a long line for the human detail (deterministic). +func preview(s string) string { + rs := []rune(s) + if len(rs) > 48 { + return string(rs[:48]) + "…" + } + return s +} diff --git a/backend/internal/pipeline/cheapgates_test.go b/backend/internal/pipeline/cheapgates_test.go new file mode 100644 index 0000000..1f16156 --- /dev/null +++ b/backend/internal/pipeline/cheapgates_test.go @@ -0,0 +1,220 @@ +package pipeline + +import ( + "context" + "testing" +) + +// --- dialogue-dash -------------------------------------------------------------- + +func TestLintDialogueDash(t *testing.T) { + cases := []struct { + name string + text string + want int + }{ + {"em-dash correct", "— Привет, — сказал он.\n— И тебе.", 0}, + {"hyphen dialogue", "- Привет, — сказал он.\n- Пока.", 2}, + {"en-dash dialogue", "– Привет, мир.", 1}, + {"straight quote dialogue", "\"Привет\", — сказал он.", 1}, + {"hyphenated word not dialogue", "Он сделал это как-то по-своему.\n-то не считается.", 0}, + {"list-like dash not dialogue", "-1 балл\n-2 очка", 0}, + {"mixing em-dash and chevrons", "— Реплика первая.\n«Реплика вторая», — подумал он.", 1}, + {"chevron only no mixing", "«Цитата из книги».\nОбычный текст.", 0}, + {"chevron term in prose not flagged", "Это была «Война и мир».", 0}, + // Self-review: an em-dash + space + digit is a scene break, NOT dialogue — must not count. + {"em-dash scene break not dialogue", "— 15 минут спустя он вернулся.", 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + n, _ := lintDialogueDash(c.text) + if n != c.want { + t.Errorf("lintDialogueDash(%q) = %d, want %d", c.text, n, c.want) + } + }) + } +} + +// --- yofikator ------------------------------------------------------------------ + +func TestLintYofikation(t *testing.T) { + cases := []struct { + name string + text string + policy string + want int + }{ + {"name both spellings auto", "Пётр вошёл. Потом Петр ушёл.", "auto", 1}, + {"name consistent auto", "Пётр вошёл и Пётр ушёл.", "auto", 0}, + {"homograph vse not flagged", "Все ушли. И всё пропало.", "auto", 0}, + {"no yo at all auto", "Все ели мед.", "auto", 0}, + {"all-e flags any yo", "Пётр ел мёд.", "all-e", 2}, + {"all-e clean", "Петр ел мед.", "all-e", 0}, + {"artem both spellings", "Артём и Артем — один человек.", "auto", 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + n, _ := lintYofikation(c.text, c.policy) + if n != c.want { + t.Errorf("lintYofikation(%q, %q) = %d, want %d", c.text, c.policy, n, c.want) + } + }) + } +} + +// --- translit interjections ----------------------------------------------------- + +func TestLintTranslitInterjections(t *testing.T) { + empty := map[string]bool{} + cases := []struct { + name string + text string + allow map[string]bool + want int + }{ + {"ara-ara and hmf", "Ара-ара, — протянула она. Хмф!", empty, 2}, + {"nani", "Нани?! — вскрикнул он.", empty, 1}, + {"inside word not flagged", "У него сильный характер и добрая душа.", empty, 0}, + {"allowlisted maa", "Маа, что же делать.", map[string]bool{"маа": true}, 0}, + {"non-allowlisted maa fires", "Маа, что же делать.", empty, 1}, + // Self-review majors: «ара» (macaw noun / name) and «уму» (dative of «ум») were removed from + // the blocklist — they must NOT flag ordinary prose. + {"bare ara is a macaw not flagged", "Красный ара сидел на ветке.", empty, 0}, + {"umu dative not flagged", "Это уму непостижимо.", empty, 0}, + {"clean prose", "Он молча кивнул и ушёл.", empty, 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + n, _ := lintTranslitInterjections(c.text, c.allow) + if n != c.want { + t.Errorf("lintTranslitInterjections(%q) = %d, want %d", c.text, n, c.want) + } + }) + } +} + +// --- CJK number parser ---------------------------------------------------------- + +func TestParseCJKNumber(t *testing.T) { + cases := []struct { + s string + want int64 + ok bool + }{ + {"三万", 30000, true}, + {"三千万", 30000000, true}, + {"一億二千万", 120000000, true}, + {"十万", 100000, true}, + {"30万", 300000, true}, + {"五億", 500000000, true}, + {"一兆", 1000000000000, true}, + {"三", 0, false}, // no big marker + {"三千", 0, false}, // no big marker (千 is a small unit) + {"", 0, false}, + } + for _, c := range cases { + v, ok := parseCJKNumber(c.s) + if ok != c.ok || (ok && v != c.want) { + t.Errorf("parseCJKNumber(%q) = (%d,%v), want (%d,%v)", c.s, v, ok, c.want, c.ok) + } + } +} + +// --- number magnitude gate ------------------------------------------------------ + +func TestLintNumberMagnitude(t *testing.T) { + cases := []struct { + name string + source string + final string + want int + }{ + {"30k mistranslated as millions", "他有三万石粮食。", "У него было три миллиона мешков зерна.", 1}, + {"30k correct as tens of thousands", "他有三万石粮食。", "У него было тридцать тысяч мешков зерна.", 0}, + {"300 million correct", "损失三億元。", "Убытки составили триста миллионов юаней.", 0}, + {"hundred million as thousand wrong", "军队三億人。", "Армия в три тысячи человек.", 1}, + {"wan as myriad not a number", "世间万物皆有灵。", "Все вещи в мире имеют душу.", 0}, + // Self-review major: CJK idioms carrying 万/億 but no digit coefficient are NOT magnitudes. + {"wan-yi idiom", "万一出事了怎么办。", "Что если что-то случится?", 0}, + {"qianwan idiom", "你千万要小心。", "Ты обязательно будь осторожен.", 0}, + {"wanfen idiom", "他万分感激。", "Он был крайне благодарен.", 0}, + {"no magnitude at all", "他走进房间。", "Он вошёл в комнату.", 0}, + {"arabic output matches", "三万里。", "30000 ли.", 0}, + {"no output number silent", "三万大军。", "Огромная армия.", 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + n, _ := lintNumberMagnitude(c.source, c.final) + if n != c.want { + t.Errorf("lintNumberMagnitude(%q → %q) = %d, want %d", c.source, c.final, n, c.want) + } + }) + } +} + +// --- combined ------------------------------------------------------------------ + +func TestRunCheapGatesCombined(t *testing.T) { + src := "他有三万石粮食。" + final := "- Ара-ара, — у Пётр было три миллиона мешков. Потом Петр ушёл." + cfg := cheapGateConfig{yoPolicy: "auto", allowlist: map[string]bool{}} + r := runCheapGates(src, final, cfg) + if r.DialogueDash == 0 { + t.Error("expected a dialogue-dash flag (hyphen-led line)") + } + if r.TranslitInterj == 0 { + t.Error("expected a translit-interjection flag (ара-ара)") + } + if r.YoInconsistent == 0 { + t.Error("expected a yofikation flag (Пётр/Петр)") + } + if r.NumberMagnitude == 0 { + t.Error("expected a number-magnitude flag (三万 → миллиона)") + } + if r.total() < 4 { + t.Errorf("expected all four gates to fire, total=%d detail=%v", r.total(), r.Detail) + } +} + +// TestCheapGatesSurfaceInStatus is the end-to-end wiring test: a chunk whose FINAL text has a style +// issue (a hyphen-led dialogue line) records a style flag in retrieval_state and surfaces it in the +// status projection — WITHOUT flagging the chunk (observability, never a disposition). +func TestCheapGatesSurfaceInStatus(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if isEditBody(body) { + return "- Привет, — сказал он.", "stop" // hyphen dialogue → dialogue-dash flag + } + return "ЧЕРНОВИК ПЕРЕВОДА", "stop" + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "テキスト。", regenerate: 0}) + ctx := context.Background() + + r := newRunner(t, bookPath) + defer r.Close() + res, err := r.TranslateBook(ctx) + if err != nil { + t.Fatal(err) + } + if res.Flagged != 0 { + t.Fatalf("a style flag must not flag the chunk (observability only), got flagged=%d", res.Flagged) + } + rs, err := r.Store.GetRetrievalState("test-book", 1, 0) + if err != nil || rs == nil { + t.Fatalf("no retrieval state persisted: %v", err) + } + if rs.NStyleFlags == 0 { + t.Fatalf("style flag not persisted in retrieval_state: %+v", rs) + } + rep, err := r.Status(ctx) + if err != nil { + t.Fatal(err) + } + if rep.StyleFlags == 0 { + t.Fatal("status must surface the book-wide style-flag count") + } + if rep.Done != 1 || rep.Flagged != 0 { + t.Fatalf("the chunk must be done, not flagged: done=%d flagged=%d", rep.Done, rep.Flagged) + } +} diff --git a/backend/internal/pipeline/ingest.go b/backend/internal/pipeline/ingest.go index 6a9e44f..75b169d 100644 --- a/backend/internal/pipeline/ingest.go +++ b/backend/internal/pipeline/ingest.go @@ -9,7 +9,14 @@ import ( "net/url" "os" "path" + "regexp" "strings" + "unicode" + "unicode/utf8" + + "golang.org/x/text/encoding/simplifiedchinese" + xunicode "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" ) // ingest.go: the шаг-3a import layer. Ingest turns a source file (txt or epub) @@ -48,14 +55,24 @@ type Document struct { Ruby []RubyReading } -// Ingest reads a source file and returns its Document. Dispatch is by extension: -// .epub → the epub reader; anything else → plain text (the example uses .txt; an -// unknown extension is treated as text rather than rejected). +// Ingest reads a source file with automatic encoding detection (the txt path), source language +// unspecified. Dispatch is by extension: .epub → the epub reader; anything else → plain text. Kept +// as the terse form used by tests and the epub path. func Ingest(p string) (*Document, error) { + return IngestEncoded(p, "", "") +} + +// IngestEncoded reads a source file and returns its Document, decoding a txt source per the book's +// declared encoding (auto|utf8|gb18030; "" == auto) and source language (zh|ja|en; "" == unknown). +// sourceLang scopes the GB18030 auto-probe to Chinese sources — a Shift-JIS/EUC-JP ja file would +// otherwise decode to Han-shaped mojibake that passes the plausibility guard (self-review critical). +// Dispatch is by extension: .epub → the epub reader (its own per-document charset handling); +// anything else → plain text. +func IngestEncoded(p, encoding, sourceLang string) (*Document, error) { if strings.EqualFold(extOf(p), ".epub") { return ingestEPUB(p) } - return ingestTXT(p) + return ingestTXT(p, encoding, sourceLang) } // extOf returns the lower-cased file extension incl. the dot ("" if none). @@ -67,20 +84,324 @@ func extOf(p string) string { return strings.ToLower(p[i:]) } -// ingestTXT reads a plain-text source: split on \f into chapters, NormalizeSource -// each (BOM/CRLF/NFC/outer-trim). No ruby in txt. -func ingestTXT(p string) (*Document, error) { +// ingestTXT reads a plain-text source: decode the raw bytes to UTF-8 per `encoding`, split on \f +// into chapters, NormalizeSource each (BOM/CRLF/NFC/outer-trim). No ruby in txt. Encoding decode +// runs BEFORE NormalizeSource so the whole downstream path (chunker fuzz-invariant, memory keys) +// sees valid UTF-8; a decode failure is a LOUD error, never silent U+FFFD corruption. +func ingestTXT(p, encoding, sourceLang string) (*Document, error) { raw, err := os.ReadFile(p) if err != nil { return nil, fmt.Errorf("pipeline: ingest txt: %w", err) } + text, err := decodeSourceBytes(raw, encoding, sourceLang) + if err != nil { + return nil, fmt.Errorf("pipeline: ingest txt %s: %w", p, err) + } var chapters []string - for _, part := range strings.Split(string(raw), chapterSep) { - chapters = append(chapters, NormalizeSource(part)) + // Honor the ASCII form feed as a hard chapter boundary (Фаза-0 example), THEN split each part + // on CJK chapter-header lines (第N章/节/回) — real zh/ja web novels mark chapters that way, not + // with \f (the 蛊真人 acceptance book uses «第N节», ~2283 of them). A part with neither stays one + // chapter (backward-compat). + for _, part := range strings.Split(text, chapterSep) { + for _, chap := range splitTextChapters(part) { + chapters = append(chapters, NormalizeSource(chap)) + } } return &Document{Chapters: chapters}, nil } +// --- CJK chapter-header splitting (D18: real zh/ja txt mark chapters as «第N章/节/回») --------- + +// chapterUnitRunes are the section-level chapter markers auto-detected in a txt. 卷 (volume) is +// deliberately EXCLUDED — it is coarser than a chapter and would carve a tiny title-only "chapter". +var chapterUnitRunes = []rune{'章', '节', '節', '回'} + +// chapterNumeralRE matches the numeral run of a chapter header: Arabic (half/fullwidth) or CJK. +var chapterNumeralRE = regexp.MustCompile(`^\s*第[0-90-9〇零一二三四五六七八九十百千两兩]+`) + +// chapterHeaderMaxRunes bounds a header line so a prose sentence that merely opens with «第三节…» +// (a longer line) is not mistaken for a header. The 蛊真人 headers are ≤23 runes; 60 leaves room for +// a long subtitle while still excluding a full prose sentence (self-review: 40 could miss a long +// legitimate subtitle → merged chapters). +const chapterHeaderMaxRunes = 60 + +// isCJKChapterHeader reports whether a single line is a chapter header for the given unit rune. It +// requires: a short trimmed line (≤60 runes); a leading 第; the unit rune immediately +// after the numerals; and — critically — the char AFTER the unit is a SEPARATOR (whitespace / ::、,. +// / dash) or end-of-line, NOT a content glyph. The separator guard is the precision fix (self- +// review): 回 is a common measure word, so prose like «第一回见面…» glues a content char (见) right +// after the unit and must NOT split; a real header writes «第一节:…» or «第1章 …» with a separator. +// (A glued-title header «第一章天空…» without a separator is not detected — rare; a recall trade for +// no false splits. Deterministic and pure.) +func isCJKChapterHeader(line string, unit rune) bool { + t := strings.TrimSpace(line) + if utf8.RuneCountInString(t) == 0 || utf8.RuneCountInString(t) > chapterHeaderMaxRunes { + return false + } + loc := chapterNumeralRE.FindStringIndex(t) + if loc == nil { + return false + } + rest := t[loc[1]:] // the runes right after the numerals + r, sz := utf8.DecodeRuneInString(rest) + if r != unit { + return false + } + after := rest[sz:] + if after == "" { + return true // the header is exactly 第N章 + } + nr, _ := utf8.DecodeRuneInString(after) + return isHeaderSeparator(nr) +} + +// isHeaderSeparator reports whether a rune separates a chapter number from its title (so the line is +// a header, not a prose sentence that continues with a content glyph after 第N章). Anything that is +// NOT a letter / ideograph / kana / digit counts as a separator (space, ::、,,.。-—— etc.). +func isHeaderSeparator(r rune) bool { + if unicode.IsLetter(r) || unicode.IsDigit(r) || + unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) { + return false + } + return true +} + +// detectChapterUnit picks the section marker that appears most as a header line (≥2 to avoid a +// single stray match). Returns 0 when no unit qualifies (→ the part stays a single chapter). +func detectChapterUnit(lines []string) rune { + best, bestN := rune(0), 0 + for _, unit := range chapterUnitRunes { + n := 0 + for _, ln := range lines { + if isCJKChapterHeader(ln, unit) { + n++ + } + } + if n >= 2 && n > bestN { + best, bestN = unit, n + } + } + return best +} + +// splitTextChapters splits one text block on its dominant CJK chapter-header lines. The header line +// STARTS its chapter (the title is kept). Any preamble before the FIRST header is merged into +// chapter 1 so «第一节» == chapter 1 (dense numbering aligns with the section numbers). A block with +// no detectable headers is returned unchanged as a single chapter. +func splitTextChapters(text string) []string { + norm := strings.ReplaceAll(text, "\r\n", "\n") + norm = strings.ReplaceAll(norm, "\r", "\n") // bare-CR (old Mac) lines too, so a CR-only header is seen + lines := strings.Split(norm, "\n") + unit := detectChapterUnit(lines) + if unit == 0 { + return []string{text} + } + var chapters []string + var cur []string + seenHeader := false + for _, ln := range lines { + if isCJKChapterHeader(ln, unit) { + if seenHeader && hasNonBlank(cur) { + chapters = append(chapters, strings.Join(cur, "\n")) + cur = nil + } + seenHeader = true + } + cur = append(cur, ln) + } + if hasNonBlank(cur) { + chapters = append(chapters, strings.Join(cur, "\n")) + } + if len(chapters) == 0 { + return []string{text} + } + return chapters +} + +// hasNonBlank reports whether any line in the slice has non-whitespace content. +func hasNonBlank(lines []string) bool { + for _, ln := range lines { + if strings.TrimSpace(ln) != "" { + return true + } + } + return false +} + +// --- source encoding detection (Task 2 / D18) ---------------------------------- +// +// Real zh .txt are frequently GB18030 (a superset of GBK/GB2312), not UTF-8 — the 蛊真人 acceptance +// book is exactly this case. decodeSourceBytes converts raw source bytes to a UTF-8 string per the +// book's declared encoding, defaulting to a conservative auto-detect. The invariant is fail-LOUD: +// any doubt is an error, never silent mojibake — a corrupt/foreign byte must never reach a U+FFFD +// replacement in ch.Text (which would defeat the lossless-chunker fuzz invariant and mis-key the +// glossary). Dependency: golang.org/x/text/encoding (already in go.mod for NFC) — first use of its +// simplifiedchinese + unicode subpackages (noted in the session journal). +func decodeSourceBytes(raw []byte, encoding, sourceLang string) (string, error) { + switch enc := strings.ToLower(strings.TrimSpace(encoding)); enc { + case "", "auto": + return autoDecodeSource(raw, sourceLang) + case "utf8", "utf-8": + b := bytes.TrimPrefix(raw, utf8BOM) + if !utf8.Valid(b) { + return "", fmt.Errorf("declared encoding utf8 but the bytes are not valid UTF-8 (declare gb18030, or convert the file)") + } + return checkNoNUL(string(b)) + case "gb18030", "gbk", "gb2312": + // Operator asserted the encoding: decode strictly (fail on undecodable bytes / U+FFFD) but + // skip the Chinese-plausibility heuristic — they told us it is GB18030. + return decodeGB18030(raw, false) + default: + return "", fmt.Errorf("unknown encoding %q in book.yaml (use auto|utf8|gb18030)", encoding) + } +} + +// checkNoNUL rejects a decoded string that contains a NUL (U+0000). Real prose never contains NUL; +// its presence means the bytes were a UTF-16 stream WITHOUT a BOM whose ASCII half is valid UTF-8 +// with interleaved NULs (silent corruption the U+FFFD check cannot catch — self-review major). +func checkNoNUL(s string) (string, error) { + if strings.IndexByte(s, 0) >= 0 { + return "", fmt.Errorf("decoded text contains NUL (U+0000) — the source is likely UTF-16 without a BOM; convert it to UTF-8 or declare its encoding") + } + return s, nil +} + +var ( + utf8BOM = []byte{0xEF, 0xBB, 0xBF} + utf16LEBOM = []byte{0xFF, 0xFE} + utf16BEBOM = []byte{0xFE, 0xFF} +) + +// autoDecodeSource is the conservative detection ladder: BOM → valid UTF-8 (+NUL guard) → GB18030 +// probe (ZH sources only) → fail-loud. Order matters: a BOM is definitive; valid UTF-8 is +// unambiguous (but rejected if it carries interleaved NUL — UTF-16 without a BOM); GB18030 is tried +// only when the bytes are NOT valid UTF-8 AND the source is Chinese (or unspecified). GB18030 is a +// Chinese encoding that maps almost every byte sequence to Han-shaped codepoints, so a ja Shift-JIS +// / EUC-JP or a European Latin-1 file would decode to CJK-shaped MOJIBAKE that the plausibility +// guard cannot distinguish from real Chinese (self-review CRITICAL). So we auto-probe GB18030 only +// for zh; for ja/en a non-UTF-8 file fails loud and the operator must convert or declare it. +func autoDecodeSource(raw []byte, sourceLang string) (string, error) { + switch { + case bytes.HasPrefix(raw, utf8BOM): + b := raw[len(utf8BOM):] + if !utf8.Valid(b) { + return "", fmt.Errorf("file has a UTF-8 BOM but the body is not valid UTF-8 (corrupt file)") + } + return checkNoNUL(string(b)) + case bytes.HasPrefix(raw, utf16LEBOM): + return decodeUTF16(raw, xunicode.LittleEndian) + case bytes.HasPrefix(raw, utf16BEBOM): + return decodeUTF16(raw, xunicode.BigEndian) + } + if utf8.Valid(raw) { + return checkNoNUL(string(raw)) // no BOM, already UTF-8 (covers en/ja/UTF-8 zh); NUL ⇒ UTF-16-no-BOM + } + // Not valid UTF-8. GB18030 auto-probe is scoped to Chinese sources (see doc above). + zhScoped := sourceLang == "" || strings.EqualFold(sourceLang, "zh") + if zhScoped { + if s, err := decodeGB18030(raw, true); err == nil { + return s, nil + } + } + probe := "was tried and failed (truncated/corrupt or not GB18030)" + if !zhScoped { + probe = fmt.Sprintf("is restricted to zh sources (this source is %q) — a Shift-JIS/EUC-JP/Latin-1 file would decode to Han-shaped mojibake and is NOT auto-detected", sourceLang) + } + return "", fmt.Errorf("could not auto-detect the source encoding: the bytes are not valid UTF-8, and the GB18030 probe %s; convert the file to UTF-8 or declare `encoding:` explicitly in book.yaml", probe) +} + +// decodeGB18030 decodes raw as GB18030 (superset of GBK/GB2312). It is strict: a decode error, or a +// U+FFFD replacement char in the output (undecodable/truncated bytes), is a failure — broken bytes +// must never reach ch.Text. When probe is true (auto-detect) it additionally requires the decoded +// text to look like Chinese (plausibleCJK), because GB18030 maps almost every byte sequence to +// SOMETHING, so a Latin-1/other file decodes without error into mojibake unless we sanity-check it. +func decodeGB18030(raw []byte, probe bool) (string, error) { + out, _, err := transform.Bytes(simplifiedchinese.GB18030.NewDecoder(), raw) + if err != nil { + return "", fmt.Errorf("GB18030 decode error (not valid GB18030): %w", err) + } + s := string(out) + if strings.ContainsRune(s, utf8.RuneError) { + return "", fmt.Errorf("GB18030 decode produced replacement chars (U+FFFD) — bytes are truncated/corrupt or not GB18030") + } + if probe { + if err := plausibleCJK(s); err != nil { + return "", err + } + } + return s, nil +} + +// decodeUTF16 decodes a BOM-prefixed UTF-16 stream (ExpectBOM consumes the BOM). Strict: a decode +// error or a U+FFFD in the output is a failure. +func decodeUTF16(raw []byte, endian xunicode.Endianness) (string, error) { + dec := xunicode.UTF16(endian, xunicode.ExpectBOM).NewDecoder() + out, _, err := transform.Bytes(dec, raw) + if err != nil { + return "", fmt.Errorf("UTF-16 decode error: %w", err) + } + s := string(out) + if strings.ContainsRune(s, utf8.RuneError) { + return "", fmt.Errorf("UTF-16 decode produced replacement chars (U+FFFD) — corrupt") + } + return s, nil +} + +// plausibleCJK is the GB18030 auto-accept guard: a real GB18030 source is Chinese, so the decoded +// text must be (a) almost entirely CJK-plausible runes (Han/kana/CJK-punct/ASCII/whitespace) and +// (b) genuinely Han-dense. Latin-1/other mojibake fails (b) — it decodes to sparse Han among ASCII +// (café → "caf" + 轳), so every rune is "plausible" in isolation yet Han density is ~0. The Han +// floor is intentionally low (5%) so a heavily Latin-mixed but real Chinese source still passes, +// while mojibake (Han≈0) is rejected loudly. Thresholds bias toward fail-loud (D18 invariant); a +// false reject is recoverable (declare `encoding: gb18030`), a false accept is silent corruption. +func plausibleCJK(s string) error { + var total, han, plausible int + for _, r := range s { + total++ + switch { + case unicode.Is(unicode.Han, r): + han++ + plausible++ + case isPlausibleSourceRune(r): + plausible++ + } + } + if total == 0 { + return fmt.Errorf("GB18030 probe: empty decode") + } + if pf := float64(plausible) / float64(total); pf < 0.90 { + return fmt.Errorf("GB18030 probe: only %.1f%% of decoded runes are CJK-plausible — likely mojibake, not Chinese", 100*pf) + } + if hf := float64(han) / float64(total); hf < 0.05 { + return fmt.Errorf("GB18030 probe: Han density %.2f%% is too low for a Chinese source — likely a mis-detected encoding; declare `encoding` explicitly if this really is GB18030", 100*hf) + } + return nil +} + +// isPlausibleSourceRune reports whether a rune belongs to the expected repertoire of a zh/ja source +// (excluding Han, which the caller counts separately): ASCII text, kana, CJK/general punctuation, +// and fullwidth/halfwidth forms. Deliberately tight — it must NOT whitelist the Latin-1 supplement +// or arbitrary symbols, or Latin-1 mojibake would pass the plausibility fraction. +func isPlausibleSourceRune(r rune) bool { + switch { + case r == '\n', r == '\r', r == '\t', r == ' ': + return true + case r >= 0x20 && r <= 0x7E: // printable ASCII + return true + case unicode.Is(unicode.Hiragana, r), unicode.Is(unicode.Katakana, r): + return true + case r >= 0x3000 && r <= 0x303F: // CJK symbols & punctuation (。、「」〜…) + return true + case r >= 0x2010 && r <= 0x206F: // general punctuation (— – ‘’ “” … ‰ etc.) + return true + case r >= 0xFF00 && r <= 0xFFEF: // halfwidth & fullwidth forms (!?()ア fullwidth digits/letters) + return true + case r == 0x30FC || r == 0x30FB: // prolonged-sound mark ー, middle dot ・ (also in Katakana block) + return true + } + return false +} + // --- epub ---------------------------------------------------------------------- // containerXML is META-INF/container.xml: it points at the OPF package file. Tags diff --git a/backend/internal/pipeline/ingest_encoding_test.go b/backend/internal/pipeline/ingest_encoding_test.go new file mode 100644 index 0000000..6e5823a --- /dev/null +++ b/backend/internal/pipeline/ingest_encoding_test.go @@ -0,0 +1,260 @@ +package pipeline + +import ( + "os" + "path/filepath" + "strings" + "testing" + "unicode/utf8" + + "golang.org/x/text/encoding/japanese" + "golang.org/x/text/encoding/simplifiedchinese" + xunicode "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +// A realistic zh chapter: Han + CJK punctuation + fullwidth quotes + a stray Latin name + digits. +const zhChapter = "第一章 天空之陨\n\n方源睁开眼睛,“这是哪里?”他喃喃自语。三万年后,Gu 之力遍布天地。" + +func gb18030Bytes(t *testing.T, s string) []byte { + t.Helper() + b, _, err := transform.Bytes(simplifiedchinese.GB18030.NewEncoder(), []byte(s)) + if err != nil { + t.Fatalf("encode gb18030: %v", err) + } + return b +} + +func utf16Bytes(t *testing.T, s string, endian xunicode.Endianness) []byte { + t.Helper() + b, _, err := transform.Bytes(xunicode.UTF16(endian, xunicode.UseBOM).NewEncoder(), []byte(s)) + if err != nil { + t.Fatalf("encode utf16: %v", err) + } + return b +} + +func TestDecodeSourceAutoGB18030(t *testing.T) { + raw := gb18030Bytes(t, zhChapter) + got, err := decodeSourceBytes(raw, "auto", "zh") + if err != nil { + t.Fatalf("auto-detect of a GB18030 chapter failed: %v", err) + } + if got != zhChapter { + t.Fatalf("GB18030 round-trip mismatch:\n got %q\nwant %q", got, zhChapter) + } + // The whole point: a GB18030 file is NOT valid UTF-8, so the naive string(raw) would be mojibake. + if strings.ContainsRune(got, '�') { + t.Error("decoded text contains U+FFFD — the lossless invariant is broken") + } +} + +func TestDecodeSourceAutoUTF8NoBOM(t *testing.T) { + got, err := decodeSourceBytes([]byte(zhChapter), "auto", "zh") + if err != nil { + t.Fatalf("valid UTF-8 must pass through: %v", err) + } + if got != zhChapter { + t.Fatalf("UTF-8 passthrough mismatch: %q", got) + } +} + +func TestDecodeSourceAutoUTF8BOM(t *testing.T) { + raw := append(append([]byte(nil), 0xEF, 0xBB, 0xBF), []byte(zhChapter)...) + got, err := decodeSourceBytes(raw, "auto", "zh") + if err != nil { + t.Fatalf("UTF-8 BOM file must decode: %v", err) + } + // The BOM byte may survive as U+FEFF here; NormalizeSource strips it downstream. What matters is + // the body decoded correctly and no mojibake appeared. + if !strings.Contains(got, "第一章 天空之陨") { + t.Fatalf("UTF-8 BOM body mis-decoded: %q", got) + } + if strings.ContainsRune(got, '�') { + t.Error("U+FFFD after BOM decode") + } +} + +func TestDecodeSourceAutoUTF16(t *testing.T) { + for _, tc := range []struct { + name string + endian xunicode.Endianness + }{{"LE", xunicode.LittleEndian}, {"BE", xunicode.BigEndian}} { + raw := utf16Bytes(t, zhChapter, tc.endian) + got, err := decodeSourceBytes(raw, "auto", "zh") + if err != nil { + t.Fatalf("UTF-16 %s must decode: %v", tc.name, err) + } + if !strings.Contains(got, "方源睁开眼睛") { + t.Fatalf("UTF-16 %s mis-decoded: %q", tc.name, got) + } + } +} + +func TestDecodeSourceAutoRejectsGarbage(t *testing.T) { + // Random high bytes: not valid UTF-8, GB18030 yields U+FFFD → fail loud, not silent junk. + raw := []byte{0x00, 0x01, 0xFF, 0xFE, 0x80, 0x81, 0x00, 0x9F, 0xC0} + if got, err := decodeSourceBytes(raw, "auto", "zh"); err == nil { + t.Fatalf("garbage bytes must fail loud, got %q", got) + } +} + +func TestDecodeSourceAutoRejectsLatin1(t *testing.T) { + // Latin-1 "Café déjà vu — naïve" is invalid UTF-8; GB18030 decodes it into mojibake that here + // contains U+FFFD, so it is rejected by the strict U+FFFD check (NOT the Han-density guard — see + // TestPlausibleCJKGuard for direct density coverage). Either way it must fail loud. + raw := []byte{0x43, 0x61, 0x66, 0xE9, 0x20, 0x64, 0xE9, 0x6A, 0xE0, 0x20, 0x76, 0x75, 0x20, 0x2D, 0x20, 0x6E, 0x61, 0xEF, 0x76, 0x65} + if got, err := decodeSourceBytes(raw, "auto", "zh"); err == nil { + t.Fatalf("Latin-1 mojibake must fail loud, got %q", got) + } +} + +// TestPlausibleCJKGuard directly locks the Han-density guard (self-review: it had zero coverage — +// every mojibake fixture was caught earlier by the U+FFFD check). A clean-decoding low-Han string +// must be rejected; a Han-dense string must pass. +func TestPlausibleCJKGuard(t *testing.T) { + if err := plausibleCJK("Hello world, ordinary Latin prose with one 一 ideograph in it."); err == nil { + t.Error("low-Han text must fail the plausibility guard") + } + if err := plausibleCJK("方源睁开眼睛,这是哪里?天地无穷,万物玄妙。"); err != nil { + t.Errorf("Han-dense Chinese must pass the plausibility guard: %v", err) + } +} + +// TestDecodeSourceRejectsShiftJISForJa is the self-review CRITICAL: a Japanese Shift-JIS source must +// NOT be silently accepted as GB18030 mojibake. With source_lang=ja the GB18030 auto-probe is off, +// so it fails loud (the operator converts to UTF-8). Shift-JIS is invalid UTF-8, so the ladder does +// reach the (now gated) GB18030 step. +func TestDecodeSourceRejectsShiftJISForJa(t *testing.T) { + ja := "彼女は静かに窓の外を眺めていた。" + sj, _, err := transform.Bytes(japanese.ShiftJIS.NewEncoder(), []byte(ja)) + if err != nil { + t.Fatalf("encode shift-jis: %v", err) + } + if utf8.Valid(sj) { + t.Fatal("setup: shift-jis bytes unexpectedly valid UTF-8") + } + if got, derr := decodeSourceBytes(sj, "auto", "ja"); derr == nil { + t.Fatalf("a ja Shift-JIS source must fail loud under auto, got %q", got) + } +} + +// TestDecodeSourceRejectsUTF16NoBOM is the self-review major: ASCII text saved as UTF-16 without a +// BOM is valid UTF-8 with interleaved NUL — the NUL guard must reject it, not pass silent corruption. +func TestDecodeSourceRejectsUTF16NoBOM(t *testing.T) { + enc := xunicode.UTF16(xunicode.LittleEndian, xunicode.IgnoreBOM).NewEncoder() + raw, _, err := transform.Bytes(enc, []byte("Hello world, a plain English sentence.")) + if err != nil { + t.Fatalf("encode utf16-no-bom: %v", err) + } + if got, derr := decodeSourceBytes(raw, "auto", "en"); derr == nil { + t.Fatalf("UTF-16 without a BOM must fail loud (NUL guard), got %q", got) + } +} + +func TestDecodeSourceExplicitGB18030(t *testing.T) { + raw := gb18030Bytes(t, zhChapter) + got, err := decodeSourceBytes(raw, "gb18030", "zh") + if err != nil { + t.Fatalf("explicit gb18030 must decode: %v", err) + } + if got != zhChapter { + t.Fatalf("explicit gb18030 mismatch: %q", got) + } +} + +func TestDecodeSourceExplicitUTF8OnGBFailsLoud(t *testing.T) { + raw := gb18030Bytes(t, zhChapter) // not valid UTF-8 + if _, err := decodeSourceBytes(raw, "utf8", "zh"); err == nil { + t.Fatal("declaring utf8 on GB18030 bytes must fail loud, not mojibake") + } +} + +func TestDecodeSourceExplicitGBOnCorruptFailsLoud(t *testing.T) { + // A valid GB18030 chapter truncated mid-multibyte → U+FFFD → strict decode fails loud. + raw := gb18030Bytes(t, zhChapter) + if got, err := decodeSourceBytes(raw[:len(raw)-1], "gb18030", "zh"); err == nil { + t.Fatalf("truncated GB18030 must fail loud (U+FFFD), got %q", got) + } +} + +func TestDecodeSourceUnknownEncoding(t *testing.T) { + if _, err := decodeSourceBytes([]byte("hi"), "shift-jis", ""); err == nil { + t.Fatal("an unsupported explicit encoding must be rejected") + } +} + +// TestIngestTXTGB18030EndToEnd exercises the whole txt path: a GB18030 file with a \f chapter break +// decodes, splits, and normalizes — the D18 acceptance-book ingest case (蛊真人 is GB18030). +func TestIngestTXTGB18030EndToEnd(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "book.txt") + twoChapters := zhChapter + "\f" + "第二章 洞窟\n\n他走进了洞窟深处。" + if err := os.WriteFile(p, gb18030Bytes(t, twoChapters), 0o644); err != nil { + t.Fatal(err) + } + doc, err := IngestEncoded(p, "gb18030", "zh") + if err != nil { + t.Fatalf("ingest gb18030 txt: %v", err) + } + if len(doc.Chapters) != 2 { + t.Fatalf("want 2 chapters (split on \\f), got %d", len(doc.Chapters)) + } + if !strings.Contains(doc.Chapters[0], "方源") || !strings.Contains(doc.Chapters[1], "洞窟") { + t.Fatalf("chapters mis-decoded: %q | %q", doc.Chapters[0], doc.Chapters[1]) + } + for _, ch := range doc.Chapters { + if strings.ContainsRune(ch, '�') { + t.Errorf("U+FFFD in a normalized chapter: %q", ch) + } + } +} + +// TestSplitTextChapters covers the CJK chapter-header splitting (D18: 蛊真人 uses «第N节» headers). +func TestSplitTextChapters(t *testing.T) { + jie := "第一节:начало\n方源开口。\n第二节:середина\n他走了。\n第三节:конец\n完。" + got := splitTextChapters(jie) + if len(got) != 3 { + t.Fatalf("第N节 split: want 3 chapters, got %d: %q", len(got), got) + } + if !strings.HasPrefix(got[0], "第一节") || !strings.HasPrefix(got[1], "第二节") { + t.Errorf("headers must start their chapter: %q", got) + } + + zhang := "第1章 первая\nтекст\n第2章 вторая\nтекст" + if got := splitTextChapters(zhang); len(got) != 2 { + t.Fatalf("第N章 (arabic) split: want 2, got %d", len(got)) + } + + // Preamble before the first header merges into chapter 1 (第一节 == chapter 1). + withPreamble := "书名:Тест\n作者:Автор\n第一节:глава\nтело первой главы\n第二节:вторая\nтело" + got = splitTextChapters(withPreamble) + if len(got) != 2 { + t.Fatalf("preamble merge: want 2 chapters, got %d: %q", len(got), got) + } + if !strings.Contains(got[0], "书名") || !strings.Contains(got[0], "第一节") { + t.Errorf("preamble must merge into chapter 1: %q", got[0]) + } + + // No headers → a single chapter (backward compat). + if got := splitTextChapters("Просто текст без глав.\nВторая строка."); len(got) != 1 { + t.Fatalf("no-header text must be one chapter, got %d", len(got)) + } + + // A long prose line that merely opens with «第三章…» is NOT a header (length guard). + prose := "第三章的内容非常精彩,讲述了一个漫长而复杂的故事,充满了各种各样的细节和转折点情节。" + if got := splitTextChapters(prose); len(got) != 1 { + t.Fatalf("prose starting with 第三章 must not split, got %d", len(got)) + } + + // Self-review major: 回 is a measure word, so «第一回见面…» (a content glyph glued after the unit) + // must NOT split; a real «第N回:» header (separator after the unit) does. + huiProse := "第一回见面时他就心动了。\n第二回重逢依然如此。" + if got := splitTextChapters(huiProse); len(got) != 1 { + t.Fatalf("回 measure-word prose must not split (separator guard), got %d: %q", len(got), got) + } + huiHeader := "第一回:初见\nтекст первой.\n第二回:重逢\nтекст второй." + if got := splitTextChapters(huiHeader); len(got) != 2 { + t.Fatalf("第N回: headers must split, got %d", len(got)) + } +} diff --git a/backend/internal/pipeline/memseed.go b/backend/internal/pipeline/memseed.go index 6ada731..511a89c 100644 --- a/backend/internal/pipeline/memseed.go +++ b/backend/internal/pipeline/memseed.go @@ -371,32 +371,50 @@ func rubyToCandidates(readings []store.RubyReading, manualSrcs map[string]bool) // never blindly trusted; full offline disambiguation is B6. Auto-candidates with no dst stay // unmatchable (deliberate). Deterministic: readings are processed sorted, and GlossaryForBook // re-sorts aliases on read, so the materialized bank is stable regardless of input order. +// +// A ruby reading is auto-derived (not curated), so a reading that would give a FIRING key to +// two seeded terms with different dst (homophone names 高橋/高梁 both read たかはし) must NOT be +// attached to either — the reading is genuinely ambiguous between them. External-review MAJOR +// (homophone first-wins): the previous pass attached-then-blocked, so the alphabetically-first +// base won the reading as an ALIAS while the other was skipped; at ≥4 kana that alias is not +// collision-prone → it fired CONFIRMED → every kana occurrence injected as an authoritative +// (and wrong) rendering for whichever base sorted first (class A2). The key must therefore be +// decided BEFORE any attach, for ALL its owners at once: a contested firing key (≥2 would-fire +// owners with different dst over overlapping spoiler windows) skips EVERY proposed attach and +// logs it (not silent) — the kana form stays unmatchable until the seed disambiguates it with a +// curated alias (whose own collisions DO fail loud, as an authoring error). Attaching would also +// trip approvedSharedKeyCollisions loud on an alias the operator cannot edit out of the seed. +// +// Window/dst-eligibility awareness (review minor): only a dst-bearing owner fires (materializeMemory +// builds no surface for a no-dst row), and two owners with the SAME dst (a merge) or NON-overlapping +// windows (a spoiler handoff) do not contradict — so those are no longer falsely skipped. func attachRubyAliasesToManual(entries []store.GlossaryEntry, readings []store.RubyReading) (skipped []string) { idx := map[string][]int{} for i := range entries { idx[entries[i].Src] = append(idx[entries[i].Src], i) } - // owners of each FIRING key (src + eligible aliases) → term indices, for collision detection. - // A ruby reading is auto-derived (not curated), so if attaching it would collide with a - // DIFFERENT term's firing key with a different dst (two homophone names 鈴木/鈴樹 both read - // すずき), attaching would make approvedSharedKeyCollisions fail the whole book loud on an - // alias the operator cannot edit out of the seed. Instead SKIP it gracefully and LOG (not - // silent): the kana form of that homophone stays unmatchable until the seed disambiguates it - // explicitly (a curated manual alias — whose collisions DO fail loud, as an authoring error). - owners := map[string][]int{} - for i, e := range entries { - for _, k := range entryFiringKeys(e) { - owners[k] = append(owners[k], i) + dstBearing := func(e *store.GlossaryEntry) bool { return strings.TrimSpace(e.Dst) != "" } + + // wouldOwn[rk] = set of term indices that would own firing key rk AFTER the proposed + // attaches: the pre-existing dst-bearing owners (src + eligible aliases already on the seed) + // plus every deferred (fires) proposal. Only dst-bearing entries fire, so only they can + // collide. addOwner keeps a set (an index is never paired with itself in contested()). + wouldOwn := map[string]map[int]bool{} + addOwner := func(rk string, ti int) { + if wouldOwn[rk] == nil { + wouldOwn[rk] = map[int]bool{} + } + wouldOwn[rk][ti] = true + } + for i := range entries { + if !dstBearing(&entries[i]) { + continue + } + for _, k := range entryFiringKeys(entries[i]) { + addOwner(k, i) } } - collides := func(rk string, ti int) bool { - for _, oi := range owners[rk] { - if oi != ti && entries[oi].Dst != entries[ti].Dst { - return true - } - } - return false - } + sorted := append([]store.RubyReading(nil), readings...) sort.Slice(sorted, func(i, j int) bool { if sorted[i].Base != sorted[j].Base { @@ -404,6 +422,17 @@ func attachRubyAliasesToManual(entries []store.GlossaryEntry, readings []store.R } return sorted[i].Reading < sorted[j].Reading }) + + // Pass 1 — gather proposals. A proposal that would actually FIRE (dst-bearing target + a key + // past the per-language floor / allow_short) is DEFERRED to the contested-key decision so a + // homophone key is resolved for all its owners together, never first-wins. An inert attach (no + // dst, or a sub-floor key) can never fire → attach it immediately; it round-trips the reading + // but cannot collide. + type prop struct { + rk, reading, base string + ti int + } + var deferred []prop for _, rr := range sorted { targets, ok := idx[rr.Base] if !ok { @@ -418,18 +447,48 @@ func attachRubyAliasesToManual(entries []store.GlossaryEntry, readings []store.R if rr.Reading == e.Src || hasAliasSurface(e.Aliases, rr.Reading) { continue } - // Only an ELIGIBLE reading (passes the min-key floor or allow_short) can actually - // fire and thus collide; an inert short reading is attached freely. - eligible := rk != "" && (significantLen(rk) >= minKeyLenFor(rk) || e.AllowShort) - if eligible && collides(rk, ti) { - skipped = append(skipped, fmt.Sprintf("%q reading %q (homophone of another seeded term — kana left unmatchable; disambiguate in the seed if needed)", rr.Base, rr.Reading)) + fires := dstBearing(e) && rk != "" && (significantLen(rk) >= minKeyLenFor(rk) || e.AllowShort) + if !fires { + e.Aliases = append(e.Aliases, store.GlossaryAlias{Alias: rr.Reading, AliasType: "чтение"}) continue } - e.Aliases = append(e.Aliases, store.GlossaryAlias{Alias: rr.Reading, AliasType: "чтение"}) - if eligible { - owners[rk] = append(owners[rk], ti) // now this term owns rk → a later homophone collides and is skipped, not double-attached + deferred = append(deferred, prop{rk: rk, reading: rr.Reading, base: rr.Base, ti: ti}) + addOwner(rk, ti) + } + } + + // contested(rk): among ALL would-fire owners of rk, two render DIFFERENT dst over OVERLAPPING + // spoiler windows — attaching for any of them would inject a wrong authoritative rendering (and + // trip the loud shared-key check). Deterministic (owner set → sorted index pairs). + contested := func(rk string) bool { + is := make([]int, 0, len(wouldOwn[rk])) + for i := range wouldOwn[rk] { + is = append(is, i) + } + sort.Ints(is) + for a := 0; a < len(is); a++ { + for b := a + 1; b < len(is); b++ { + ei, ej := entries[is[a]], entries[is[b]] + if ei.Dst != ej.Dst && windowsOverlap(ei.SinceCh, ei.UntilCh, ej.SinceCh, ej.UntilCh) { + return true + } } } + return false + } + + // Pass 2 — attach every deferred reading whose key is uncontested; skip+log the contested ones + // (for ALL owners symmetrically, closing the first-wins hole). + for _, p := range deferred { + if contested(p.rk) { + skipped = append(skipped, fmt.Sprintf("%q reading %q (homophone of another seeded term — kana left unmatchable; disambiguate in the seed if needed)", p.base, p.reading)) + continue + } + e := &entries[p.ti] + if hasAliasSurface(e.Aliases, p.reading) { // a same-key sibling proposal already attached this exact surface + continue + } + e.Aliases = append(e.Aliases, store.GlossaryAlias{Alias: p.reading, AliasType: "чтение"}) } return skipped } diff --git a/backend/internal/pipeline/memseed_test.go b/backend/internal/pipeline/memseed_test.go index 25ac737..548fdb5 100644 --- a/backend/internal/pipeline/memseed_test.go +++ b/backend/internal/pipeline/memseed_test.go @@ -330,10 +330,12 @@ func TestRubyReadingBecomesManualAlias(t *testing.T) { } // TestRubyHomophoneSkippedNotFailLoud covers the self-review interaction: two homophone seeded -// names (鈴木/鈴樹 both read すずき, different dst) must NOT both auto-get the kana alias — that -// would make approvedSharedKeyCollisions fail the whole book loud on an alias the operator -// cannot edit out. The colliding reading is skipped+reported (graceful), and the resulting -// entry set passes the shared-key collision check. +// names (鈴木/鈴樹 both read すずき, different dst) must NOT get the kana alias — the reading is +// genuinely ambiguous between them, and attaching it would make approvedSharedKeyCollisions fail +// the whole book loud on an alias the operator cannot edit out. External-review MAJOR (homophone +// first-wins): the fix skips the reading for BOTH owners symmetrically (previously the +// alphabetically-first base claimed it and only the latecomer was skipped). Both readings are +// skipped+reported (graceful), and the resulting entry set passes the shared-key collision check. func TestRubyHomophoneSkippedNotFailLoud(t *testing.T) { entries := []store.GlossaryEntry{ {BookID: "b", Src: "鈴木", Dst: "Судзуки", Status: "approved", Source: "seed"}, @@ -344,18 +346,19 @@ func TestRubyHomophoneSkippedNotFailLoud(t *testing.T) { {BookID: "b", Base: "鈴樹", Reading: "すずき", FirstChapter: 3, Occurrences: 4}, // homophone → collision } skipped := attachRubyAliasesToManual(entries, readings) - if len(skipped) != 1 { - t.Fatalf("want exactly one skipped homophone reading, got %d: %v", len(skipped), skipped) + // BOTH readings are skipped (first-wins fix): neither base may claim a contested reading. + if len(skipped) != 2 { + t.Fatalf("want both homophone readings skipped, got %d: %v", len(skipped), skipped) } - // Exactly ONE term got the kana alias (the first, deterministically); the other was skipped. + // NEITHER term got the kana alias. got := 0 for _, e := range entries { if hasAliasSurface(e.Aliases, "すずき") { got++ } } - if got != 1 { - t.Fatalf("want the kana alias on exactly one term, got %d", got) + if got != 0 { + t.Fatalf("want the contested kana alias on NEITHER term, got %d", got) } // The resulting set must NOT trip the shared-key collision fail-loud (the whole point). if cols := approvedSharedKeyCollisions(entries); len(cols) != 0 { @@ -363,6 +366,77 @@ func TestRubyHomophoneSkippedNotFailLoud(t *testing.T) { } } +// TestRubyHomophoneNoWrongConfirmedInjection is the sharp mutation-verifier for the external-review +// MAJOR (homophone first-wins, class A2). Two seeded names 高橋/高梁 both read たかはし (4 kana, so +// the key is NOT collision-prone → it would fire CONFIRMED, not AMBIGUOUS). Under the old +// attach-then-block pass the alphabetically-first base won たかはし as an authoritative alias, so a +// kana chunk injected it as a CONFIRMED (and wrong) rendering for that base. The fix skips the +// reading for both, so NEITHER is injected — the kana form is left unmatchable, never wrongly +// authoritative. Reverting the fix (restoring first-wins) makes one of them fire CONFIRMED here. +func TestRubyHomophoneNoWrongConfirmedInjection(t *testing.T) { + entries := []store.GlossaryEntry{ + {BookID: "b", Src: "高橋", Dst: "Такахаси", Status: "approved", Source: "seed"}, + {BookID: "b", Src: "高梁", Dst: "Такаянаги", Status: "approved", Source: "seed"}, + } + readings := []store.RubyReading{ + {BookID: "b", Base: "高橋", Reading: "たかはし", FirstChapter: 1, Occurrences: 9}, + {BookID: "b", Base: "高梁", Reading: "たかはし", FirstChapter: 1, Occurrences: 2}, + } + skipped := attachRubyAliasesToManual(entries, readings) + if len(skipped) != 2 { + t.Fatalf("want both 4-kana homophone readings skipped, got %d: %v", len(skipped), skipped) + } + if cols := approvedSharedKeyCollisions(entries); len(cols) != 0 { + t.Errorf("contested reading must not be attached, got collisions: %v", cols) + } + // End-to-end: a kana occurrence of たかはし must NOT inject either name (no wrong authoritative + // rendering). Under first-wins the alphabetically-first base would be present here. + b := materializeMemory(entries, false) + inj := injMap(b.Select("たかはしが来た。", 1, nil, 0)) + if d, ok := inj["高橋"]; ok { + t.Errorf("高橋 wrongly injected (%s) for an ambiguous homophone reading", d) + } + if d, ok := inj["高梁"]; ok { + t.Errorf("高梁 wrongly injected (%s) for an ambiguous homophone reading", d) + } +} + +// TestRubyReadingSharedSameDstAttached locks the review minor (window/dst-eligibility awareness): +// a reading shared by two seeded terms that render the SAME dst is NOT a contradiction (both inject +// identically), and a reading shared across NON-overlapping spoiler windows is a legitimate handoff +// — neither is falsely skipped anymore. The old collides() (dst-only, window-blind) skipped both. +func TestRubyReadingSharedSameDstAttached(t *testing.T) { + // Same dst (two kanji spellings of one character) — attach to both. + same := []store.GlossaryEntry{ + {BookID: "b", Src: "斉藤", Dst: "Сайто", Status: "approved", Source: "seed"}, + {BookID: "b", Src: "齊藤", Dst: "Сайто", Status: "approved", Source: "seed"}, + } + sameReadings := []store.RubyReading{ + {BookID: "b", Base: "斉藤", Reading: "さいとう", FirstChapter: 1, Occurrences: 4}, + {BookID: "b", Base: "齊藤", Reading: "さいとう", FirstChapter: 1, Occurrences: 3}, + } + if skipped := attachRubyAliasesToManual(same, sameReadings); len(skipped) != 0 { + t.Fatalf("same-dst shared reading must not be skipped, got: %v", skipped) + } + for _, e := range same { + if !hasAliasSurface(e.Aliases, "さいとう") { + t.Errorf("same-dst reading not attached to %q: %+v", e.Src, e.Aliases) + } + } + // Different dst but NON-overlapping windows (a spoiler handoff of the same reading) — attach both. + handoff := []store.GlossaryEntry{ + {BookID: "b", Src: "序盤", Dst: "Раннее", Status: "approved", Source: "seed", SinceCh: 1, UntilCh: 100}, + {BookID: "b", Src: "終盤", Dst: "Позднее", Status: "approved", Source: "seed", SinceCh: 101}, + } + handoffReadings := []store.RubyReading{ + {BookID: "b", Base: "序盤", Reading: "じょばん", FirstChapter: 1, Occurrences: 5}, + {BookID: "b", Base: "終盤", Reading: "じょばん", FirstChapter: 101, Occurrences: 5}, + } + if skipped := attachRubyAliasesToManual(handoff, handoffReadings); len(skipped) != 0 { + t.Fatalf("non-overlapping-window shared reading must not be skipped, got: %v", skipped) + } +} + // TestSeedSurroundingWhitespaceTrimmed covers the workflow-confirmed seed major: surrounding // whitespace on a keying surface passed the emptiness check but was stored raw, so the term // keyed WITH the space and could never match (silently inert), and a trailing-space sense diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index d258464..7a399fc 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -354,7 +354,13 @@ func (r *Runner) snapshotID() (id, payload string, err error) { // but it determines a checkpoint's RESOLVED verdict, so a gate change must be // a loud --resnapshot, not a silent re-verdict on resume (coverageSnapshot). Coverage coverageSnap `json:"coverage"` - Stages []stageSnap `json:"stages"` + // StyleCheckVersion versions the cheap deterministic post-check rules (dialogue-dash, + // yofikator, translit-interjection blocklist, 万/億 magnitude gate — cheapgates.go). They + // are observability, not wire, but editing a rule shifts the recorded style-flag counts, so + // a bump is a loud --resnapshot (same verdict class as ClassifierVersion). The ё-policy is + // part of BriefHash (a book field), so a policy change already re-pins via brief_hash. + StyleCheckVersion string `json:"style_check_version"` + Stages []stageSnap `json:"stages"` }{ BriefHash: r.Book.BriefHash(), ChunkerVersion: chunkerVersion, @@ -371,9 +377,10 @@ func (r *Runner) snapshotID() (id, payload string, err error) { OverlapTokens: r.Pipeline.Context.OverlapTokens, CacheTTL: r.Pipeline.Context.CacheTTL, }, - MemoryVersion: r.memoryVersion(), - PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate, - Coverage: r.coverageSnapshot(), + MemoryVersion: r.memoryVersion(), + PostcheckGate: r.Pipeline.Gates.Glossary.PostcheckGate, + Coverage: r.coverageSnapshot(), + StyleCheckVersion: cheapGateVersion, } for _, st := range r.Pipeline.Stages { ss := stageSnap{ @@ -501,8 +508,9 @@ func (e *CompletedWithFlags) Error() string { // error) aborts with an error, from which resume continues. func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) { // Ingest reads + normalizes the source (txt/epub) into ordered per-chapter text - // and captures ruby readings (ingest.go). Offline and deterministic ($0, no LLM). - doc, err := Ingest(r.Book.SourceFile) + // and captures ruby readings (ingest.go), decoding the txt source per book.encoding + // (auto/utf8/gb18030). Offline and deterministic ($0, no LLM). + doc, err := IngestEncoded(r.Book.SourceFile, r.Book.Encoding, r.Book.SourceLang) if err != nil { return nil, err } @@ -786,11 +794,27 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st } } + // Cheap deterministic style/number flaggers on the FINAL text (cheapgates.go): observability + // only, never a disposition. Runs on the same fresh-or-resumed output as the post-check, so it + // is resume-reproducible; skipped when a stage flagged (no usable output). Source is ch.Text + // (the 万/億 magnitude gate compares source↔output). + var cheap cheapGateResult + if !flagged && prev != "" { + cheap = runCheapGates(ch.Text, prev, r.cheapGateConfig()) + if cheap.total() > 0 { + r.Log.InfoContext(ctx, "cheap style gates flagged the chunk (observability, not a gate)", + "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "style_flags", cheap.total(), + "dialogue_dash", cheap.DialogueDash, "yo", cheap.YoInconsistent, + "translit_interj", cheap.TranslitInterj, "number_magnitude", cheap.NumberMagnitude) + } + } + // Persist the per-chunk retrieval-state (registry gate #4: convert silent memory - // degradation into a loud, visible record). Deterministic + idempotent, so a resume - // re-derives the identical row. Only when a glossary is materialized. + // degradation into a loud, visible record) plus the cheap style-flag counts. Deterministic + + // idempotent, so a resume re-derives the identical row. Only when a glossary is materialized + // (always true in a normal run — seedGlossary sets an at-least-empty bank). if r.memory != nil { - if err := r.persistRetrievalState(snapID, ch, memSel, postMisses, outputChecked); err != nil { + if err := r.persistRetrievalState(snapID, ch, memSel, postMisses, outputChecked, cheap); err != nil { return out, activeIDs, err } } @@ -809,9 +833,15 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st // selection + the post-check result. n_exact_hits/n_sticky/n_ambiguous count the INJECTED // records (what the model saw); spoiler/eviction are the dropped-and-logged totals; // post-check misses are recorded only when the translator actually produced text. -func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelection, misses []postcheckMiss, outputChecked bool) error { +func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelection, misses []postcheckMiss, outputChecked bool, cheap cheapGateResult) error { rs := store.RetrievalState{ BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, SnapshotID: snapID, + NStyleFlags: cheap.total(), + } + if cheap.total() > 0 { + if b, err := json.Marshal(cheap); err == nil { + rs.StyleDetail = string(b) + } } for _, p := range sel.injected { if p.via == "sticky" { @@ -847,6 +877,16 @@ func (r *Runner) persistRetrievalState(snapID string, ch Chunk, sel memorySelect return r.Store.UpsertRetrievalState(rs) } +// cheapGateConfig builds the cheap-gate knobs from the book brief: the ё-policy and the +// lower-cased per-project interjection allowlist. Pure, no store access. +func (r *Runner) cheapGateConfig() cheapGateConfig { + allow := make(map[string]bool, len(r.Book.StyleAllowlist)) + for _, s := range r.Book.StyleAllowlist { + allow[s] = true + } + return cheapGateConfig{yoPolicy: r.Book.YoPolicy, allowlist: allow} +} + // runStage runs ONE stage of ONE chunk to a terminal disposition. It first // resumes a resolved verdict (chunk_status read BEFORE any render — anti-wedge // #1), otherwise walks the attempt axis: render → per-attempt checkpoint resume diff --git a/backend/internal/pipeline/status.go b/backend/internal/pipeline/status.go index 9fca63b..b677560 100644 --- a/backend/internal/pipeline/status.go +++ b/backend/internal/pipeline/status.go @@ -41,6 +41,7 @@ type ChapterPassport struct { StagesSkipped int `json:"stages_skipped"` // per-stage skip count (downstream of a flag) Escalations int `json:"escalations"` PostcheckMisses int `json:"postcheck_misses"` // confirmed post-check misses (retrieval_state) + StyleFlags int `json:"style_flags"` // cheap style/number gate hits (observability, not a disposition) WorstFlagReason string `json:"worst_flag_reason,omitempty"` Verdict string `json:"verdict"` // pass | attention | fail (exp07: 0/1/≥2 flagged chunks) CostUSD float64 `json:"cost_usd"` @@ -68,8 +69,20 @@ type StatusReport struct { Pending int `json:"pending"` PercentDone float64 `json:"percent_done"` // 100·done/total, unit-count only (no synthetic time-bar) + // GlossaryMissFlagged counts chunks the post-check GATE promoted to flagged/glossary_miss (a + // CONFIRMED miss on an otherwise-ok chunk). A SUBSET of Flagged and NOT re-drivable: the miss + // re-derives from the unchanged glossary, so the fix is a seed edit + `translate --resnapshot`, + // never a redrive (a no-op for them). Flagged − GlossaryMissFlagged is the re-drivable remainder, + // which is what the status CLI must advise `tmctl redrive` for (minor 1d: the old blanket hint + // sent glossary_miss chunks into a redrive dead-end). + GlossaryMissFlagged int `json:"glossary_miss_flagged"` + Escalations int `json:"escalations"` PostcheckMisses int `json:"postcheck_misses"` + // StyleFlags is the book-wide total of the cheap deterministic style/number gate hits + // (dialogue-dash, ё, translit-interjection, 万/億 magnitude). Observability, never a + // disposition — a nonzero count is "attention worth a human glance", not a failed chunk. + StyleFlags int `json:"style_flags"` CommittedUSD float64 `json:"committed_usd"` ReservedUSD float64 `json:"reserved_usd"` @@ -104,10 +117,18 @@ func flagReasonSeverity(reason string) int { // bookChunks re-derives the book's chunk manifest — Ingest + SplitChunks — for the honest N/M // denominator. Pure and $0 (no LLM); unlike TranslateBook it does NOT persist ruby or seed the -// glossary, so status stays a pure read. A source edit or a chunker-version change is reflected -// here (the manifest is CURRENT), which is exactly what makes a snapshot drift visible. +// glossary, so status stays a pure read. A change to the chunk BOUNDARIES (added/removed text that +// shifts the manifest) or the chunker VERSION is reflected here. +// +// CAVEAT (minor 1d — narrowed overclaim): a source edit that leaves the boundaries unchanged (a +// typo fix inside a chunk) is INVISIBLE to status — it changes the chunk's content_hash but not the +// manifest and not the snapshot (which carries chunker_version, not the source bytes), so status +// still reads done/pass over a translation of the OLD text. Surfacing it would need a per-chunk +// content_hash re-render ($0) compared against the stored chunk_status.content_hash; deferred, +// because the resume fast-path already re-checks content_hash on the NEXT translate — which is +// where such an edit surfaces (as a re-bill of exactly the touched chunks), just not in status. func (r *Runner) bookChunks() ([]Chunk, error) { - doc, err := Ingest(r.Book.SourceFile) + doc, err := IngestEncoded(r.Book.SourceFile, r.Book.Encoding, r.Book.SourceLang) if err != nil { return nil, err } @@ -178,8 +199,10 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) { return nil, err } missByChunk := map[chunkKey]int{} + styleByChunk := map[chunkKey]int{} for _, rs := range states { missByChunk[chunkKey{rs.Chapter, rs.ChunkIdx}] = rs.NPostcheckMiss + styleByChunk[chunkKey{rs.Chapter, rs.ChunkIdx}] = rs.NStyleFlags } stagesTotal := len(r.Pipeline.Stages) @@ -209,11 +232,15 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) { miss := missByChunk[key] p.PostcheckMisses += miss rep.PostcheckMisses += miss + style := styleByChunk[key] + p.StyleFlags += style + rep.StyleFlags += style if gateOn && state == ChunkDone && miss > 0 { // Matches translateChunk: the gate flags only an otherwise-ok chunk. Not re-drivable // (the miss re-derives from the unchanged glossary; a fix is a glossary edit = - // --resnapshot, not a redrive). + // --resnapshot, not a redrive) — counted separately so the CLI advises the right action. state, reason = ChunkFlagged, string(FlagGlossaryMiss) + rep.GlossaryMissFlagged++ } if escalated { p.Escalations++ @@ -276,12 +303,9 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) { // seed FILE that was not re-run is NOT detected here (the stored glossary is what status // projects); it surfaces on the next translate's re-seed. if rep.Snapshot != "" { - if rows, gerr := r.Store.GlossaryForBook(r.Book.BookID); gerr == nil { - r.memory = materializeMemory(rows, gateOn) - if curSnap, _, serr := r.snapshotID(); serr == nil && curSnap != rep.Snapshot { - rep.ConfigDrift = true - rep.CurrentSnapshot = curSnap - } + if curSnap, serr := r.currentSnapshotProjected(); serr == nil && curSnap != rep.Snapshot { + rep.ConfigDrift = true + rep.CurrentSnapshot = curSnap } } @@ -305,6 +329,12 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) { } // ETA (secondary): mean fresh-call throughput × remaining processing. No synthetic bar. + // DEVIATION from D12 (which ratified an EWMA) — minor 1d, made explicit: this is a plain + // arithmetic mean, not an exponentially-weighted one. For a batch book run throughput is + // ~stationary, so the mean and an EWMA converge; a recency-weighted EWMA (which would adapt to + // a mid-run provider slowdown) is deferred because it needs the ORDERED per-call latencies, not + // the sum+count FreshCallLatencyMS returns. Kept secondary/advisory in the output so it is never + // mistaken for a committed completion time. totalMS, freshCalls, err := r.Store.FreshCallLatencyMS(r.Book.BookID) if err != nil { return nil, err @@ -317,6 +347,23 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) { return rep, nil } +// currentSnapshotProjected computes the CURRENT config's snapshotID from the STORED glossary +// (read-only: materialize what is persisted, no re-seed, no write) — the single projection +// Status uses for ConfigDrift and Redrive uses to guard its destructive reset. Side effect +// (deliberate, on the read path): it sets r.memory to the stored-glossary materialization, the +// same as Status did inline. A seed-FILE edit not yet re-run is NOT reflected here (the STORED +// glossary is projected, not the seed file) — that drift surfaces on the next translate's +// re-seed, exactly as it does for `translate` itself. +func (r *Runner) currentSnapshotProjected() (string, error) { + rows, err := r.Store.GlossaryForBook(r.Book.BookID) + if err != nil { + return "", err + } + r.memory = materializeMemory(rows, r.Pipeline.Gates.Glossary.PostcheckGate) + id, _, err := r.snapshotID() + return id, err +} + // RedriveSelector picks the flagged chunks to re-attack. -1 on Chapter/ChunkIdx means "any" // (a valid chunk index is ≥0, a chapter ≥1, so -1 is an unambiguous "unset"); an empty Reason // matches any flag_reason. DryRun reports what WOULD be reset without touching anything. @@ -388,6 +435,10 @@ func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSumm return order[i].chunkIdx < order[j].chunkIdx }) + // Build the reset PLAN first — pure, no mutation — so the destructive reset happens only + // after the drift guard below clears (Task 1c). Each target resets its flagged stage AND its + // downstream skipped stages; upstream DispOK stages are never included (D12 — never re-pay ok + // work), which is the invariant the redrive tests mutation-lock. summary := &RedriveSummary{DryRun: sel.DryRun} for _, k := range order { rows := byChunk[k] @@ -402,7 +453,6 @@ func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSumm if !targeted { continue } - // Reset the flagged stage AND its downstream skipped stages; keep upstream DispOK. var stages []string for _, cs := range rows { if cs.Disposition == string(DispFlagged) || cs.Disposition == string(DispSkipped) { @@ -413,16 +463,48 @@ func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSumm summary.Targets = append(summary.Targets, RedriveTarget{ Chapter: k.chapter, ChunkIdx: k.chunkIdx, FlagReason: reason, Stages: stages, }) - if !sel.DryRun { - if err := r.Store.ResetChunkStages(r.Book.BookID, k.chapter, k.chunkIdx, stages); err != nil { - return summary, nil, fmt.Errorf("pipeline: redrive reset ch%d/chunk%d: %w", k.chapter, k.chunkIdx, err) - } - } } if sel.DryRun || len(summary.Targets) == 0 { return summary, nil, nil } + + // Config/seed-drift guard BEFORE the destructive reset (external-review 1c). A redrive re-runs + // under the CURRENT config; if it drifted from the snapshot the stored rows carry, TranslateBook + // fails loud in runStage with the resnapshot guidance — but the OLD code had already deleted the + // flagged chunk_status rows + checkpoints (ResetChunkStages), so that fail-loud left the flag + // telemetry destroyed and a later `status` read the chunk as pending/pass. + // + // The snapshot MUST be computed exactly the way TranslateBook will compute it — i.e. AFTER + // re-seeding the glossary from the seed FILE (self-review major: a read-only stored-glossary + // projection misses a seed-FILE edit — precisely what the glossary_miss CLI hint tells the + // operator to make — so the guard would pass, the reset would fire, and the re-run's re-seed + // would then fail loud AFTER the destructive delete). seedGlossary is idempotent and touches + // only glossary rows (never chunk_status/checkpoints), so re-seeding here is safe; TranslateBook + // re-seeds again identically. A seed error (e.g. a new collision) also surfaces here, before any + // reset. Skipped under --resnapshot (the operator explicitly accepts the re-pin/re-pay). + if !r.Resnapshot { + if err := r.seedGlossary(ctx); err != nil { + return summary, nil, fmt.Errorf("pipeline: redrive seed glossary: %w", err) + } + curSnap, _, serr := r.snapshotID() + if serr != nil { + return summary, nil, fmt.Errorf("pipeline: redrive snapshot check: %w", serr) + } + for _, cs := range statuses { + if cs.SnapshotID != "" && cs.SnapshotID != curSnap { + return summary, nil, fmt.Errorf("pipeline: redrive прерван — строки книги под снапшотом %.12s, а текущий конфиг/сид рендерит %.12s (конфиг/промпты/капабилити/сид-глоссарий изменились): сброс сейчас пере-оплатил бы книгу и уничтожил бы флаг-телеметрию; повторите `translate --resnapshot`, чтобы принять пере-оплату явно, либо верните конфиг/сид", cs.SnapshotID, curSnap) + } + } + } + + // Destructive reset — safe now that drift has been ruled out. + for _, t := range summary.Targets { + if err := r.Store.ResetChunkStages(r.Book.BookID, t.Chapter, t.ChunkIdx, t.Stages); err != nil { + return summary, nil, fmt.Errorf("pipeline: redrive reset ch%d/chunk%d: %w", t.Chapter, t.ChunkIdx, err) + } + } + // Re-run the durable loop: reset chunks re-attack fresh; everything else resumes at $0. res, err := r.TranslateBook(ctx) summary.ResetRun = true diff --git a/backend/internal/pipeline/status_test.go b/backend/internal/pipeline/status_test.go index 725259a..fc5f6d7 100644 --- a/backend/internal/pipeline/status_test.go +++ b/backend/internal/pipeline/status_test.go @@ -174,6 +174,12 @@ func TestStatusSurfacesGlossaryMissGate(t *testing.T) { if rep.Flagged != 1 || rep.Done != 0 { t.Fatalf("status must match translate (surface glossary_miss): done=%d flagged=%d, want 0/1", rep.Done, rep.Flagged) } + // The gate-promoted flag is counted as a glossary_miss (NOT re-drivable) so the CLI advises a + // seed fix + --resnapshot, not a redrive dead-end (minor 1d). Flagged − GlossaryMissFlagged = 0 + // re-drivable here. + if rep.GlossaryMissFlagged != 1 { + t.Fatalf("the gate flag must be counted as glossary_miss: GlossaryMissFlagged=%d, want 1", rep.GlossaryMissFlagged) + } if len(rep.Chapters) != 1 || rep.Chapters[0].WorstFlagReason != string(FlagGlossaryMiss) { t.Fatalf("passport must show glossary_miss: %+v", rep.Chapters) } @@ -234,6 +240,235 @@ func TestStatusDetectsConfigDrift(t *testing.T) { } } +// TestRedriveResetsOnlyFlaggedStageNotDispOK mutation-locks the D12 invariant "a redrive NEVER +// resets DispOK work" against a mutant that resets EVERY stage (external-review 1c). The existing +// end-to-end test flags the FIRST stage (draft), so its reset set == the full set and a +// reset-everything mutant survives. Here draft is OK and only EDIT is flagged, so the reset set is +// {edit} alone: the mutant (reset draft too) is caught by both the plan (Stages == [edit]) and the +// re-run cost (draft must resume at $0 → exactly ONE fresh edit call). Reverting the +// Flagged||Skipped stage filter to `true` makes draft re-bill and Stages == [draft,edit]. +func TestRedriveResetsOnlyFlaggedStageNotDispOK(t *testing.T) { + rec := &reqRec{} + var mu sync.Mutex + refuseEdit := true // the EDIT stage refuses until flipped; draft is always OK + srv := newJSONProvider(rec, func(body string) (string, string) { + if isEditBody(body) { + mu.Lock() + r := refuseEdit + mu.Unlock() + if r { + return "Извините, я не могу продолжить.", "stop" // soft refusal (matches the blacklist) → edit flagged, non-retryable + } + return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" + } + return "ЧЕРНОВИК ПЕРЕВОДА", "stop" + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОДНАГЛАВА", regenerate: 0}) + ctx := context.Background() + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + callsAfterRun1 := rec.count() // draft(ok) + edit(refusal) = 2 + if callsAfterRun1 != 2 { + t.Fatalf("want 2 provider calls after run1, got %d", callsAfterRun1) + } + // draft is OK, edit is flagged. + if cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "ok" { + t.Fatalf("draft must be ok: %+v", cs) + } + if cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "edit"); cs == nil || cs.Disposition != "flagged" { + t.Fatalf("edit must be flagged: %+v", cs) + } + r1.Close() + + // Dry-run plan: only the EDIT stage is reset — the DispOK draft is untouched (the invariant). + r2 := newRunner(t, bookPath) + sum, _, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, DryRun: true}) + if err != nil { + t.Fatal(err) + } + if len(sum.Targets) != 1 { + t.Fatalf("want 1 target, got %+v", sum.Targets) + } + if got := strings.Join(sum.Targets[0].Stages, ","); got != "edit" { + t.Fatalf("reset plan = %q, want just \"edit\" (draft is DispOK and must not be reset)", got) + } + r2.Close() + + // Real redrive with the editor fixed: only edit re-runs (1 fresh call); draft resumes at $0. + mu.Lock() + refuseEdit = false + mu.Unlock() + r3 := newRunner(t, bookPath) + defer r3.Close() + sum3, res3, err := r3.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1}) + if err != nil { + t.Fatal(err) + } + if !sum3.ResetRun || res3 == nil || res3.Flagged != 0 { + t.Fatalf("redrive should reset+re-run and clear the flag: %+v", res3) + } + if got := rec.count() - callsAfterRun1; got != 1 { + t.Fatalf("redrive must make exactly ONE fresh call (edit only; draft resumes $0), made %d", got) + } + // The draft checkpoint was never discarded — its cost is not re-billed. + committed, _, _ := r3.Store.SpentUSD("test-book") + if math.Abs(committed-3*fakeCallUSD) > 1e-9 { + t.Errorf("committed = %v, want %v (draft + refused-edit + redriven-edit)", committed, 3*fakeCallUSD) + } +} + +// TestRedriveReasonSelectorMismatch locks the --reason selector: a redrive whose reason does not +// match the flagged chunk's flag_reason must find NO target and touch nothing (mutating +// RedriveSelector.matches to ignore Reason would wrongly re-attack the chunk). The matching reason +// then does select it (so the test also proves the selector is live, not a no-op that always misses). +func TestRedriveReasonSelectorMismatch(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) { + return "Извините, я не могу перевести это.", "stop" // soft_refusal + } + return draftEdit(body) + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0}) + ctx := context.Background() + + r := newRunner(t, bookPath) + defer r.Close() + if _, err := r.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + calls := rec.count() + + // Non-matching reason → no target, no reset, no re-run. + sum, res, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagLength)}) + if err != nil { + t.Fatal(err) + } + if len(sum.Targets) != 0 || sum.ResetRun || res != nil { + t.Fatalf("a non-matching --reason must select nothing, got %+v", sum) + } + if rec.count() != calls { + t.Fatal("non-matching redrive made provider calls") + } + if cs, _ := r.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" { + t.Fatalf("the flagged row must be untouched after a non-matching redrive: %+v", cs) + } + + // The MATCHING reason does select it (dry-run, so nothing is mutated) — proves the selector fires. + sum2, _, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagSoftRefusal), DryRun: true}) + if err != nil { + t.Fatal(err) + } + if len(sum2.Targets) != 1 { + t.Fatalf("the matching --reason must select the flagged chunk, got %+v", sum2.Targets) + } +} + +// TestRedriveAbortsOnConfigDriftPreservingTelemetry locks Task 1c: a redrive under a DRIFTED config +// must refuse loudly BEFORE the destructive reset, leaving the flag telemetry intact. The old code +// reset (deleted) the flagged chunk_status + checkpoints first, then TranslateBook failed loud on +// the snapshot mismatch — so the flagged row was gone and status read the chunk as pending/pass. +// Reverting the guard deletes the flagged draft row here; the "still flagged" assertion catches it. +func TestRedriveAbortsOnConfigDriftPreservingTelemetry(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) { + return "Извините, я не могу перевести это.", "stop" + } + return draftEdit(body) + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0}) + ctx := context.Background() + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + r1.Close() + callsAfterRun := rec.count() + + // Drift the config: bump the draft prompt_version → the current config renders a new snapshot. + dir := filepath.Dir(bookPath) + body, err := os.ReadFile(filepath.Join(dir, "pipeline.yaml")) + if err != nil { + t.Fatal(err) + } + changed := strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test-drift", 1) + if changed == string(body) { + t.Fatal("setup: prompt_version token not found") + } + writeFile(t, filepath.Join(dir, "pipeline.yaml"), changed) + + r2 := newRunner(t, bookPath) + defer r2.Close() + sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1}) + if err == nil { + t.Fatal("a drifted config must make redrive fail loud") + } + if !strings.Contains(err.Error(), "snapshot") && !strings.Contains(err.Error(), "снапшот") { + t.Errorf("drift error should mention the snapshot mismatch, got: %v", err) + } + if res != nil || sum.ResetRun { + t.Errorf("a drift-aborted redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun) + } + // The whole point: the flag telemetry is intact (the row was NOT reset). + if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" { + t.Fatalf("the flagged row must survive a drift-aborted redrive, got: %+v", cs) + } + if rec.count() != callsAfterRun { + t.Fatal("a drift-aborted redrive must make no provider calls") + } +} + +// TestRedriveAbortsOnSeedFileDriftPreservingTelemetry locks the self-review MAJOR: the redrive guard +// must catch a SEED-FILE edit (not just a config-file edit), because that is exactly what the +// glossary_miss CLI hint tells the operator to make. The guard re-seeds (like TranslateBook will) so +// an approved-term change shifts the snapshot and the redrive aborts BEFORE the destructive reset — +// leaving the flag telemetry intact. A read-only stored-glossary projection would miss it, reset, +// then fail loud on the re-seed AFTER deleting the flagged rows. +func TestRedriveAbortsOnSeedFileDriftPreservingTelemetry(t *testing.T) { + seed := "terms:\n - src: 蛊\n dst: Гу\n status: approved\n" + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) { + return "Извините, я не могу перевести это.", "stop" + } + return draftEdit(body) + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0, glossarySeed: seed}) + ctx := context.Background() + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + r1.Close() + + // Edit the seed FILE — change the approved dst → different memoryVersion → different snapshot. + dir := filepath.Dir(bookPath) + writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), "terms:\n - src: 蛊\n dst: Гу-изменённый\n status: approved\n") + + r2 := newRunner(t, bookPath) + defer r2.Close() + sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1}) + if err == nil { + t.Fatal("a seed-FILE edit must make redrive fail loud (re-seed drift guard)") + } + if res != nil || sum.ResetRun { + t.Errorf("a seed-drift-aborted redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun) + } + if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" { + t.Fatalf("the flagged row must survive a seed-drift-aborted redrive, got: %+v", cs) + } +} + // TestRedriveNoTargets confirms a selector that matches nothing is a clean no-op (no re-run). func TestRedriveNoTargets(t *testing.T) { rec := &reqRec{} diff --git a/backend/internal/store/glossary.go b/backend/internal/store/glossary.go index 0b72f20..6d3a688 100644 --- a/backend/internal/store/glossary.go +++ b/backend/internal/store/glossary.go @@ -72,6 +72,8 @@ type RetrievalState struct { NPostcheckMiss int PostcheckDetail string // JSON InjectedIDs string // JSON + NStyleFlags int // cheap style/number gate hits (dialogue-dash, ё, translit-interj, 万/億) + StyleDetail string // JSON breakdown of the style-flag hits } // ReplaceGlossary replaces a book's ENTIRE glossary (rows + aliases) with the given @@ -246,8 +248,9 @@ func (s *Store) UpsertRetrievalState(rs RetrievalState) error { INSERT INTO retrieval_state ( book_id, chapter, chunk_idx, snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged, n_spoiler_blocked, n_evicted, - embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now')) + embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids, + n_style_flags, style_detail, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now')) ON CONFLICT (book_id, chapter, chunk_idx) DO UPDATE SET snapshot_id = excluded.snapshot_id, n_exact_hits = excluded.n_exact_hits, @@ -259,10 +262,13 @@ func (s *Store) UpsertRetrievalState(rs RetrievalState) error { n_postcheck_miss = excluded.n_postcheck_miss, postcheck_detail = excluded.postcheck_detail, injected_ids = excluded.injected_ids, + n_style_flags = excluded.n_style_flags, + style_detail = excluded.style_detail, updated_at = excluded.updated_at`, rs.BookID, rs.Chapter, rs.ChunkIdx, rs.SnapshotID, rs.NExactHits, rs.NSticky, rs.NAmbiguousFlagged, rs.NSpoilerBlocked, rs.NEvicted, - rs.EmbeddingTierUsed, rs.NPostcheckMiss, rs.PostcheckDetail, rs.InjectedIDs) + rs.EmbeddingTierUsed, rs.NPostcheckMiss, rs.PostcheckDetail, rs.InjectedIDs, + rs.NStyleFlags, rs.StyleDetail) return err } @@ -273,11 +279,13 @@ func (s *Store) GetRetrievalState(bookID string, chapter, chunkIdx int) (*Retrie rs := RetrievalState{BookID: bookID, Chapter: chapter, ChunkIdx: chunkIdx} err := s.r.QueryRowContext(ctx, ` SELECT snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged, n_spoiler_blocked, - n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids + n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids, + n_style_flags, style_detail FROM retrieval_state WHERE book_id = ? AND chapter = ? AND chunk_idx = ?`, bookID, chapter, chunkIdx).Scan( &rs.SnapshotID, &rs.NExactHits, &rs.NSticky, &rs.NAmbiguousFlagged, &rs.NSpoilerBlocked, - &rs.NEvicted, &rs.EmbeddingTierUsed, &rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs) + &rs.NEvicted, &rs.EmbeddingTierUsed, &rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs, + &rs.NStyleFlags, &rs.StyleDetail) if errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -294,7 +302,8 @@ func (s *Store) RetrievalStatesForBook(bookID string) ([]RetrievalState, error) defer cancel() rows, err := s.r.QueryContext(ctx, ` SELECT chapter, chunk_idx, snapshot_id, n_exact_hits, n_sticky, n_ambiguous_flagged, - n_spoiler_blocked, n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids + n_spoiler_blocked, n_evicted, embedding_tier_used, n_postcheck_miss, postcheck_detail, injected_ids, + n_style_flags, style_detail FROM retrieval_state WHERE book_id = ? ORDER BY chapter, chunk_idx`, bookID) if err != nil { @@ -306,7 +315,8 @@ func (s *Store) RetrievalStatesForBook(bookID string) ([]RetrievalState, error) rs := RetrievalState{BookID: bookID} if err := rows.Scan(&rs.Chapter, &rs.ChunkIdx, &rs.SnapshotID, &rs.NExactHits, &rs.NSticky, &rs.NAmbiguousFlagged, &rs.NSpoilerBlocked, &rs.NEvicted, &rs.EmbeddingTierUsed, - &rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs); err != nil { + &rs.NPostcheckMiss, &rs.PostcheckDetail, &rs.InjectedIDs, + &rs.NStyleFlags, &rs.StyleDetail); err != nil { return nil, err } out = append(out, rs) diff --git a/backend/internal/store/migrate.go b/backend/internal/store/migrate.go index 6e5d64e..67a9779 100644 --- a/backend/internal/store/migrate.go +++ b/backend/internal/store/migrate.go @@ -263,6 +263,15 @@ var migrations = []string{ ALTER TABLE chunk_status ADD COLUMN escalated INTEGER NOT NULL DEFAULT 0; ALTER TABLE chunk_status ADD COLUMN escalation_model TEXT NOT NULL DEFAULT ''; `, + // v7: the cheap deterministic style flaggers (dialogue-dash, yofikator, translit-interjection + // blocklist, 万/億 magnitude gate — cheapgates.go) ride the same per-chunk observability row as + // the glossary post-check. n_style_flags is the total hit count; style_detail is the JSON + // breakdown for the report. Recomputed deterministically each run (self-heals on resume), NOT + // wire-load-bearing (observability, never a disposition). + ` + ALTER TABLE retrieval_state ADD COLUMN n_style_flags INTEGER NOT NULL DEFAULT 0; + ALTER TABLE retrieval_state ADD COLUMN style_detail TEXT NOT NULL DEFAULT ''; + `, } // migrate runs all pending migrations on the write pool, one transaction per diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 36a03f4..804646d 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -573,6 +573,26 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор - Известная граница `status`/`redrive`: glossary_miss re-деривится из неизменного глоссария → не re-drivable (фикс = правка глоссария = --resnapshot); status теперь честно это показывает. - F3 at-most-once — следующий D12-хвост (status/redrive закрыты). +### 2026-07-09 (пакет-2) — Приёмка-prep Ф1 на 蛊真人 (fix-лист ревью + GB18030 + дешёвые гейты + D15.2 v2 + smoke) ЗАВЕРШЕНА + +Мандат `BACKEND_SESSION_PROMPT_ACCEPTANCE_PREP.md` (5 задач) выполнен. Порядок 1 → 2 → (3∥4) → 5. `go build/vet/test ./... -race` зелёные; фиксы mutation-verified; `tmctl report` $0. Смоук-книга/производные/сид — ВНЕ git (Р8; в журнал только метрики). + +- **Задача 1 — fix-лист внешнего ревью.** (1a, **major, mutation-verified**) homophone first-wins в `attachRubyAliasesToManual`: контестед-ключ решается ДО аттача для ВСЕХ владельцев симметрично (было attach-then-block → алфавитно-первая база получала kana-alias CONFIRMED = A2 неверная авторитетная инъекция при ≥4 каны). Плюс window+dst-eligibility awareness (минор 1d — меньше ложных скипов). Ревертом ловится тест `TestRubyHomophoneNoWrongConfirmedInjection` (たかはし 4-каны материализуется → NEITHER инъектится). (1b) redrive-инвариант «DispOK НЕ сбрасывается» **закреплён мутационно** (draft=ok/edit=flagged → сброс ТОЛЬКО edit, draft резюмится за $0; мутант `filter→true` даёт Stages=[draft,edit] → тест валит) + тест НЕсовпадающего `--reason`. (1c) snapshot-чек ДО `ResetChunkStages` (**mutation-verified**: без гарда drift-редрайв удаляет флаг-строку → status pending/pass). (1d) minors: `--json` тоже exit 2 при флагах; hint для glossary_miss отдельный (redrive для него no-op → правка сида + --resnapshot); README миграции v1–v6; ETA — явная пометка D12-отступления (mean, не EWMA); overclaim-коммент `bookChunks` сужен (правка исходника без смены границ невидима status'у). +- **Задача 2 — GB18030/кодировки в ingest.** `decodeSourceBytes`: лестница BOM(UTF-8/UTF-16) → валидный UTF-8 → **GB18030-проба (только zh-источник)** → fail-loud. Гарды: U+FFFD, NUL (UTF-16-без-BOM), Han-density+plausibleCJK (анти-мохибаке). `book.yaml encoding: auto|utf8|gb18030` + `source_lang`-скоуп пробы. Chapter-split добавлен для txt: **«第N章/节/回» авто-детект дом. юнита + separator-guard** (回 — мера, «第一回见面» НЕ дробит; сепаратор после юнита обязателен), preamble→гл.1. Новая внешняя зависимость: `golang.org/x/text/encoding/{simplifiedchinese,unicode}` (+ `japanese` в тестах) — первая за пределами go.sum-минимума (x/text уже был для NFC). +- **Задача 3 — 4 дешёвых детерминированных гейта (наблюдаемость, НЕ гейты, на финальном тексте).** Линтер тире-диалогов (Розенталь §47–52: прямые кавычки/дефис/en-dash вместо «—», смешение стилей), ёфикатор (Пётр/Петр + brief-политика all-ё/all-е, ~30 омографов-исключений), блок-лист транслит-междометий (ара-ара/маа/нани/…; per-project allowlist), число-гейт 万/億 (CJK-парсер значений + диапазоны-множители на выходе; digit-coefficient-guard исключает идиомы 万一/千万/万物). `cheapGateVersion` свёрнут в snapshot (правка правил = --resnapshot, как classifierVersion); миграция store **v7** (`retrieval_state.n_style_flags`+`style_detail`); счётчики в `tmctl status`/`report`/паспорте главы + `StatusReport.StyleFlags`/`GlossaryMissFlagged`. +- **Задача 4 — спека D15.2 v2** (`backend/docs/D15.2-content-addressed-resume-spec.md`, дизайн, реализация ОСТАЁТСЯ заблокированной до ратификации v2). Закрыты 3 дыры ревью: (a) fast-path-гард — отдельная колонка `guard_hash` (весь wire stage-план, суперсет текущего); (b) расщепление `memory_version` ОБЯЗАТЕЛЬНО — inject-часть в `content_hash` (из ключа вон), post-check+gate+decl → `verdictSnapshotID` (пересчёт на resume бесплатно); (c) замена loud-гейта согласия pre-flight dry-run «N чанков, ~$X» + порог `--accept-rebill`. Плюс: полный полевой маппинг снапшота (wire/verdict/dropped), судьба `chunk_status.snapshot_id`/status-ридеров, sequencing миграции (ДО первой онгоинг-книги; миграция v8), blast-radius append-а (sticky depth2 + F2 eviction + каскад на редактор). 3 открытых вопроса оркестратору в §13. **На утверждение.** +- **Задача 5 — D18 smoke на 蛊真人.** (а) **ingest+chunker на реальном GB18030-файле (15.5 МБ):** 2283 главы (第N节-детект), **5088 чанков** (медиана 1445 ток., целевой 1500), ~6.52M токенов (медиана главы 2832), 0 ruby (zh), U+FFFD=0. (б) escalate_to заведён (интерим glm-5 фолбэком черновика). (в) **живой smoke 1 главы (draft=deepseek-v4-flash → edit=glm-5, escalate=glm-5; БЕЗ xAI — гигиена ключа D8 не подтверждена для книжного контента; потолок $0.10):** деньги сходятся **до цента** — Σ(request_log billed) == committed == **$0.031634**, reserved=$0; **живое эхо+эскалация** (chunk3 deepseek→cjk_artifact→glm-5 ok); **redrive на реальном флаге** (esc-off вариант дал cjk_artifact-флаг → redrive сбросил+перезапустил, DispOK за $0, 2-й проход: empty→удвоение бюджета→ok → флаг снят, 6/6 done); cheap gates фаернули на реальном выводе (万/億-разряды, дефис-диалоги). Полигонный сид (49 терминов, 35 approved/14 draft) грузится **чисто** через мой `loadGlossarySeed`+`approvedSharedKeyCollisions`+`materializeMemory` (схема совпадает). Суммарный живой спенд сессии ≈ $0.065. +- **Финал — агентское адверсариальное селфревью** (6 дименсий × find→refute-by-default verify, 21 агент, ~835k ток.): **15 находок, 14 подтверждено, все закрыты** с регресс-тестами. Крит: Shift-JIS/EUC-JP молча принимался как GB18030-мохибаке (плаузибилити не отличает CJK-shaped мусор) → GB18030-проба скоупнута на zh (**mutation-verified**). Мажоры: seed-FILE drift не ловился redrive-гардом (read-only проекция) → гард **re-seed'ит** как TranslateBook, ловит ДО сброса (**mutation-verified**); UTF-16-без-BOM ASCII (interleaved NUL) → NUL-гард; chapter false-split на 回 → separator-guard; cheapgate ложные позитивы («уму»/«ара» убраны из блок-листа, 万-идиомы digit-guard, ё-омографы расширены, em-dash scene-break не считается диалогом); plausibleCJK получил прямой тест (была 0-покрытость). Миноры: StyleAllowlist сортируется до BriefHash, redrive-abort-сообщение переформулировано. + +**Блокеры полной приёмки (владельцу/оркестратору):** +1. **Сид готов, но политические развязки D10 открыты** (полигон §вопросы п.3): имена гу перевод-vs-транслит, 古月=Гуюэ vs Гу Юэ, пол 白凝冰 (draft/female — 他 в первых главах, gender=hidden трап). Гейтят финализацию сида. +2. **Пороги гейтов:** coverage len_ratio zh-ru [2.2,4.2] (полигон подтвердил на срезе); postcheck-гейт держать OFF до замера precision на реальной инфлекции (E1); дешёвые стиль-гейты — наблюдаемость, порогов не имеют. +3. **Канал 18+/violence:** полигон — grok thinking-off эхает 40% как ПЕРЕВОДЧИК (класс deepseek-эхо) → канал B: Mistral-переводчик + echo-гейт, ИЛИ grok reasoning-ON + reasoning-буфер (D6.2). xAI clean-key для боевого прогона (гигиена D8) — не подтверждён; smoke сознательно обошёл xAI. +4. **D3-цепочка эскалации не заведена** (deepseek-v4-pro/glm-5.1/gemini — цены/ключи) — интерим glm-5; заводка — отдельный шаг. +5. Полный прогон целой книги — **отдельная сессия** по готовности сида (п.1) + порогов (п.2) + канала (п.3). + +**Вопросы оркестратору:** (1) **D15.2 v2 на ратификацию** (3 развязки в §13: пер-стадийная гранулярность wire-снапшота vs book-global; дефолт порога `rebill_consent_usd` + имя флага; слой для `Adult`). (2) Редактор smoke — glm-5 вместо ратифицированного grok-4.3 из-за xAI-гигиены: для боевой приёмки нужен ЧИСТЫЙ xAI-ключ ИЛИ решение держать glm/Mistral редактором. (3) ja-путь: Shift-JIS сознательно НЕ авто-детектится (fail-loud) — ja-книги должны быть UTF-8/epub (ja-приёмка отложена D18 — ок). + ## Полигон (секция параллельной сессии — записи добавлять сюда) @@ -781,3 +801,23 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор > **[ГИГИЕНА]** Модель полигона `qwen3-abliterated:30b-a3b` (6.4 ГБ VRAM) **не выселялась** — GPU-проба шла в свободном CUDA-окне (WSL2 спилит в shared RAM), после — cleanup. Артефакты: `eval/adaptive_probe.py`, `eval/adaptive_incontext.py` (+ `eval/data/*.json`), новые доки — только `research/14`. Изолированный CUDA-env (Pascal-проба) — в scratchpad, не в репозитории. Полный потолок QLoRA-7B+reranker на **чистых** 8ГБ — за скоординированным стенд-окном (не мерил, чтоб не выселять 30b); на вердикт не влияет (кандидата нет). > **[ДОБАВЛЕНО, 05.07 — «долбить детерминированную сторону»]** По запросу владельца (раз гибрид отклонён — максимизировать коэффициент канон-консистентности детерминированно) — замер рычагов L1→L4 на ТРУДНОМ чанке (`eval/adaptive_levers.py`, 11 сущностей, deepseek+grok), research/14 §9. **Порядок бэкенду:** **L1 post-check ЛЕММАТИЗИРУЮЩИЙ (pymorphy3), не регэксп** — ПЕРВЫМ (наивный регэксп даёт **18–36% ЛОЖНЫХ флагов** на трудном чанке; pymorphy сворачивает и OOV-транслит Вана→ван; количественная валидация несущего риска research/13 §2). **L2 recall — нормализация ПОЛНАЯ+тестируемая (OpenCC, не мини-карта: живой A4-баг 爺→爷 молча промахнул 赵太爷) + alias-граф + sticky** (recall 0.00→0.50→0.75). **L3 re-ask** (deepseek 0.82→0.91). **L4 таблица Палладия (B6) — потолок ПРАВИЛЬНОСТИ.** Побочно: 钱 (односимвольный гомограф Цянь/деньги) упорно промахивается → **min_key_len/запрет одиночных ключей A3 обязателен.** Поправка §2: soft-глоссарий на лёгком чанке = **~1.0** (0.875 был регэксп-артефакт), demos не бьют — потолок. Артефакты: `eval/adaptive_reask.py`, `eval/adaptive_levers.py`. Инстинкт «тюнить ранкер» — легитимен только во 2-м эшелоне (bge-m3 без разделяющего порога), не в костяке. + +## Голос и состояние +(секция ресёрч-сессии «Голос и состояние» — записи добавлять сюда) + +### 2026-07-09 — Сессия «Голос и состояние» ЗАВЕРШЕНА → [research/15-voice-and-state.md](research/15-voice-and-state.md) + +Метод: 12 направлений-сборщиков → адверсариальная верификация несущих клеймов refute-by-default (~60 верификаторов) → completeness-критик → 6 доборов (91 агент, ~3.2M ток., 0 отказов) + **живые пробы на SFW-чанках 蛊真人** ($0.04 из $2.50; сырые артефакты в scratchpad сессии; боевой драфт-промпт deepseek, редактор grok `reasoning_effort:none`, судьи кросс-семейно со свапом позиций). Зоны чужие не тронуты, ничего не закоммичено. + +**Вердикт (кратко):** +- **Клейм «voice exemplars > дескрипторов» — REFUTED в универсальной форме** (прямого сравнения не существует нигде; ChatHaruhi количественной абляции НЕ содержит), но количественное ядро есть: RoleLLM — формат демо важнее наличия (9.85→30.19→61.79% win-rate); дистиллированный voice-sheet бьёт RAG-пример; насыщение ~5 реплик; **similarity-retrieval вредит** (69→36 AA) — удачно совпадает с детерминизмом банка. Строить **гибрид «1 строка регистра + 3–5 RU-реплик»**. Проба подтвердила направление: гибрид — ранг-1 у судей 5/5 (2 семейства); детерминированный маркер (奴婢-самоуничижение служанки): база 1/4, дескрипторы 3/4, **экземпляры/гибрид 4/4**; на «лёгкой» сцене (братья) инъекция нейтральна/шумит → **селективность по яркости голоса**. +- **Голос выигрывается на ДРАФТЕ:** grok-редактор с узким мандатом вернул черновик **дословно** в 3/4 проб (сглаживание НЕ воспроизвелось; восстановление сплющенного голоса voice-блоком — тоже НЕ произошло). Редактору — констрейнты сохранения, переводчику — инъекция. +- **Прайор-арт: клейм стратревью «аналогов нет» сузить, не снять.** Пер-персонажный голос в промышленном MT существует как продукт-клейм (NAVER «캐릭터 인식 번역», Mantra, XL8 — механизмы не раскрыты); детерминированный no-LLM словарный слой и селективная инъекция по матчу — массовая практика фан-стека (GalTransl/LunaTranslator/SakuraLLM — «учебник» найден). **Уникальными остаются:** спойлер-окна since/until (нигде, вкл. патентные индексы), scene-state и voice-exemplars в MT, формула «disposition+окна+бюджет+детерминизм». +- **Scene-state: полная схема — ниша пуста; единственный доказанный срез — направленный регистр пары** (ты/вы = 67% деиксис-ошибок En→Ru; тег-контроль 73–92% acc) → **реестр обращений пар** (speaker→addressee, ты/вы, since/until; индустриальный прототип — Netflix Treatments list / 呼称表) — самый дешёвый новый тип записи. DelTA после сверки первоисточника: чистый вклад памяти = +3.95 пп LTCR при −0.02 COMET (консистентность и качество **развязаны** — выигрыш виден только специализированной осью). +- **Эпистемика читателя: spoiler-aware MT — 0 работ (ниша пуста);** механика у нас есть — reveal-окна = расширение спойлер-окон (`reveal_ch` + pre/post-reveal алиасы); механический спойлер-канал zh→ru — **род прошедшего времени** (конкретизация C3). Имплицитному трекингу «кто что знает» доверять нельзя (FANToM 26.6% vs человек 87.5%). Dramatic irony registry в Ф1–Ф2 не строить. +- **Промптинг/параметры:** «двухпроходность в одном вызове» (V1.13) — арм НЕ ставить (план не работает, работает refinement = наш draft→editor); NO_CHANGE давать, но доля — опция-артефакт (плацебо-эффект −15.75 пп); формат инъекции — модель-специфичный гиперпараметр пилота (JSON худший по замеру OpenAI); менять draft 0.3/editor 0.4 оснований нет, НО **thinking DeepSeek молча игнорирует temperature → temp драфта, вероятно, no-op** (wire-аудит — пре-шаг любого свипа). +- **Живые грабли проб (все с сырыми артефактами):** deepseek-эхо воспроизведено на приёмочной книге (2/3 сэмплов одной конфигурации, стохастично); **gemini-2.5-flash — интермиттентный 404 «no longer available» при наличии в /models** (⚠ это судья fidelity-оси `memory_eval`!); **gemini-3.5-flash эмитит `finish_reason=content_filter: PROHIBITED_CONTENT`** на сцене насилия → вывод exp07 «ветка content_filter мертва» НЕ переносится на Gemini 3.5; gemini-3.1-flash-lite работает; CJK-утечка «每人» в ru-выход (cjk-гейт нужен, класс живой). + +> **[ОРКЕСТРАТОРУ — что ратифицировать]** (1) **Voice-профиль** (схема research/15 §1.5: register/self_ref/lexicon_markers/ng_lexicon/exemplars 3–5 RU-реплик/brightness/окна) и **реестр обращений пар** (§2.1) — как механизмы Ф2 в план/реестр; сид обоих для приёмочной книги дёшев и делается вручную. (2) **Reveal-окна** (`reveal_ch`+pre/post-reveal алиасы) — расширение спойлер-окон, Ф2. (3) **Новые армы пилота:** voice-A/B/C/D (скаффолд готов — проба), формат инъекции (plain vs markdown vs XML, отдельно deepseek/grok), «сэндвич» (повтор констрейнтов после чанка), minimal-diff редактор (search/replace + гибкий апплай — режет output-токены 90%-статьи COGS); **армы НЕ ставить:** двухпроходность-в-одном-вызове, min_p. (4) **Скоуп Annotator Ф2** (§2.3): ядро = speaker+addressee с disposition (детерминированный пре-гейт 说/道-кии + LLM на implicit; CONFIRMED-only в инъекцию); отношения курировать как глоссарий (zero-shot F1 0.26–0.52 — непригоден); мусор не аннотировать (пространство/эмоции/эпистемика вне reveal). (5) Поправка постановки: SAMAS (2602.19840) — style-fidelity мультиагентник, НЕ speaker-aware. (6) Сузить формулировку «аналогов не найдено» в доках по таблице research/15 §5. +> **[ПОЛИГОНУ — готовые ТЗ]** (1) ⚠ **СРОЧНО: судья `memory_eval` gemini-2.5-flash интермиттентно 404** («model no longer available», при этом модель ЕСТЬ в /models — «есть в /models ≠ работает»); мигрировать судью (gemini-3.1-flash-lite проверен пробой — работает; 3.5-flash — 503 + content_filter) и **перепроверить refusal-таксономию exp02/07 на Gemini 3.x** — content_filter-ветка ожила (`PROHIBITED_CONTENT` на сцене пощёчин!). (2) **Параметр-свип** (ТЗ — research/15 §4): пре-шаг wire-аудита (пишутся ли sampling-параметры в thinking-канале DeepSeek; V4-маппинг temp; grok `effort:none`+penalties) → потом temp-армы с ДВУМЯ осями (fidelity + натуральность). (3) **Анти-эхо матрица** (ТЗ — §3-bis): {DeepSeek beta prefix русским × совместимость с thinking; дубль инструкции после чанка; 2 микропримера zh→ru в кэш-префиксе} на плотных CJK-чанках 蛊真人; метрика echo-rate уже в харнессе. (4) **Voice-A/B на трудном материале**: скаффолд пробы переиспользуй (scratchpad сессии `/tmp/claude-1000/-home-ubuntu-projects-textmachine/6960d2e3-8669-4b06-a5ed-12d603f53907/scratchpad/probes/` — `probe_voice.py`+`analyze_voice.py`+сырые выходы; /tmp волатилен — забрать до ребута; книжные производные в git не класть); мерить на вебновелл-срезе вне претрейна и на ТРУДНЫХ ты/вы-парах (равный статус, смена отношений — наши пары оказались «лёгкими», лифт реестра не измерен). (5) Эвал-база ru-voice-детектора: IWSLT22 formality EN→RU (600 золотых пар) + ru-срез mSynthSTEL + пертурбации 蛊真人. +> **[БЭКЕНДУ — требования к схеме на будущую веху, НЕ код сейчас]** (1) Банк v2 — заложить МЕСТО (не реализацию) под два новых типа записей: `voice_profile` (расширение `speech`: register/self_ref/lexicon_markers/ng_lexicon/exemplars[]/brightness + окна) и `address_pair` (направленная пара speaker→addressee, register ты/вы, since/until — активация по ДВУМ именам тем же AC); оба ездят существующим Select/disposition/бюджетом. (2) Reveal-окна: полю сущности — `reveal_ch` + pre/post-reveal алиасы (механика since/until уже есть). (3) Инъекция голоса — в ДРАФТ-слот (проба P3: редактор не восстанавливает); редактору — только констрейнт сохранения. (4) Wire-probe в бэклог: пишется ли temperature в thinking-канале DeepSeek (наш temp 0.3, вероятно, no-op — молчаливый вендор-факт); grok-4.3: вендорская дока на слаг исчезла (доки описывают 4.5) — квирки держатся на наших фактчеках. (5) Формат инъекции не менять до пилота (модель-специфично); в system-префиксе явно объявить формат блока (дешёвый буст робастности).