Land backend package 3: D15.2 spec v3, D20.4 fix-list mutation-verified, D3 chain and channel B config with live-checked slugs, Gemini additive_total reasoning billing
This commit is contained in:
parent
4908f956ba
commit
e1fcee4e25
20 changed files with 857 additions and 140 deletions
|
|
@ -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) остаётся заблокированной до ратификации оркестратором.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue