Land backend package 2: review fix-list, GB18030 ingest with CJK chapter split, four cheap style flaggers with migration v7, D15.2 v2 spec, Gu Zhenren smoke

This commit is contained in:
Claude (backend session) 2026-07-09 21:58:28 +03:00
parent 3011b886ca
commit b51ac4c2e0
16 changed files with 2492 additions and 227 deletions

View file

@ -1,6 +1,6 @@
# backend/ — Go-бэкенд TextMachine
Зона сессии «Бэкенд». Контракт — `docs/architecture/05-decisions-log.md` (D1D17); контракты Фазы 0 — `03-implementation-notes.md`; квирки провайдеров — `docs/experiments/00-provider-quirks.md`. **`.env` не читать.**
Зона сессии «Бэкенд». Контракт — `docs/architecture/05-decisions-log.md` (D1D18); контракты Фазы 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), идемпотентные миграции v1v5, reserve/settle+checkpoint, chunk_status, глоссарий, ruby, retrieval_state, request_log | `ledger.go`, `migrate.go`, `glossary.go` |
| `internal/store` | SQLite (modernc, CGO-free), идемпотентные миграции v1v6, 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 | |

View file

@ -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 {
if redrivable := rep.Flagged - rep.GlossaryMissFlagged; redrivable > 0 {
fmt.Printf("\n%d флагнутых чанк(ов) ждут внимания — переатаковать: tmctl redrive --config %s [--chapter N --chunk M --reason R]\n",
rep.Flagged, cfgPath)
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

View file

@ -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», а «+TL»).
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**.

View file

@ -7,6 +7,7 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
@ -34,11 +35,23 @@ 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: один файл = один чанк
// 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: <book_id>.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
@ -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)")
}
@ -141,7 +177,9 @@ func (b *Book) BriefHash() string {
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}
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.

View file

@ -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 §4752): 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
}

View file

@ -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)
}
}

View file

@ -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-9-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 第<numerals>; 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

View file

@ -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, '<27>') {
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, '<27>') {
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, '<27>') {
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))
}
}

View file

@ -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
}

View file

@ -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

View file

@ -354,6 +354,12 @@ 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"`
// 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(),
@ -374,6 +380,7 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
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

View file

@ -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,14 +303,11 @@ 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 {
if curSnap, serr := r.currentSnapshotProjected(); serr == nil && curSnap != rep.Snapshot {
rep.ConfigDrift = true
rep.CurrentSnapshot = curSnap
}
}
}
// Money.
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
@ -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

View file

@ -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{}

View file

@ -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)

View file

@ -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

View file

@ -573,6 +573,26 @@ keep-alive (Ф12), инъекция глоссария (`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 миграции v1v6; 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 дешёвых детерминированных гейта (наблюдаемость, НЕ гейты, на финальном тексте).** Линтер тире-диалогов (Розенталь §4752: прямые кавычки/дефис/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 (Ф12), инъекция глоссария (`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), не регэксп** — ПЕРВЫМ (наивный регэксп даёт **1836% ЛОЖНЫХ флагов** на трудном чанке; 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 строка регистра + 35 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; тег-контроль 7392% 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 35 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.260.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-префиксе явно объявить формат блока (дешёвый буст робастности).