From e1fcee4e2577286f792a307d0828af572f1fb4fe Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Fri, 10 Jul 2026 04:02:10 +0300 Subject: [PATCH] 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 --- backend/README.md | 4 +- backend/cmd/tmctl/main.go | 24 +- backend/configs/models.yaml | 114 ++++++++- backend/configs/pipeline-c1.yaml | 26 +- .../D15.2-content-addressed-resume-spec.md | 238 +++++++++++++----- backend/internal/config/config_test.go | 29 +++ backend/internal/config/models.go | 6 +- backend/internal/config/pipeline.go | 20 ++ backend/internal/llm/httpllm.go | 10 +- backend/internal/llm/httpllm_test.go | 43 ++++ backend/internal/llm/provider_openai.go | 20 +- backend/internal/pipeline/cheapgates.go | 105 ++++++-- backend/internal/pipeline/cheapgates_test.go | 10 + backend/internal/pipeline/ingest.go | 13 +- .../internal/pipeline/ingest_encoding_test.go | 26 ++ backend/internal/pipeline/runner.go | 23 +- backend/internal/pipeline/runner_test.go | 136 ++++++++++ backend/internal/pipeline/status.go | 29 ++- backend/internal/pipeline/status_test.go | 91 +++++++ backend/internal/store/migrate.go | 30 +++ 20 files changed, 857 insertions(+), 140 deletions(-) diff --git a/backend/README.md b/backend/README.md index e3dd208..b125c0a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -9,7 +9,7 @@ | `cmd/tmctl` | CLI: `translate` / `report` / `status` (read-only проекция N/M+паспорта глав+деньги, `--json`) / `redrive` (переатака флагнутых: `--chapter/--chunk/--reason/--dry-run`, D15.3) | `main.go`, `internal/pipeline/status.go` | | `internal/llm` | OpenAI-совместимый транспорт + retry/backoff, **capability-слой** (budget_field/temperature/reasoning per-модель), failover (написан, НЕ подключён — D4: только local-роли), нативный Anthropic = DEPRECATED-референс под будущий Gemini | `httpllm.go`, `capability.go`, `failover.go` | | `internal/ledger` | Цены по usage (вкл. reasoning/cache-поля), `PriceForResponse` по фактической модели | `pricing.go` | -| `internal/store` | SQLite (modernc, CGO-free), идемпотентные миграции v1–v6, reserve/settle+checkpoint, chunk_status, глоссарий, ruby, retrieval_state, request_log | `ledger.go`, `migrate.go`, `glossary.go` | +| `internal/store` | SQLite (modernc, CGO-free), идемпотентные миграции v1–v7, 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) остаётся заблокированной до ратификации оркестратором. diff --git a/backend/cmd/tmctl/main.go b/backend/cmd/tmctl/main.go index ea372c3..a67d465 100644 --- a/backend/cmd/tmctl/main.go +++ b/backend/cmd/tmctl/main.go @@ -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 { diff --git a/backend/configs/models.yaml b/backend/configs/models.yaml index 049a6d0..08dd19a 100644 --- a/backend/configs/models.yaml +++ b/backend/configs/models.yaml @@ -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=total−prompt−completion, +# иначе 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_tokens−prompt−completion (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 } # полигон: чанки 63–278 с 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 diff --git a/backend/configs/pipeline-c1.yaml b/backend/configs/pipeline-c1.yaml index e6103a6..b062193 100644 --- a/backend/configs/pipeline-c1.yaml +++ b/backend/configs/pipeline-c1.yaml @@ -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: diff --git a/backend/docs/D15.2-content-addressed-resume-spec.md b/backend/docs/D15.2-content-addressed-resume-spec.md index ba2f4d8..4b79deb 100644 --- a/backend/docs/D15.2-content-addressed-resume-spec.md +++ b/backend/docs/D15.2-content-addressed-resume-spec.md @@ -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). diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index c5a2515..a5830b7 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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{ diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go index 1aebb35..702fd4b 100644 --- a/backend/internal/config/models.go +++ b/backend/internal/config/models.go @@ -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) diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go index 0d0982e..b5769c6 100644 --- a/backend/internal/config/pipeline.go +++ b/backend/internal/config/pipeline.go @@ -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) { diff --git a/backend/internal/llm/httpllm.go b/backend/internal/llm/httpllm.go index 96edda2..e1afb44 100644 --- a/backend/internal/llm/httpllm.go +++ b/backend/internal/llm/httpllm.go @@ -200,8 +200,14 @@ func (r openAIRequest) MarshalJSON() ([]byte, error) { } type openAIUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` + 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"` diff --git a/backend/internal/llm/httpllm_test.go b/backend/internal/llm/httpllm_test.go index 967a1e8..b7c2adb 100644 --- a/backend/internal/llm/httpllm_test.go +++ b/backend/internal/llm/httpllm_test.go @@ -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 = total−prompt−completion = 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 diff --git a/backend/internal/llm/provider_openai.go b/backend/internal/llm/provider_openai.go index 9bd6b4d..79fdf77 100644 --- a/backend/internal/llm/provider_openai.go +++ b/backend/internal/llm/provider_openai.go @@ -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 30–44%). + // dropping it undercounted Grok spend by 30–44%). 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 == "" { diff --git a/backend/internal/pipeline/cheapgates.go b/backend/internal/pipeline/cheapgates.go index 3579109..a0428b8 100644 --- a/backend/internal/pipeline/cheapgates.go +++ b/backend/internal/pipeline/cheapgates.go @@ -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) - } - for _, rg := range ranges { - if maxSrc >= rg[0] && maxSrc <= rg[1] { - return 0, nil // the top source magnitude IS represented — no mismatch + // 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 三万) } } + wordRanges := magnitudeWordRanges(final) + for _, rg := range wordRanges { + if maxSrc >= rg[0] && maxSrc <= rg[1] { + 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 } diff --git a/backend/internal/pipeline/cheapgates_test.go b/backend/internal/pipeline/cheapgates_test.go index 1f16156..805f08a 100644 --- a/backend/internal/pipeline/cheapgates_test.go +++ b/backend/internal/pipeline/cheapgates_test.go @@ -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) { diff --git a/backend/internal/pipeline/ingest.go b/backend/internal/pipeline/ingest.go index 75b169d..779ce99 100644 --- a/backend/internal/pipeline/ingest.go +++ b/backend/internal/pipeline/ingest.go @@ -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 diff --git a/backend/internal/pipeline/ingest_encoding_test.go b/backend/internal/pipeline/ingest_encoding_test.go index 6e5823a..f6267e2 100644 --- a/backend/internal/pipeline/ingest_encoding_test.go +++ b/backend/internal/pipeline/ingest_encoding_test.go @@ -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 { diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index 7a399fc..4ab3f01 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -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 := models.CheckKeys(pipe); err != nil { + 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 diff --git a/backend/internal/pipeline/runner_test.go b/backend/internal/pipeline/runner_test.go index 1683b7f..da42eb4 100644 --- a/backend/internal/pipeline/runner_test.go +++ b/backend/internal/pipeline/runner_test.go @@ -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) + } +} diff --git a/backend/internal/pipeline/status.go b/backend/internal/pipeline/status.go index b677560..f7b5182 100644 --- a/backend/internal/pipeline/status.go +++ b/backend/internal/pipeline/status.go @@ -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). + // 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 { - if err := r.seedGlossary(ctx); err != nil { - return summary, nil, fmt.Errorf("pipeline: redrive seed glossary: %w", err) - } curSnap, _, serr := r.snapshotID() if serr != nil { return summary, nil, fmt.Errorf("pipeline: redrive snapshot check: %w", serr) diff --git a/backend/internal/pipeline/status_test.go b/backend/internal/pipeline/status_test.go index fc5f6d7..cbb012f 100644 --- a/backend/internal/pipeline/status_test.go +++ b/backend/internal/pipeline/status_test.go @@ -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 diff --git a/backend/internal/store/migrate.go b/backend/internal/store/migrate.go index 67a9779..be37be2 100644 --- a/backend/internal/store/migrate.go +++ b/backend/internal/store/migrate.go @@ -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.1–3), 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 {