diff --git a/backend/README.md b/backend/README.md
index 173aeaf..c4caa7f 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -6,7 +6,7 @@
| Пакет | Что делает | Ключевые файлы |
|---|---|---|
-| `cmd/tmctl` | CLI: `translate --config book.yaml` / `report` (status/redrive — в работе, D15.3) | `main.go` |
+| `cmd/tmctl` | CLI: `translate` / `report` / `status` (read-only проекция N/M+паспорта глав+деньги, `--json`) / `redrive` (переатака флагнутых: `--chapter/--chunk/--reason/--dry-run`, D15.3) | `main.go`, `internal/pipeline/status.go` |
| `internal/llm` | OpenAI-совместимый транспорт + retry/backoff, **capability-слой** (budget_field/temperature/reasoning per-модель), failover (написан, НЕ подключён — D4: только local-роли), нативный Anthropic = DEPRECATED-референс под будущий Gemini | `httpllm.go`, `capability.go`, `failover.go` |
| `internal/ledger` | Цены по usage (вкл. reasoning/cache-поля), `PriceForResponse` по фактической модели | `pricing.go` |
| `internal/store` | SQLite (modernc, CGO-free), идемпотентные миграции v1–v5, reserve/settle+checkpoint, chunk_status, глоссарий, ruby, retrieval_state, request_log | `ledger.go`, `migrate.go`, `glossary.go` |
@@ -16,7 +16,7 @@
## Инварианты — ЛОМАТЬ НЕЛЬЗЯ (каждый закреплён тестами)
-1. **Деньги:** reserve → call → **settle+checkpoint одной транзакцией**; `committed == SUM(checkpoints)` переживает kill -9 (`kill9_test.go`); цена — по фактически ответившей модели; потолки книга/день считают committed+reserved.
+1. **Деньги:** reserve → call → **settle+checkpoint одной транзакцией**; `committed == SUM(checkpoints)` переживает kill -9 (`kill9_test.go`); цена — по фактически ответившей модели; потолки книга/день считают committed+reserved. **Одно исключение — явный `tmctl redrive`** (D15.3): удаляет чекпоинты сброшенных стадий, НЕ возвращая committed (реально потраченные деньги остаются) → после redrive `committed ≥ SUM(checkpoints)` — безопасное направление (потолок не занижает), задокументировано в `ResetChunkStages`.
2. **Snapshot-дисциплина:** всё, что влияет на wire-байты ИЛИ на вердикты (capability, память content-hash, context-assembly, версии classify/coverage/chunker/maxtok, эскалационные оверрайды), свёрнуто в `snapshotID`; правка = громкий `--resnapshot`. ⚠ Любой resnapshot = переоплата книги (D15) — content-addressed reuse спроектировать до онгоинга.
3. **Эхо-мина DeepSeek:** thinking у deepseek НИКОГДА не отключать — `reasoning:"off"` там осознанный no-op; `echoes_when_thinking_off` + рекурсивный скан extra_body валят конфиг fail-fast. У grok наоборот: off = ЯВНЫЙ `reasoning_effort:"none"`.
4. **Порядок classify:** refusal-blacklist / CJK-echo — ДО length/empty (усечённый отказ не превращается в платный ретрай); retry-flagged только {length, empty} на той же модели; loop-чек ПЕРЕД удвоением max_tokens.
@@ -24,6 +24,12 @@
6. **Детерминизм:** рендер — чистая функция snapshot; сортировки перед записью; никакого map-order в выводе; length-prefixed хэши. Сорс-чанкер lossless (fuzz-тесты).
7. **Нейтральность адаптера:** `internal/llm` не знает про перевод (src/target/coverage) — контент-вердикты живут в раннере (disposition-слой, post-settle).
+## Подготовка ja-книги (чек-лист сида)
+
+- **Kana-написания имён = matchable.** Ruby-чтения (`鈴木`) захватываются на инжесте и авто-подцепляются как alias соответствующей ручной записи (D16.4, `attachRubyAliasesToManual`), поэтому кана-форма имени, у которого есть кандзи+фуригана, матчится автоматически. **Сверх этого — руками:** имя, которое встречается ТОЛЬКО каной (без кандзи-написания с ruby), не даст авто-alias → внеси кана-написание в сид как `aliases:` вручную, иначе кана-упоминание молча не заматчится («тихо пусто»).
+- **Двойные чтения (強敵→とも) — под флагом.** Авто-alias ставится для формы «all-Han база + all-kana чтение»; семантическое двойное чтение неотличимо оффлайн (нужен yomi-словарь, B6). Такой alias фаерится AMBIGUOUS (короткий/коллизионный) и проходит post-check, но длинное двойное чтение может дать CONFIRMED-ложь — при подозрении убери его из сида. Полная дизамбигуация — Фаза 2 (B6/Палладий-Поливанов).
+- **Kana-precision ≥4 не замерена** (minKeyLenPhonetic=3 — консервативный дефолт): кана-ключи НЕ проверяются на границу слова (в отличие от латиницы/кириллицы — D16.3), т.к. у каны нет сегментации (как у Han) — иначе ломается частый кейс «имя+частица» (すずきは). ≥4-кана-компаунд-precision — задача полигона (расширение E1) + токенизатор B6.
+
## Как гонять
```bash
diff --git a/backend/cmd/tmctl/main.go b/backend/cmd/tmctl/main.go
index 6977b60..6591096 100644
--- a/backend/cmd/tmctl/main.go
+++ b/backend/cmd/tmctl/main.go
@@ -4,6 +4,7 @@ package main
import (
"bufio"
"context"
+ "encoding/json"
"errors"
"flag"
"fmt"
@@ -38,7 +39,7 @@ func main() {
func run() error {
if len(os.Args) < 2 {
- return fmt.Errorf("usage: tmctl --config book.yaml")
+ return fmt.Errorf("usage: tmctl --config book.yaml")
}
cmd, args := os.Args[1], os.Args[2:]
@@ -49,6 +50,11 @@ func run() error {
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
cfgPath := fs.String("config", "", "path to book.yaml")
resnapshot := fs.Bool("resnapshot", false, "re-pin existing jobs to the current config snapshot (пере-перевод оплаченных чанков — явное согласие)")
+ asJSON := fs.Bool("json", false, "status: emit the projection as JSON (stable disposition/flag_reason enums) for CI/IDE")
+ chapter := fs.Int("chapter", -1, "redrive: restrict to this chapter (default: any)")
+ chunk := fs.Int("chunk", -1, "redrive: restrict to this chunk index within the chapter (default: any)")
+ reason := fs.String("reason", "", "redrive: restrict to this flag_reason (default: any)")
+ dryRun := fs.Bool("dry-run", false, "redrive: report what would be re-attacked without touching anything")
if err := fs.Parse(args); err != nil {
return err
}
@@ -73,8 +79,14 @@ func run() error {
return translate(ctx, *cfgPath, *resnapshot)
case "report":
return report(*cfgPath)
+ case "status":
+ return status(ctx, *cfgPath, *asJSON)
+ case "redrive":
+ return redrive(ctx, *cfgPath, pipeline.RedriveSelector{
+ Chapter: *chapter, ChunkIdx: *chunk, Reason: *reason, DryRun: *dryRun,
+ })
default:
- return fmt.Errorf("unknown command %q (want translate|report)", cmd)
+ return fmt.Errorf("unknown command %q (want translate|report|status|redrive)", cmd)
}
}
@@ -222,6 +234,126 @@ func report(cfgPath string) error {
return nil
}
+// 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).
+func status(ctx context.Context, cfgPath string, asJSON bool) error {
+ r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+
+ rep, err := r.Status(ctx)
+ if err != nil {
+ return err
+ }
+
+ if asJSON {
+ enc := json.NewEncoder(os.Stdout)
+ enc.SetIndent("", " ")
+ return enc.Encode(rep)
+ }
+
+ snap := rep.Snapshot
+ if snap == "" {
+ snap = "—"
+ } else if len(snap) > 12 {
+ snap = snap[:12]
+ }
+ drift := ""
+ if rep.SnapshotDrift {
+ drift += " ⚠ SNAPSHOT-DRIFT (несколько снапшотов в chunk_status — конфиг менялся; --resnapshot)"
+ }
+ if rep.ConfigDrift {
+ drift += " ⚠ CONFIG-DRIFT (текущий конфиг рендерит другой снапшот — правка после прогона; translate потребует --resnapshot = переоплата книги)"
+ }
+ 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)
+ ceil := ""
+ if rep.BookCeilingUSD > 0 {
+ ceil = fmt.Sprintf(" (потолок $%.2f — %.1f%%)", rep.BookCeilingUSD, rep.CeilingPct)
+ }
+ 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)
+ }
+
+ if len(rep.Chapters) > 0 {
+ fmt.Printf("\n%-5s %-9s %-10s %-6s %-4s %-16s %10s\n", "ch", "done/all", "verdict", "flag", "esc", "worst_flag", "cost_usd")
+ for _, p := range rep.Chapters {
+ fmt.Printf("%-5d %d/%-7d %-10s %-6d %-4d %-16s %10.6f\n",
+ p.Chapter, p.ChunksDone, p.ChunksTotal, p.Verdict, p.ChunksFlagged, p.Escalations,
+ 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`.
+ if rep.Flagged > 0 {
+ fmt.Printf("\n%d флагнутых чанк(ов) ждут внимания — переатаковать: tmctl redrive --config %s [--chapter N --chunk M --reason R]\n",
+ rep.Flagged, cfgPath)
+ return &pipeline.CompletedWithFlags{Flagged: rep.Flagged, Total: rep.TotalChunks}
+ }
+ return nil
+}
+
+// dashIfEmpty renders "" as "—" for a table cell.
+func dashIfEmpty(s string) string {
+ if s == "" {
+ return "—"
+ }
+ return s
+}
+
+// redrive re-attacks the FLAGGED chunks matching the selector (D15.3): it resets their terminal
+// flag (chunk_status + checkpoints of the flagged/skipped stages) and re-runs the durable loop
+// with a FRESH retry/escalation budget, never touching DispOK work. On --dry-run it only reports
+// the plan. Redrive-calls bill normally; money already spent on the discarded attempts stays
+// committed (honest). Requires the current config to render the same snapshot (no --resnapshot).
+func redrive(ctx context.Context, cfgPath string, sel pipeline.RedriveSelector) error {
+ r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
+ if err != nil {
+ return err
+ }
+ defer r.Close()
+
+ summary, res, err := r.Redrive(ctx, sel)
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("=== REDRIVE: %s ===\n", r.Book.BookID)
+ if len(summary.Targets) == 0 {
+ fmt.Println("Флагнутых чанков под селектор не найдено — нечего переатаковать.")
+ return nil
+ }
+ fmt.Printf("Целей: %d флагнутых чанк(ов) — сброс терминального флага + свежий retry/escalation-бюджет (DispOK не тронут):\n", len(summary.Targets))
+ for _, t := range summary.Targets {
+ fmt.Printf(" ch%d/chunk%d (%s) → стадии: %s\n", t.Chapter, t.ChunkIdx, t.FlagReason, strings.Join(t.Stages, ", "))
+ }
+ if summary.DryRun {
+ fmt.Println("[dry-run: ничего не сброшено, вызовов не сделано]")
+ return nil
+ }
+ fmt.Println("[сброшено; перезапуск durable-цикла — DispOK чанки резюмируются за $0, сброшенные переатакуются]")
+ fmt.Println()
+
+ if res != nil {
+ fmt.Printf("ИТОГО re-run (этот запуск): $%.6f — чанков %d, флагов %d\n", res.TotalUSD, len(res.Chunks), res.Flagged)
+ committed, reserved, serr := r.Store.SpentUSD(res.BookID)
+ if serr == nil {
+ fmt.Printf("Ledger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)
+ }
+ if res.Flagged > 0 {
+ return &pipeline.CompletedWithFlags{Flagged: res.Flagged, Total: len(res.Chunks)}
+ }
+ }
+ return nil
+}
+
// loadDotEnv reads KEY=VALUE lines into the environment without overriding
// already-set variables. Ключи — из backend/.env (gitignored); секреты никогда
// не попадают в конфиги/репо.
diff --git a/backend/configs/pipeline-c1.yaml b/backend/configs/pipeline-c1.yaml
index cf1622b..e6103a6 100644
--- a/backend/configs/pipeline-c1.yaml
+++ b/backend/configs/pipeline-c1.yaml
@@ -46,7 +46,10 @@ stages:
# models.yaml) — омиссия у grok = low. Прод: только чистый xAI-ключ без data-sharing.
model: grok-4.3
prompt: ../prompts/editor.md
- prompt_version: v0-draft
+ # D1-уточнение №3: коммит 6a68dfd сделал editor.md монолингвальным (убрал {{text}}) →
+ # сменил SHA, но лейбл остался v0-draft (один лейбл на два SHA). Бамп на v1-monolingual;
+ # осознанно меняет снапшот (боевых прогонов ещё не было).
+ prompt_version: v1-monolingual
temperature: 0.4
reasoning: "off"
diff --git a/backend/configs/pipeline-c2.yaml b/backend/configs/pipeline-c2.yaml
index 3611684..fe9702d 100644
--- a/backend/configs/pipeline-c2.yaml
+++ b/backend/configs/pipeline-c2.yaml
@@ -45,7 +45,10 @@ stages:
# (Стадия select выше — judge/glm-5, Gemini-слот Фазы 2; её НЕ трогаем.)
model: grok-4.3
prompt: ../prompts/editor.md
- prompt_version: v0-draft
+ # D1-уточнение №3: editor.md стал монолингвальным (6a68dfd убрал {{text}}) → SHA сменился,
+ # лейбл v0-draft остался (один лейбл на два SHA). Бамп на v1-monolingual (тот же editor.md,
+ # что в C1). Стадию select выше НЕ трогаем.
+ prompt_version: v1-monolingual
temperature: 0.4
reasoning: "off"
diff --git a/backend/docs/D15.2-content-addressed-resume-spec.md b/backend/docs/D15.2-content-addressed-resume-spec.md
new file mode 100644
index 0000000..80c76ae
--- /dev/null
+++ b/backend/docs/D15.2-content-addressed-resume-spec.md
@@ -0,0 +1,160 @@
+# D15.2 — Спека: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
+
+**Статус:** проект спеки бэкенд-сессии, сдан на утверждение оркестратору. Реализация — Ф1.5,
+**после** ратификации. Зона: спека написана в `backend/docs/` (зона бэкенда); при утверждении
+оркестратор переносит/сворачивает в `docs/architecture/03-implementation-notes.md` новым разделом.
+
+**Гейтит:** онгоинг-режим (`add-chapters`, сегмент «ИИ-фабрик», Р9) — D15.2. Текущий
+all-or-nothing resnapshot **приемлем для статичной приёмочной книги Ф1 (D15.1) — НЕ трогать до
+приёмки**; эта спека — для онгоинга.
+
+---
+
+## 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).
+
+Лгущие комментарии `runner.go`/`coverage.go` про «re-classify from checkpoint for FREE» уже
+исправлены в этом пакете (Задача 1a): сегодня это правда ТОЛЬКО внутри неизменного снапшота.
+Цель спеки — **сделать это правдой и через смену снапшота**, без потери snapshot-гарантий.
+
+## 2. Ключевое наблюдение: снапшот смешивает ДВА разных класса входов
+
+`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` в ключе).
+
+**(B) Вердикт-определяющие входы** — меняют, как ответ КЛАССИФИЦИРУЕТСЯ, но НЕ байты запроса:
+- `classifier_version` (порядок/пороги intrinsic classify), `coverage` (enabled + пороги +
+ `coverage_gate_version`), `postcheck_gate` (on/off; сам АЛГОРИТМ post-check — в `memory_version`,
+ но он влияет на вердикт, не на wire… см. §6 нюанс).
+
+Сегодня (B) сидит в `RequestHash` → вердикт-only правка промахивается по всем чекпоинтам и
+пере-оплачивает книгу. Это и есть дефект.
+
+## 3. Дизайн: расщепить снапшот на wire-часть и вердикт-часть
+
+- **`wireSnapshotID`** = хеш ТОЛЬКО (A)-входов, НЕ покрытых `msgs`/прямыми полями: резолвнутая
+ `capability`, `model_extra`, провайдер-оверрайды (temp/max_tokens/model), эскалационные
+ capability/extra/оверрайды. (Всё остальное wire-определяющее уже в `msgsContentHash` или в
+ прямых полях `RequestHash`.)
+- **`verdictSnapshotID`** = хеш (B)-входов: `classifier_version`, coverage-конфиг+версия,
+ `postcheck_gate`-состояние.
+
+**`RequestHash` (ключ чекпоинта) = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temp,
+reasoning, jsonOnly, maxTokens, `wireSnapshotID`, msgs).** — убираем `snapshotID`, добавляем
+`wireSnapshotID`. Вердикт-версии из ключа чекпоинта **уходят**.
+
+Полный `snapshotID` (для `jobs.snapshot_id`, `snapshots`-таблицы, отчётности) остаётся как
+`hash(wireSnapshotID, verdictSnapshotID)` — но перестаёт быть частью ключа вызова.
+
+### Что тогда re-bill-ится, а что бесплатно (операторский контракт)
+
+| Изменение | Класс | Эффект |
+|---|---|---|
+| Флип `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 (честно: тело/ответ реально другие) |
+
+**Онгоинг-выигрыш (флагман «ИИ-фабрик»):** подтверждение пачки терминов на границе тома больше не
+пере-покупает готовые главы — только те чанки, где термин действительно матчится. Это прямо снимает
+D15-мину онгоинга.
+
+## 4. Resume: когда байт-идентичный запрос легально переиспользуется
+
+Reuse чекпоинта легален ⟺ **полная wire-идентичность** совпадает: `msgsContentHash` И не-msgs
+wire-параметры (`wireSnapshotID` + прямые поля model/temp/reasoning/maxTokens). Это ровно то, что
+даёт новый `RequestHash` — совпал ключ ⇒ совпал wire ⇒ переиспользуем оплаченный ответ (то же
+основание, что у сегодняшнего same-snapshot resume; провайдер недетерминирован при temp>0, но
+чекпоинт — валидный ответ на ЭТОТ wire).
+
+**Вердикт-различие НИКОГДА не блокирует reuse** — доказуемо wire-нейтрально (см. §5).
+
+## 5. Re-classify по новым порогам (и почему это безопасно, F1)
+
+Машинерия re-classify **уже есть**: на checkpoint-hit `runAttempt` вызывает `classifyOutput` над
+текстом чекпоинта (не доверяет старому вердикту). Единственная правка — чтобы чекпоинт ХИТИЛ при
+вердикт-only смене (§3 убирает вердикт-версии из ключа). Тогда:
+
+- **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`, модель эскалации).
+
+**Ось безопасности (F1 — никакого тихого расходящегося re-pay):** reuse — precondition на ТОЧНОЕ
+совпадение wire-идентичности (`msgsContentHash` — сильнейший гард: точные байты промпта+инъекции;
+плюс не-msgs wire-параметры). Вердикт-смена меняет только post-settle классификацию, НИКОГДА не
+запрос, поэтому re-classify матчнутого чекпоинта не может отдать расходящийся ПЕРЕВОД — только другой
+ВЕРДИКТ на том же переводе (искомый бесплатный re-verdict). Опасный кейс — reuse чекпоинта, чей wire
+на новом конфиге был бы ДРУГИМ — исключён требованием точного wire-match.
+
+## 6. Нюансы и открытые вопросы к оркестратору
+
+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-инвариант.
+
+## 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).
+
+---
+
+**Резюме для оркестратора:** дефект — `snapshotID` в ключе вызова смешивает wire- и вердикт-входы.
+Фикс — расщепить; ключ чекпоинта держать на wire-идентичности (по факту это `msgsContentHash` +
+не-msgs wire-параметры), вердикт-версии убрать из ключа и re-classify-ить на resume бесплатно.
+Безопасность (F1) сохраняется: reuse — на точном wire-match, вердикт-смена доказуемо wire-нейтральна.
+Онгоинг-мина D15 снимается: append-термин re-bill-ит только реально затронутые чанки. Реализация —
+малая и хирургическая (машинерия re-classify уже есть), но гейтит онгоинг Ф1.5 и требует ратификации.
diff --git a/backend/internal/config/additive_reasoning_test.go b/backend/internal/config/additive_reasoning_test.go
new file mode 100644
index 0000000..6db0ac1
--- /dev/null
+++ b/backend/internal/config/additive_reasoning_test.go
@@ -0,0 +1,96 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// additive_reasoning_test.go is the D13.6 fail-fast gate: a stage that THINKS
+// (reasoning low|medium|high) on an ADDITIVE-billing provider (xAI — reasoning bills on top
+// of completion) must declare reasoning_max_tokens, or LoadPipeline fails loud — the same
+// structural discipline as the echo mine. Reverting the check in LoadPipeline makes the
+// "armed" cases below stop failing.
+func TestReasoningBufferFailFast(t *testing.T) {
+ today := time.Now().UTC().Format("2006-01-02")
+ dir := t.TempDir()
+ // A prompt template LoadPipeline can stat + split.
+ promptPath := filepath.Join(dir, "p.md")
+ if err := os.WriteFile(promptPath, []byte("sys\n---USER---\n{{text}}"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ modelsPath := filepath.Join(dir, "models.yaml")
+ if err := os.WriteFile(modelsPath, []byte(fmt.Sprintf(`
+prices_checked: %q
+default_model: additive-model
+providers:
+ xai:
+ kind: openai
+ base_url: http://x
+ reasoning: additive
+ ds:
+ kind: openai
+ base_url: http://y
+ reasoning: subset
+models:
+ additive-model: { provider: xai, price: { input_per_m: 1, output_per_m: 2 } }
+ subset-model: { provider: ds, price: { input_per_m: 1, output_per_m: 2 } }
+`, today)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ models, err := LoadModels(modelsPath)
+ if err != nil {
+ t.Fatalf("models load: %v", err)
+ }
+
+ load := func(stage string) error {
+ pp := filepath.Join(dir, "pipe.yaml")
+ body := fmt.Sprintf("core: C1\nversion: 1\ndefaults: { max_output_ratio: 2, min_max_tokens: 512 }\nstages:\n%s", stage)
+ if err := os.WriteFile(pp, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ _, e := LoadPipeline(pp, models)
+ return e
+ }
+ stage := func(model, reasoning string, buf int, escalateTo string) string {
+ s := fmt.Sprintf(" - { name: s, role: translator, model: %s, prompt: %s, prompt_version: v, reasoning: %q", model, promptPath, reasoning)
+ if buf > 0 {
+ s += fmt.Sprintf(", reasoning_max_tokens: %d", buf)
+ }
+ if escalateTo != "" {
+ s += fmt.Sprintf(", escalate_to: %s", escalateTo)
+ }
+ return s + " }"
+ }
+
+ cases := []struct {
+ name string
+ stage string
+ wantErr bool
+ }{
+ {"think-on additive no buffer → fail", stage("additive-model", "high", 0, ""), true},
+ {"think-on additive with buffer → ok", stage("additive-model", "medium", 4000, ""), false},
+ {"think-on subset no buffer → ok", stage("subset-model", "high", 0, ""), false},
+ {"off additive no buffer → ok", stage("additive-model", "off", 0, ""), false},
+ // finding #4: "" on an additive provider falls to the grok default (thinks) → MUST fail.
+ {"empty-reasoning additive no buffer → fail", stage("additive-model", "", 0, ""), true},
+ {"empty-reasoning additive with buffer → ok", stage("additive-model", "", 4000, ""), false},
+ {"empty-reasoning subset no buffer → ok", stage("subset-model", "", 0, ""), false},
+ {"escalate_to additive think-on no buffer → fail", stage("subset-model", "high", 0, "additive-model"), true},
+ {"escalate_to additive empty no buffer → fail", stage("subset-model", "", 0, "additive-model"), true},
+ {"escalate_to additive think-on with buffer → ok", stage("subset-model", "high", 4000, "additive-model"), false},
+ }
+ for _, c := range cases {
+ err := load(c.stage)
+ if c.wantErr {
+ if err == nil || !strings.Contains(err.Error(), "reasoning_max_tokens") {
+ t.Errorf("%s: want an error mentioning reasoning_max_tokens, got %v", c.name, err)
+ }
+ } else if err != nil {
+ t.Errorf("%s: want ok, got %v", c.name, err)
+ }
+ }
+}
diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go
index 64a121b..1aebb35 100644
--- a/backend/internal/config/models.go
+++ b/backend/internal/config/models.go
@@ -247,6 +247,35 @@ func (m *Models) providerPermissive(modelName string) bool {
return m.Providers[mod.Provider].Permissive
}
+// providerReasoning returns a model's provider billing semantic ("subset" | "additive" | "";
+// D3/D6.2). Unknown model → "" (no additive assumption).
+func (m *Models) providerReasoning(modelName string) string {
+ mod, ok := m.Models[modelName]
+ if !ok {
+ return ""
+ }
+ return m.Providers[mod.Provider].Reasoning
+}
+
+// AdditiveReasoningTokens is the reasoning-token buffer to ADD to a reservation for a call to
+// modelName at effort `reasoning` (D13.6): the stage's declared reasoning_max_tokens when the
+// provider bills reasoning ADDITIVELY (xAI — on top of completion) AND the effort MAY think,
+// else 0 (a subset provider folds reasoning into maxTokens). "May think" = any effort except an
+// explicit "off": low|medium|high think, and an OMITTED/"" effort falls to the provider default
+// (xAI-grok = thinking ON) — so "" must reserve the buffer too (self-review finding #4). The
+// runner passes this to ledger.EstimateUSD so the reservation covers the overshoot instead of
+// blinding the ceiling. LoadPipeline fail-fasts if the buffer is missing for an additive
+// non-"off" stage, so a >0 declaredBuffer is guaranteed here for that case.
+func (m *Models) AdditiveReasoningTokens(modelName, reasoning string, declaredBuffer int) int {
+ if declaredBuffer <= 0 || reasoning == "off" {
+ return 0
+ }
+ if m.providerReasoning(modelName) == "additive" {
+ return declaredBuffer
+ }
+ return 0
+}
+
// APIKey resolves a provider's key from the environment ("" if the provider
// declares no env var — the local backend).
func (p Provider) APIKey() string {
diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go
index 4531d55..0d0982e 100644
--- a/backend/internal/config/pipeline.go
+++ b/backend/internal/config/pipeline.go
@@ -64,6 +64,12 @@ type Stage struct {
PromptVersion string `yaml:"prompt_version"`
Temperature float64 `yaml:"temperature"`
Reasoning string `yaml:"reasoning"` // "", off, low, medium, high
+ // ReasoningMaxTokens is the explicit reasoning-token BUFFER reserved for this stage
+ // when it thinks (reasoning low|medium|high) on an ADDITIVE-billing provider (xAI —
+ // reasoning bills on top of completion, D13.6). Required (>0) in exactly that case
+ // (fail-fast in LoadPipeline); ignored for subset providers / reasoning-off. It only
+ // sizes the reservation (EstimateUSD), so it is NOT part of the snapshot/wire.
+ ReasoningMaxTokens int `yaml:"reasoning_max_tokens"`
// EscalateTo is the SINGLE-HOP fallback model tried ONCE when this stage's
// output is a deterministic content-failure another model might fix (echo /
// excision / refusal — D12). Empty = no escalation (e.g. the editor is pinned
@@ -222,6 +228,24 @@ func LoadPipeline(path string, models *Models) (*Pipeline, error) {
default:
bad("stage %q: reasoning must be off|low|medium|high, got %q", st.Name, st.Reasoning)
}
+ // D13.6: on an ADDITIVE-billing provider (xAI — reasoning bills ON TOP of completion) a
+ // stage that MAY think needs a reserved reasoning_max_tokens buffer, else the reservation
+ // under-budgets and the spend ceiling is blind to the overshoot pricing.go only
+ // acknowledges. "May think" = any effort EXCEPT an explicit reasoning:"off" (which sends
+ // the provider's disable — off_effort:"none" for grok): low|medium|high think, and an
+ // OMITTED/"" effort falls to the provider DEFAULT, which for xAI-grok is "low" (thinking
+ // ON, additive) — so "" must NOT escape the gate (self-review finding #4: the earlier
+ // thinks-only check let it through). Subset providers never need a buffer. This is the
+ // structural gate D6.2 requires before think-ON is allowed on grok (the contrast the task
+ // draws with the echo mine). Checks the primary AND the escalate_to model (same effort).
+ if st.Reasoning != "off" && st.ReasoningMaxTokens <= 0 {
+ if models.providerReasoning(st.Model) == "additive" {
+ bad("stage %q: reasoning=%q on additive-billing provider (model %q) requires reasoning_max_tokens>0 — xAI bills reasoning on top of completion and thinks by default unless reasoning is explicitly \"off\"; without a reserved buffer the spend ceiling is blind to the overshoot (D6.2/D13.6)", st.Name, st.Reasoning, st.Model)
+ }
+ if st.EscalateTo != "" && models.providerReasoning(st.EscalateTo) == "additive" {
+ bad("stage %q: reasoning=%q with escalate_to on an additive-billing provider (model %q) requires reasoning_max_tokens>0 (D6.2/D13.6)", st.Name, st.Reasoning, st.EscalateTo)
+ }
+ }
switch st.Channel {
case "", "sfw", "adult":
default:
diff --git a/backend/internal/ledger/pricing.go b/backend/internal/ledger/pricing.go
index 749d2c9..08f9bc3 100644
--- a/backend/internal/ledger/pricing.go
+++ b/backend/internal/ledger/pricing.go
@@ -106,14 +106,24 @@ func CostUSD(price ModelPrice, u llm.Usage) float64 {
// misses the cache — or, worse, is entirely WRITTEN to the cache (Anthropic
// write ×1.25/×2 дороже input) — and the completion runs to maxTokens.
// Deliberately pessimistic: реальная цена не должна превышать резерв, иначе
-// settle пробивает потолок, пропущенный на допуске. Известный остаточный
-// случай: additive reasoning-токены (xAI) биллятся ПОВЕРХ completion и оценкой
-// не покрыты — допустимый овершут ≤ одной резервации, как у донора.
-func EstimateUSD(price ModelPrice, promptTokens, maxTokens int) float64 {
+// settle пробивает потолок, пропущенный на допуске.
+//
+// reasoningBudgetTokens (D13.6) reserves an EXPLICIT allowance for providers that
+// bill reasoning ADDITIVELY (xAI: reasoning tokens land ON TOP of completion, not
+// inside maxTokens — the overshoot pricing.go used to only acknowledge). It is
+// priced at OutputPerM (reasoning bills as output) and MUST be > 0 for a think-ON
+// stage on such a provider (config fail-fasts otherwise — pipeline.go); it is 0 for
+// subset-billing providers (deepseek/zai/kimi: reasoning ⊆ completion ≤ maxTokens,
+// already covered) and for reasoning-off stages. This closes the D6.2 block on
+// think-ON grok: the ceiling is no longer blind to the additive reasoning spend.
+func EstimateUSD(price ModelPrice, promptTokens, maxTokens, reasoningBudgetTokens int) float64 {
inPerM := price.InputPerM
if price.CacheWritePerM > inPerM {
inPerM = price.CacheWritePerM
}
+ if reasoningBudgetTokens < 0 {
+ reasoningBudgetTokens = 0
+ }
return float64(promptTokens)*inPerM/1_000_000 +
- float64(maxTokens)*price.OutputPerM/1_000_000
+ float64(maxTokens+reasoningBudgetTokens)*price.OutputPerM/1_000_000
}
diff --git a/backend/internal/ledger/pricing_test.go b/backend/internal/ledger/pricing_test.go
index f7f9dcd..6ec7ef6 100644
--- a/backend/internal/ledger/pricing_test.go
+++ b/backend/internal/ledger/pricing_test.go
@@ -92,7 +92,7 @@ func TestPricerNeverZero(t *testing.T) {
func TestEstimateUSDIsPessimistic(t *testing.T) {
price := ModelPrice{InputPerM: 2.0, CachedPerM: 0.2, OutputPerM: 10.0}
- est := EstimateUSD(price, 1_000, 2_000)
+ est := EstimateUSD(price, 1_000, 2_000, 0)
approx(t, est, 1_000*2.0/1e6+2_000*10.0/1e6, "estimate")
// Реальный вызов с кэш-хитом обязан стоить не больше оценки — иначе
// settle раздует committed выше потолка, пропущенного на допуске.
@@ -101,3 +101,22 @@ func TestEstimateUSDIsPessimistic(t *testing.T) {
t.Fatalf("real cost %v exceeds reservation estimate %v", real, est)
}
}
+
+// TestEstimateUSDAdditiveReasoningBuffer covers D13.6: the additive-reasoning buffer is
+// reserved as output tokens, so an xAI-style think-ON call whose reasoning lands on top of
+// completion no longer overshoots the reservation and blinds the ceiling.
+func TestEstimateUSDAdditiveReasoningBuffer(t *testing.T) {
+ price := ModelPrice{InputPerM: 2.0, OutputPerM: 10.0}
+ base := EstimateUSD(price, 1_000, 2_000, 0)
+ withBuf := EstimateUSD(price, 1_000, 2_000, 3_000)
+ // The buffer adds exactly 3_000 output-priced tokens over the base estimate.
+ approx(t, withBuf-base, 3_000*10.0/1e6, "reasoning buffer term")
+ // A real additive call (completion 2_000 + reasoning 2_500 ≤ buffer 3_000) stays within.
+ real := CostUSD(price, llm.Usage{PromptTokens: 1_000, CompletionTokens: 2_000, ReasoningTokens: 2_500})
+ if real > withBuf {
+ t.Fatalf("additive real cost %v exceeds buffered reservation %v", real, withBuf)
+ }
+ if real <= base {
+ t.Fatalf("without the buffer the reservation %v would be blind to the reasoning spend (real %v)", base, real)
+ }
+}
diff --git a/backend/internal/llm/capability.go b/backend/internal/llm/capability.go
index 56e5912..64167d8 100644
--- a/backend/internal/llm/capability.go
+++ b/backend/internal/llm/capability.go
@@ -1,6 +1,6 @@
package llm
-// capability.go is the per-MODEL wire-shape layer (03-decisions-log D3.1).
+// capability.go is the per-MODEL wire-shape layer (05-decisions-log D3.1).
// OpenAI-compatible providers share an ENDPOINT but not a BODY: Kimi accepts
// only temperature:1 (else 400), gpt-5-mini needs max_completion_tokens WITHOUT
// temperature (else 400), Gemini 3.1 Pro 400s if thinking is disabled. The
diff --git a/backend/internal/pipeline/coverage.go b/backend/internal/pipeline/coverage.go
index 292e276..263b82f 100644
--- a/backend/internal/pipeline/coverage.go
+++ b/backend/internal/pipeline/coverage.go
@@ -26,11 +26,15 @@ import (
// coverageGateVersion versions the segmentation + metric of this gate. It is folded
// into the job snapshot (like chunkerVersion/estimatorVersion), so a change to the
-// algorithm — or turning the gate ON — is a loud --resnapshot that re-evaluates every
-// chunk from its checkpoint for FREE (no re-bill), never a silent divergence between
-// an old checkpoint's stored verdict and a new gate. The segmentation is pinned 1:1
-// to eval/refusal_bench.py (D12 Q1); any change is Python-first, in lock-step, and
-// bumps this constant.
+// algorithm — or turning the gate ON — is a loud --resnapshot, never a silent divergence
+// between an old checkpoint's stored verdict and a new gate. HONEST COST (D15): because
+// the snapshotID is baked into every call's RequestHash (render.go), that --resnapshot
+// re-bills the WHOLE book — every checkpoint misses on the new snapshot, even wire-identical
+// requests whose verdict merely needs re-classifying (the content-addressed reuse that
+// could make them free is gated on an unchanged snapshot). Acceptable for the static
+// acceptance book (loud, no divergence); the reason content-addressed checkpoint reuse is
+// required before ongoing (D15.2). The segmentation is pinned 1:1 to eval/refusal_bench.py
+// (D12 Q1); any change is Python-first, in lock-step, and bumps this constant.
const coverageGateVersion = "coverage-v1-naive-split-lower-bound"
// oracleDefaultCorridorLow is refusal_bench.py's fallback len_ratio LOWER bound for a
diff --git a/backend/internal/pipeline/memnorm.go b/backend/internal/pipeline/memnorm.go
index 44c6e24..4dd556f 100644
--- a/backend/internal/pipeline/memnorm.go
+++ b/backend/internal/pipeline/memnorm.go
@@ -40,7 +40,7 @@ var trad2simpRaw string
// editing/completing the data file also invalidates — drift-proof (D8): you cannot
// forget to bump a version when you change the table, because the table's bytes ARE
// the version.
-const memoryNormAlgoVersion = "memnorm-v2-nfkc+stripignorable+trad+kana+lower/nfc+dash+stripignorable+lower+yofold"
+const memoryNormAlgoVersion = "memnorm-v3-nfkc+apos+stripignorable+trad+kana+lower/nfc+dash+apos+stripignorable+lower+yofold"
var (
// trad2simp is the parsed Traditional→Simplified single-char map (data/trad2simp.txt).
@@ -106,6 +106,9 @@ func normalizeSourceKey(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
+ if isApostropheVariant(r) {
+ r = '\'' // typographic apostrophe (O’Brien) folds to ASCII so a key matches either form
+ }
if isIgnorableForMatch(r) {
continue // zero-width / format / variation selector: invisible, never part of the key
}
@@ -145,6 +148,9 @@ func normalizeTargetForm(s string) string {
if isDashVariant(r) {
r = '-'
}
+ if isApostropheVariant(r) {
+ r = '\'' // symmetric with the source side (д’Артаньян) — no false post-check MISS on a typographic apostrophe
+ }
if isIgnorableForMatch(r) {
continue // drop zero-width/format/VS (soft hyphen already folded to '-' above)
}
@@ -175,6 +181,19 @@ func isDashVariant(r rune) bool {
return false
}
+// isApostropheVariant reports whether r is a typographic apostrophe/single-quote variant that
+// should fold to ASCII '\” (Task-6 re-audit): U+2018/U+2019 curly single quotes, U+02BC
+// modifier-letter apostrophe, U+FF07 fullwidth apostrophe. Smart-quote editors turn a source
+// key O'Brien's apostrophe into U+2019, which NFKC/NFC do NOT fold to U+0027 — leaving the key
+// silently un-matchable (тихо пусто) on en names. Folded symmetrically on both sides.
+func isApostropheVariant(r rune) bool {
+ switch r {
+ case '‘', '’', 'ʼ', ''':
+ return true
+ }
+ return false
+}
+
// isIgnorableForMatch reports whether r is a default-ignorable / format code point that
// must be DROPPED before matching. These survive NFKC/NFC unchanged yet are invisible or
// carry no character identity, so leaving them in silently breaks a match — on the source
diff --git a/backend/internal/pipeline/memnorm_test.go b/backend/internal/pipeline/memnorm_test.go
index f812f98..232cbe4 100644
--- a/backend/internal/pipeline/memnorm_test.go
+++ b/backend/internal/pipeline/memnorm_test.go
@@ -3,6 +3,8 @@ package pipeline
import (
"strings"
"testing"
+
+ "textmachine/backend/internal/store"
)
// TestTradTableCoversKnownCases asserts the embedded trad→simp table covers every
@@ -163,3 +165,22 @@ func TestNormalizeStripsDefaultIgnorable(t *testing.T) {
t.Errorf("soft hyphen on the target side = %q, want %q (must fold to '-', not be stripped)", got, want)
}
}
+
+// TestApostropheFold covers the Task-6 re-audit finding: a typographic apostrophe (U+2019,
+// smart-quote editors) folds to ASCII on BOTH sides, so a source key O'Brien matches either
+// spelling (no тихо пусто) and the post-check does not false-MISS a д'Артаньян. Reverting the
+// isApostropheVariant folds breaks these.
+func TestApostropheFold(t *testing.T) {
+ if normalizeSourceKey("O’Brien") != normalizeSourceKey("O'Brien") {
+ t.Error("source: typographic and ASCII apostrophe must normalize identically")
+ }
+ if normalizeTargetForm("д’Артаньян") != normalizeTargetForm("д'Артаньян") {
+ t.Error("target: typographic and ASCII apostrophe must normalize identically")
+ }
+ // End-to-end: an ASCII-apostrophe key matches a chunk written with a typographic apostrophe.
+ e := gl("o'brien", "О'Брайен", "", "approved")
+ b := bankFrom([]store.GlossaryEntry{e})
+ if _, ok := injMap(b.Select("mr o’brien arrived.", 1, nil, 0))["o'brien"]; !ok {
+ t.Error("typographic apostrophe in the chunk must match the ASCII-apostrophe key")
+ }
+}
diff --git a/backend/internal/pipeline/memory.go b/backend/internal/pipeline/memory.go
index 1c7c304..5d1b128 100644
--- a/backend/internal/pipeline/memory.go
+++ b/backend/internal/pipeline/memory.go
@@ -32,7 +32,11 @@ import (
// ban, longest-match containment, spoiler gate, sticky, budget priority order). Folded
// into memoryVersion() → a change is a loud --resnapshot (it shifts the injected bytes
// → the wire → a resumed checkpoint's content). Sibling of memoryNormVersion.
-const memoryMatchVersion = "memmatch-v2-perlang-minkey+collision-ambiguous+longest+spoiler+sticky+budget+postcheck-declaware"
+// v3 (D16.2/D16.3): sticky now INHERITS the original match's disposition instead of
+// recomputing it status-based (a collision-prone AMBIGUOUS carry no longer silently
+// upgrades to CONFIRMED), and a spaced-phonetic (Latin/Cyrillic) source key is validated
+// against a word boundary (approved "rose" no longer fires inside "roseanne").
+const memoryMatchVersion = "memmatch-v3-perlang-minkey+collision-ambiguous+longest+spoiler+sticky-inherit-disp+phonetic-srcboundary+budget+postcheck-declaware"
// minKeyLenHan / minKeyLenPhonetic are the per-language single-key floors (A3, registry
// "min_key_len per-язык"). An ideographic (Han) key is semantically distinct at 2 chars
@@ -129,10 +133,15 @@ type pickedEntry struct {
// memorySelection is the hot path's output for one chunk.
type memorySelection struct {
- injected []pickedEntry // in budget priority order (what the model sees)
- rejected []pickedEntry // spoiler-window rejects (C1, logged)
- evicted []pickedEntry // dropped by the token budget (F2, logged)
- activeIDs map[string]bool // exact-matched ids (NOT sticky) → the next chunk's sticky_prev
+ injected []pickedEntry // in budget priority order (what the model sees)
+ rejected []pickedEntry // spoiler-window rejects (C1, logged)
+ evicted []pickedEntry // dropped by the token budget (F2, logged)
+ // activeIDs are the exact-matched ids (NOT sticky) → the next chunk's sticky_prev,
+ // each mapped to the DISPOSITION it fired with. Sticky carries this disposition
+ // forward instead of recomputing it (D16.2): a sticky carry has no firing key, so it
+ // cannot re-derive the collision-prone downgrade, and recomputing from status alone
+ // would silently upgrade a collision-prone AMBIGUOUS match to CONFIRMED.
+ activeIDs map[string]injectionDisposition
}
func (b *MemoryBank) Version() string { return b.version }
@@ -286,9 +295,14 @@ func glossaryLineTokens(e *memoryEntry) int {
// unbounded; otherwise records are kept in priority order while the cumulative injected
// token cost stays within budget — the rest are EVICTED (dropped but logged, F2; the
// budget may be underfilled, "лучше ничего, чем мусор").
-func (b *MemoryBank) Select(chunk string, chapter int, stickyPrev map[string]bool, budgetTokens int) memorySelection {
+func (b *MemoryBank) Select(chunk string, chapter int, stickyPrev map[string]injectionDisposition, budgetTokens int) memorySelection {
ntext := []rune(normalizeSourceKey(chunk))
occ := b.ac.matches(ntext)
+ // Source word-boundary for spaced-phonetic keys (D16.3): drop an occurrence of a Latin/
+ // Cyrillic key that fired INSIDE a longer word of the same script ("rose" in "roseanne").
+ // Closes the source-vs-target boundary asymmetry (the target side already has
+ // containsWholeWord). Han/kana are NOT boundary-checked (no word segmentation) — deliberate.
+ occ = b.suppressUnboundedPhonetic(occ, ntext)
// Longest-match: drop a key fully inside a strictly-longer key's span — but ONLY when
// the longer key belongs to a spoiler-VALID entry at this chapter (self-review #6). A
// spoiler-blocked longer key must not suppress a valid shorter name nested inside it.
@@ -305,7 +319,7 @@ func (b *MemoryBank) Select(chunk string, chapter int, stickyPrev map[string]boo
}
}
- sel := memorySelection{activeIDs: map[string]bool{}}
+ sel := memorySelection{activeIDs: map[string]injectionDisposition{}}
hits := map[string]pickedEntry{} // id → picked (exact)
// Iterate entries in their frozen (ORDER BY-stable) order — never map order.
@@ -319,15 +333,21 @@ func (b *MemoryBank) Select(chunk string, chapter int, stickyPrev map[string]boo
sel.rejected = append(sel.rejected, pickedEntry{e, via, memReject})
continue
}
- hits[e.id] = pickedEntry{e, via, dispositionFor(e, via)}
- sel.activeIDs[e.id] = true // exact-matched ids feed the next chunk's sticky (NOT sticky-carried)
+ disp := dispositionFor(e, via)
+ hits[e.id] = pickedEntry{e, via, disp}
+ sel.activeIDs[e.id] = disp // exact-matched ids feed the next chunk's sticky WITH the disposition they fired with (D16.2)
}
// Sticky scene-inertia: carry prior-chunk exact matches not re-matched here, unless
- // the spoiler window blocks them (spoiler beats sticky).
+ // the spoiler window blocks them (spoiler beats sticky). The carry INHERITS the prior
+ // match's disposition (D16.2) — a sticky carry has no firing key, so it cannot re-derive
+ // the collision-prone downgrade; recomputing status-based would silently upgrade a
+ // collision-prone AMBIGUOUS match to CONFIRMED, past the post-check, into the editor
+ // constraints. An AMBIGUOUS carry stays AMBIGUOUS (⟨проверить⟩, excluded from the editor).
for ei := range b.entries {
e := &b.entries[ei]
- if !stickyPrev[e.id] {
+ carryDisp, sticky := stickyPrev[e.id]
+ if !sticky {
continue
}
if _, already := hits[e.id]; already {
@@ -336,7 +356,7 @@ func (b *MemoryBank) Select(chunk string, chapter int, stickyPrev map[string]boo
if spoilerBlocked(e, chapter) {
continue
}
- hits[e.id] = pickedEntry{e, "sticky", dispositionFor(e, "sticky")}
+ hits[e.id] = pickedEntry{e, "sticky", carryDisp}
}
// Deterministic priority order for the budget: confirmed>ambiguous, exact>sticky,
@@ -387,7 +407,9 @@ func spoilerBlocked(e *memoryEntry, chapter int) bool {
// AMBIGUOUS (unverified + forced post-check), because such a key may have fired inside an
// unrelated word rather than on the entity (external-review major #2 — the A2
// "surface-collision → AMBIGUOUS" branch). allow_short is the author's explicit override.
-// A sticky carry (via=="sticky") has no firing key, so it keeps its status-based disposition.
+// Called ONLY for an EXACT match (a real firing key): a sticky carry INHERITS its prior
+// disposition instead (D16.2), never routing through here, so it cannot silently upgrade a
+// collision-prone match. The via!="sticky" guard is kept defensive.
func dispositionFor(e *memoryEntry, via string) injectionDisposition {
if via != "sticky" && !e.allowShort && collisionProneKey(via) {
return memAmbiguous
@@ -616,6 +638,67 @@ func (b *MemoryBank) suppressContained(ms []acMatch, chapter int) []acMatch {
return kept
}
+// suppressUnboundedPhonetic drops an occurrence of a SPACED-SCRIPT phonetic key (Latin or
+// Cyrillic — scripts that delimit words with whitespace/non-letters, exactly like the
+// target-side containsWholeWord) when a neighbouring rune is a letter of the SAME script:
+// the key fired INSIDE a longer word ("rose" in "roseanne"/"roses"), not on the entity
+// (D16.3). This closes the source-vs-target boundary asymmetry the ≤3 collision-downgrade
+// does not (a ≥4 phonetic key fires CONFIRMED with no boundary today). A cross-script
+// neighbour (a Latin name abutting a Han char in unspaced source) is a valid boundary, so
+// only a SAME-script letter suppresses — mirroring "не-буквенных или чужескриптовых".
+// KANA and HAN are deliberately NOT boundary-checked: they have no word segmentation, so a
+// letter-boundary rule would false-negative the ubiquitous kana name+particle case (すずきは)
+// or contradict the long-key-CONFIRMED contract — their ≥4 precision is deferred to the
+// kana-precision measurement + the B6 tokenizer (D16; flagged to the orchestrator). ntext is
+// the already-normalized chunk. O(n) over the few matches in a chunk.
+func (b *MemoryBank) suppressUnboundedPhonetic(ms []acMatch, ntext []rune) []acMatch {
+ var kept []acMatch
+ for _, m := range ms {
+ script := spacedPhoneticScript(b.ac.keys[m.keyIdx])
+ if script != nil {
+ beforeBad := m.start > 0 && letterInScript(ntext[m.start-1], script)
+ afterBad := m.end < len(ntext) && letterInScript(ntext[m.end], script)
+ if beforeBad || afterBad {
+ continue // the key is inside a longer same-script word — not a whole-entity match
+ }
+ }
+ kept = append(kept, m)
+ }
+ return kept
+}
+
+// spacedPhoneticScript returns the word-delimited alphabetic script a normalized key belongs
+// to for the source word-boundary check — unicode.Latin or unicode.Cyrillic — or nil when the
+// key carries a Han ideograph or kana (no word segmentation) or mixes the two spaced scripts.
+// Digits/punctuation don't set the script but don't disqualify (so "o'brien" is still Latin).
+func spacedPhoneticScript(normKey string) *unicode.RangeTable {
+ var script *unicode.RangeTable
+ for _, r := range normKey {
+ switch {
+ case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
+ return nil // an ideographic/kana anchor is never boundary-checked here
+ case unicode.In(r, unicode.Latin):
+ if script == unicode.Cyrillic {
+ return nil // mixed spaced scripts — do not boundary-check
+ }
+ script = unicode.Latin
+ case unicode.In(r, unicode.Cyrillic):
+ if script == unicode.Latin {
+ return nil
+ }
+ script = unicode.Cyrillic
+ }
+ }
+ return script
+}
+
+// letterInScript reports whether r is a LETTER of the given spaced script — the "same-script
+// letter" that breaks a word boundary. A digit, punctuation, space, or a letter of another
+// script is a valid boundary and returns false.
+func letterInScript(r rune, script *unicode.RangeTable) bool {
+ return unicode.IsLetter(r) && unicode.In(r, script)
+}
+
// injectivityCollisions reports approved dst collisions (B2): two distinct source
// terms mapped to the SAME dst (one Russian surface for two entities → the reader
// cannot tell them apart), returned as human-readable strings for a load-time warning.
diff --git a/backend/internal/pipeline/memory_test.go b/backend/internal/pipeline/memory_test.go
index 7d36b1f..70a2b75 100644
--- a/backend/internal/pipeline/memory_test.go
+++ b/backend/internal/pipeline/memory_test.go
@@ -104,9 +104,10 @@ func TestHotPathSpecT1toT10(t *testing.T) {
t.Error("T5 trad→simp: 魯鎮 did not match 鲁镇")
}
- // T6: pronominal chunk — sticky carries 四叔 from the previous chunk.
+ // T6: pronominal chunk — sticky carries 四叔 from the previous chunk, INHERITING the
+ // prior chunk's CONFIRMED disposition (D16.2: sticky carries the disposition forward).
sishuID := "四叔\x1fchar\x1f0\x1f0"
- sel = b.Select("他慢慢站起来,叹了口气。", 2, map[string]bool{sishuID: true}, budget)
+ sel = b.Select("他慢慢站起来,叹了口气。", 2, map[string]injectionDisposition{sishuID: memConfirmed}, budget)
var stickySishu *pickedEntry
for i := range sel.injected {
if sel.injected[i].entry.src == "四叔" {
@@ -399,6 +400,107 @@ func TestPerLanguageMinKeyAndCollisionDisposition(t *testing.T) {
}
}
+// TestStickyInheritsDisposition covers D16.2: a collision-prone AMBIGUOUS exact match must
+// carry into the next chunk's sticky AS AMBIGUOUS — never silently upgraded to CONFIRMED. A
+// status-based recompute would upgrade it (past the post-check, into the editor constraints).
+// Reverting the inherit change (recomputing via dispositionFor) makes the carry CONFIRMED here.
+func TestStickyInheritsDisposition(t *testing.T) {
+ // An approved name whose only key is collision-prone (3 kana): an exact match fires
+ // AMBIGUOUS (the A2 collision downgrade), not CONFIRMED, despite the approved status.
+ suzuki := gl("すずき", "Судзуки", "", "approved")
+ b := bankFrom([]store.GlossaryEntry{suzuki})
+
+ sel1 := b.Select("すずきが来た。", 1, nil, 0)
+ if injMap(sel1)["すずき"] != memAmbiguous {
+ t.Fatalf("collision-prone approved key must fire AMBIGUOUS: %v", injMap(sel1))
+ }
+ if len(sel1.activeIDs) != 1 {
+ t.Fatalf("want exactly one active id, got %d", len(sel1.activeIDs))
+ }
+ for _, disp := range sel1.activeIDs {
+ if disp != memAmbiguous {
+ t.Fatalf("activeIDs must carry the AMBIGUOUS disposition, got %q", disp)
+ }
+ }
+
+ // Pronominal chunk (no key): sticky carries すずき, and its disposition MUST stay AMBIGUOUS.
+ sel2 := b.Select("彼は立ち上がった。", 1, sel1.activeIDs, 0)
+ var carried *pickedEntry
+ for i := range sel2.injected {
+ if sel2.injected[i].entry.src == "すずき" {
+ carried = &sel2.injected[i]
+ }
+ }
+ if carried == nil || carried.via != "sticky" {
+ t.Fatalf("sticky did not carry すずき: %v", injMap(sel2))
+ }
+ if carried.disp != memAmbiguous {
+ t.Errorf("D16.2: sticky carry upgraded disposition to %q, want AMBIGUOUS", carried.disp)
+ }
+}
+
+// TestUnionStickyNewestWins covers self-review finding #6: unionSticky merges the sticky
+// window so the MOST RECENT firing's disposition wins — an id that fired CONFIRMED via a clean
+// key then AMBIGUOUS via a collision alias must carry AMBIGUOUS (no silent upgrade); a revert to
+// keep-first would reintroduce the D16.2 upgrade. This exercises the merge that
+// TestStickyInheritsDisposition (single-chunk activeIDs) bypasses.
+func TestUnionStickyNewestWins(t *testing.T) {
+ id := "x\x1f\x1f0\x1f0"
+ // Oldest→newest CONFIRMED then AMBIGUOUS → newest (AMBIGUOUS) wins (the anti-upgrade case).
+ if got := unionSticky([]map[string]injectionDisposition{{id: memConfirmed}, {id: memAmbiguous}})[id]; got != memAmbiguous {
+ t.Errorf("newest firing must win: got %q, want ambiguous", got)
+ }
+ // Reversed → a re-established CONFIRMED (via a clean key in the newer chunk) wins.
+ if got := unionSticky([]map[string]injectionDisposition{{id: memAmbiguous}, {id: memConfirmed}})[id]; got != memConfirmed {
+ t.Errorf("a re-established CONFIRMED must win: got %q, want confirmed", got)
+ }
+ if unionSticky(nil) != nil {
+ t.Error("empty window → nil")
+ }
+}
+
+// TestSourcePhoneticWordBoundary covers D16.3: a spaced-phonetic (Latin/Cyrillic) source key
+// fires only as a WHOLE WORD, never inside a longer same-script word ("rose" in "roseanne").
+// A cross-script neighbour is a valid boundary. Reverting suppressUnboundedPhonetic fires
+// "rose" inside "roseanne" and fails here.
+func TestSourcePhoneticWordBoundary(t *testing.T) {
+ rose := gl("rose", "Роза", "", "approved") // 4 Latin → CONFIRMED, but boundary-checked
+ b := bankFrom([]store.GlossaryEntry{rose})
+
+ // Inside a longer Latin word → suppressed (both a suffix and a prefix collision).
+ if s := b.Select("roseanne walked in.", 1, nil, 0); len(s.injected) != 0 {
+ t.Errorf("latin key fired inside 'roseanne': %v", injMap(s))
+ }
+ if s := b.Select("the roses bloomed.", 1, nil, 0); len(s.injected) != 0 {
+ t.Errorf("latin key fired inside 'roses': %v", injMap(s))
+ }
+ // Standalone / at string edges → fires CONFIRMED.
+ if injMap(b.Select("a rose bloomed.", 1, nil, 0))["rose"] != memConfirmed {
+ t.Error("latin key must fire as a whole word")
+ }
+ if injMap(b.Select("rose.", 1, nil, 0))["rose"] != memConfirmed {
+ t.Error("latin key at string edges must fire")
+ }
+ // Cross-script neighbour (a Han char in unspaced source) is a valid boundary → fires.
+ if injMap(b.Select("我叫rose。", 1, nil, 0))["rose"] != memConfirmed {
+ t.Error("latin key abutting a cross-script (Han) neighbour must fire")
+ }
+}
+
+// TestKanaNotSourceBoundaryChecked documents the D16.3 kana carve-out: kana has no word
+// segmentation, so a kana key is NOT boundary-checked — it fires between kana neighbours (a
+// name+particle case すずきは), matching the long-key-CONFIRMED contract. The ≥4-kana compound
+// precision is deferred to the kana-precision measurement + B6 tokenizer (flagged).
+func TestKanaNotSourceBoundaryChecked(t *testing.T) {
+ // A long kana key must still fire between kana neighbours (と…が), unaffected by the
+ // source boundary check (this mirrors the existing long-phonetic-CONFIRMED expectation).
+ name := gl("ながいなまえ", "Длинное имя", "", "approved")
+ b := bankFrom([]store.GlossaryEntry{name})
+ if injMap(b.Select("私とながいなまえが会った。", 1, nil, 0))["ながいなまえ"] != memConfirmed {
+ t.Error("kana key must NOT be source-boundary-suppressed (name+particle recall)")
+ }
+}
+
// TestOverlappingNonNestedKeysBothFire documents the eval-review low finding: two keys
// that OVERLAP without nesting both fire (both surfaces are genuinely present in the
// text). Accepted behavior (precision-side), asserted so a future change is deliberate.
diff --git a/backend/internal/pipeline/memseed.go b/backend/internal/pipeline/memseed.go
index e52eae4..6ada731 100644
--- a/backend/internal/pipeline/memseed.go
+++ b/backend/internal/pipeline/memseed.go
@@ -148,16 +148,32 @@ func loadGlossarySeed(path string) ([]store.GlossaryEntry, error) {
}
out = append(out, e)
}
- // Overlapping spoiler windows for the SAME (src,sense) with DIFFERENT dst inject two
- // contradictory renderings at a chapter in the overlap — silently (external-review
- // minor #4). Fail loud. UNIQUE(src,sense,window) already blocks identical windows, so
- // this catches the partial-overlap case. Iterate `out` in order (deterministic message).
+ // Overlapping spoiler windows for the SAME src inject CONTRADICTORY renderings at a
+ // chapter in the overlap — silently (external-review minor #4; D16.1). The deterministic
+ // matcher keys ONLY on src (it cannot disambiguate sense within a chunk), so BOTH rows
+ // fire together (keyOwners maps the shared key to both indices) and the post-check is
+ // guaranteed to miss one — with the gate ON, a deterministic per-chunk livelock. Fail
+ // loud. UNIQUE(src,sense,window) already blocks identical (src,sense,window), so this
+ // catches partial overlap and the different-SENSE case the old same-sense-only guard let
+ // through. Iterate `out` in order (deterministic message).
for i := range out {
for j := 0; j < i; j++ {
a, b := out[i], out[j]
- if a.Src == b.Src && a.Sense == b.Sense && a.Dst != b.Dst && windowsOverlap(a.SinceCh, a.UntilCh, b.SinceCh, b.UntilCh) {
+ if a.Src != b.Src || a.Dst == b.Dst || !windowsOverlap(a.SinceCh, a.UntilCh, b.SinceCh, b.UntilCh) {
+ continue
+ }
+ if a.Sense == b.Sense {
problems = append(problems, fmt.Sprintf("term %q (sense %q): spoiler windows [%d,%d] and [%d,%d] overlap with different dst (%q vs %q) — a term has ONE rendering per chapter",
a.Src, a.Sense, b.SinceCh, b.UntilCh, a.SinceCh, a.UntilCh, b.Dst, a.Dst))
+ continue
+ }
+ // Different sense, overlapping windows, different dst (D16.1 polysemy livelock).
+ // Scope to a MATCHABLE src: an A3-banned single-char src (道 — significantLen below
+ // the min-key floor, never in the automaton) is inert and cannot livelock, so its
+ // polysemy stays allowed. Both rows must be matchable for the contradiction to fire.
+ if srcKeyFires(a.Src, a.AllowShort) && srcKeyFires(b.Src, b.AllowShort) {
+ problems = append(problems, fmt.Sprintf("term %q: senses %q→%q and %q→%q have OVERLAPPING spoiler windows [%d,%d] and [%d,%d] — the deterministic matcher keys only on src and cannot pick a sense, so both inject as authoritative and the post-check is guaranteed to miss one (give them non-overlapping windows, merge the sense, or mark one status: auto)",
+ a.Src, b.Sense, b.Dst, a.Sense, a.Dst, b.SinceCh, b.UntilCh, a.SinceCh, a.UntilCh))
}
}
}
@@ -167,6 +183,98 @@ func loadGlossarySeed(path string) ([]store.GlossaryEntry, error) {
return out, nil
}
+// approvedSharedKeyCollisions detects the alias-generalization of the D16.1 polysemy livelock
+// (Task-6 re-audit, confirmed by execution): a firing KEY (normalized src OR alias) shared by
+// two approved terms with DIFFERENT dst and OVERLAPPING spoiler windows. The matcher keys only
+// on the surface (keyOwners maps the shared key to BOTH terms), so both inject as authoritative
+// and the post-check is guaranteed to miss one — a deterministic chunk livelock with the gate
+// ON, exactly like same-src polysemy but reached via a shared прозвище (老赵 for two 赵). It does
+// NOT skip same-src pairs: loadGlossarySeed's D16.1 check scopes its fail-loud to a MATCHABLE
+// src (srcKeyFires), so a same-src pair whose src is single-key-BANNED (道) but that shares an
+// ELIGIBLE alias (大道) slips D16.1 entirely — this catches it via the shared alias key (self-
+// review finding #1). In the common case (an eligible shared src) loadGlossarySeed fails loud
+// FIRST and seedGlossary never reaches here, so there is no double report. Returns human
+// messages (deterministic order); seedGlossary fails loud on any. A shared key with the SAME
+// dst (a legitimate merge) or NON-overlapping windows (a spoiler handoff) is not a contradiction.
+func approvedSharedKeyCollisions(entries []store.GlossaryEntry) []string {
+ owners := map[string][]int{} // normalized firing key → indices of approved terms that own it
+ for i, e := range entries {
+ if e.Status != "approved" || strings.TrimSpace(e.Dst) == "" {
+ continue
+ }
+ for _, k := range entryFiringKeys(e) {
+ owners[k] = append(owners[k], i)
+ }
+ }
+ keys := make([]string, 0, len(owners))
+ for k := range owners {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ var out []string
+ seenPair := map[[2]int]bool{}
+ for _, k := range keys {
+ idxs := owners[k]
+ for a := 0; a < len(idxs); a++ {
+ for b := a + 1; b < len(idxs); b++ {
+ i, j := idxs[a], idxs[b]
+ ei, ej := entries[i], entries[j]
+ if ei.Dst == ej.Dst {
+ continue // same dst → both render identically, no contradiction (incl. an alias of one entry)
+ }
+ if !windowsOverlap(ei.SinceCh, ei.UntilCh, ej.SinceCh, ej.UntilCh) {
+ continue
+ }
+ pair := [2]int{i, j}
+ if seenPair[pair] {
+ continue
+ }
+ seenPair[pair] = true
+ out = append(out, fmt.Sprintf("firing key %q is shared by different terms %q→%q and %q→%q with overlapping spoiler windows — both inject as authoritative and the deterministic matcher cannot pick one (livelock; give non-overlapping windows, a more specific alias, or drop the shared surface)",
+ k, ei.Src, ei.Dst, ej.Src, ej.Dst))
+ }
+ }
+ }
+ return out
+}
+
+// entryFiringKeys returns the normalized keys of a term ELIGIBLE to enter the automaton (the
+// same eligibility materializeMemory applies: src + aliases, each passing the per-language
+// single-key floor or allow_short). Mirrors the matcher so the collision check sees exactly the
+// keys that would fire.
+func entryFiringKeys(e store.GlossaryEntry) []string {
+ surfaces := []string{e.Src}
+ for _, a := range e.Aliases {
+ surfaces = append(surfaces, a.Alias)
+ }
+ var keys []string
+ seen := map[string]bool{}
+ for _, s := range surfaces {
+ nk := normalizeSourceKey(s)
+ if nk == "" || seen[nk] {
+ continue
+ }
+ if significantLen(nk) >= minKeyLenFor(nk) || e.AllowShort {
+ seen[nk] = true
+ keys = append(keys, nk)
+ }
+ }
+ return keys
+}
+
+// srcKeyFires reports whether src normalizes to a key eligible to ENTER the matcher's
+// automaton — it passes the per-language single-key floor (minKeyLenFor), or allow_short
+// overrides it. Mirrors materializeMemory's eligibility test. An ineligible src (a single-
+// char Han like 道, banned by A3) never fires on the hot path, so a polysemy contradiction on
+// it is inert and must not fail the seed load (D16.1 scoping).
+func srcKeyFires(src string, allowShort bool) bool {
+ nk := normalizeSourceKey(src)
+ if nk == "" {
+ return false
+ }
+ return allowShort || significantLen(nk) >= minKeyLenFor(nk)
+}
+
// windowsOverlap reports whether two spoiler windows share any chapter. since_ch 0 means
// "from chapter 1"; until_ch 0 means "no end".
func windowsOverlap(since1, until1, since2, until2 int) bool {
@@ -252,6 +360,90 @@ func rubyToCandidates(readings []store.RubyReading, manualSrcs map[string]bool)
return out
}
+// attachRubyAliasesToManual makes the kana READING of a manually-seeded name matchable
+// (D16.4): a manual term 鈴木 that appears in a kana-written chunk as すずき would otherwise
+// miss — rubyToCandidates SKIPS manual bases, so the reading is discarded, a "тихо пусто"
+// recall hole on the ja acceptance book. For each name-shape reading (all-Han base +
+// all-kana reading — the furigana-name form classifyRubyReading calls rubyClassName) whose
+// base is a manual src, it appends the reading as an ALIAS of that manual entry IN PLACE,
+// deduped. The reading then fires with the ordinary alias disposition — AMBIGUOUS when
+// short/collision-prone (post-checked), so a possible double-reading (強敵→とも "friend") is
+// 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.
+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)
+ }
+ }
+ 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 {
+ return sorted[i].Base < sorted[j].Base
+ }
+ return sorted[i].Reading < sorted[j].Reading
+ })
+ for _, rr := range sorted {
+ targets, ok := idx[rr.Base]
+ if !ok {
+ continue // the reading's base is not a manual term
+ }
+ if classifyRubyReading(rr.Base, rr.Reading) != rubyClassName {
+ continue // only the all-kana furigana-NAME shape is a match surface; a kanji-bearing gloss/double-reading is a footnote, not an alias
+ }
+ rk := normalizeSourceKey(rr.Reading)
+ for _, ti := range targets {
+ e := &entries[ti]
+ 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))
+ 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
+ }
+ }
+ }
+ return skipped
+}
+
+// hasAliasSurface reports whether an alias with the given raw surface already exists on a term.
+func hasAliasSurface(aliases []store.GlossaryAlias, surface string) bool {
+ for _, a := range aliases {
+ if a.Alias == surface {
+ return true
+ }
+ }
+ return false
+}
+
// ruby classifier classes (deterministic HINTS for human promotion).
const (
rubyClassName = "name_candidate" // all-Han base + all-kana reading: the furigana-name SHAPE
diff --git a/backend/internal/pipeline/memseed_test.go b/backend/internal/pipeline/memseed_test.go
index 19ab03c..25ac737 100644
--- a/backend/internal/pipeline/memseed_test.go
+++ b/backend/internal/pipeline/memseed_test.go
@@ -94,13 +94,50 @@ terms:
`)); err == nil {
t.Error("duplicate (src,sense,window) term must fail loud")
}
- // Same src, different SENSE → allowed (polysemy).
+ // Same src, different SENSE on a SINGLE-CHAR Han src (道): the src is A3-banned
+ // (significantLen 1 < min-key 2), so it never enters the automaton → its polysemy is
+ // inert and cannot livelock → still allowed (D16.1 fail-loud is scoped to a matchable src).
if _, err := loadGlossarySeed(writeSeed(t, `
terms:
- { src: 道, dst: путь, sense: way }
- { src: 道, dst: дао, sense: dao }
`)); err != nil {
- t.Errorf("distinct senses should be allowed: %v", err)
+ t.Errorf("polysemy on a single-key-banned src is inert and must stay allowed: %v", err)
+ }
+}
+
+// TestSeedPolysemyOverlapFailsLoud covers D16.1: two approved rows with the same MATCHABLE
+// src, DIFFERENT senses, DIFFERENT dst and OVERLAPPING spoiler windows both inject as
+// CONFIRMED (the matcher keys only on src) and the post-check is guaranteed to miss one —
+// with the gate ON a deterministic chunk livelock. Fail loud. Reverting the different-sense
+// branch in loadGlossarySeed passes the contradiction through and fails here.
+func TestSeedPolysemyOverlapFailsLoud(t *testing.T) {
+ // Matchable src (神通, 2 Han ≥ min-key), different senses, default (overlapping) windows,
+ // different dst → livelock → fail loud.
+ if _, err := loadGlossarySeed(writeSeed(t, `
+terms:
+ - { src: 神通, dst: сила, sense: power }
+ - { src: 神通, dst: чудо, sense: miracle }
+`)); err == nil {
+ t.Error("D16.1: polysemy on a matchable src with overlapping windows must fail loud")
+ }
+ // Same src+senses but NON-overlapping windows is a legitimate spoiler-boundary rendering
+ // change (only one sense can fire per chapter) → allowed.
+ if _, err := loadGlossarySeed(writeSeed(t, `
+terms:
+ - { src: 神通, dst: сила, sense: power, until_ch: 5 }
+ - { src: 神通, dst: чудо, sense: miracle, since_ch: 6 }
+`)); err != nil {
+ t.Errorf("polysemy with non-overlapping windows should be allowed: %v", err)
+ }
+ // Same MATCHABLE src+sense with the SAME dst and overlapping windows → NOT a contradiction
+ // (both render identically), allowed.
+ if _, err := loadGlossarySeed(writeSeed(t, `
+terms:
+ - { src: 神通, dst: сила, sense: power, until_ch: 5 }
+ - { src: 神通, dst: сила, sense: might, since_ch: 3 }
+`)); err != nil {
+ t.Errorf("same-dst overlap is not a contradiction: %v", err)
}
}
@@ -194,6 +231,138 @@ func TestRubyToCandidates(t *testing.T) {
}
}
+// TestApprovedSharedKeyCollisions covers the Task-6 re-audit finding: a firing key shared by
+// two DIFFERENT approved terms with different dst + overlapping windows is the alias
+// generalization of the D16.1 polysemy livelock. Reverting approvedSharedKeyCollisions (or its
+// seedGlossary wiring) admits the contradictory injection. Legitimate cases (same dst,
+// non-overlapping windows) and the same-src case (D16.1's job) must NOT be flagged here.
+func TestApprovedSharedKeyCollisions(t *testing.T) {
+ al := func(a string) []store.GlossaryAlias {
+ return []store.GlossaryAlias{{Alias: a, AliasType: "прозвище"}}
+ }
+ // Shared alias 老赵 across two different approved terms, different dst, default (overlapping)
+ // windows → collision (livelock).
+ e1 := store.GlossaryEntry{Src: "赵甲", Dst: "Чжао Цзя", Status: "approved", Aliases: al("老赵")}
+ e2 := store.GlossaryEntry{Src: "赵乙", Dst: "Чжао И", Status: "approved", Aliases: al("老赵")}
+ if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1, e2}); len(cols) == 0 {
+ t.Error("shared alias 老赵 across different terms must be flagged (livelock)")
+ }
+ // Same dst → a legitimate merge (both render identically), no contradiction.
+ e2same := e2
+ e2same.Dst = "Чжао Цзя"
+ if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1, e2same}); len(cols) != 0 {
+ t.Errorf("same-dst shared surface is a merge, not a collision: %v", cols)
+ }
+ // Non-overlapping windows → a spoiler-boundary handoff (only one fires per chapter) → allowed.
+ e1w, e2w := e1, e2
+ e1w.UntilCh, e2w.SinceCh = 5, 6
+ if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1w, e2w}); len(cols) != 0 {
+ t.Errorf("non-overlapping windows are allowed: %v", cols)
+ }
+ // Self-review #1: a SUB-FLOOR src (道, single-key-banned) that shares an ELIGIBLE alias
+ // (大道) between different-dst rows slips loadGlossarySeed's src-scoped D16.1 check, so this
+ // guard must catch it via the shared alias key (livelock).
+ s1 := store.GlossaryEntry{Src: "道", Dst: "путь", Sense: "way", Status: "approved", Aliases: al("大道")}
+ s2 := store.GlossaryEntry{Src: "道", Dst: "дао", Sense: "dao", Status: "approved", Aliases: al("大道")}
+ if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{s1, s2}); len(cols) == 0 {
+ t.Error("#1: sub-floor src sharing an eligible alias with a different dst must be flagged")
+ }
+ // The same sub-floor src with DISTINCT aliases is fine (道 does not fire, no shared key).
+ d1 := store.GlossaryEntry{Src: "道", Dst: "путь", Sense: "way", Status: "approved", Aliases: al("正道")}
+ d2 := store.GlossaryEntry{Src: "道", Dst: "дао", Sense: "dao", Status: "approved", Aliases: al("大道")}
+ if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{d1, d2}); len(cols) != 0 {
+ t.Errorf("sub-floor src with distinct aliases has no shared firing key: %v", cols)
+ }
+ // A same-src pair with an ELIGIBLE shared src key IS a real collision in isolation (in the
+ // live flow loadGlossarySeed's D16.1 check fails loud first, so no double report).
+ p1 := store.GlossaryEntry{Src: "神通", Dst: "сила", Sense: "a", Status: "approved"}
+ p2 := store.GlossaryEntry{Src: "神通", Dst: "чудо", Sense: "b", Status: "approved"}
+ if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{p1, p2}); len(cols) == 0 {
+ t.Error("an eligible shared src key is a real collision here too")
+ }
+ // An auto term sharing the key is not CONFIRMED, so no authoritative contradiction → allowed.
+ a1 := store.GlossaryEntry{Src: "钱甲", Dst: "Цянь А", Status: "approved", Aliases: al("老钱")}
+ a2 := store.GlossaryEntry{Src: "钱乙", Dst: "Цянь Б", Status: "auto", Aliases: al("老钱")}
+ if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{a1, a2}); len(cols) != 0 {
+ t.Errorf("an auto term sharing a key is not a CONFIRMED contradiction: %v", cols)
+ }
+}
+
+// TestRubyReadingBecomesManualAlias covers D16.4: a manual term's name-shape ruby reading
+// (all-Han base + all-kana reading) becomes a matchable ALIAS so the kana spelling of a
+// seeded name is found. A kanji-bearing gloss/double-reading is NOT attached (footnote, not
+// a surface), and a reading whose base is not a manual term is left to the auto-candidate
+// path. Reverting attachRubyAliasesToManual leaves the manual entry alias-less and fails here.
+func TestRubyReadingBecomesManualAlias(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: 5}, // name-shape → alias
+ {BookID: "b", Base: "泣蟲", Reading: "泣き虫", FirstChapter: 1, Occurrences: 2}, // gloss (carries kanji) → NOT an alias
+ {BookID: "b", Base: "田中", Reading: "たなか", FirstChapter: 1, Occurrences: 3}, // base not manual → skipped (auto path)
+ }
+ attachRubyAliasesToManual(entries, readings)
+
+ if !hasAliasSurface(entries[0].Aliases, "すずき") {
+ t.Errorf("鈴木 did not gain its kana reading as an alias: %+v", entries[0].Aliases)
+ }
+ if hasAliasSurface(entries[1].Aliases, "泣き虫") {
+ t.Errorf("a kanji-bearing gloss reading must NOT become an alias: %+v", entries[1].Aliases)
+ }
+ // Idempotent: a second attach must not duplicate the alias.
+ attachRubyAliasesToManual(entries, readings)
+ n := 0
+ for _, a := range entries[0].Aliases {
+ if a.Alias == "すずき" {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Errorf("ruby-alias attach not idempotent: すずき appears %d times", n)
+ }
+ // End-to-end: the kana spelling now matches through the materialized bank.
+ b := materializeMemory(entries, false)
+ if _, ok := injMap(b.Select("きのう、すずきに会った。", 1, nil, 0))["鈴木"]; !ok {
+ t.Error("kana spelling すずき did not match the manual 鈴木 via the reading alias")
+ }
+}
+
+// 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.
+func TestRubyHomophoneSkippedNotFailLoud(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: 8},
+ {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)
+ }
+ // Exactly ONE term got the kana alias (the first, deterministically); the other was skipped.
+ 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)
+ }
+ // The resulting set must NOT trip the shared-key collision fail-loud (the whole point).
+ if cols := approvedSharedKeyCollisions(entries); len(cols) != 0 {
+ t.Errorf("homophone skip must avoid the fail-loud, got collisions: %v", cols)
+ }
+}
+
// TestSeedSurroundingWhitespaceTrimmed covers the workflow-confirmed seed major: surrounding
// whitespace on a keying surface passed the emptiness check but was stored raw, so the term
// keyed WITH the space and could never match (silently inert), and a trailing-space sense
diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go
index edda6eb..d258464 100644
--- a/backend/internal/pipeline/runner.go
+++ b/backend/internal/pipeline/runner.go
@@ -153,14 +153,21 @@ type contextSnap struct {
}
// coverageSnap freezes the excision coverage-gate config inside the snapshot, so
-// enabling the gate OR tuning its thresholds is a loud --resnapshot that re-derives
-// every chunk's verdict from its checkpoint for free (checkpoint-hit → re-classify,
-// no re-bill), never a silent mismatch between an old checkpoint's stored disposition
-// and a changed gate. Only folded when ENABLED: tweaking a disabled gate's bounds must
-// not force a re-pin (the gate produces no verdicts while off). json.Marshal sorts the
-// LenRatio map keys → deterministic render. Mirrors the estimator/max_tokens-policy
-// discipline (render.go) for a NON-wire input that still determines a checkpoint's
-// resolved verdict.
+// enabling the gate OR tuning its thresholds is a loud --resnapshot, never a silent
+// mismatch between an old checkpoint's stored disposition and a changed gate. HONEST
+// COST (D15): the snapshotID is folded into every call's RequestHash (render.go), so a
+// --resnapshot changes EVERY request_hash → every checkpoint misses → the whole book is
+// RE-BILLED, even for byte-identical wire requests whose verdict merely needs
+// re-classifying. There is no free "re-classify from the checkpoint" here: the
+// content-addressed reuse that COULD make an unchanged wire request free (msgsContentHash,
+// render.go) is gated on an UNCHANGED snapshot (runner.go), so it never fires across a
+// resnapshot. This all-or-nothing re-pay is ACCEPTED for the static acceptance book (the
+// gate is loud, divergences do not occur) but is exactly why content-addressed checkpoint
+// reuse must be designed before the ongoing/append-chapters mode (D15.2). Only folded when
+// ENABLED: tweaking a disabled gate's bounds must not force a re-pin (the gate produces no
+// verdicts while off). json.Marshal sorts the LenRatio map keys → deterministic render.
+// Mirrors the estimator/max_tokens-policy discipline (render.go) for a NON-wire input that
+// still determines a checkpoint's resolved verdict.
type coverageSnap struct {
Enabled bool `json:"enabled"`
Version string `json:"version,omitempty"`
@@ -535,7 +542,7 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
// the CURRENT chapter, reset at a chapter boundary (a new chapter is a scene change).
// Rebuilt deterministically each run because translateChunk recomputes the ($0)
// selection for EVERY chunk, resumed ones included — so sticky is resume-stable.
- var stickyWin []map[string]bool
+ var stickyWin []map[string]injectionDisposition
prevChapter := 0
for _, ch := range chunks {
if ch.Chapter != prevChapter {
@@ -562,15 +569,18 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
return res, nil
}
-// unionSticky merges the recent chunks' exact-matched id sets into one sticky_prev.
-func unionSticky(win []map[string]bool) map[string]bool {
+// unionSticky merges the recent chunks' exact-matched ids into one sticky_prev, carrying
+// each id's DISPOSITION (D16.2). When an id fired in several recent chunks, the MOST RECENT
+// firing wins (win is ordered oldest→newest), so a re-established CONFIRMED match overrides
+// an older collision-prone AMBIGUOUS one — and vice-versa, never silently upgrading.
+func unionSticky(win []map[string]injectionDisposition) map[string]injectionDisposition {
if len(win) == 0 {
return nil
}
- out := map[string]bool{}
+ out := map[string]injectionDisposition{}
for _, s := range win {
- for id := range s {
- out[id] = true
+ for id, disp := range s {
+ out[id] = disp
}
}
return out
@@ -599,10 +609,24 @@ func (r *Runner) seedGlossary(ctx context.Context) error {
if err != nil {
return err
}
+ // D16.4: attach a manual term's kana ruby-reading as an alias (kana spelling matchable)
+ // BEFORE appending the auto-candidates, so it only touches the curated manual entries. A
+ // reading that would collide with a different seeded term (homophone) is skipped+logged,
+ // not attached (which would fail the book loud on an alias the operator cannot edit out).
+ if skipped := attachRubyAliasesToManual(entries, ruby); len(skipped) > 0 {
+ r.Log.WarnContext(ctx, "ruby kana-alias skipped as a homophone collision (kana form left unmatchable; disambiguate in the seed if needed)",
+ "skipped", strings.Join(skipped, "; "))
+ }
entries = append(entries, rubyToCandidates(ruby, manualSrcs)...)
for i := range entries {
entries[i].BookID = r.Book.BookID
}
+ // Task-6 re-audit: fail loud on a firing key shared by two DIFFERENT approved terms with
+ // different dst + overlapping windows (the alias generalization of the D16.1 polysemy
+ // livelock) — checked over the FULL entry set (incl. ruby-attached aliases) before persisting.
+ if cols := approvedSharedKeyCollisions(entries); len(cols) > 0 {
+ return fmt.Errorf("pipeline: glossary shared-key collisions (A2 / D16.1 livelock class):\n - %s", strings.Join(cols, "\n - "))
+ }
if err := r.Store.ReplaceGlossary(r.Book.BookID, entries); err != nil {
return err
}
@@ -675,7 +699,7 @@ func (r *Runner) persistRuby(readings []RubyReading) error {
// persists the per-chunk retrieval-state (observability). Returns the chunk outcome, the
// exact-matched entity ids (the next chunk's sticky_prev, A5), and an error only on an
// infra failure. stickyPrev is the prior chunks' exact matches in this chapter.
-func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, stickyPrev map[string]bool) (*ChunkOutcome, map[string]bool, error) {
+func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, stickyPrev map[string]injectionDisposition) (*ChunkOutcome, map[string]injectionDisposition, error) {
out := &ChunkOutcome{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Disposition: DispOK}
prev := ""
flagged := false
@@ -686,7 +710,7 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st
// included) so the sticky window and the injected bytes are resume-stable.
var memSel memorySelection
var translatorInjection, editorInjection string
- activeIDs := map[string]bool{}
+ activeIDs := map[string]injectionDisposition{}
if r.memory != nil {
memSel = r.memory.Select(ch.Text, ch.Chapter, stickyPrev, r.Pipeline.Context.GlossaryTokenBudget)
translatorInjection = renderGlossaryBlock(memSel.injected)
@@ -999,6 +1023,7 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name,
SnapshotID: snapID, ContentHash: contentHash, Disposition: string(disposition), FlagReason: string(last.cls.Reason),
Attempts: attemptsMade, FinalHash: finalHash, CostUSD: cumCost, Detail: last.cls.Detail,
+ Escalated: escalated, EscalationModel: escModel,
}); err != nil {
return nil, fmt.Errorf("pipeline: record chunk_status: %w", err)
}
@@ -1084,7 +1109,11 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
for _, m := range msgs {
promptEst += EstimateTokens(m.Content)
}
- estimate := ledger.EstimateUSD(price, promptEst, maxTokens)
+ // D13.6: reserve the additive reasoning buffer for the ACTUAL model being called (the
+ // escalate_to fallback may differ from st.Model and have its own provider). 0 for
+ // subset providers / reasoning-off — the ceiling then sees only completion, as before.
+ reasoningBudget := r.Models.AdditiveReasoningTokens(model, st.Reasoning, st.ReasoningMaxTokens)
+ estimate := ledger.EstimateUSD(price, promptEst, maxTokens, reasoningBudget)
resv, verdict, err := r.Store.Reserve(r.Book.BookID, estimate, store.Ceilings{
BookUSD: r.Book.Ceilings.BookUSD, DayUSD: r.Book.Ceilings.DayUSD,
@@ -1241,6 +1270,10 @@ func (r *Runner) resumeFromChunkStatus(ctx context.Context, st config.Stage, ch
CostUSD: 0, CumCostUSD: cs.CostUSD,
Disposition: Disposition(cs.Disposition), FlagReason: FlagReason(cs.FlagReason),
Detail: cs.Detail, Attempts: cs.Attempts,
+ // Escalation attribution now travels on chunk_status (D15.3), so it is preserved
+ // across resume for BOTH an ok escalation AND a flagged-then-escalated chunk (the
+ // status read-model reads it without re-opening the checkpoint).
+ Escalated: cs.Escalated, EscalationModel: cs.EscalationModel,
}
if cs.Disposition == string(DispOK) {
cp, err := r.Store.GetCheckpoint(cs.FinalHash)
@@ -1260,12 +1293,6 @@ func (r *Runner) resumeFromChunkStatus(ctx context.Context, st config.Stage, ch
sr.Model = cp.ModelActual
sr.FinishReason = cp.FinishReason
sr.Text = cp.ResponseText
- // Preserve escalation attribution across resume (self-review): a chunk resolved
- // via a fallback checkpoint is still an escalation, so a resumed run reports it.
- if cp.Escalation {
- sr.Escalated = true
- sr.EscalationModel = cp.ModelRequested
- }
}
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
diff --git a/backend/internal/pipeline/status.go b/backend/internal/pipeline/status.go
new file mode 100644
index 0000000..9fca63b
--- /dev/null
+++ b/backend/internal/pipeline/status.go
@@ -0,0 +1,430 @@
+package pipeline
+
+import (
+ "context"
+ "fmt"
+ "sort"
+
+ "textmachine/backend/internal/store"
+)
+
+// status.go: the READ-ONLY progress projection (`tmctl status`) and the targeted re-attack
+// of flagged chunks (`tmctl redrive`) — the D12 "прогресс — единственный реальный пробел"
+// tail, ratified by D15.3. Status makes ZERO LLM calls and ZERO checkpoint replays: it reads
+// the book manifest (a deterministic, $0 re-chunk of the source) and projects it against the
+// stored chunk_status / spend / retrieval_state. Redrive resets the flagged chunks' terminal
+// state (their chunk_status + checkpoints) and re-runs the durable loop, so the DispOK work
+// resumes at $0 and only the reset stages re-attack with a fresh retry/escalation budget.
+
+// ChunkState is a chunk's resolved lifecycle state for the projection — a chunk-level view
+// over its per-stage chunk_status rows. `skipped` is deliberately NOT a chunk state: it is a
+// per-STAGE substate (the downstream stages of a flagged chunk), surfaced in the passport's
+// stage counts, since the linear C1 core never skips a whole chunk.
+type ChunkState string
+
+const (
+ ChunkDone ChunkState = "done" // every pipeline stage resolved ok
+ ChunkFlagged ChunkState = "flagged" // a stage flagged (chunk not translated; ≠ infra failure)
+ ChunkInProgress ChunkState = "in_progress" // some stages resolved, not all, none flagged (an interrupted run)
+ ChunkPending ChunkState = "pending" // no stage attempted yet
+)
+
+// ChapterPassport is the per-chapter quality passport (D12): unit counts, the worst flag
+// reason, a pass|attention|fail verdict (exp07 chapter rule) and the chapter's spend.
+type ChapterPassport struct {
+ Chapter int `json:"chapter"`
+ ChunksTotal int `json:"chunks_total"`
+ ChunksDone int `json:"chunks_done"`
+ ChunksFlagged int `json:"chunks_flagged"`
+ ChunksInFlight int `json:"chunks_in_progress"`
+ ChunksPending int `json:"chunks_pending"`
+ 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)
+ 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"`
+}
+
+// StatusReport is the whole-book read-model.
+type StatusReport struct {
+ BookID string `json:"book_id"`
+ Snapshot string `json:"snapshot_id"` // the snapshot the stored rows were resolved under ("" = nothing run yet)
+ // SnapshotDrift is true when the stored rows carry more than one snapshot id (a config
+ // changed mid-book without a full --resnapshot re-pin) — a loud signal, not a silent skew.
+ SnapshotDrift bool `json:"snapshot_drift"`
+ // ConfigDrift is true when the CURRENT config renders a snapshot DIFFERENT from the one the
+ // stored rows were resolved under — a wire/verdict-affecting edit since the last run (e.g. a
+ // prompt-version bump, a gate flip) that `tmctl translate` would --resnapshot. Without this
+ // the operator sees "done/pass" and false confidence (finding #3). CurrentSnapshot is what
+ // the config renders now (empty when it could not be computed or matches).
+ ConfigDrift bool `json:"config_drift"`
+ CurrentSnapshot string `json:"current_snapshot,omitempty"`
+
+ TotalChunks int `json:"total_chunks"`
+ Done int `json:"done"`
+ InProgress int `json:"in_progress"`
+ Flagged int `json:"flagged"`
+ Pending int `json:"pending"`
+ PercentDone float64 `json:"percent_done"` // 100·done/total, unit-count only (no synthetic time-bar)
+
+ Escalations int `json:"escalations"`
+ PostcheckMisses int `json:"postcheck_misses"`
+
+ CommittedUSD float64 `json:"committed_usd"`
+ ReservedUSD float64 `json:"reserved_usd"`
+ BookCeilingUSD float64 `json:"book_ceiling_usd,omitempty"`
+ CeilingPct float64 `json:"ceiling_pct,omitempty"` // 100·(committed+reserved)/book_ceiling
+ ProjectedBookUSD float64 `json:"projected_book_usd"` // committed extrapolated over the whole book
+ ETASeconds float64 `json:"eta_seconds,omitempty"` // secondary: mean fresh-call throughput × remaining
+
+ Chapters []ChapterPassport `json:"chapters"`
+}
+
+// flagReasonSeverity ranks flag reasons worst-first for the chapter's "worst flag" passport
+// field. Lower = worse. Deterministic content-failures (refusal/echo/excision) outrank
+// retryable budget symptoms (length/empty), which are the least alarming.
+func flagReasonSeverity(reason string) int {
+ switch FlagReason(reason) {
+ case FlagHardRefusal, FlagSoftRefusal, FlagContentFilter, FlagHardBlock:
+ return 0
+ case FlagCJKArtifact, FlagExcisionSuspect, FlagCoverageFail:
+ return 1
+ case FlagLoopDegenerate:
+ return 2
+ case FlagDecodeError:
+ return 3
+ case FlagGlossaryMiss:
+ return 4
+ case FlagLength, FlagEmpty:
+ return 5
+ }
+ return 6
+}
+
+// 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.
+func (r *Runner) bookChunks() ([]Chunk, error) {
+ doc, err := Ingest(r.Book.SourceFile)
+ if err != nil {
+ return nil, err
+ }
+ return SplitChunks(doc.Chapters), nil
+}
+
+// chunkKey identifies a chunk positionally.
+type chunkKey struct {
+ chapter, chunkIdx int
+}
+
+// resolveChunkState maps a chunk's per-stage rows to its chunk-level state, plus its cost,
+// escalation flag and (when flagged) the flag reason. stagesTotal is the pipeline stage count.
+func resolveChunkState(rows []store.ChunkStatus, stagesTotal int) (st ChunkState, reason string, cost float64, escalated bool, skipped int) {
+ if len(rows) == 0 {
+ return ChunkPending, "", 0, false, 0
+ }
+ ok := 0
+ for _, cs := range rows {
+ cost += cs.CostUSD
+ if cs.Escalated {
+ escalated = true
+ }
+ switch cs.Disposition {
+ case string(DispOK):
+ ok++
+ case string(DispFlagged):
+ // The first (only) flagged stage decides the chunk; keep its reason.
+ st, reason = ChunkFlagged, cs.FlagReason
+ case string(DispSkipped):
+ skipped++
+ }
+ }
+ if st == ChunkFlagged {
+ return st, reason, cost, escalated, skipped
+ }
+ if ok == stagesTotal {
+ return ChunkDone, "", cost, escalated, skipped
+ }
+ return ChunkInProgress, "", cost, escalated, skipped
+}
+
+// Status builds the read-only progress projection. It opens no jobs, reserves nothing, makes
+// no LLM call and replays no checkpoint — the D12 query-handler analogue. Safe to run whenever
+// the project file is not held by a running tmctl (the same exclusive-lock rule as `report`).
+func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
+ chunks, err := r.bookChunks()
+ if err != nil {
+ return nil, err
+ }
+ statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
+ if err != nil {
+ return nil, err
+ }
+ byChunk := map[chunkKey][]store.ChunkStatus{}
+ snapSeen := map[string]bool{}
+ for _, cs := range statuses {
+ byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
+ if cs.SnapshotID != "" {
+ snapSeen[cs.SnapshotID] = true
+ }
+ }
+
+ // Per-chunk post-check misses (retrieval_state) — the glossary-consistency signal that is
+ // re-derived each run and NOT a chunk_status disposition (default flagger mode).
+ states, err := r.Store.RetrievalStatesForBook(r.Book.BookID)
+ if err != nil {
+ return nil, err
+ }
+ missByChunk := map[chunkKey]int{}
+ for _, rs := range states {
+ missByChunk[chunkKey{rs.Chapter, rs.ChunkIdx}] = rs.NPostcheckMiss
+ }
+
+ stagesTotal := len(r.Pipeline.Stages)
+ // The post-check GATE (opt-in) flags a chunk at the CHUNK level AFTER the stage loop, so it
+ // is NEVER written as a chunk_status row (every stage stays DispOK). Without accounting for
+ // it here, status would report a gate-flagged chunk as done/pass while `tmctl translate`
+ // exits 2 (finding #2). When the gate is ON, promote an otherwise-done chunk with a CONFIRMED
+ // post-check miss to flagged/glossary_miss so status matches the run.
+ gateOn := r.Pipeline.Gates.Glossary.PostcheckGate
+ rep := &StatusReport{BookID: r.Book.BookID, TotalChunks: len(chunks)}
+ passports := map[int]*ChapterPassport{}
+ var chapterOrder []int
+ var processedCost float64 // spend of PROCESSED chunks (done+flagged) — the projection base (finding #7)
+
+ for _, ch := range chunks {
+ p := passports[ch.Chapter]
+ if p == nil {
+ p = &ChapterPassport{Chapter: ch.Chapter, Verdict: "pass"}
+ passports[ch.Chapter] = p
+ chapterOrder = append(chapterOrder, ch.Chapter)
+ }
+ p.ChunksTotal++
+ key := chunkKey{ch.Chapter, ch.ChunkIdx}
+ state, reason, cost, escalated, skipped := resolveChunkState(byChunk[key], stagesTotal)
+ p.CostUSD += cost
+ p.StagesSkipped += skipped
+ miss := missByChunk[key]
+ p.PostcheckMisses += miss
+ rep.PostcheckMisses += miss
+ 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).
+ state, reason = ChunkFlagged, string(FlagGlossaryMiss)
+ }
+ if escalated {
+ p.Escalations++
+ rep.Escalations++
+ }
+ switch state {
+ case ChunkDone:
+ rep.Done++
+ p.ChunksDone++
+ case ChunkFlagged:
+ rep.Flagged++
+ p.ChunksFlagged++
+ if p.WorstFlagReason == "" || flagReasonSeverity(reason) < flagReasonSeverity(p.WorstFlagReason) {
+ p.WorstFlagReason = reason
+ }
+ case ChunkInProgress:
+ rep.InProgress++
+ p.ChunksInFlight++
+ case ChunkPending:
+ rep.Pending++
+ p.ChunksPending++
+ }
+ if state == ChunkDone || state == ChunkFlagged {
+ processedCost += cost // a fully-attempted chunk's cost feeds the projection
+ }
+ }
+
+ // Chapter verdicts (exp07 chapter rule: 0 flagged = pass, 1 = attention, ≥2 = fail).
+ sort.Ints(chapterOrder)
+ for _, n := range chapterOrder {
+ p := passports[n]
+ switch {
+ case p.ChunksFlagged >= 2:
+ p.Verdict = "fail"
+ case p.ChunksFlagged == 1:
+ p.Verdict = "attention"
+ default:
+ p.Verdict = "pass"
+ }
+ rep.Chapters = append(rep.Chapters, *p)
+ }
+
+ if rep.TotalChunks > 0 {
+ rep.PercentDone = 100 * float64(rep.Done) / float64(rep.TotalChunks)
+ }
+ if len(snapSeen) == 1 {
+ for s := range snapSeen {
+ rep.Snapshot = s
+ }
+ } else if len(snapSeen) > 1 {
+ rep.SnapshotDrift = true
+ }
+
+ // Config-drift (finding #3): compute the CURRENT config's snapshot READ-ONLY (materialize
+ // the STORED glossary — no re-seed, no write) and compare to the single snapshot the stored
+ // rows carry. A wire/verdict-affecting config edit since the last run (a prompt bump, a gate
+ // flip — this very package bumps the editor prompt_version) is otherwise hidden behind
+ // "done/pass" until translate forces a --resnapshot. Only meaningful when a single snapshot
+ // is on record (multi-snapshot is already flagged as SnapshotDrift). Note: a change to the
+ // seed FILE that was not re-run is NOT detected here (the stored glossary is what status
+ // projects); it surfaces on the next translate's re-seed.
+ if rep.Snapshot != "" {
+ if rows, gerr := r.Store.GlossaryForBook(r.Book.BookID); gerr == nil {
+ r.memory = materializeMemory(rows, gateOn)
+ if curSnap, _, serr := r.snapshotID(); serr == nil && curSnap != rep.Snapshot {
+ rep.ConfigDrift = true
+ rep.CurrentSnapshot = curSnap
+ }
+ }
+ }
+
+ // Money.
+ committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
+ if err != nil {
+ return nil, err
+ }
+ rep.CommittedUSD, rep.ReservedUSD = committed, reserved
+ rep.BookCeilingUSD = r.Book.Ceilings.BookUSD
+ if rep.BookCeilingUSD > 0 {
+ rep.CeilingPct = 100 * (committed + reserved) / rep.BookCeilingUSD
+ }
+ // Projected book cost: extrapolate the per-PROCESSED-chunk average over the whole book
+ // (done + flagged = a chunk fully attempted). Uses processedCost, NOT book committed:
+ // committed also carries partial spend on IN-PROGRESS chunks (excluded from the denominator),
+ // which would over-estimate (finding #7). The clean average × total is the honest estimate.
+ processed := rep.Done + rep.Flagged
+ if processed > 0 {
+ rep.ProjectedBookUSD = processedCost / float64(processed) * float64(rep.TotalChunks)
+ }
+
+ // ETA (secondary): mean fresh-call throughput × remaining processing. No synthetic bar.
+ totalMS, freshCalls, err := r.Store.FreshCallLatencyMS(r.Book.BookID)
+ if err != nil {
+ return nil, err
+ }
+ remaining := rep.TotalChunks - processed
+ if processed > 0 && freshCalls > 0 && remaining > 0 {
+ meanMSPerChunk := float64(totalMS) / float64(processed)
+ rep.ETASeconds = meanMSPerChunk * float64(remaining) / 1000
+ }
+ return rep, nil
+}
+
+// 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.
+type RedriveSelector struct {
+ Chapter int
+ ChunkIdx int
+ Reason string
+ DryRun bool
+}
+
+func (s RedriveSelector) matches(cs store.ChunkStatus) bool {
+ if s.Chapter >= 0 && cs.Chapter != s.Chapter {
+ return false
+ }
+ if s.ChunkIdx >= 0 && cs.ChunkIdx != s.ChunkIdx {
+ return false
+ }
+ if s.Reason != "" && cs.FlagReason != s.Reason {
+ return false
+ }
+ return true
+}
+
+// RedriveTarget is one chunk selected for re-attack and the stages that were (or would be) reset.
+type RedriveTarget struct {
+ Chapter int `json:"chapter"`
+ ChunkIdx int `json:"chunk_idx"`
+ FlagReason string `json:"flag_reason"`
+ Stages []string `json:"reset_stages"`
+}
+
+// RedriveSummary reports the plan/outcome of the reset half of a redrive.
+type RedriveSummary struct {
+ Targets []RedriveTarget `json:"targets"`
+ DryRun bool `json:"dry_run"`
+ ResetRun bool `json:"reset_run"` // the re-translate ran (false on dry-run / no targets)
+}
+
+// Redrive re-attacks the FLAGGED chunks matching sel (D15.3 / D12 Step-Functions redrive). It
+// NEVER touches DispOK work (D12): for each selected chunk it resets only the flagged stage and
+// its downstream skipped stages — their chunk_status + checkpoints are deleted (ResetChunkStages),
+// so the durable re-run re-derives them with a FRESH retry/escalation budget (a fresh provider
+// call, not a deterministic replay of the flagged completion) while the upstream ok stages resume
+// at $0. Money bills normally (reserve/settle+checkpoint); the previously-spent money on the
+// discarded attempts stays committed (honest — it was really billed), so the ceilings remain
+// accurate. Requires the CURRENT config to render the SAME snapshot the flagged rows carry (no
+// --resnapshot): a config change makes the re-run fail loud with the resnapshot guidance instead
+// of silently re-pricing the book. Returns the reset plan, and the re-run's BookResult (nil on
+// dry-run or when nothing matched).
+func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSummary, *BookResult, error) {
+ statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
+ if err != nil {
+ return nil, nil, err
+ }
+ // Group by chunk and find the targets: a chunk with a FLAGGED stage matching the selector.
+ byChunk := map[chunkKey][]store.ChunkStatus{}
+ var order []chunkKey
+ for _, cs := range statuses {
+ k := chunkKey{cs.Chapter, cs.ChunkIdx}
+ if _, seen := byChunk[k]; !seen {
+ order = append(order, k)
+ }
+ byChunk[k] = append(byChunk[k], cs)
+ }
+ sort.Slice(order, func(i, j int) bool {
+ if order[i].chapter != order[j].chapter {
+ return order[i].chapter < order[j].chapter
+ }
+ return order[i].chunkIdx < order[j].chunkIdx
+ })
+
+ summary := &RedriveSummary{DryRun: sel.DryRun}
+ for _, k := range order {
+ rows := byChunk[k]
+ targeted := false
+ reason := ""
+ for _, cs := range rows {
+ if cs.Disposition == string(DispFlagged) && sel.matches(cs) {
+ targeted, reason = true, cs.FlagReason
+ break
+ }
+ }
+ 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) {
+ stages = append(stages, cs.Stage)
+ }
+ }
+ sort.Strings(stages)
+ 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
+ }
+ // Re-run the durable loop: reset chunks re-attack fresh; everything else resumes at $0.
+ res, err := r.TranslateBook(ctx)
+ summary.ResetRun = true
+ return summary, res, err
+}
diff --git a/backend/internal/pipeline/status_test.go b/backend/internal/pipeline/status_test.go
new file mode 100644
index 0000000..725259a
--- /dev/null
+++ b/backend/internal/pipeline/status_test.go
@@ -0,0 +1,262 @@
+package pipeline
+
+import (
+ "context"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// TestStatusAndRedrive exercises the D15.3 read-model + targeted re-attack end-to-end against
+// the mock provider ($0, no real LLM): a book with one flagged chapter and one ok chapter →
+// status projects the honest N/M + per-chapter passports + money; redrive dry-run identifies
+// the flagged chunk without touching it; a real redrive resets ONLY the flagged stages, re-runs
+// with a fresh budget (ok work resumes at $0), and the previously-spent money stays committed.
+func TestStatusAndRedrive(t *testing.T) {
+ rec := &reqRec{}
+ var mu sync.Mutex
+ refuse := true // ch1 draft refuses until flipped
+ srv := newJSONProvider(rec, func(body string) (string, string) {
+ mu.Lock()
+ r := refuse
+ mu.Unlock()
+ if r && strings.Contains(body, "ОТКАЗНАЯГЛАВА") && !isEditBody(body) {
+ return "Извините, я не могу перевести это.", "stop" // soft refusal → flagged, non-retryable
+ }
+ return draftEdit(body)
+ })
+ defer srv.Close()
+ bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗНАЯГЛАВА\fНОРМАЛЬНАЯГЛАВА", regenerate: 0})
+ ctx := context.Background()
+
+ // --- Run 1: ch1 flagged (soft_refusal), ch2 done. ---
+ r1 := newRunner(t, bookPath)
+ res1, err := r1.TranslateBook(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res1.Flagged != 1 {
+ t.Fatalf("want 1 flagged chunk, got %d", res1.Flagged)
+ }
+ callsAfterRun1 := rec.count() // draft(ch1)=refusal, draft(ch2)+edit(ch2)=ok → 3
+ if callsAfterRun1 != 3 {
+ t.Fatalf("want 3 provider calls after run1, got %d", callsAfterRun1)
+ }
+
+ // --- STATUS projection (read-only). ---
+ rep, err := r1.Status(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.TotalChunks != 2 || rep.Done != 1 || rep.Flagged != 1 || rep.Pending != 0 || rep.InProgress != 0 {
+ t.Fatalf("status counts wrong: %+v", rep)
+ }
+ if math.Abs(rep.PercentDone-50) > 1e-9 {
+ t.Errorf("percent_done = %v, want 50", rep.PercentDone)
+ }
+ if len(rep.Chapters) != 2 {
+ t.Fatalf("want 2 chapter passports, got %d", len(rep.Chapters))
+ }
+ if rep.Chapters[0].Verdict != "attention" || rep.Chapters[0].WorstFlagReason != string(FlagSoftRefusal) || rep.Chapters[0].ChunksFlagged != 1 {
+ t.Errorf("ch1 passport = %+v (want attention/soft_refusal/1 flagged)", rep.Chapters[0])
+ }
+ if rep.Chapters[1].Verdict != "pass" || rep.Chapters[1].ChunksDone != 1 {
+ t.Errorf("ch2 passport = %+v (want pass/1 done)", rep.Chapters[1])
+ }
+ wantCommitted := 3 * fakeCallUSD
+ if math.Abs(rep.CommittedUSD-wantCommitted) > 1e-9 {
+ t.Errorf("committed = %v, want %v", rep.CommittedUSD, wantCommitted)
+ }
+ // Projected extrapolates the per-processed-chunk average over the whole book (here both
+ // chunks are processed, so it equals committed).
+ if math.Abs(rep.ProjectedBookUSD-wantCommitted) > 1e-9 {
+ t.Errorf("projected = %v, want %v", rep.ProjectedBookUSD, wantCommitted)
+ }
+ r1.Close()
+ if rec.count() != callsAfterRun1 {
+ t.Fatalf("status made provider calls: %d -> %d", callsAfterRun1, rec.count())
+ }
+
+ // --- REDRIVE dry-run: identify the target, mutate nothing. ---
+ r2 := newRunner(t, bookPath)
+ sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagSoftRefusal), DryRun: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res != nil || sum.ResetRun {
+ t.Error("dry-run must not re-run the book")
+ }
+ if len(sum.Targets) != 1 || sum.Targets[0].Chapter != 1 || sum.Targets[0].ChunkIdx != 0 {
+ t.Fatalf("dry-run targets = %+v", sum.Targets)
+ }
+ // The flagged stage AND its skipped downstream are both selected for reset.
+ if strings.Join(sum.Targets[0].Stages, ",") != "draft,edit" {
+ t.Errorf("dry-run reset stages = %v, want draft,edit", sum.Targets[0].Stages)
+ }
+ if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
+ t.Fatalf("dry-run mutated chunk_status: %+v", cs)
+ }
+ r2.Close()
+ if rec.count() != callsAfterRun1 {
+ t.Fatal("dry-run made provider calls")
+ }
+
+ // --- REDRIVE real: flip the provider to success, reset ch1, re-run. ---
+ mu.Lock()
+ refuse = false
+ mu.Unlock()
+ r3 := newRunner(t, bookPath)
+ defer r3.Close()
+ sum3, res3, err := r3.Redrive(ctx, RedriveSelector{Chapter: 1, ChunkIdx: 0, Reason: ""})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !sum3.ResetRun || res3 == nil {
+ t.Fatal("real redrive should reset and re-run")
+ }
+ if res3.Flagged != 0 {
+ t.Fatalf("redrive should have fixed the flag, still %d flagged", res3.Flagged)
+ }
+ // Exactly two FRESH calls (ch1 draft + ch1 edit); ch2 resumes at $0.
+ if got := rec.count() - callsAfterRun1; got != 2 {
+ t.Fatalf("redrive should make exactly 2 fresh calls (ch1 draft+edit), made %d", got)
+ }
+ if cs, _ := r3.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "ok" {
+ t.Fatalf("redrive did not fix ch1 draft: %+v", cs)
+ }
+ // MONEY (documented): the discarded refusal's spend STAYS committed; redrive bills the two
+ // new calls on top → committed reflects total lifetime spend (5 calls), ceilings stay honest.
+ committed, _, _ := r3.Store.SpentUSD("test-book")
+ if math.Abs(committed-5*fakeCallUSD) > 1e-9 {
+ t.Errorf("committed after redrive = %v, want %v (3 original + 2 redrive)", committed, 5*fakeCallUSD)
+ }
+ // A clean status now: everything done.
+ rep2, err := r3.Status(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep2.Done != 2 || rep2.Flagged != 0 {
+ t.Errorf("post-redrive status: done=%d flagged=%d, want 2/0", rep2.Done, rep2.Flagged)
+ }
+}
+
+// TestStatusSurfacesGlossaryMissGate covers self-review finding #2: the post-check GATE flags a
+// chunk at the CHUNK level (never a chunk_status row), so status must promote a gate-on chunk
+// with a CONFIRMED post-check miss to flagged/glossary_miss — otherwise it reports done/pass
+// while `tmctl translate` exits 2, contradicting the run.
+func TestStatusSurfacesGlossaryMissGate(t *testing.T) {
+ rec := &reqRec{}
+ srv := newJSONProvider(rec, func(body string) (string, string) {
+ if isEditBody(body) {
+ return "ОТРЕДАКТИРОВАННЫЙ.", "stop"
+ }
+ return "Некто пошёл в библиотеку.", "stop" // drops the approved name → CONFIRMED post-check miss
+ })
+ defer srv.Close()
+ bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, postcheckGate: true})
+ ctx := context.Background()
+ r := newRunner(t, bookPath)
+ defer r.Close()
+ res, err := r.TranslateBook(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Flagged != 1 {
+ t.Fatalf("translate must flag the glossary_miss chunk: flagged=%d", res.Flagged)
+ }
+ rep, err := r.Status(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ 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)
+ }
+ if len(rep.Chapters) != 1 || rep.Chapters[0].WorstFlagReason != string(FlagGlossaryMiss) {
+ t.Fatalf("passport must show glossary_miss: %+v", rep.Chapters)
+ }
+ if rep.Chapters[0].Verdict == "pass" {
+ t.Error("a chapter with a glossary_miss must not be 'pass'")
+ }
+}
+
+// TestStatusDetectsConfigDrift covers self-review finding #3: after a wire-affecting config edit
+// (a prompt_version bump) since the last run, status must flag ConfigDrift — the current config
+// renders a different snapshot than the stored rows, which translate would --resnapshot (re-bill).
+// Without it status shows a misleading "done/consistent".
+func TestStatusDetectsConfigDrift(t *testing.T) {
+ rec := &reqRec{}
+ srv := newJSONProvider(rec, draftEdit)
+ defer srv.Close()
+ bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
+ ctx := context.Background()
+
+ r1 := newRunner(t, bookPath)
+ if _, err := r1.TranslateBook(ctx); err != nil {
+ t.Fatal(err)
+ }
+ r1.Close()
+
+ // No config change → no drift.
+ r2 := newRunner(t, bookPath)
+ rep, err := r2.Status(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rep.ConfigDrift {
+ t.Fatalf("an unchanged config must not drift (current=%s)", rep.CurrentSnapshot)
+ }
+ r2.Close()
+
+ // Bump a wire-affecting field (draft prompt_version) → the current config renders a new
+ // snapshot → drift.
+ 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-test2", 1)
+ if changed == string(body) {
+ t.Fatal("setup: prompt_version token not found to change")
+ }
+ writeFile(t, filepath.Join(dir, "pipeline.yaml"), changed)
+
+ r3 := newRunner(t, bookPath)
+ defer r3.Close()
+ rep3, err := r3.Status(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !rep3.ConfigDrift || rep3.CurrentSnapshot == "" {
+ t.Errorf("a prompt_version bump since the run must surface as ConfigDrift: %+v", rep3)
+ }
+}
+
+// TestRedriveNoTargets confirms a selector that matches nothing is a clean no-op (no re-run).
+func TestRedriveNoTargets(t *testing.T) {
+ rec := &reqRec{}
+ srv := newJSONProvider(rec, draftEdit)
+ defer srv.Close()
+ bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАОДИН\fГЛАВАДВА"})
+ ctx := context.Background()
+
+ r := newRunner(t, bookPath)
+ defer r.Close()
+ if _, err := r.TranslateBook(ctx); err != nil {
+ t.Fatal(err)
+ }
+ calls := rec.count()
+ // Nothing is flagged → redrive finds no targets and never re-runs.
+ sum, res, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(sum.Targets) != 0 || sum.ResetRun || res != nil {
+ t.Fatalf("redrive with no flagged chunks must be a no-op: %+v", sum)
+ }
+ if rec.count() != calls {
+ t.Fatal("no-target redrive made provider calls")
+ }
+}
diff --git a/backend/internal/store/chunkstatus.go b/backend/internal/store/chunkstatus.go
index 56b83fc..e5e2b58 100644
--- a/backend/internal/store/chunkstatus.go
+++ b/backend/internal/store/chunkstatus.go
@@ -19,18 +19,20 @@ import (
// ChunkStatus is one (book, chapter, chunk, stage) disposition row.
type ChunkStatus struct {
- BookID string
- Chapter int
- ChunkIdx int
- Stage string
- SnapshotID string
- ContentHash string // signature of the rendered msgs (the source is NOT in the snapshot); guards the resume fast-path against serving a stale translation after a source edit
- Disposition string // ok | flagged | skipped
- FlagReason string // "" when ok
- Attempts int // number of attempts made for this chunk×stage
- FinalHash string // request_hash of the authoritative checkpoint (ok path)
- CostUSD float64 // sum across all attempts (F3-honest)
- Detail string
+ BookID string
+ Chapter int
+ ChunkIdx int
+ Stage string
+ SnapshotID string
+ ContentHash string // signature of the rendered msgs (the source is NOT in the snapshot); guards the resume fast-path against serving a stale translation after a source edit
+ Disposition string // ok | flagged | skipped
+ FlagReason string // "" when ok
+ Attempts int // number of attempts made for this chunk×stage
+ FinalHash string // request_hash of the authoritative checkpoint (ok path)
+ CostUSD float64 // sum across all attempts (F3-honest)
+ Detail string
+ Escalated bool // a single-hop fallback was used for this chunk×stage (D12/D15.3 telemetry)
+ EscalationModel string // the fallback model that answered ("" when not escalated)
}
// UpsertChunkStatus writes (or overwrites) the disposition row. Overwrite is the
@@ -42,20 +44,24 @@ func (s *Store) UpsertChunkStatus(cs ChunkStatus) error {
_, err := s.w.ExecContext(ctx, `
INSERT INTO chunk_status (
book_id, chapter, chunk_idx, stage, snapshot_id, content_hash,
- disposition, flag_reason, attempts, final_hash, cost_usd, detail, updated_at
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
+ disposition, flag_reason, attempts, final_hash, cost_usd, detail,
+ escalated, escalation_model, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (book_id, chapter, chunk_idx, stage) DO UPDATE SET
- snapshot_id = excluded.snapshot_id,
- content_hash = excluded.content_hash,
- disposition = excluded.disposition,
- flag_reason = excluded.flag_reason,
- attempts = excluded.attempts,
- final_hash = excluded.final_hash,
- cost_usd = excluded.cost_usd,
- detail = excluded.detail,
- updated_at = excluded.updated_at`,
+ snapshot_id = excluded.snapshot_id,
+ content_hash = excluded.content_hash,
+ disposition = excluded.disposition,
+ flag_reason = excluded.flag_reason,
+ attempts = excluded.attempts,
+ final_hash = excluded.final_hash,
+ cost_usd = excluded.cost_usd,
+ detail = excluded.detail,
+ escalated = excluded.escalated,
+ escalation_model = excluded.escalation_model,
+ updated_at = excluded.updated_at`,
cs.BookID, cs.Chapter, cs.ChunkIdx, cs.Stage, cs.SnapshotID, cs.ContentHash,
- cs.Disposition, cs.FlagReason, cs.Attempts, cs.FinalHash, cs.CostUSD, cs.Detail)
+ cs.Disposition, cs.FlagReason, cs.Attempts, cs.FinalHash, cs.CostUSD, cs.Detail,
+ boolToInt(cs.Escalated), cs.EscalationModel)
return err
}
@@ -64,12 +70,16 @@ func (s *Store) GetChunkStatus(bookID string, chapter, chunkIdx int, stage strin
ctx, cancel := opContext()
defer cancel()
cs := ChunkStatus{BookID: bookID, Chapter: chapter, ChunkIdx: chunkIdx, Stage: stage}
+ var escalated int
err := s.r.QueryRowContext(ctx, `
- SELECT snapshot_id, content_hash, disposition, flag_reason, attempts, final_hash, cost_usd, detail
+ SELECT snapshot_id, content_hash, disposition, flag_reason, attempts, final_hash, cost_usd, detail,
+ escalated, escalation_model
FROM chunk_status
WHERE book_id = ? AND chapter = ? AND chunk_idx = ? AND stage = ?`,
bookID, chapter, chunkIdx, stage).Scan(
- &cs.SnapshotID, &cs.ContentHash, &cs.Disposition, &cs.FlagReason, &cs.Attempts, &cs.FinalHash, &cs.CostUSD, &cs.Detail)
+ &cs.SnapshotID, &cs.ContentHash, &cs.Disposition, &cs.FlagReason, &cs.Attempts, &cs.FinalHash, &cs.CostUSD, &cs.Detail,
+ &escalated, &cs.EscalationModel)
+ cs.Escalated = escalated != 0
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
@@ -86,7 +96,7 @@ func (s *Store) ChunkStatusesForBook(bookID string) ([]ChunkStatus, error) {
defer cancel()
rows, err := s.r.QueryContext(ctx, `
SELECT chapter, chunk_idx, stage, snapshot_id, content_hash, disposition, flag_reason,
- attempts, final_hash, cost_usd, detail
+ attempts, final_hash, cost_usd, detail, escalated, escalation_model
FROM chunk_status WHERE book_id = ?
ORDER BY chapter, chunk_idx, stage`, bookID)
if err != nil {
@@ -96,11 +106,56 @@ func (s *Store) ChunkStatusesForBook(bookID string) ([]ChunkStatus, error) {
var out []ChunkStatus
for rows.Next() {
cs := ChunkStatus{BookID: bookID}
+ var escalated int
if err := rows.Scan(&cs.Chapter, &cs.ChunkIdx, &cs.Stage, &cs.SnapshotID, &cs.ContentHash,
- &cs.Disposition, &cs.FlagReason, &cs.Attempts, &cs.FinalHash, &cs.CostUSD, &cs.Detail); err != nil {
+ &cs.Disposition, &cs.FlagReason, &cs.Attempts, &cs.FinalHash, &cs.CostUSD, &cs.Detail,
+ &escalated, &cs.EscalationModel); err != nil {
return nil, err
}
+ cs.Escalated = escalated != 0
out = append(out, cs)
}
return out, rows.Err()
}
+
+// ResetChunkStages deletes the chunk_status rows AND the checkpoints for the given stages of
+// ONE chunk, in a single transaction — the durable half of `tmctl redrive` (D15.3). After
+// this, the next translate re-derives those stages: their chunk_status is gone (the runner
+// re-runs instead of resuming the flag) and their checkpoints are gone (a FRESH provider call
+// is made, not a deterministic replay of the flagged completion). Only the passed stages are
+// touched — an upstream OK stage keeps its checkpoint and resumes at $0 (D12: never re-pay
+// DispOK work). MONEY (documented, honest): the money already spent on the discarded attempts
+// STAYS committed in `spend` (it was really billed at the provider), so the spend ceilings
+// remain honest; only the resume cache and the escalation-budget accounting are reset, which
+// is the "fresh retry/escalation budget" redrive grants an explicit operator command. Thus
+// after a redrive committed(spend) >= SUM(checkpoints) — the safe direction (a ceiling never
+// under-counts). Checkpoints are joined to jobs by (book, chapter, stage); request_log
+// (append-only telemetry) is deliberately NOT touched, so the paid history is still auditable.
+func (s *Store) ResetChunkStages(bookID string, chapter, chunkIdx int, stages []string) error {
+ if len(stages) == 0 {
+ return nil
+ }
+ ctx, cancel := opContext()
+ defer cancel()
+ tx, err := s.w.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ for _, stage := range stages {
+ if _, err := tx.ExecContext(ctx, `
+ DELETE FROM checkpoints
+ WHERE chunk_idx = ? AND job_id IN (
+ SELECT id FROM jobs WHERE book_id = ? AND chapter = ? AND stage = ?)`,
+ chunkIdx, bookID, chapter, stage); err != nil {
+ return err
+ }
+ if _, err := tx.ExecContext(ctx, `
+ DELETE FROM chunk_status
+ WHERE book_id = ? AND chapter = ? AND chunk_idx = ? AND stage = ?`,
+ bookID, chapter, chunkIdx, stage); err != nil {
+ return err
+ }
+ }
+ return tx.Commit()
+}
diff --git a/backend/internal/store/migrate.go b/backend/internal/store/migrate.go
index ae1d215..6e5d64e 100644
--- a/backend/internal/store/migrate.go
+++ b/backend/internal/store/migrate.go
@@ -253,6 +253,16 @@ var migrations = []string{
PRIMARY KEY (book_id, chapter, chunk_idx)
);
`,
+ // v6: surface single-hop escalation on the chunk_status RESOLVE (D15.3 status/redrive).
+ // The fallback fact lived only on the checkpoint (escalation flag, v3); folding it into
+ // chunk_status lets `tmctl status` project escalation-on-flagged-resume WITHOUT re-reading
+ // checkpoints (the deferred Milestone-2.5 finding #6 — the escalation column the status
+ // read-model needs). Derivable from checkpoints, so a lost/rebuilt row self-heals on the
+ // next resolve. escalation_model is the fallback that answered (empty when not escalated).
+ `
+ ALTER TABLE chunk_status ADD COLUMN escalated INTEGER NOT NULL DEFAULT 0;
+ ALTER TABLE chunk_status ADD COLUMN escalation_model TEXT NOT NULL DEFAULT '';
+ `,
}
// migrate runs all pending migrations on the write pool, one transaction per
diff --git a/backend/internal/store/requestlog.go b/backend/internal/store/requestlog.go
index 3a12dc9..b37c978 100644
--- a/backend/internal/store/requestlog.go
+++ b/backend/internal/store/requestlog.go
@@ -69,6 +69,19 @@ func (s *Store) LogRequest(ctx context.Context, log *slog.Logger, rl RequestLog)
}
}
+// FreshCallLatencyMS sums the wall-clock latency of the book's FRESH provider calls (tm_hit=0
+// — a checkpoint replay has no answer latency to count) and their count. It is the throughput
+// input for the `tmctl status` ETA (D15.3): mean fresh-call latency × remaining work, a
+// throughput estimate, never a synthetic time-bar. Pure read, $0.
+func (s *Store) FreshCallLatencyMS(bookID string) (totalMS int64, calls int, err error) {
+ ctx, cancel := opContext()
+ defer cancel()
+ err = s.r.QueryRowContext(ctx,
+ `SELECT COALESCE(SUM(latency_ms),0), COUNT(*) FROM request_log WHERE book_id = ? AND tm_hit = 0`,
+ bookID).Scan(&totalMS, &calls)
+ return
+}
+
// RequestLogView is one request_log row for inspection (tmctl report).
type RequestLogView struct {
TS string