Land backend package 3: D15.2 spec v3, D20.4 fix-list mutation-verified, D3 chain and channel B config with live-checked slugs, Gemini additive_total reasoning billing

This commit is contained in:
Claude (backend session) 2026-07-10 04:02:10 +03:00
parent 4908f956ba
commit e1fcee4e25
20 changed files with 857 additions and 140 deletions

View file

@ -9,7 +9,7 @@
| `cmd/tmctl` | CLI: `translate` / `report` / `status` (read-only проекция N/M+паспорта глав+деньги, `--json`) / `redrive` (переатака флагнутых: `--chapter/--chunk/--reason/--dry-run`, D15.3) | `main.go`, `internal/pipeline/status.go` |
| `internal/llm` | OpenAI-совместимый транспорт + retry/backoff, **capability-слой** (budget_field/temperature/reasoning per-модель), failover (написан, НЕ подключён — D4: только local-роли), нативный Anthropic = DEPRECATED-референс под будущий Gemini | `httpllm.go`, `capability.go`, `failover.go` |
| `internal/ledger` | Цены по usage (вкл. reasoning/cache-поля), `PriceForResponse` по фактической модели | `pricing.go` |
| `internal/store` | SQLite (modernc, CGO-free), идемпотентные миграции v1v6, reserve/settle+checkpoint, chunk_status, глоссарий, ruby, retrieval_state, request_log | `ledger.go`, `migrate.go`, `glossary.go` |
| `internal/store` | SQLite (modernc, CGO-free), идемпотентные миграции v1v7, reserve/settle+checkpoint, chunk_status, глоссарий, ruby, retrieval_state (вкл. стиль-флаги v7), request_log | `ledger.go`, `migrate.go`, `glossary.go` |
| `internal/config` | fail-fast загрузка models/pipeline/book; **эхо-мина-гейт** `echoMineViolation`; `CheckRunnable` блокирует неисполнимое (C2/fanout/judge) | `models.go`, `pipeline.go` |
| `internal/pipeline` | Раннер (циклы, disposition, single-hop эскалация с ре-гейтом, resume), чанкер v4, ingest txt/epub+ruby, classify/coverage, **банк памяти v2** (Aho-Corasick, спойлер-окна, disposition, post-check), рендер+инъекция | `runner.go`, `memory.go`, `disposition.go`, `coverage.go` |
| `internal/obs` | trace_id, структурные логи, safego | |
@ -44,4 +44,4 @@ set -a; . ./.env; set +a; TM_LIVE=1 go test -tags live -run TestLive -v ./intern
## Известный техдолг (не трогать молча — см. D-лог)
F3 at-most-once (после D15.2); Escal.Chains — валидируемый мёртвый конфиг (раннер читает только `escalate_to`, полные цепочки = шаг 7); модели D3-эскалации не заведены в `models.yaml` (интерим-заглушки помечены — пакет №3); min-budget Kimi≥16k/Gemini≥8k — комментарии, не схема; fix-лист №3 (D20.4) + v3-правки спеки D15.2 (D20.1) — промт пакета №3.
F3 at-most-once (после D15.2); Escal.Chains — валидируемый мёртвый конфиг (раннер читает только `escalate_to`, полные цепочки = шаг 7); D3-цепочка/канал B ЗАВЕДЕНЫ в `models.yaml` (пакет №3, слаги live-фактчекнуты 2026-07-10 — gemini ИМЕННО `-preview`); `escalation.budget_usd>0` требуется, чтобы single-hop `escalate_to` черновика стрелял (приёмка); min-budget Kimi≥16k/Gemini≥8k — комментарии, не схема; реализация content-addressed resume (D15.2 v3) остаётся заблокированной до ратификации оркестратором.

View file

@ -82,7 +82,7 @@ func run() error {
case "status":
return status(ctx, *cfgPath, *asJSON)
case "redrive":
return redrive(ctx, *cfgPath, pipeline.RedriveSelector{
return redrive(ctx, *cfgPath, *resnapshot, pipeline.RedriveSelector{
Chapter: *chapter, ChunkIdx: *chunk, Reason: *reason, DryRun: *dryRun,
})
default:
@ -151,7 +151,8 @@ func flagSuffix(r pipeline.FlagReason) string {
}
func report(cfgPath string) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
// Read-only ($0): no API keys required (D20.4 — a store audit must not demand provider keys).
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
@ -254,7 +255,8 @@ func report(cfgPath string) error {
// (completed-with-flags) when flagged>0 (minor 1d — --json used to always exit 0), so a consumer
// never reads a clean 0 over a book that still needs attention.
func status(ctx context.Context, cfgPath string, asJSON bool) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
// Read-only ($0): no API keys required (D20.4 — a progress projection must not demand keys).
r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
@ -310,10 +312,12 @@ func status(ctx context.Context, cfgPath string, asJSON bool) error {
}
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")
// style = cheap deterministic style/number flags (observability, not a disposition) — a per-
// chapter glance count so a human sees where the linters fired without opening `report` (D20.4).
fmt.Printf("\n%-5s %-9s %-10s %-6s %-4s %-6s %-16s %10s\n", "ch", "done/all", "verdict", "flag", "esc", "style", "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,
fmt.Printf("%-5d %d/%-7d %-10s %-6d %-4d %-6d %-16s %10.6f\n",
p.Chapter, p.ChunksDone, p.ChunksTotal, p.Verdict, p.ChunksFlagged, p.Escalations, p.StyleFlags,
dashIfEmpty(p.WorstFlagReason), p.CostUSD)
}
}
@ -348,13 +352,17 @@ func dashIfEmpty(s string) string {
// 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 {
// committed (honest). By default it requires the current config to render the SAME snapshot the
// flagged rows carry (else it fails loud); passing --resnapshot wires r.Resnapshot so Redrive skips
// that drift guard and accepts the re-pin/re-pay explicitly (D20.4: the flag used to be parsed but
// silently ignored — Redrive already honoured r.Resnapshot, only the CLI never set it).
func redrive(ctx context.Context, cfgPath string, resnapshot bool, sel pipeline.RedriveSelector) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
r.Resnapshot = resnapshot
summary, res, err := r.Redrive(ctx, sel)
if err != nil {

View file

@ -1,13 +1,22 @@
# Модели и цены TextMachine (Р4/Р5: состав и цены — ТОЛЬКО здесь, с датой
# проверки; код не знает ни одной цены).
#
# Источники проверки 2026-07-04 (воркфлоу-фактчек, официальные страницы):
# DeepSeek https://api-docs.deepseek.com/quick_start/pricing
# Z.AI/GLM https://docs.z.ai/guides/overview/pricing
# Источники проверки 2026-07-10 (D3-цепочка/канал B заведены пакетом №3; каждый
# слаг live-фактчекнут — /models И пробный вызов, правило двух направлений):
# DeepSeek https://api-docs.deepseek.com/quick_start/pricing (v4-pro $0.435/$0.87, cache-hit $0.003625)
# Z.AI/GLM https://docs.z.ai/guides/overview/pricing (glm-5.1 $1.4/$4.4, cached $0.26)
# Kimi https://platform.kimi.ai/docs/pricing/chat-k26
# xAI https://docs.x.ai/docs/models (Grok 4.1 Fast retired 15.05.2026!)
# Gemini https://ai.google.dev/gemini-api/docs/pricing
prices_checked: "2026-07-04"
# Gemini https://ai.google.dev/gemini-api/docs/pricing (3.1-pro-preview $2/$12 ≤200k, cache $0.20, output incl. thinking)
# Mistral https://mistral.ai/pricing/api (Large 3 = mistral-large-2512 $0.5/$1.5; FAQ "$2/$6" = legacy Large 2)
# OpenAI https://developers.openai.com/api/docs/models/gpt-5-mini (gpt-5-mini $0.25/$2.0, cached $0.025)
# Live-факты пакета №3 (2026-07-10, все HTTP 200 пробным вызовом, кроме отмеченного):
# • gemini-3.1-pro → 404 NOT_FOUND; callable слаг ТОЛЬКО gemini-3.1-pro-preview (D3-текст назвать -preview — пинг).
# • Gemini thinking-токены НЕ в completion_tokens (проба: completion=2, total=847 → 823 thinking в total_tokens,
# поля reasoning_tokens нет) → провайдер gemini reasoning:additive_total (адаптер деривит reasoning=totalpromptcompletion,
# иначе mistral-класс слепоты потолка на mandatory-thinking апексе).
# • gpt-5-mini жив пробой (→ dated gpt-5-mini-2025-08-07), но снят с прайс-страницы (там gpt-5.4/5.5/5.6); кандидат D3, не в цепочке Ф1.
prices_checked: "2026-07-10"
# Fallback-якорь цены: неизвестная модель (например, фактическая модель после
# фейловера, которой нет в таблице) книжится по этой цене, никогда по $0.
@ -54,6 +63,7 @@ providers:
base_url: https://api.x.ai/v1
api_key_env: XAI_API_KEY
reasoning: additive # xAI биллит reasoning ПОВЕРХ completion (проверено vojo); think-ON заблокирован до резерва в EstimateUSD (D6.2/F3)
permissive: true # главный пермиссив-тир канала B + reasoning-ON эскалация (D3/D19.1): channel:adult-стадия/escalate_to изолируются ТИПОМ на permissive-провайдера (D4.1)
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
# Anthropic убран из стека по решению владельца (04.07): дорого, в RU-контуре
@ -62,11 +72,43 @@ providers:
# DEPRECATED, но НЕ удалён — оставлен как референс нативного адаптера под
# Gemini Фазы 2 (см. provider_anthropic.go). Эскалация — не-Anthropic (Kimi/GLM).
gemini: # Google Gemini через OpenAI-совместимый слой (base .../v1beta/openai). Апекс канала A +
# судья Фазы 2. ⚠️ Нативный Gemini API нужен для safety-off судьи 18+ (safety_settings не
# проходят через OpenAI-слой — exp00); OpenAI-слой достаточен для перевода/эскалации (фильтры OFF
# по дефолту у 3.x, кроме неконфигурируемого PROHIBITED_CONTENT — D21.9).
kind: openai
base_url: https://generativelanguage.googleapis.com/v1beta/openai
api_key_env: GEMINI_API_KEY
# reasoning:additive_total — thinking у Gemini биллится как output, но в OpenAI-слое НЕ попадает в
# completion_tokens и НЕ отдаётся полем reasoning_tokens (в отличие от xAI); виден ТОЛЬКО как
# total_tokenspromptcompletion (live-проба 2026-07-10: completion=2, total=847 → 823 thinking).
# Адаптер деривит их из total и биллит по output — иначе mandatory-thinking апекс слепил бы потолок.
reasoning: additive_total
timeouts: { attempt_s: 300, max_attempts: 3, backoff_cap_s: 60 } # апекс думает дольше
openai: # OpenAI прямой ключ (D3: Anthropic убран, OpenAI ОСТАЁТСЯ). gpt-5-nano альт-черновик,
# gpt-5-mini кандидат-редактор/second-opinion судья — НЕ в дефолтной цепочке Ф1 (слот под Ф2).
kind: openai
base_url: https://api.openai.com/v1
api_key_env: OPENAI_API_KEY
reasoning: subset # gpt-5: reasoning_tokens ⊆ completion (OpenAI-спека), покрыто maxTokens
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
mistral: # Mistral La Plateforme (base api.mistral.ai/v1). Переводчик-дефолт канала B (D19.1):
# 10/10 чисто на violence, policy 11.06.2026 без запрета adult, НЕ reasoning-модель → эхо-мины нет.
kind: openai
base_url: https://api.mistral.ai/v1
api_key_env: MISTRAL_API_KEY
reasoning: subset # mistral-large — не thinking-модель; reasoning-токенов нет
permissive: true # переводчик канала B (D19.1); channel:adult-стадия изолируется ТИПОМ (D4.1)
timeouts: { attempt_s: 240, max_attempts: 3, backoff_cap_s: 60 }
local: # ollama/llama-server на стенде (боевой env — memory textmachine-local-stand)
kind: local
base_url: http://127.0.0.1:11434/v1
model: huihui_ai/qwen3-abliterated:8b
max_tokens: 8192 # у ollama лимит покрывает thinking+ответ вместе
permissive: true # abliterated-скрининг/дешёвый фолбэк канала B (D3/D19.1)
timeouts: { attempt_s: 600, max_attempts: 2, backoff_cap_s: 10 } # полигон: чанки 63278 с
models:
@ -77,6 +119,15 @@ models:
Черновик (Р4). Автокэш по префиксу, отдельной цены записи нет.
deepseek-chat/reasoner отключаются 24.07.2026 — сюда не возвращаться.
deepseek-v4-pro:
provider: deepseek
# $0.435/$0.87, cache-hit $0.003625 (факт. 2026-07-10, api-docs.deepseek.com/quick_start/pricing).
price: { input_per_m: 0.435, cached_per_m: 0.003625, cache_write_per_m: 0, output_per_m: 0.87 }
note: >-
Эскалация черновика канала A, хоп 1 (D3): deepseek-эхо 25% на приёмочной 蛊真人 (D18) → single-hop
фолбэк с draft. Наследует deepseek: thinking ON (эхо-мина того же класса — echoes_when_thinking_off),
reasoning ⊆ completion (subset). Живьём 2026-07-10: /models ✓, chat 200 (reasoning_content, перевёл).
glm-5:
provider: zai
price: { input_per_m: 1.0, cached_per_m: 0.2, cache_write_per_m: 0, output_per_m: 3.2 }
@ -90,6 +141,18 @@ models:
off_extra_body: { thinking: { type: disabled } } # эмпирика полигона: thinking GLM — таймауты ×3
note: Редактор ru-стиля (Р4). Флагманы линейки уже GLM-5.1/5.2 ($1.4/$4.4) — пересмотреть к Фазе 2.
glm-5.1:
provider: zai
# $1.4/$4.4, cached $0.26 (факт. 2026-07-10, docs.z.ai/guides/overview/pricing).
price: { input_per_m: 1.4, cached_per_m: 0.26, cache_write_per_m: 0, output_per_m: 4.4 }
capabilities:
reasoning:
control: extra_body_disable
off_extra_body: { thinking: { type: disabled } } # как glm-5: thinking GLM — таймауты ×3 (полигон)
note: >-
Эскалация канала A, хоп 2 (D3), кросс-семейный к deepseek-хопу 1. Живьём 2026-07-10: /models ✓
(glm-4.5..5.2 листятся), chat 200 (thinking:disabled → reasoning_tokens=0). Флагман линейки vs glm-5.
kimi-k2.6:
provider: kimi
price: { input_per_m: 0.95, cached_per_m: 0.16, cache_write_per_m: 0, output_per_m: 4.0 }
@ -118,8 +181,47 @@ models:
reasoning additive: think-ON только после резерва в EstimateUSD (D6.2/техдолг F3). Включена
в edit-стадию C1/C2 (D1/D3, консолидация 05.07); прод-прогон — только с ЧИСТЫМ xAI-ключом без data-sharing.
gemini-3.1-pro-preview: # ⚠️ слаг ИМЕННО -preview: gemini-3.1-pro → HTTP 404 NOT_FOUND (live 2026-07-10)
provider: gemini
# $2/$12 за 1M ≤200k (факт. 2026-07-10, ai.google.dev/gemini-api/docs/pricing; >200k тир $4/$18 —
# не моделируем, чанки книги ≤200k). Output ВКЛЮЧАЕТ thinking-токены (reasoning-as-output, D3.2/D6.3).
price: { input_per_m: 2.0, cached_per_m: 0.20, cache_write_per_m: 0, output_per_m: 12.0 }
capabilities:
# mandatory: thinking обязателен (thinking_budget:0 → HTTP 400, D6.3); адаптер молча глотает
# reasoning:"off", forced thinking биллится как output. Учёт токенов — provider gemini reasoning:additive_total.
reasoning: { control: mandatory }
note: >-
Апекс канала A / судья Фазы 2 (D3). Дать max_tokens ≥8000 (thinking ⊆ бюджета → резерв не слепнет; settle
биллит thinking через additive_total). Судья 18+ требует НАТИВНОГО Gemini (safety-off) — Ф2. Живьём 2026-07-10: chat 200.
mistral-large-2512:
provider: mistral
# $0.5/$1.5 за 1M (факт. 2026-07-10, mistral.ai/pricing/api — Mistral Large 3, релиз дек.2025, Apache 2.0,
# 262K). ⚠️ Маркетинг-FAQ mistral.ai/pricing даёт generic "$2/$6" = legacy Large 2; per-model /pricing/api
# = $0.5/$1.5 (OpenRouter согласен) → разрешает D19-подозрение ($0.5/$1.5 при $2/$6 = 4×). cached=input
# консервативно (cache-read не моделируем; канал B — свежий вход/чанк, hit≈0, потолок не слепнет).
price: { input_per_m: 0.5, cached_per_m: 0.5, cache_write_per_m: 0, output_per_m: 1.5 }
note: >-
Переводчик-дефолт канала B (D19.1): 10/10 чисто на violence, policy 11.06.2026 без запрета adult, не
reasoning-модель (эхо-мины нет). Полный wiring канала B — Ф2 (конфиг-слот). Полигон перепроверяет цену (D19.5б).
Живьём 2026-07-10: /models ✓, chat 200.
gpt-5-mini: # кандидат-редактор/second-opinion судья (D3) — НЕ в дефолтной цепочке Ф1
provider: openai
# $0.25/$2.0, cached $0.025 (факт. 2026-07-10, developers.openai.com/api/docs/models/gpt-5-mini). Слаг жив
# (chat 200 → dated gpt-5-mini-2025-08-07), но снят с текущей прайс-страницы (там gpt-5.4/5.5/5.6) — пересмотр к Ф2.
price: { input_per_m: 0.25, cached_per_m: 0.025, cache_write_per_m: 0, output_per_m: 2.0 }
capabilities:
budget_field: max_completion_tokens # gpt-5: max_tokens → HTTP 400
temperature: { mode: omit } # gpt-5: temperature не принимается → HTTP 400
reasoning: { control: effort, off_effort: minimal } # reasoning ON выжигает бюджет → пустой ответ; шлём minimal
note: >-
Кандидат-редактор Р4 / second-opinion судья (D3). Не в дефолтной цепочке Ф1 (слот под Ф2).
Живьём 2026-07-10 — chat 200.
# claude-sonnet-5 / claude-opus-4-8 удалены вместе с провайдером anthropic
# (см. комментарий у providers выше). Судья Фазы 2 — Gemini (нативный адаптер).
# (см. комментарий у providers выше). Судья Фазы 2 — Gemini: нативный адаптер для
# safety-off судьи 18+; для перевода/эскалации достаточно OpenAI-слоя (провайдер gemini выше).
local-qwen3-8b:
provider: local

View file

@ -39,6 +39,10 @@ stages:
prompt_version: v0-draft
temperature: 0.3
reasoning: "off"
# single-hop echo-эскалация (D18: deepseek-эхо 25% на 蛊真人 → фолбэк выстрелит). deepseek-v4-pro —
# хоп 1 цепочки A (D3); стреляет ТОЛЬКО при escalation.budget_usd>0 (см. ниже). Escalate_to разрешён
# лишь на translator-роли (editor pinned — D12); результат ре-гейтится.
escalate_to: deepseek-v4-pro
- name: edit
role: editor
# Дефолт-редактор — grok-4.3 (D1/D3, bake-off эксп-04; glm-5 был плейсхолдером).
@ -67,16 +71,18 @@ gates:
escalation:
chains:
# ⚠️ ИНТЕРИМ-ЗАГЛУШКА, НЕ контракт. Ратифицированная D3-цепочка:
# канал A: deepseek-v4-pro → glm-5.1 → gemini-3.1-pro; канал B: grok-4.3.
# Эти модели и провайдеры gemini/openai ещё НЕ заведены в models.yaml (цены/ключи +
# пересчёт cost-model полигоном гейтят заводку) → пока провизорный kimi-k2.6.
# Заводка D3-цепочки — отдельный шаг. В Фазе 0 / при gates off эскалация не
# исполняется (CheckKeys не требует её ключей, пока budget_usd=0).
default: [kimi-k2.6]
adult: [kimi-k2.6]
# Адресный премиум-бюджет книги, $. Формула «≤2×» из Р5 неоднозначна после
# токен-калибровки — [НУЖНО РЕШЕНИЕ] п.1; до решения бюджет задаётся явно.
# D3-цепочка ЗАВЕДЕНА (пакет №3; все слаги live-фактчекнуты 2026-07-10 — /models + пробный вызов):
# канал A: deepseek-v4-pro → glm-5.1 → gemini-3.1-pro-preview (кросс-семейно; апекс — mandatory-thinking Gemini).
# канал B (18+): grok-4.3 reasoning-ON (D19.1; переводчик канала B = Mistral, задаётся channel:adult-стадией — Ф2).
# ⚠️ Именованные цепочки — валидируемый конфиг для мульти-хопа (шаг 7); раннер Ф1 исполняет
# SINGLE-HOP через stage.escalate_to (draft → deepseek-v4-pro выше). Слаг ИМЕННО gemini-3.1-pro-preview
# (gemini-3.1-pro → HTTP 404, live 2026-07-10). adult-цепочка — permissive-провайдеры (D4.1).
default: [deepseek-v4-pro, glm-5.1, gemini-3.1-pro-preview]
adult: [grok-4.3]
# Адресный премиум-бюджет книги, $. Формула «≤2×» из Р5 неоднозначна — [НУЖНО РЕШЕНИЕ] п.1.
# ⚠️ budget_usd=0 ⇒ эскалация НЕ исполняется (НИ chains, НИ stage.escalate_to — escalationBudgetRemains=false;
# CheckKeys не требует её ключей). Приёмочная сессия 蛊真人 ОБЯЗАНА выставить budget_usd>0, иначе echo-эскалация
# черновика (D18) не выстрелит. Дефолт 0 держит CI/Ф0 зелёными без gemini/mistral-ключей.
budget_usd: 0
fanout:

View file

@ -1,25 +1,36 @@
# D15.2 — Спека v2: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
# D15.2 — Спека v3: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
**Статус:** **v2, сдан на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до
ратификации v2** (D-лог §Ратификации 09.07 п.2: направление ратифицировано, реализация заперта до
доработки трёх дыр). Зона: спека в `backend/docs/` (зона бэкенда); при утверждении оркестратор
сворачивает её в `docs/architecture/03-implementation-notes.md` новым разделом.
**Статус:** **v3, сдана на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до
ратификации v3** (реализацию НЕ начинать без подписи оркестратора — D20.1). Зона: спека в
`backend/docs/` (зона бэкенда); при утверждении оркестратор сворачивает её в
`docs/architecture/03-implementation-notes.md` новым разделом.
**Supersedes:** D15.2-content-addressed-resume-spec.md (v1). Направление v1 (расщепить `snapshotID`
на wire-часть и вердикт-часть; ключ чекпоинта держать на wire-идентичности; вердикт-смену
re-classify-ить на resume бесплатно) — **ратифицировано** и сохранено. v2 **закрывает три
дизайн-дыры внешнего ревью**, которые заблокировали реализацию v1:
**Supersedes:** v2 (направление v1+v2 ратифицировано D-лог §Ратификации 09.07 п.2 и D20.1: расщепить
`snapshotID` на wire/вердикт-части; ключ чекпоинта — на wire-идентичности; вердикт-смену
re-classify-ить на resume бесплатно; три дыры v1 закрыты v2). **v3 закрывает три правки D20.1**
(«направление и 90% деталей ратифицированы; реализация разблокируется после v3-правок»):
- **(a)** fast-path-гард v1 (content_hash + wireSnapshotID + verdict_snapshot) **слабее текущего
гарда**: не покрывает ПРЯМЫЕ wire-поля `RequestHash` (model / temperature / reasoning / maxTokens /
escalate_to). Смена `temperature` 0.3→0.4 или `reasoning` low→none тихо отдала бы stale-текст —
ре-открытие дефекта stale-serve F1. → §5, отдельная колонка `guard_hash`.
- **(b)** расщепление `memory_version` (wire vs verdict) в v1 было «(Опц.)». **Сделано ОБЯЗАТЕЛЬНЫМ**
(иначе главный сценарий онгоинга — append-термина/toggle гейта — не материализуется без переоплаты
книги). → §6.
- **(c)** loud-гейт согласия (`--resnapshot` fail-loud, Р6 «согласие на трату») content-addressed
resume **демонтирует**; v1 просто удалял его. **Заменён pre-flight dry-run** «N чанков, ~$X» с
порогом явного флага. → §7.
- **(a)** guard_hash **обязан нести `role` + имя стадии** — без них формула НЕ суперсет текущей
защиты: конструируемый stale-VERDICT fast-path hit при флипе роли (клейм «строго сильнее» был
ложен). → §5, контрпример + §3.3/§3.4 формула.
- **(b)** dry-run §7 нуждается в **аналитическом правиле editor-каскада**: любая стадия НИЖЕ
re-bill-юнита того же чанка сама считается re-bill (иначе консент-проекция занижает ~2× на самом
дорогом классе правок — temp/reasoning/model translator'а, чей новый вывод $0-dry-run
материализовать не может). → §7 п.1-бис.
- **(c)** полевой маппинг §4 **доукомплектован**: `style_check_version`; слой YoPolicy/StyleAllowlist;
target-часть `memoryNormVersion` (→ и в postcheck-версию); судьба `jobs.snapshot_id`; verdict-фолд
конфига гейтов только при enabled. → §4, §6, §9.
**Ответы D20.2 вписаны в текст v3:** Q1 — пер-стадийная гранулярность `wireSnapshotID`/`guard_hash`
**ПОДТВЕРЖДЕНА** (§3.1); Q2 — порог `min($0.50, 5%×ProjectedBookUSD)` + флаг `--accept-rebill[=usd]`
(опциональный потолок суммы) + fallback-флор при `ProjectedBookUSD=0` **ПРИНЯТЫ**, но фикс каскада
(b) — ПЕРВЫМ (§7); Q3 — `Adult` остаётся wire/вердикт-НЕЙТРАЛЬНЫМ, слой не назначается: вместо хеша
**load-time adult-линт** (реализован бэкенд-пакетом №3: `Pipeline.CheckAdultChannel`; §4, §13).
**v2-контекст (сохранён):** v2 закрыл три дыры внешнего ревью — (a) fast-path-гард слабее текущего →
колонка `guard_hash`; (b) расщепление `memory_version` сделано ОБЯЗАТЕЛЬНЫМ; (c) loud-гейт согласия
заменён pre-flight dry-run. v3 доводит (a) до суперсета (role+stage), (b) до полноты (target-norm в
postcheck), (c) — маппинг §4 доукомплектован и каскад в проекции аналитический.
**Гейтит:** онгоинг-режим (`add-chapters`, сегмент «ИИ-фабрик», Р9 / D15 п.2). Текущий
all-or-nothing resnapshot остаётся **приемлемым для статичной приёмочной книги Ф1 (D15.1 / D18 蛊真人)
@ -100,12 +111,21 @@ re-classify на fast-path (§5, §8).
### 3.3 `guard_hash` — ПЕР-СТАДИЙНЫЙ, хранится в `chunk_status`, гейт fast-path (НЕ в `RequestHash`)
Хеш ВСЕГО wire stage-плана для чанка×стадии — суперсет того, что fast-path обязан проверить перед
доверием строке (закрывает дыру (a), §5):
`guard_hash = H(tm-guard-v1, content_hash, wireSnapshotID, model, temperature, reasoning, jsonOnly,
estimatorVersion, maxOutputRatio, minMaxTokens, maxTokensPolicyVersion)`.
`guard_hash = H(tm-guard-v2, stageName, role, content_hash, wireSnapshotID, model, temperature,
reasoning, jsonOnly, estimatorVersion, maxOutputRatio, minMaxTokens, maxTokensPolicyVersion)`.
Прямые wire-поля (`model`/`temperature`/`reasoning`/`jsonOnly`) и ДЕРИВАЦИЯ `maxTokens`
(версии+ratio+floor; сам текст для деривации уже в `content_hash` через `msgs`) — то, чего `content_hash`
+ `wireSnapshotID` не покрывают. `escalate_to` и эскалационная wire уже внутри `wireSnapshotID`.
**Правка D20.1(a): `stageName` + `role` ОБЯЗАТЕЛЬНЫ, префикс `tm-guard-v1``tm-guard-v2`.** Оба входят
в per-стадийный компонент старого `snapshotID` (stageSnap: `Name`,`Role`,… — `runner.go:387`) И в
`RequestHash` (`f(…, stage, role, model,…)` — §3.4). Без них `guard_hash` НЕ суперсет ни старого
snapshot-гарда, ни `RequestHash`-осей `stage`/`role`, из-за чего фаст-пас может отдать stale-ВЕРДИКТ
при флипе роли на wire-нейтральном чанке (контрпример — §5). `role` — не косметика: он определяет,
применяется ли coverage-гейт (только к translator-выходу, `runner.go:198-200`), т.е. РЕЗОЛВНУТЫЙ
вердикт. `stageName` — ключ строки `chunk_status` (PK по имени) и дизамбигуатор при book-global дифе
проекции §7. С правкой guard_hash строго ⊇ (per-стадийный snapshot-компонент прямые wire-поля).
### 3.4 Итоговый `RequestHash` (ключ чекпоинта), бамп `tm-request-v2``tm-request-v3`
`RequestHash = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temperature, reasoning,
jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wireSnapshotID`; всё остальное
@ -126,7 +146,7 @@ jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wir
| Поле (сегодня) | Класс | Куда в v2 | Почему |
|---|---|---|---|
| `brief_hash` | wire | **dropped**`content_hash` | Метаданные книги рендерятся в шаблон (`render.go:108-121`) → в `msgs`; per-chunk `content_hash` ловит правку брифа гранулярно. Caveat: поле `Adult` (`book.go:151,156`) и НЕрендеримые плейсхолдеры не в `msgs` — они wire-нейтральны by construction (не уходят на провод); если `Adult` когда-либо станет wire/verdict-влияющим — вернуть в `wireSnapshotID` (или `verdictSnapshotID`). |
| `brief_hash` | wire | **dropped**`content_hash` | Метаданные книги рендерятся в шаблон (`render.go:108-121`) → в `msgs`; per-chunk `content_hash` ловит правку брифа гранулярно. **Caveat закрыт v3:** НЕрендеримые брифа-поля не покрываются `content_hash` — их судьбы назначены явно: `YoPolicy`/`StyleAllowlist``verdictSnapshotID` (строки выше, кормят стиль-флаггер); `Adult` (`book.go`) остаётся **wire/вердикт-НЕЙТРАЛЬНЫМ** (D20.2-Q3): слой не назначается, вместо хеша — **load-time adult-линт** `Pipeline.CheckAdultChannel` (реализован пакетом №3: `adult:true` без `channel:adult`-стадии → fail-loud). Первый рантайм-потребитель `Adult` (если появится) обязан ЯВНО вернуть его в `wireSnapshotID`/`verdictSnapshotID` — §13-Q3. |
| `chunker_version` | wire | **dropped**`content_hash` | Правила чанкера дают `ch.Text``msgs`. Ре-чанкинг меняет границы/число чанков → `content_hash` почти всех → честный broad re-bill, гранулярно. |
| `estimator_version` | wire (деривация) | **guard_hash** (не в `RequestHash`) | Деривирует `maxTokens`, а фактический `maxTokens` — уже прямое поле `RequestHash`. В ключе избыточен; fast-path нуждается — кладём в `guard_hash`. Бамп busts fast-path каждого чанка; платят только те, чей `maxTokens` реально сменился (остальные — checkpoint-hit, re-classify $0). |
| `max_tokens_policy` | wire (деривация) | **guard_hash** | Как `estimator_version`, для attempt≥1 (`maxTokensForAttempt`). Избыточен в ключе (фактический `maxTokens` там), нужен fast-path-у. |
@ -157,11 +177,14 @@ jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wir
| stage `name` | wire | **dropped из снапшота** → прямое поле `stage` + `content_hash` | `RequestHash` несёт `stage`; структура плана отражена в `msgs` через `prev`-цепочку. |
| stage `role` | wire | **dropped из снапшота** → прямое поле `role` + `content_hash` | `role` — прямое поле; он же выбирает инъекцию (translator/editor) → `msgs`. |
| `memory_version` (матчер+инъекция часть) = `memoryInjectVersion` | wire | **dropped**`content_hash` | `memoryNormVersion` + матчер-часть `memoryMatchVersion` определяют, КАКИЕ записи инъектятся → инъекц-сообщение → `msgs`. §6. |
| `memory_version` (post-check часть) = `memoryPostcheckVersion` | verdict | **verdictSnapshotID** | Алгоритм/пороги post-check — вердикт, пересчитывается вживую (`translateChunk`). §6. |
| `memory_version` (post-check часть) = `memoryPostcheckVersion` | verdict | **verdictSnapshotID** | Алгоритм/пороги post-check — вердикт, пересчитывается вживую (`translateChunk`). §6. **Правка D20.1(c): `memoryNormVersion` двусторонний — SOURCE-часть нормализует ключи матчера (wire, → `memoryInjectVersion` → `content_hash`), а TARGET-часть нормализует dst-формы, по которым post-check ищет перевод в выводе (вердикт). Значит `memoryNormVersion` (target-сторона) входит ТАКЖЕ в `memoryPostcheckVersion` → `verdictSnapshotID`** — иначе правка target-нормализации сменила бы post-check вердикт, но не отразилась бы в `verdict_snapshot` → stale-вердикт на resume. §6. |
| `postcheck_gate` (top-level + `gate:` в memory) | verdict | **verdictSnapshotID** | Toggle меняет резолвнутую диспозицию; пересчёт бесплатен. Сегодня двойной фолд (top-level `PostcheckGate` + `gate:bool` в `computeMemoryVersion`) → в v2 один раз. §6. |
| decl-контент инъектнутых записей | verdict (при gate ON) | **verdictSnapshotID** | Decl НЕ инъектится в `msgs`, но при gate ON влияет на post-check вердикт (`memory.go:225-229`). §6. |
| `classifier_version` | verdict | **verdictSnapshotID** | Пороги/порядок `classify`; пересчёт `classifyOutput` бесплатен на checkpoint-hit. |
| `coverage` (enabled+len_ratio+sent_cov_min+min_chunk_chars+`coverageGateVersion`) | verdict | **verdictSnapshotID** | Excision-гейт не трогает провод; `coverageCheck` пересчитывается вживую над текстом чекпоинта. |
| `coverage` (enabled+len_ratio+sent_cov_min+min_chunk_chars+`coverageGateVersion`) | verdict | **verdictSnapshotID** (ТОЛЬКО при `enabled`) | Excision-гейт не трогает провод; `coverageCheck` пересчитывается вживую над текстом чекпоинта. **Правка D20.1(c): фолдить в `verdictSnapshotID` ТОЛЬКО когда гейт enabled** — зеркаля дисциплину `coverageSnapshot()` (`runner.go:181-190`: `{enabled:false}` когда off, полный конфиг+версия когда on). Иначе твик порогов ВЫКЛЮЧЕННОГО гейта форсировал бы re-verdict/bust fast-path на всей книге, хотя off-гейт вердиктов не выносит. |
| `style_check_version` (`cheapGateVersion`, cheapgates.go) | verdict | **verdictSnapshotID** | Правка D20.1(c). Версионирует 4 дешёвых стиль-флаггера (тире-диалоги/ёфикатор/транслит-междометия/万-億). Они — наблюдаемость (пост-check `runCheapGates`, пересчёт вживую, в `chunk_status` НЕ хранятся, как classifier/coverage), но правка правила сдвигает записанные счётчики стиль-флагов → это ВЕРДИКТ-версия. Из wire-ключа УБИРАЕТСЯ (провод не трогает), кладётся в `verdictSnapshotID`; на resume re-classify стиля бесплатен. Сегодня фолдится как `StyleCheckVersion` в `snapshotID` (`runner.go:383`) — переезжает в `verdictSnapshotID`. |
| `YoPolicy` (book.yaml `yo_policy`) | verdict | **verdictSnapshotID** (не через `brief_hash`) | Правка D20.1(c). Ё-политика (auto/all-yo/all-e) кормит ёфикатор — вердикт-сторона, live-recomputed (класс post-check). ⚠ Сегодня едет через `BriefHash` (book-поле), но `brief_hash` в v2 задропан → `content_hash`; **YoPolicy НЕ рендерится ни в один шаблон** (это конфиг гейта, не текст модели), значит `content_hash` его НЕ ловит. Явно кладём в `verdictSnapshotID` (иначе смена ё-политики тихо не пере-выносит стиль-вердикт на resume). |
| `StyleAllowlist` (book.yaml `style_allowlist`) | verdict | **verdictSnapshotID** (не через `brief_hash`) | Правка D20.1(c). Как YoPolicy: пер-проектный allowlist транслит-междометий кормит стиль-флаггер (вердикт), НЕ рендерится в промпт → `content_hash` не ловит → явно в `verdictSnapshotID`. |
**Примечание к `cache_ttl`/`CacheBoundary`:** `RequestHash` включает `m.CacheBoundary`
(`render.go:219`), `msgsContentHash` — нет (`render.go:245-247`), а `guard_hash` строится из
@ -205,6 +228,37 @@ v1-fast-path ХИТИТ → отдаёт stale-текст, сгенерённы
сохраняет пер-стадийную гранулярность ключа и не смешивает деривац-версии (которые в ключе
избыточны) с ключом.
**Контрпример к «строго сильнее» БЕЗ role/stage (правка D20.1(a), обоснование правки §3.3).**
Возьмём чанк, у которого инъекция ПУСТА (глоссарий не сматчился / отключён): тогда
`renderGlossaryBlock` (translator) и `renderEditorConstraintBlock` (editor) дают ОДИНАКОВОЕ пустое
инъекц-сообщение → `msgs` идентичны → `content_hash` идентичен для обеих ролей. Оператор флипает
роль стадии `editor``translator` (проба «translator-редактуры»), НЕ трогая model/temperature/
reasoning/maxTokens. Тогда под guard_hash БЕЗ role:
- `content_hash` — тот же (пустая инъекция в обе стороны, вход `{{draft}}`/`{{text}}` тот же текст);
- `wireSnapshotID` — тот же (capability/model_extra/escalate не менялись);
- прямые wire-поля (`model`/`temp`/`reasoning`/`jsonOnly`) + деривация `maxTokens` — те же;
- → `guard_hash` СОВПАДАЕТ. И `verdict_snapshot` совпадает (role в него не входит).
Fast-path ХИТИТ и отдаёт ХРАНИМУЮ диспозицию, посчитанную под СТАРОЙ ролью. Но флип
`editor``translator` ВКЛЮЧАЕТ coverage-гейт (он бежит ТОЛЬКО на translator-выходе,
`classifyOutput`/`runner.go:198-200`): корректная диспозиция могла бы стать `excision_suspect`
(flagged), а хранимая — `ok` (editor, coverage не применялся). **Fast-path отдаёт stale `ok` — тихий
неверный вердикт, ре-открытие класса F1 на вердикт-оси.** С `role` внутри `guard_hash` флип роли
меняет `guard_hash` → fast-path проваливается в attempt-цикл → там `RequestHash` (который НЕСЁТ
`role`, §3.4 / `render.go:212`) **промахивается** мимо старого (role=editor) чекпоинта → **свежий
ОПЛАЧЕННЫЙ вызов**, ре-классифицированный под НОВОЙ ролью с применённым coverage → корректный вердикт.
**Это честный re-bill, НЕ $0-переклассификация:** т.к. `role` — прямое поле `RequestHash`, флип
роли расщепляет ключ чекпоинта (два role-конфига стадии не делят чекпоинт), поэтому dry-run §7 верно
покажет это как re-bill под порогом `--accept-rebill`, а не как бесплатную смену. Ключевой ВЫИГРЫШ
правки D20.1(a) — не $0, а **устранение stale-`ok` serve** (безопасность вердикт-оси); стоимость флипа
роли честна. (В общем случае `role` wire-влияющий — инъекция translator≠editor — так что держать его в
`RequestHash` консервативно верно; вырожденный пустой-инъекция кейс даёт лишь расточительный, но
корректно СОГЛАСУЕМЫЙ через §7 re-bill. Альтернатива «убрать `role` из `RequestHash` ради free
re-classify» конфлейтила бы два role-конфига одной стадии в один чекпоинт — не берём без явного
решения ратификации.) Тот же аргумент показывает, что `guard_hash` без `role` НЕ суперсет старого
snapshot-гарда (старый book-global `snapshot_id` нёс role/name всех стадий) — потому клейм «строго
сильнее» без правки ложен.
**Fast-path v2** (`runStage`): доверять строке ⟺
`cs.guard_hash == guard_hash(тек.) && cs.verdict_snapshot == verdictSnapshotID(тек.) &&
cs.disposition != skipped`. `content_hash` продолжаем хранить (сигнатура msgs — нужна dry-runу §7 и
@ -232,8 +286,15 @@ wire-идентичности → **re-classify бесплатно** → re-upse
инъекц-сообщение → `msgs``content_hash`. **Из ключа УБИРАЕТСЯ полностью; отдельным полем ключа
НЕ является** — покрыт `content_hash` пер-чанково (это и даёт гранулярный re-bill append-а, §10).
- **`memoryPostcheckVersion`** (verdict) = алгоритм+пороги post-check + `gate:bool` (`postcheck_gate`)
+ **`decl`-контент ВСЕХ инъектируемых записей при gate ON**. Уходит в **`verdictSnapshotID`**
(§3.2), пересчитывается на resume бесплатно.
+ **`decl`-контент ВСЕХ инъектируемых записей при gate ON** + **TARGET-часть `memoryNormVersion`
(правка D20.1(c))**. Уходит в **`verdictSnapshotID`** (§3.2), пересчитывается на resume бесплатно.
**`memoryNormVersion` двусторонний:** SOURCE-нормализация ключей матчера — wire (→
`memoryInjectVersion``content_hash`), но post-check ищет dst-формы в ВЫВОДЕ по TARGET-нормализации
(`memory.go` пост-check матчит нормализованный dst), значит TARGET-часть `memoryNormVersion`
ВЕРДИКТ и ОБЯЗАНА входить в `memoryPostcheckVersion`. Если норм-версия единая (не расщепляется на
src/target внутри слага) — держать `memoryNormVersion` В ОБЕИХ (`memoryInjectVersion` И
`memoryPostcheckVersion`): дубль безопасен (inject-часть всё равно в `content_hash`), а пропуск
target-стороны из вердикта дал бы stale-вердикт post-check на resume после правки нормализатора.
**Что именно ПОКИДАЕТ wire-ключ:** весь `memory_version`. Матчер/нормализация/инъект-контент — их
эффект целиком в `msgs`. **Что входит в `verdictSnapshotID`:**
@ -272,19 +333,40 @@ job-snapshot-mismatch из fail-loud в пер-чанковое `content_hash`/`
1. **Проекция (источник — тот же пер-чанковый `content_hash`/`guard_hash`-диф, что и на fast-path).**
Для каждого чанка×стадии материализуем ТЕКУЩИЕ `msgs` (`MessagesWithInjection` — дёшево, только
стр__ковая подстановка) и считаем `content_hash` + `guard_hash`. Сравниваем со СТРОКОЙ
строковая подстановка) и считаем `content_hash` + `guard_hash`. Сравниваем со СТРОКОЙ
`chunk_status`. Единица «будет re-bill», если её `guard_hash` отличается от хранимого И хранимая
диспозиция была оплачена (ok/flagged). Этот диф **естественно ловит все эффекты второго порядка**
(sticky, eviction, каскад на редактор — §10), потому что он пере-материализует `msgs` per
chunk×stage, а не рассуждает о них аналитически.
диспозиция была оплачена (ok/flagged). Диф ловит эффекты второго порядка, чьи `msgs`
МАТЕРИАЛИЗУЕМЫ из хранимого банка (sticky, eviction — §10 пп.1-2): их инъекц-сообщение
пересобирается из глоссария на $0.
1-бис. **Аналитическое правило editor-каскада (правка D20.1(b)) — ОБЯЗАТЕЛЬНО.** Диф §7.1 ловит
каскад на редактор ТОЛЬКО когда меняется ИНЪЕКЦИЯ (append-термина, §10 п.3-i: T попадает и в
editor-констрейнт-блок — материализуемо). Но для самого дорогого класса правок — **смена wire-параметра
TRANSLATOR'а (temperature/reasoning/model)** — editor меняется через §10 п.3-ii (изменённый
`{{draft}}`), а НОВЫЙ черновик $0-dry-run материализовать НЕ может (он появится только когда
translator реально пере-вызовется). В проекции вход `{{draft}}` редактора остаётся СТАРЫМ
(хранимым) → `content_hash`/`guard_hash` редактора выглядят НЕИЗМЕННЫМИ → редактор НЕ попадает в
re-bill → **проекция занижает ~2× на 2-стадийном C1** (посчитан только translator, пропущен
гарантированный каскад на editor). Фикс — правило поверх дифа: **любая стадия СТРОГО НИЖЕ re-bill-юнита
ТОГО ЖЕ чанка сама считается re-bill, независимо от её собственного `guard_hash`-дифа** (её вход
сменится, как только пере-выполнится upstream; downstream цена — из `chunk_status.cost_usd`
downstream-строки, либо `EstimateUSD` над её текущими `msgs` с ПОДСТАВЛЕННЫМ проецируемым upstream).
Правило чисто структурное (линейный C1: draft→edit), не требует материализации нового вывода.
⚠ Порядок реализации (D20.2-Q2): каскад-фикс §7.1-бис ЛОЖИТСЯ ПЕРЕД семантикой флага `--accept-rebill`
— иначе порог согласия считается от заниженной вдвое суммы.
2. **Стоимость.** Проецируемый $ = Σ по изменившимся единицам их хранимого `chunk_status.cost_usd`
(честная прошлая фактическая цена; для каскадных downstream-единиц без прошлой цены — оценка
`ledger.EstimateUSD` над новыми `msgs`). Выдаём «**N чанков будет пере-оплачено, ~$X**».
3. **Порог + флаг.** Порог `rebill_consent_usd` (конфиг книги; дефолт, напр., `$0.50` ИЛИ ≤5% от
`ProjectedBookUSD`, что меньше). Ниже порога — **проходим автоматически** (append-термина в 3
чанках стоит центы — без трения согласия). Выше порога — **отказ без явного флага
`--accept-rebill`** (зеркалит старую семантику `--resnapshot`, но теперь content-scoped: гейт
срабатывает только на РЕАЛЬНУЮ проецируемую трату, а не на любой вердикт-only $0-change).
3. **Порог + флаг (форма ратифицирована D20.2-Q2).** Порог `rebill_consent_usd` (конфиг книги) с
дефолтом **`min($0.50, 5% × ProjectedBookUSD)`**. **Fallback-флор при `ProjectedBookUSD=0`** (книга
ещё не считалась / нет обработанных чанков → 5%-ветка даёт $0 и заблокировала бы даже центовый
append): при `ProjectedBookUSD=0` порог = абсолютный флор `$0.50`. Ниже порога — **проходим
автоматически** (append-термина в 3 чанках стоит центы — без трения согласия). Выше порога — **отказ
без явного флага `--accept-rebill[=usd]`**: форма с ОПЦИОНАЛЬНЫМ потолком суммы (`--accept-rebill`
принимает проецируемый re-bill целиком; `--accept-rebill=1.50` — только если проекция ≤ $1.50, иначе
стоп: Р6 = согласие на КОНКРЕТНУЮ трату, не бланкетное). Гейт content-scoped: срабатывает только на
РЕАЛЬНУЮ проецируемую трату, а не на любой вердикт-only $0-change. `--resnapshot` остаётся
алиасом-синонимом (семантика — «прими проецируемый re-bill», не «пере-пинай всё»).
Это сохраняет Р6-класс согласия на трату, убирая ложные тревоги (вердикт-only смена больше не просит
согласия — она $0). `--resnapshot` как флаг остаётся синонимом-алиасом для обратной совместимости, но
@ -323,6 +405,11 @@ wire на новом конфиге был бы ДРУГИМ из-за temp/reas
отчётность), но **перестаёт быть гейтом корректности**. Первичный resume-гейт — `guard_hash` +
`verdict_snapshot` + `content_hash` (§5). Условие `cs.SnapshotID == snapID` (`runStage:899`)
снимается.
- **`jobs.snapshot_id`** (правка D20.1(c), одним предложением) — следует за `chunk_status.snapshot_id`:
**демотируется до advisory** (какой job-снапшот держит стадию главы), а book-global job-mismatch
fail-loud (`runStage:865-869`/`904-908`: `job.SnapshotID != snapID` → стоп без `--resnapshot`)
**заменяется** пер-чанковым `guard_hash`-решением + dry-run-проекцией §7; `UpdateJobSnapshot`
оставляем для re-pin к текущему снапшоту (self-heal), FK `jobs.snapshot_id→snapshots` цел.
- **`SnapshotDrift`** (`status.go:53-55,283-284`: >1 snapshot_id среди строк) — **демотируется до
информационного advisory**, не сигнала тревоги: при content-addressed resume сосуществование строк
под разными job-снапшотами НОРМАЛЬНО (wire-идентичные чанки, резолвнутые в разное время). Больше не
@ -400,13 +487,14 @@ per chunk×stage, а не рассуждает аналитически):**
- **Одноразовый re-pin осознан и задокументирован** (как v1→v2 сейчас): ни одна онгоинг-книга не
должна пересекать миграцию в середине жизненного цикла.
## 12. Минимальный план реализации (Ф1.5, после ратификации v2)
## 12. Минимальный план реализации (Ф1.5, после ратификации v3)
1. **Расщепить `snapshotID()`**`wireSnapshotID(stage)` (пер-стадийный, §3.1) + `verdictSnapshotID()`
(book-global, §3.2). Полный `snapshotID` = их хеш (advisory).
2. **`RequestHash`**: `snapshotID``wireSnapshotID`, бамп `tm-request-v3` (§3.4).
3. **`guard_hash`** (§3.3): функция `guardHash(content_hash, wireSnapshotID, model, temp, reasoning,
jsonOnly, estimator/ratio/floor/policy)`, префикс `tm-guard-v1`.
3. **`guard_hash`** (§3.3): функция `guardHash(stageName, role, content_hash, wireSnapshotID, model,
temp, reasoning, jsonOnly, estimator/ratio/floor/policy)`, префикс **`tm-guard-v2`** (правка
D20.1(a): `stageName`+`role` ОБЯЗАТЕЛЬНЫ — иначе не суперсет, §3.3/§5-контрпример).
4. **Расщепить `memoryMatchVersion`/`computeMemoryVersion` (ОБЯЗАТЕЛЬНО, §6)** на `memoryInjectVersion`
(→ `msgs`, из ключа убрать) и `memoryPostcheckVersion` (+`gate`+`decl`-при-gateON → `verdictSnapshotID`).
Снять двойной фолд `postcheck_gate`.
@ -416,40 +504,54 @@ per chunk×stage, а не рассуждает аналитически):**
verdictSnapshotID(тек.) && disposition != skipped`; иначе `runAttempt` (re-classify бесплатно, §8).
7. **Job-snapshot-mismatch**: демотировать fail-loud стоп (`runStage:865-869`) в пер-чанковое
`guard_hash`-решение.
8. **Pre-flight dry-run (§7)**: пер-чанковый `guard_hash`-диф → «N чанков, ~$X»; порог
`rebill_consent_usd` + флаг `--accept-rebill`. `tmctl status`: заменить булев `ConfigDrift` на
проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф целей + проекция (§9).
9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl) → $0 re-classify,
вердикт обновлён; (б) append-термина → re-bill ТОЛЬКО фаерящих чанков + sticky+2 + editor-каскад,
остальные $0 (§10); (в) wire-смена (промпт/модель/**temperature**/**reasoning**/capability/
estimator) → честный re-bill; (г) **F1/дыра-a мутация**: ослабить `guard_hash` (убрать temp) →
тест ловит stale-serve при temp 0.3→0.4; (д) dry-run порог: ниже — авто, выше — отказ без флага;
(е) миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`.
8. **Pre-flight dry-run (§7)**: пер-чанковый `guard_hash`-диф + **правило каскада §7.1-бис (любая
стадия ниже re-bill-юнита чанка = re-bill; ЛОЖИТСЯ ПЕРВЫМ)** → «N чанков, ~$X»; порог
`rebill_consent_usd` = `min($0.50, 5%×ProjectedBookUSD)` c fallback-флором $0.50 при
`ProjectedBookUSD=0`; флаг `--accept-rebill[=usd]` (опциональный потолок). `tmctl status`: заменить
булев `ConfigDrift` на проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф
целей + проекция (§9). Adult: слой НЕ назначать — линт `CheckAdultChannel` (уже в пакете №3).
9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**)
→ $0 re-classify, вердикт обновлён; (б) append-термина → re-bill ТОЛЬКО фаерящих чанков + sticky+2 +
editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/модель/**temperature**/**reasoning**/
capability/estimator) → честный re-bill; (г) **F1/дыра-a мутация**: ослабить `guard_hash` (убрать
temp) → тест ловит stale-serve при temp 0.3→0.4; **(г-бис) role-флип на wire-нейтральном чанке
(пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role` — re-classify под
coverage (§5-контрпример);** (д) dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис)
editor-каскад: смена temperature translator'а → проекция считает re-bill ОБЕИХ стадий чанка, не
только translator (§7.1-бис; мутация «убрать правило каскада» роняет проекцию до ~½);** (е)
миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`.
## 13. Открытые вопросы оркестратору (решения, гейтящие ратификацию v2)
## 13. Решённые вопросы (ответы D20.2 — вписаны в текст v3)
1. **Пер-стадийная гранулярность `wireSnapshotID`/`guard_hash`** — это v2-уточнение над book-global
`snapshotID` v1: правка `capability`/`model_extra` РЕДАКТОРА больше не пере-оплачивает ЧЕРНОВИК
(§3.1). Выигрыш реальный, но добавляет поверхность реализации (хеш на стадию, а не на книгу).
**Подтвердить: берём пер-стадийную гранулярность или держим book-global** (проще, но грубее)?
2. **Дефолт порога dry-run** `rebill_consent_usd` (§7) и **имя флага**. Предложение: порог =
`min($0.50, 5% × ProjectedBookUSD)`; флаг `--accept-rebill` (отдельный от `--resnapshot`, который
остаётся алиасом). **Нужно решение владельца/оркестратора** — это Р6-класс согласия на трату.
3. **Caveat `Adult`** (`book.go`): дроп `brief_hash` опирается на `content_hash`, но булев `Adult`
НЕ рендерится ни в один шаблон → wire-нейтрален by construction (§4, строка `brief_hash`). Если
`Adult` когда-либо должен инвалидировать работу (напр. смена канала/цензуры) — его надо ЯВНО
вернуть в `wireSnapshotID` или `verdictSnapshotID`. **Подтвердить, что `Adult` остаётся
wire/verdict-нейтральным**, либо назначить ему слой.
Все три §13-вопроса v2 РЕШЕНЫ D20.2; сохранены здесь как запись контракта.
1. **Пер-стадийная гранулярность `wireSnapshotID`/`guard_hash` — ПОДТВЕРЖДЕНА** (D20.2-Q1). Живой
довод оркестратора: вероятная смена редактора glm-5→grok-4.3 при book-global пере-оплатила бы весь
translator-бэклог; поверхность мала — `snapshotID` уже итерирует стадии. Взято пер-стадийно (§3.1).
2. **Порог dry-run и флаг — ПРИНЯТЫ** (D20.2-Q2) с поправками: порог `min($0.50, 5%×ProjectedBookUSD)`,
флаг `--accept-rebill[=usd]` (опциональный потолок суммы, отдельный от `--resnapshot`-алиаса),
fallback-флор при `ProjectedBookUSD=0`. ⚠ Порядок: **фикс каскада §7.1-бис ПЕРВЫМ** (иначе порог
считается от заниженной вдвое суммы). Форма вписана в §7 п.3 / §12 п.8.
3. **`Adult` остаётся wire/вердикт-НЕЙТРАЛЬНЫМ, слой НЕ назначается** (D20.2-Q3). Вместо хеша —
**load-time линт консистентности**: `adult:true` без единой `channel:adult`-стадии → громко. Линт
**реализован бэкенд-пакетом №3** (`Pipeline.CheckAdultChannel`, вызывается в `NewRunner`; обратная
сторона — `channel:adult` при `adult:false` — сознательно НЕ гейтится, Adult нейтрален до первого
рантайм-потребителя, который обязан ЯВНО назначить слой). §4 (строка `brief_hash`).
---
**Резюме для оркестратора.** Дефект — `snapshotID` в ключе вызова смешивает wire-, вердикт- и
избыточные входы. Фикс v2: три хеша на трёх слоях — `wireSnapshotID` (пер-стадийный, в `RequestHash`),
избыточные входы. Фикс: три хеша на трёх слоях — `wireSnapshotID` (пер-стадийный, в `RequestHash`),
`verdictSnapshotID``chunk_status`, не в ключе), `guard_hash` (пер-стадийный суперсет wire-плана,
гейт fast-path). Три дыры закрыты: **(a)** `guard_hash` покрывает прямые wire-поля, которых fast-path
v1 терял (temp/reasoning/maxTokens/escalate) → нет ре-открытия stale-serve; **(b)** `memory_version`
расщеплён ОБЯЗАТЕЛЬНО — inject-часть уходит в `content_hash` (из ключа вон), post-check+gate+decl → в
`verdictSnapshotID` (пересчёт бесплатен); **(c)** loud-гейт согласия заменён pre-flight dry-run «N
чанков, ~$X» с порогом `--accept-rebill` (Р6 сохранён). Безопасность F1 усилена (guard_hash — сильнее
прежнего гарда). Онгоинг-мина D15 снята: append re-bill-ит только реально затронутые чанки×стадии.
Реализация мала и хирургична, но **остаётся ЗАБЛОКИРОВАННОЙ до ратификации v2**.
гейт fast-path). Три дыры v2 закрыты + **три правки v3 (D20.1)**: **(a)** `guard_hash` покрывает
прямые wire-поля (temp/reasoning/maxTokens/escalate) **И `stageName`+`role`** → строгий суперсет, нет
ре-открытия stale-serve ни на wire-, ни на вердикт-оси (контрпример с флипом роли — §5); **(b)**
`memory_version` расщеплён ОБЯЗАТЕЛЬНО — inject-часть в `content_hash`, post-check+gate+decl+**target-norm**
`verdictSnapshotID` (пересчёт бесплатен); dry-run §7 несёт **аналитическое правило editor-каскада**
(любая стадия ниже re-bill-юнита = re-bill, иначе консент занижен ~2×); **(c)** loud-гейт согласия
заменён pre-flight dry-run «N чанков, ~$X» с порогом `min($0.50, 5%×ProjectedBookUSD)` и флагом
`--accept-rebill[=usd]` (Р6 сохранён); маппинг §4 доукомплектован (`style_check_version`,
YoPolicy/StyleAllowlist, `jobs.snapshot_id`, coverage-фолд-при-enabled); `Adult` нейтрален — линт
`CheckAdultChannel` (уже в пакете №3). Безопасность F1 усилена. Онгоинг-мина D15 снята: append
re-bill-ит только реально затронутые чанки×стадии. Реализация мала и хирургична, но **остаётся
ЗАБЛОКИРОВАННОЙ до ратификации v3** (реализацию НЕ начинать без подписи оркестратора — D20.1).

View file

@ -34,6 +34,35 @@ func TestCheckRunnableRejectsPhase2Mechanics(t *testing.T) {
}
}
// TestCheckAdultChannel covers the D20.2-Q3 load-time consistency lint: an adult:true book must
// route through a channel:adult stage, else the 18+ text runs on the SFW channel. The reverse
// (channel:adult stage in an adult:false book) is deliberately NOT gated (Adult stays neutral).
// Reverting CheckAdultChannel makes the "adult no adult-stage" case stop erroring.
func TestCheckAdultChannel(t *testing.T) {
sfw := Pipeline{Stages: []Stage{{Name: "draft", Role: "translator"}, {Name: "edit", Role: "editor"}}}
withAdult := Pipeline{Stages: []Stage{{Name: "draft", Role: "translator", Channel: "adult"}, {Name: "edit", Role: "editor"}}}
cases := []struct {
name string
p Pipeline
adult bool
wantErr bool
}{
{"sfw book, sfw pipeline", sfw, false, false},
{"adult book, adult stage present", withAdult, true, false},
{"adult book, NO adult stage → loud", sfw, true, true},
{"sfw book, adult stage present (reverse not gated)", withAdult, false, false},
}
for _, c := range cases {
err := c.p.CheckAdultChannel(c.adult)
if c.wantErr && (err == nil || !strings.Contains(err.Error(), "channel:adult")) {
t.Errorf("%s: want an error mentioning channel:adult, got %v", c.name, err)
}
if !c.wantErr && err != nil {
t.Errorf("%s: want ok, got %v", c.name, err)
}
}
}
func TestCheckKeysOnlyUsedNonLocalModels(t *testing.T) {
m := &Models{
Providers: map[string]Provider{

View file

@ -35,7 +35,7 @@ type Provider struct {
Kind string `yaml:"kind"` // openai | anthropic | local
BaseURL string `yaml:"base_url"`
APIKeyEnv string `yaml:"api_key_env"`
Reasoning string `yaml:"reasoning"` // subset | additive (openai kind only)
Reasoning string `yaml:"reasoning"` // subset | additive | additive_total (openai kind only)
// EchoesWhenThinkingOff marks a provider empirically shown to ECHO the
// untranslated CJK source instead of translating when thinking is OFF
// (DeepSeek, traced 2026-07-04 — reproducibly on dense CJK, 3/3 retries;
@ -183,8 +183,8 @@ func LoadModels(path string) (*Models, error) {
if p.BaseURL == "" && p.Kind != "anthropic" {
bad("provider %s: base_url is required", name)
}
if p.Kind == "openai" && p.Reasoning != "" && p.Reasoning != "subset" && p.Reasoning != "additive" {
bad("provider %s: reasoning must be subset|additive, got %q", name, p.Reasoning)
if p.Kind == "openai" && p.Reasoning != "" && p.Reasoning != "subset" && p.Reasoning != "additive" && p.Reasoning != "additive_total" {
bad("provider %s: reasoning must be subset|additive|additive_total, got %q", name, p.Reasoning)
}
if p.Kind == "anthropic" && p.CacheTTL != "" && p.CacheTTL != "5m" && p.CacheTTL != "1h" {
bad("provider %s: cache_ttl must be 5m|1h, got %q", name, p.CacheTTL)

View file

@ -157,6 +157,26 @@ func (p *Pipeline) CheckRunnable() error {
return nil
}
// CheckAdultChannel enforces the D20.2-Q3 load-time consistency lint: a book marked
// adult:true MUST route through at least one channel:adult stage, else the 18+ text would
// silently run on the SFW pipeline (all-SFW providers that refuse/excise explicit content).
// Adult stays wire/verdict-NEUTRAL (no snapshot layer — D20.2-Q3: the first runtime consumer
// assigns meaning; here it is only a routing sanity check, never a hash input). The REVERSE
// (a channel:adult stage in an adult:false book) is deliberately NOT gated: the stage's
// channel already forces a permissive provider (D4.1), and Adult carries no assigned meaning
// yet, so a permissive stage may legitimately exist before the book flag is set.
func (p *Pipeline) CheckAdultChannel(bookAdult bool) error {
if !bookAdult {
return nil
}
for _, st := range p.Stages {
if st.Channel == "adult" {
return nil
}
}
return fmt.Errorf("book adult:true, но ни одна стадия не имеет channel:adult — 18+ текст пошёл бы по SFW-каналу (SFW-провайдеры отказывают/вырезают explicit); заведите channel:adult-стадию на permissive-провайдере (D4.1/D20.2-Q3) либо поставьте adult:false")
}
// LoadPipeline reads and validates a pipeline config against the models known
// to models.yaml.
func LoadPipeline(path string, models *Models) (*Pipeline, error) {

View file

@ -202,6 +202,12 @@ func (r openAIRequest) MarshalJSON() ([]byte, error) {
type openAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
// TotalTokens is prompt+completion for spec providers, but for Gemini via the
// OpenAI-compat layer it ALSO carries the mandatory thinking tokens, which do NOT
// appear in completion_tokens and have no reasoning_tokens field (live-probe
// 2026-07-10: completion=2, prompt=22, total=847 → 823 hidden thinking). The
// ReasoningAdditiveTotal adapter derives thinking = total prompt completion.
TotalTokens int `json:"total_tokens"`
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`

View file

@ -119,6 +119,49 @@ func TestAdditiveReasoningSemantics(t *testing.T) {
}
}
// TestAdditiveTotalReasoningSemantics covers the Gemini case (live-probe 2026-07-10): thinking
// tokens are billed as output but appear ONLY in total_tokens — completion_tokens excludes them
// and there is no reasoning_tokens field. The adapter must derive reasoning = total prompt
// completion so the mandatory-thinking spend is not invisible to the ledger. Reverting the
// ReasoningAdditiveTotal branch drops reasoning to 0 and fails this test.
func TestAdditiveTotalReasoningSemantics(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Mirrors the real Gemini shape: completion=2, prompt=22, total=847 → 823 hidden thinking,
// NO completion_tokens_details.reasoning_tokens field at all.
openAIOK(t, w, `{"id":"g1","model":"gemini-3.1-pro-preview","choices":[{"message":{"content":"391"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":22,"completion_tokens":2,"total_tokens":847}}`)
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "gemini", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningAdditiveTotal}, nil)
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 8192})
if err != nil {
t.Fatal(err)
}
if resp.Usage.ReasoningTokens != 823 {
t.Fatalf("additive_total must derive hidden thinking = totalpromptcompletion = 823, got %+v", resp.Usage)
}
if resp.Usage.CompletionTokens != 2 {
t.Fatalf("completion tokens must stay the visible 2 (thinking is separate reasoning), got %+v", resp.Usage)
}
// A spec provider whose total == prompt+completion must NOT derive a phantom reasoning count
// (guard the >0 branch — never inflate the ceiling).
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
openAIOK(t, w, `{"id":"g2","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`)
}))
defer srv2.Close()
c2 := NewOpenAICompatClient(OpenAICompatConfig{Name: "gemini", BaseURL: srv2.URL, Profile: fastProfile(), Reasoning: ReasoningAdditiveTotal}, nil)
resp2, err := c2.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil {
t.Fatal(err)
}
if resp2.Usage.ReasoningTokens != 0 {
t.Fatalf("total==prompt+completion must derive 0 reasoning, got %+v", resp2.Usage)
}
}
func TestRetryOn429HonoursRetryAfter(t *testing.T) {
var calls atomic.Int32
var firstRetryAt, secondCallAt time.Time

View file

@ -23,8 +23,16 @@ const (
ReasoningSubset ReasoningSemantics = "subset"
// ReasoningAdditive: thinking is reported separately and billed ON TOP of
// completion_tokens (xAI — verified in vojo against cost_in_usd_ticks;
// dropping it undercounted Grok spend by 3044%).
// dropping it undercounted Grok spend by 3044%). Read from
// completion_tokens_details.reasoning_tokens.
ReasoningAdditive ReasoningSemantics = "additive"
// ReasoningAdditiveTotal: thinking is billed as output but reported ONLY in
// total_tokens — Gemini via the OpenAI-compat layer omits it from
// completion_tokens AND has no reasoning_tokens field (live-probe 2026-07-10:
// completion=2, prompt=22, total=847 → 823 hidden thinking). Derived as
// total prompt completion so the mandatory-thinking spend (D6.3) is billed
// as output instead of blinding the ceiling.
ReasoningAdditiveTotal ReasoningSemantics = "additive_total"
)
// OpenAICompatConfig configures one provider instance.
@ -89,8 +97,16 @@ func (c *openAICompatClient) Complete(ctx context.Context, req LLMRequest) (*LLM
CachedTokens: resp.Usage.cacheRead(),
CompletionTokens: resp.Usage.CompletionTokens,
}
if c.reasoning == ReasoningAdditive {
switch c.reasoning {
case ReasoningAdditive:
usage.ReasoningTokens = resp.Usage.CompletionTokensDetails.ReasoningTokens
case ReasoningAdditiveTotal:
// Gemini: thinking is only in total_tokens (not completion, no reasoning field).
// Guard >0 so a spec provider whose total == prompt+completion derives 0, not a
// negative (never inflate the ceiling either).
if extra := resp.Usage.TotalTokens - resp.Usage.PromptTokens - resp.Usage.CompletionTokens; extra > 0 {
usage.ReasoningTokens = extra
}
}
model := resp.Model
if model == "" {

View file

@ -31,7 +31,14 @@ import (
// never shift silently between runs under the same snapshot. [D15.2 note: this is a VERDICT
// version — once content-addressed resume lands it moves to verdictSnapshotID and a rule edit
// re-classifies free instead of re-paying the book.]
const cheapGateVersion = "cheapgate-v1"
//
// v2 (D20.4, пакет №3) closes three adversarial-review false positives, so the bump is LOUD by
// design (counts shift): (1) a chevron CITATION at line start no longer counts as dialogue-style
// mixing — only the «…», — attribution shape does; (2) a source 万/億 magnitude is no longer
// false-flagged against a STRAY output integer (a year, a count) — only a mismatching magnitude
// WORD triggers, bare integers can merely confirm; (3) same mechanism covers a 万-in-a-name +
// unrelated output number. No live book has run under v1 (D18 acceptance pending), so the re-pin is free.
const cheapGateVersion = "cheapgate-v2"
// cheapGateConfig carries the brief-derived knobs: the ё-policy and the per-project allowlist of
// surfaces that look like a blocklisted interjection but are legitimate here (e.g. a character
@ -82,7 +89,7 @@ func runCheapGates(source, final string, cfg cheapGateConfig) cheapGateResult {
// «- 1» list item does not fire); chevron lines count only under mixing (a chunk that uses «…» for
// speech throughout may be a deliberate style, but mixing it with dashes is an inconsistency).
func lintDialogueDash(text string) (int, []string) {
var emDash, hyphenLike, chevronLead int
var emDash, hyphenLike, chevronSpeech int
var detail []string
for _, line := range strings.Split(text, "\n") {
t := strings.TrimLeft(line, " \t ")
@ -105,18 +112,54 @@ func lintDialogueDash(text string) (int, []string) {
detail = append(detail, "реплика через дефис/короткое тире вместо длинного «—»: "+preview(t))
hyphenLike++
}
case '«': // chevron-led line: only an issue if the chunk ALSO uses em-dash speech (mixing)
chevronLead++
case '«': // chevron-led line counts toward mixing ONLY as chevron DIALOGUE (not a citation/title)
if chevronSpeechShape(rs) { // D20.4 FP: «-цитата в начале строки больше не считается смешением
chevronSpeech++
}
}
}
n := hyphenLike
if emDash > 0 && chevronLead > 0 {
n += chevronLead
detail = append(detail, fmt.Sprintf("смешение стилей прямой речи в чанке: %d реплик через «—» и %d через «…»", emDash, chevronLead))
if emDash > 0 && chevronSpeech > 0 {
n += chevronSpeech
detail = append(detail, fmt.Sprintf("смешение стилей прямой речи в чанке: %d реплик через «—» и %d через «…»", emDash, chevronSpeech))
}
return n, detail
}
// chevronSpeechShape reports whether a chevron-led line is a chevron DIALOGUE turn rather than a
// citation, title or quoted term (which also open with «). The high-precision signal is the Russian
// dialogue-attribution join: a closing » directly followed by a comma, then (spaces) an em/en/hyphen
// dash — «Реплика», — сказал он. A quotation/definition uses «X» — … (a dash WITHOUT the comma) or
// ends mid-sentence, so it does NOT match, which kills the D20.4 false positive «-цитата с начала
// строки как «смешение стилей». It deliberately MISSES exclamatory «Реплика!» — and unattributed
// «Реплика.» chevron dialogue (precision over recall — the gate header's stated bias).
func chevronSpeechShape(rs []rune) bool {
i := 1 // rs[0] is '«'
for i < len(rs) && (rs[i] == ' ' || rs[i] == ' ') {
i++
}
if i >= len(rs) || !unicode.IsLetter(rs[i]) {
return false // «» empty or «123…» — not spoken content
}
for ; i < len(rs); i++ {
if rs[i] != '»' {
continue
}
j := i + 1
if j >= len(rs) || rs[j] != ',' { // the attribution comma must sit right after the closing »
continue
}
j++
for j < len(rs) && (rs[j] == ' ' || rs[j] == ' ') {
j++
}
if j < len(rs) && (rs[j] == '—' || rs[j] == '' || rs[j] == '-') {
return true
}
}
return false
}
// dialogueShape reports whether the runes after a leading marker look like spoken text: an optional
// space then a letter. It filters out non-dialogue leading dashes (word wraps, «-1», bare marks).
func dialogueShape(after []rune) bool {
@ -239,6 +282,12 @@ func tokenizeCyrillic(text string) []string {
// listed (self-review majors): «ара» (a macaw, also a name) and «уму» (dative of «ум») were removed
// as high-false-positive collisions; «ара-ара» (the reduplicated JP filler) stays. A residual name
// collision is handled by the per-project allowlist. Lower-cased.
//
// NOTE (D20.4): only the EXACT hyphenated reduplications enumerated here fire. Other reduplicated
// fillers a translit-leak might emit («уху-уху», «эхе-хе», «ня-ня», «фу-фу») are NOT caught — a
// generic «X-X»-reduplication rule was rejected as too false-positive-prone (legitimate Russian
// reduplications: «еле-еле», «чуть-чуть», «крепко-накрепко»). Extend this list per corpus finding
// rather than by heuristic; the residual leak is an accepted recall gap (precision over recall).
var translitInterjections = []string{
"ара-ара", "маа", "хмф", "нани", "ауч", "упс", "кья", "десу",
}
@ -307,15 +356,29 @@ func lintNumberMagnitude(source, final string) (int, []string) {
if maxSrc < 4 { // only gate on 万+ magnitudes (the 万/億 concern)
return 0, nil
}
ranges := outputMagnitudeRanges(final)
if len(ranges) == 0 {
return 0, nil // no numeric magnitude in the output to compare against — silent (could be rephrased prose)
// A bare output integer (a year, a count, a page number) may CONFIRM the magnitude but must never
// TRIGGER a flag: an unrelated number of a different order is not evidence the 万/億 was
// mistranslated (D20.4 FPs: «万 в составе имени + постороннее число в выводе», «перефраз магнитуды +
// год»). So an Arabic figure of the RIGHT order suppresses; a mismatching one is ignored.
for _, o := range arabicNumberOrders(final) {
if o == maxSrc {
return 0, nil // an Arabic figure of the source order confirms coverage (30000 for 三万)
}
for _, rg := range ranges {
}
wordRanges := magnitudeWordRanges(final)
for _, rg := range wordRanges {
if maxSrc >= rg[0] && maxSrc <= rg[1] {
return 0, nil // the top source magnitude IS represented — no mismatch
return 0, nil // a magnitude WORD covers the source order (三万 → «тридцать тысяч»)
}
}
// Only a mismatching magnitude WORD is evidence of a разряд error (三万 → «три миллиона»). Absent
// any magnitude word, stay silent: the magnitude was rephrased as prose, or the number in the
// output is unrelated (the two D20.4 FPs above). Accepted recall cost: a dropped-magnitude error
// rendered as a BARE integer of a smaller order (三万 → «300») is now also silent — indistinguishable
// offline from an unrelated stray integer without number alignment (Ф2). Precision over recall.
if len(wordRanges) == 0 {
return 0, nil
}
return 1, []string{fmt.Sprintf("разряд источника 10^%d (万/億) не отражён в порядках величин перевода — возможна ошибка разряда (напр. 三万→«три миллиона»)", maxSrc)}
}
@ -476,11 +539,12 @@ func orderOf(v int64) int {
return o
}
// outputMagnitudeRanges returns the covered [minOrder,maxOrder] ranges implied by the Russian
// output. A magnitude WORD covers [base, base+2] because an unseen multiplier can lift it up to two
// orders (триста миллионов = 3·10^8, base 6 → order 8). An Arabic integer covers its own order
// exactly (grouping spaces/commas/NBSP between 3-digit groups are stitched first).
func outputMagnitudeRanges(text string) [][2]int {
// magnitudeWordRanges returns the covered [minOrder,maxOrder] ranges implied by Russian magnitude
// WORDS in the output. A word covers [base, base+2] because an unseen multiplier can lift it up to
// two orders (триста миллионов = 3·10^8, base 6 → order 8). Arabic integers are handled separately
// by the caller (they may only CONFIRM coverage, never trigger a mismatch — D20.4), so they are NOT
// folded in here.
func magnitudeWordRanges(text string) [][2]int {
low := strings.ToLower(text)
var ranges [][2]int
for stem, base := range map[string]int{"тысяч": 3, "миллион": 6, "миллиард": 9, "триллион": 12} {
@ -488,9 +552,6 @@ func outputMagnitudeRanges(text string) [][2]int {
ranges = append(ranges, [2]int{base, base + 2})
}
}
for _, o := range arabicNumberOrders(text) {
ranges = append(ranges, [2]int{o, o})
}
return ranges
}

View file

@ -22,6 +22,10 @@ func TestLintDialogueDash(t *testing.T) {
{"mixing em-dash and chevrons", "— Реплика первая.\n«Реплика вторая», — подумал он.", 1},
{"chevron only no mixing", "«Цитата из книги».\nОбычный текст.", 0},
{"chevron term in prose not flagged", "Это была «Война и мир».", 0},
// D20.4 FP1: a chevron CITATION/definition at line start (« … » — prose, NO attribution comma)
// mixed with em-dash dialogue must NOT count as style mixing.
{"chevron citation at line start not mixing", "— Реплика.\n«Мастер Гу» — так его называли.", 0},
{"chevron title alone at line start not mixing", "— Реплика.\n«Война и мир» стояла на полке.", 0},
// Self-review: an em-dash + space + digit is a scene break, NOT dialogue — must not count.
{"em-dash scene break not dialogue", "— 15 минут спустя он вернулся.", 0},
}
@ -141,6 +145,12 @@ func TestLintNumberMagnitude(t *testing.T) {
{"no magnitude at all", "他走进房间。", "Он вошёл в комнату.", 0},
{"arabic output matches", "三万里。", "30000 ли.", 0},
{"no output number silent", "三万大军。", "Огромная армия.", 0},
// D20.4 FP3: a magnitude rephrased as prose + a STRAY unrelated output integer (a year) must
// NOT flag — the year is not evidence the 万 was mistranslated (bare integers only confirm).
{"rephrased magnitude plus stray year not flagged", "三万大军压境时值1990年。", "Несметное войско подступило; шёл 1990 год.", 0},
// D20.4 FP2: 万 inside a NAME (digit-before-万 makes it parse as a magnitude) + an unrelated
// output number must NOT flag.
{"wan in a name plus stray year not flagged", "他叫赵三万生于1985年。", "Его звали Чжао Саньвань, родился в 1985 году.", 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View file

@ -257,11 +257,14 @@ func decodeSourceBytes(raw []byte, encoding, sourceLang string) (string, error)
}
// checkNoNUL rejects a decoded string that contains a NUL (U+0000). Real prose never contains NUL;
// its presence means the bytes were a UTF-16 stream WITHOUT a BOM whose ASCII half is valid UTF-8
// with interleaved NULs (silent corruption the U+FFFD check cannot catch — self-review major).
// its presence means a mis-detected / mis-declared encoding: a UTF-16 stream WITHOUT a BOM whose
// ASCII half is valid UTF-8 with interleaved NULs, OR a UTF-16/binary file wrongly fed to the
// GB18030/UTF-16 decoder (0x00 decodes to a VALID U+0000, not U+FFFD, so the replacement-char check
// cannot catch it — self-review major, D20.4). Applied on EVERY decode path (utf8/gb18030/utf16),
// not just UTF-8, so the guard is symmetric across encodings.
func checkNoNUL(s string) (string, error) {
if strings.IndexByte(s, 0) >= 0 {
return "", fmt.Errorf("decoded text contains NUL (U+0000) — the source is likely UTF-16 without a BOM; convert it to UTF-8 or declare its encoding")
return "", fmt.Errorf("decoded text contains NUL (U+0000) — the source is likely UTF-16 without a BOM, or a binary/mis-declared file; convert it to UTF-8 or declare its encoding correctly")
}
return s, nil
}
@ -329,7 +332,7 @@ func decodeGB18030(raw []byte, probe bool) (string, error) {
return "", err
}
}
return s, nil
return checkNoNUL(s) // 0x00 bytes decode to a valid U+0000, not U+FFFD — a mis-declared UTF-16/binary file slips the replacement-char check (D20.4)
}
// decodeUTF16 decodes a BOM-prefixed UTF-16 stream (ExpectBOM consumes the BOM). Strict: a decode
@ -344,7 +347,7 @@ func decodeUTF16(raw []byte, endian xunicode.Endianness) (string, error) {
if strings.ContainsRune(s, utf8.RuneError) {
return "", fmt.Errorf("UTF-16 decode produced replacement chars (U+FFFD) — corrupt")
}
return s, nil
return checkNoNUL(s) // a genuine UTF-16 stream carrying embedded NULs (or a mis-detected binary) is caught here (D20.4)
}
// plausibleCJK is the GB18030 auto-accept guard: a real GB18030 source is Chinese, so the decoded

View file

@ -163,6 +163,32 @@ func TestDecodeSourceExplicitGB18030(t *testing.T) {
}
}
// TestDecodeSourceGB18030NULGuard is the D20.4 fix: a UTF-16/binary file mis-DECLARED as gb18030
// decodes each 0x00 byte to a VALID U+0000 (not U+FFFD), so the replacement-char check passes it —
// the NUL guard on the GB18030 path must catch it. Bytes "H\0i\0" (ASCII saved UTF-16LE-no-BOM, then
// declared gb18030). Reverting the checkNoNUL on the GB18030 return lets the corruption through.
func TestDecodeSourceGB18030NULGuard(t *testing.T) {
raw := []byte{0x48, 0x00, 0x69, 0x00} // "H\0i\0"
if got, err := decodeSourceBytes(raw, "gb18030", "zh"); err == nil {
t.Fatalf("NUL-carrying bytes declared gb18030 must fail loud (NUL guard), got %q", got)
} else if !strings.Contains(err.Error(), "NUL") {
t.Fatalf("expected a NUL-guard error, got %v", err)
}
}
// TestDecodeSourceUTF16NULGuard covers the sibling of the GB18030 NUL guard: a BOM-prefixed UTF-16LE
// stream carrying an embedded U+0000 decodes cleanly (no U+FFFD) via decodeUTF16, so only the NUL
// guard on that path catches it. Reverting checkNoNUL on the decodeUTF16 return lets it through.
func TestDecodeSourceUTF16NULGuard(t *testing.T) {
// BOM(FF FE) + "H"(48 00) + U+0000(00 00) + "i"(69 00) — valid UTF-16LE, decodes to "H\0i".
raw := []byte{0xFF, 0xFE, 0x48, 0x00, 0x00, 0x00, 0x69, 0x00}
if got, err := decodeSourceBytes(raw, "auto", "en"); err == nil {
t.Fatalf("a BOM'd UTF-16 stream with an embedded NUL must fail loud (NUL guard), got %q", got)
} else if !strings.Contains(err.Error(), "NUL") {
t.Fatalf("expected a NUL-guard error, got %v", err)
}
}
func TestDecodeSourceExplicitUTF8OnGBFailsLoud(t *testing.T) {
raw := gb18030Bytes(t, zhChapter) // not valid UTF-8
if _, err := decodeSourceBytes(raw, "utf8", "zh"); err == nil {

View file

@ -72,6 +72,19 @@ type Runner struct {
// NewRunner loads all three configs rooted at book.yaml and opens the project
// store. Caller owns Close.
func NewRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
return openRunner(bookPath, logger, true)
}
// NewReadOnlyRunner builds a runner for the $0 read-only commands (`tmctl report`/`status`): it
// SKIPS the API-key preflight so an auditor WITHOUT provider keys can read the store (D20.4). Those
// commands make zero LLM calls (they only project the persisted rows / re-chunk the source), so a
// missing key is irrelevant — the config-mechanics and adult-channel lints still run. translate and
// redrive (which DO call providers) keep NewRunner.
func NewReadOnlyRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
return openRunner(bookPath, logger, false)
}
func openRunner(bookPath string, logger *slog.Logger, checkKeys bool) (*Runner, error) {
book, err := config.LoadBook(bookPath)
if err != nil {
return nil, err
@ -89,9 +102,17 @@ func NewRunner(bookPath string, logger *slog.Logger) (*Runner, error) {
if err := pipe.CheckRunnable(); err != nil {
return nil, err
}
if err := pipe.CheckAdultChannel(book.Adult); err != nil {
return nil, err
}
// Key preflight only for the call paths that actually reach a provider — read-only report/status
// are $0 (D20.4). CheckRunnable/CheckAdultChannel above stay unconditional (they cost nothing and
// catch a broken config even on a read).
if checkKeys {
if err := models.CheckKeys(pipe); err != nil {
return nil, err
}
}
pricer, err := models.Prices()
if err != nil {
return nil, err

View file

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -1556,3 +1557,138 @@ func TestRunnerChannelBRequiresPermissive(t *testing.T) {
}
r.Close()
}
// TestReadOnlyRunnerSkipsKeyCheck is the D20.4 read-only-audit fix: `report`/`status` build via
// NewReadOnlyRunner, which must open a project whose stage provider's API key is UNSET — NewRunner
// (translate/redrive path) must still fail loud on the same config. Reverting the checkKeys guard
// makes NewReadOnlyRunner fail here too.
func TestReadOnlyRunnerSkipsKeyCheck(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "prompts", "translator.md"), "T\n---USER---\n{{text}}")
writeFile(t, filepath.Join(dir, "prompts", "editor.md"), "E\n---USER---\n{{text}}\n{{draft}}")
writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: keyed-model
providers:
keyed:
kind: openai
base_url: http://127.0.0.1:1/v1
api_key_env: TM_TEST_DEFINITELY_UNSET_KEY
timeouts: { attempt_s: 5, max_attempts: 1, backoff_cap_s: 1 }
models:
keyed-model:
provider: keyed
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
`, time.Now().UTC().Format("2006-01-02")))
writeFile(t, filepath.Join(dir, "pipeline.yaml"), `
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
stages:
- { name: draft, role: translator, model: keyed-model, prompt: prompts/translator.md, prompt_version: v, temperature: 0.3, reasoning: "off" }
`)
writeFile(t, filepath.Join(dir, "source.txt"), "テキスト。")
writeFile(t, filepath.Join(dir, "book.yaml"), `
book_id: test-book
title: Тест
source_lang: ja
target_lang: ru
genre: ранобэ
audience: тест
venuti: 0.5
honorifics: keep
transcription: polivanov
footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 1.0, day_usd: 2.0 }
`)
bookPath := filepath.Join(dir, "book.yaml")
if _, err := NewRunner(bookPath, obs.NewLogger()); err == nil || !strings.Contains(err.Error(), "TM_TEST_DEFINITELY_UNSET_KEY") {
t.Fatalf("NewRunner must fail loud on the missing stage key, got: %v", err)
}
r, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatalf("NewReadOnlyRunner must open the project without the API key ($0 read), got: %v", err)
}
r.Close()
}
// TestRunnerAdditiveReasoningBufferReserved verifies the xAI path (Task 3): a reasoning-ON stage on
// an ADDITIVE-billing provider reserves the declared reasoning_max_tokens buffer in EstimateUSD, so
// the additive reasoning spend is NOT invisible to the book ceiling. Method: a huge buffer pushes
// the FIRST draft reservation past a $1 ceiling — the run aborts loud. Reverting the
// AdditiveReasoningTokens wiring in runAttempt (reserve 0) lets the reservation fit and the run
// succeeds, failing this test. A subset provider with the same buffer must NOT reserve it (control).
func TestRunnerAdditiveReasoningBufferReserved(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
setup := func(t *testing.T, providerReasoning string) string {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "prompts", "translator.md"),
"Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}")
writeFile(t, filepath.Join(dir, "prompts", "editor.md"),
"Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}")
writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: fake-model
providers:
fake:
kind: openai
base_url: %q
reasoning: %s
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
models:
fake-model:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
`, time.Now().UTC().Format("2006-01-02"), srv.URL, providerReasoning))
// reasoning:high on the draft + a 10M-token buffer → buffer term = 10M×$2/M = $20 ≫ the $1
// ceiling; the base reservation (tiny) fits alone. So the buffer alone decides ceiling-trip.
writeFile(t, filepath.Join(dir, "pipeline.yaml"), `
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
stages:
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: high, reasoning_max_tokens: 10000000 }
- { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" }
`)
writeFile(t, filepath.Join(dir, "source.txt"), "静かな図書館の朝。")
writeFile(t, filepath.Join(dir, "book.yaml"), `
book_id: test-book
title: Тест
source_lang: ja
target_lang: ru
genre: ранобэ
audience: тест
venuti: 0.5
honorifics: keep
transcription: polivanov
footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 1.0, day_usd: 2.0 }
`)
return filepath.Join(dir, "book.yaml")
}
// Additive provider: the buffer is reserved → the draft reservation trips the $1 ceiling.
r := newRunner(t, setup(t, "additive"))
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err == nil || !errors.Is(err, errReserveCeiling) {
t.Fatalf("additive+reasoning-on must reserve the buffer and trip the book ceiling, got err=%v", err)
}
// Subset provider, identical buffer: reasoning ⊆ completion ≤ maxTokens, so NO buffer is
// reserved and the run proceeds (the buffer is inert for subset billing).
rSub := newRunner(t, setup(t, "subset"))
defer rSub.Close()
if _, err := rSub.TranslateBook(context.Background()); err != nil {
t.Fatalf("subset provider must NOT reserve the additive buffer (run should proceed), got err=%v", err)
}
}

View file

@ -475,18 +475,25 @@ func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSumm
// flagged chunk_status rows + checkpoints (ResetChunkStages), so that fail-loud left the flag
// telemetry destroyed and a later `status` read the chunk as pending/pass.
//
// The snapshot MUST be computed exactly the way TranslateBook will compute it — i.e. AFTER
// re-seeding the glossary from the seed FILE (self-review major: a read-only stored-glossary
// projection misses a seed-FILE edit — precisely what the glossary_miss CLI hint tells the
// operator to make — so the guard would pass, the reset would fire, and the re-run's re-seed
// would then fail loud AFTER the destructive delete). seedGlossary is idempotent and touches
// only glossary rows (never chunk_status/checkpoints), so re-seeding here is safe; TranslateBook
// re-seeds again identically. A seed error (e.g. a new collision) also surfaces here, before any
// reset. Skipped under --resnapshot (the operator explicitly accepts the re-pin/re-pay).
if !r.Resnapshot {
// Re-seed the glossary from the seed FILE BEFORE the destructive reset — on BOTH paths, incl.
// --resnapshot (пакет №3 fix: the redrive-resnapshot wiring in cmd/tmctl exposed that the old code
// nested this seed inside `if !r.Resnapshot`, so under --resnapshot a seed-FILE edit that
// introduces a collision would fail loud only in TranslateBook's re-seed AFTER ResetChunkStages
// already deleted the flag telemetry — re-opening the external-review 1c torn-state class for the
// resnapshot path). seedGlossary is idempotent and touches only glossary rows (never
// chunk_status/checkpoints), so a seed error (e.g. a new shared-key collision) surfaces HERE,
// before any reset; TranslateBook re-seeds identically afterwards (no double cost). It also
// materializes r.memory so the snapshot comparison below matches what TranslateBook renders.
if err := r.seedGlossary(ctx); err != nil {
return summary, nil, fmt.Errorf("pipeline: redrive seed glossary: %w", err)
}
// Config/seed-drift guard BEFORE the destructive reset (external-review 1c): if the current config
// drifted from the snapshot the stored rows carry, TranslateBook would fail loud in runStage — but
// the reset (ResetChunkStages) would already have deleted the flagged rows/checkpoints, so a later
// `status` reads the chunk as pending/pass. Refuse loud here instead. Skipped under --resnapshot
// (the operator explicitly accepts the re-pin/re-pay) — but ONLY the snapshot COMPARISON is skipped,
// never the seed-error-before-reset protection above.
if !r.Resnapshot {
curSnap, _, serr := r.snapshotID()
if serr != nil {
return summary, nil, fmt.Errorf("pipeline: redrive snapshot check: %w", serr)

View file

@ -426,6 +426,97 @@ func TestRedriveAbortsOnConfigDriftPreservingTelemetry(t *testing.T) {
}
}
// TestRedriveResnapshotAcceptsDrift is the D20.4 counterpart: `redrive --resnapshot` (which the CLI
// now wires into r.Resnapshot — previously the flag was parsed but silently ignored) must SKIP the
// drift guard and accept the re-pin, resetting + re-attacking instead of failing loud. Same drifted
// setup as TestRedriveAbortsOnConfigDriftPreservingTelemetry, but with Resnapshot=true. Reverting the
// `if !r.Resnapshot` guard-skip makes this fail loud like the abort case.
func TestRedriveResnapshotAcceptsDrift(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop"
}
return draftEdit(body)
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
ctx := context.Background()
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
// Drift the config (a prompt_version bump renders a new snapshot).
dir := filepath.Dir(bookPath)
body, err := os.ReadFile(filepath.Join(dir, "pipeline.yaml"))
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "pipeline.yaml"),
strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test-drift", 1))
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // <-- what `redrive --resnapshot` sets
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
if err != nil {
t.Fatalf("redrive --resnapshot must NOT abort on drift, got: %v", err)
}
if !sum.ResetRun || res == nil {
t.Fatalf("redrive --resnapshot must reset + re-run under the new snapshot: resetRun=%v res=%v", sum.ResetRun, res)
}
}
// TestRedriveResnapshotSeedCollisionAbortsBeforeReset is the self-review CONFIRMED (fixes-safety):
// even under --resnapshot, a seed-FILE edit that introduces an approved shared-key COLLISION must
// fail loud BEFORE the destructive reset, not after (which would delete the flag telemetry, then the
// re-seed would fail loud — the external-review 1c torn state, re-opened for the resnapshot path by
// wiring r.Resnapshot into redrive). Reverting the seedGlossary hoist (nesting it back inside
// `if !r.Resnapshot`) deletes the flagged row here.
func TestRedriveResnapshotSeedCollisionAbortsBeforeReset(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop"
}
return draftEdit(body)
})
defer srv.Close()
clean := "terms:\n - src: Гуюэ\n dst: Гу\n status: approved\n"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0, glossarySeed: clean})
ctx := context.Background()
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
// Introduce an approved shared-key collision: same ELIGIBLE firing src (phonetic ≥3), different
// sense+dst, overlapping windows → the D16.1 polysemy livelock class → seedGlossary fails loud.
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"),
"terms:\n - src: Гуюэ\n dst: Гу\n sense: a\n status: approved\n - src: Гуюэ\n dst: Губец\n sense: b\n status: approved\n")
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // --resnapshot: skips the snapshot COMPARISON, but NOT the seed-error-before-reset guard
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
if err == nil {
t.Fatal("a seed collision must make redrive --resnapshot fail loud (before the reset)")
}
if res != nil || sum.ResetRun {
t.Errorf("a seed-collision-aborted redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun)
}
// The whole point: the reset never fired, so the flag telemetry is intact.
if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
t.Fatalf("the flagged row must survive a seed-collision-aborted redrive --resnapshot, got: %+v", cs)
}
}
// TestRedriveAbortsOnSeedFileDriftPreservingTelemetry locks the self-review MAJOR: the redrive guard
// must catch a SEED-FILE edit (not just a config-file edit), because that is exactly what the
// glossary_miss CLI hint tells the operator to make. The guard re-seeds (like TranslateBook will) so

View file

@ -274,6 +274,36 @@ var migrations = []string{
`,
}
// DESIGN NOTE (D21.10 — reserved memory-bank v2 record types; Ф2, NOT a migration, NOT code).
//
// research/15 (D21) ratified three future memory MECHANISMS whose storage will extend the bank. This
// note reserves their shape so a later Ф2 migration lands cleanly against the existing determinism/
// snapshot discipline. It is DELIBERATELY not appended to `migrations` above: the mechanisms are Ф2
// (D21.13), gated on the pilot and on D15.2 landing — reserving a column now would be dead schema.
//
// • voice_profile (D21.1) — per-character voice exemplars + descriptors, injected DRAFT-side
// (cap ≤300 tok/chunk; injection priority CONFIRMED-glossary > address_pair > voice_profile >
// reveal-aliases). FROZEN per-job until D15.2 lands (self-populating exemplars = mid-run memory
// append = book re-pay under D8/D15.1). Likely a `voice_profiles` table keyed (book_id, term_id)
// with exemplar/descriptor columns + a since_ch/until_ch window mirroring the glossary spoiler
// axis. Injected content rides content_hash; any live verdict rides verdictSnapshotID (D15.2).
//
// • address_pair (D21.2) — the ты/вы register for an ORDERED character pair. It is the
// MATERIALIZATION of the D7-Правка-1 ты/вы transition JOURNAL, NOT a second store: the single
// source of truth is the journal (a ты↔вы switch is a STORY event), and address_pair is only its
// resolved current-state PROJECTION (recomputable, self-healing). Deterministic flagger
// unconditionally; injection only after the hard-pairs probe (lift on easy pairs = 0 — a
// flagger-only outcome is legitimate). Likely `address_pairs` (book_id, term_id_a, term_id_b,
// register, since_ch) DERIVED from a `speech_transitions` journal — must not duplicate the
// journal's authority (glossary.speech today is a static note; the journal supersedes it in Ф2).
//
// • reveal_ch + pre/post-reveal aliases (D21.3) — a reveal window (`reveal_ch`) plus alias sets
// valid BEFORE vs AFTER the reveal (gender/identity twist: a post-reveal dst must not leak before
// reveal_ch — same self-inflicted-spoiler logic as since_ch/until_ch windows). Likely a `reveal_ch`
// column on glossary + alias rows tagged phase=pre|post, so injection selects by current_ch vs reveal_ch.
//
// None of these is a column today; Ф2 implements them behind their own migration + tests.
// migrate runs all pending migrations on the write pool, one transaction per
// step, recording each in schema_version.
func (s *Store) migrate(ctx context.Context) error {