textmachine/backend/docs/D15.2-content-addressed-resume-spec.md

610 lines
75 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# D15.2 — Спека v3.1: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
**Статус:** **v3.1 — реализация РАЗБЛОКИРОВАНА (D30.9, промт `BACKEND_D152_SESSION_PROMPT.md`,
этап Б).** v3.1 закрывает два ОБЯЗАТЕЛЬНЫХ амендмента D22.2 (§0-бис ниже) + пре-имплементационные
пункты (axis-тест, паритетные не-цели §8, освежение line-refs). Зона: спека в `backend/docs/`
(зона бэкенда); при лендинге оркестратор сворачивает её в
`docs/architecture/03-implementation-notes.md` новым разделом.
## 0-бис. v3.1-амендменты (D22.2 два обязательных + пре-имплементационные)
Ревью D22.2 сконструировало и ИСПОЛНИЛО stale-hit, который v3 пропускал. Два обязательных
амендмента до реализации:
- **(v3.1-a) `Book.SourceLang`/`Book.TargetLang``verdictSnapshotID`.** Языки — ВЕРДИКТ-входы,
потребляемые ВНЕ рендера: `classify` использует `TargetLang` для cjk-echo-проверки
(`disposition.go:196` `isCJKTarget`), `coverageCheck` использует пару `SourceLang`/`TargetLang`
для коридора `len_ratio` (`chunkrun.go:56`). `editor.md` НЕ рендерит `{{target_lang}}` (после
флипа D30.1 билингв-редактор рендерит `{{source_lang}}`, но не target) → на editor-стадии
`content_hash` их НЕ ловит, а вердикт (cjk/coverage) от них зависит. Сегодня они в ключе через
`brief_hash` (book-поле), но v3 дропает `brief_hash``content_hash`; значит нужен ЯВНЫЙ
вердикт-фейт. **Правило §4 переформулировано: судьбу обязано получить ЛЮБОЕ brief-поле,
потребляемое ВНЕ рендера** (языки — в `verdictSnapshotID`; инцидентально они и в translator
`content_hash` через рендер, но вердикт-зависимость — несущая). §4-строка добавлена.
- **(v3.1-b) `cache_ttl`-расщепление: `Context.CacheTTL` — advisory (НЕ в `wireSnapshotID`);
`prov.CacheTTL` — в `wireSnapshotID`.** Проверено кодом: `clients.go:38` передаёт клиенту
`CacheTTL: prov.CacheTTL` (провайдер-уровень, models.yaml, Anthropic-kind cache_control) —
**`Context.CacheTTL` (pipeline `context.cache_ttl`, «5m») НЕ доходит НИ ДО ОДНОГО клиента =
wire-ИНЕРТЕН.** v3 клал в `wireSnapshotID` инертный `Context.CacheTTL` и терял wire-влияющий
`prov.CacheTTL` — задом наперёд. v3.1: `Context.CacheTTL` демотирован до advisory (response-
нейтрален, reuse безопасен; НЕ пиним); `prov.CacheTTL` (пер-стадийно через резолвнутый провайдер)
`wireSnapshotID` + `guard_hash` (меняет cache_control на Anthropic-проводе). Anthropic снят со
стека (models.yaml) → экспозиция нулевая, но механизм чист. §4-строки поправлены.
**Пре-имплементационные (D22.2):** (1) axis-sensitivity тест `RequestHash` — флип `role`/`stage`
меняет хеш (сейчас мутация «удалить role» пережила бы сьют) → §12.9 тест-лист (`г-тер`);
(2) абзац «паритетные не-цели» §8 (classify-Source для editor-строк; `Retries`/`Escal.BudgetUSD`
вне хешей; транспорт-ретраи вне ключа); (3) line-refs освежены — **⚠ рефактор D23 расщепил
`runner.go` на `stagerun.go`/`chunkrun.go`/`escalation.go`/`bookrun.go`/`resume.go`/`status.go`;
исторические `runner.go:NNN`-ссылки ниже ИНДИКАТИВНЫ (по классу кода), актуальные локации —
в §12-плане и по grep имени функции.**
**Supersedes:** v2 (направление v1+v2 ратифицировано D-лог §Ратификации 09.07 п.2 и D20.1: расщепить
`snapshotID` на wire/вердикт-части; ключ чекпоинта — на wire-идентичности; вердикт-смену
re-classify-ить на resume бесплатно; три дыры v1 закрыты v2). **v3 закрывает три правки D20.1**
(«направление и 90% деталей ратифицированы; реализация разблокируется после v3-правок»):
- **(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 蛊真人)
НЕ трогать до приёмки**; эта спека — для онгоинга (Ф1.5).
---
## 1. Проблема (верифицирована D15 / 07 §4.4)
`snapshotID` (`runner.go:263-431`) вшит в `RequestHash` как поле `snapshotID` (`render.go:198,212-214`,
length-prefixed хеш). Значит **любое** изменение снапшота — флип coverage-гейта, toggle
`postcheck_gate`, бамп `classifierVersion`, append approved-терминов через `memoryVersion`, правка
инертной ручки — после `--resnapshot` пере-оплачивает **всю книгу**, включая **байт-идентичные**
запросы (воспроизведено: 2→4 оплаченных вызова на wire-идентичных телах). Механизм честного reuse
существует (`msgsContentHash`, `render.go:233-249`), но заперт: fast-path в `runStage`
(`runner.go:899`) доверяет строке `chunk_status` только при `cs.SnapshotID == snapID`, поэтому
через смену снапшота не срабатывает.
Лгущие комментарии `runner.go`/`coverage.go` про «re-classify from checkpoint for FREE» уже
исправлены (пакет 09.07): сегодня это правда ТОЛЬКО внутри неизменного снапшота. Цель — сделать это
правдой и через смену снапшота, без потери snapshot-гарантий и без ре-открытия stale-serve.
## 2. Ключевое наблюдение: снапшот смешивает ДВА класса входов + ТРЕТИЙ, избыточный
`snapshotID` сегодня сворачивает вместе три категории, хотя ключ вызова нуждается только в одной:
**(A) Wire-определяющие, УЖЕ покрытые иначе** (msgs/content_hash или прямые поля `RequestHash`).
`brief_hash`, `chunker_version`, `context_assembly` (инъекционные ручки), `memory_version`
(матчер+инъекция) — всё материализуется в `msgs` (метаданные книги через шаблон `render.go:108-121`,
нарезка → `ch.Text`, инъекция глоссария как ОТДЕЛЬНОЕ сообщение `render.go:174-190`) →
`msgsContentHash`. `model`, `temperature`, `reasoning`, `jsonOnly`, `maxTokens` — уже ПРЯМЫЕ поля
`RequestHash`. `estimator_version`, `max_tokens_policy`, `max_output_ratio`, `min_max_tokens` — лишь
ДЕРИВИРУЮТ `maxTokens`, а его фактическое значение уже прямое поле.
**(B) Wire-определяющие, НЕ покрытые иначе.** Резолвнутая `capability`, `model_extra`,
провайдер-оверрайды (local `temp`/`max_tokens`/`model`), эскалационные `escalate_to` +
capability/extra/оверрайды, `pipeline_core` (C0…C3 — механика сборки), `cache_ttl` (cache-control на
проводе, не в `content_hash`). Меняют ТЕЛО запроса, но не заходят в ключ никак, кроме снапшота — это
единственная wire-часть снапшота, которую надо сохранить в ключе.
**(C) Вердикт-определяющие** — меняют, как ответ КЛАССИФИЦИРУЕТСЯ, но НЕ байты запроса:
`classifier_version` (`classifyOutput``classify`, `runner.go:201-219`), `coverage`
(enabled+пороги+`coverageGateVersion`), `postcheck_gate` (on/off) и алгоритм/данные post-check
(`memoryPostcheckVersion` + decl инъектнутых записей при gate ON — `computeMemoryVersion`,
`memory.go:225-229`). Все они пересчитываются ВЖИВУЮ на resume и **в `chunk_status` не хранятся**
(классификатор/coverage — в `classifyOutput`; post-check — в `translateChunk` над финальным `prev`,
`runner.go:774-788`). Значит из ключа вызова их можно убрать целиком.
Сегодня (B) и (C) сидят в `RequestHash` → вердикт-only или book-global-wire правка промахивается по
ВСЕМ чекпоинтам и пере-оплачивает книгу. Это и есть дефект.
## 3. Дизайн: три хеша вместо одного снапшота-в-ключе
Расщепляем `snapshotID()` и вводим отдельный fast-path-гард. Появляются ТРИ независимых хеша, каждый
на своём слое:
### 3.1 `wireSnapshotID` — ПЕР-СТАДИЙНЫЙ, идёт в `RequestHash`
Хеш ТОЛЬКО категории (B) для КОНКРЕТНОЙ стадии: резолвнутая `capability`, `model_extra`,
провайдер-оверрайды (`ProviderTemp`/`ProviderMaxTok`/`ProviderModel`), эскалационные
`EscalateTo`+`EscalateCapability`+`EscalateExtra`+`EscalateProvider*`, `pipeline_core`, `cache_ttl`.
Префикс версии `tm-wire-v1`.
**Пер-стадийность — часть выигрыша гранулярности.** Сегодня `snapshotID` book-global: правка
`capability` стадии-2 (редактор) меняет `RequestHash` стадии-1 (черновик). В v2 `RequestHash`
стадии-1 несёт `wireSnapshotID` ТОЛЬКО стадии-1 → правка редактора не пере-оплачивает черновик
(каскад на редактор идёт честно через изменённый `prev`, §10). Book-global входы (`pipeline_core`,
`cache_ttl`) кладём в `wireSnapshotID` каждой стадии одинаковым значением.
Смена `wireSnapshotID` стадии → `RequestHash` этой стадии по всей книге меняется → честный re-bill
(эти входы реально меняют тело/ответ каждого вызова; v1 §3 это уже фиксировал).
### 3.2 `verdictSnapshotID` — book-global, идёт в `chunk_status.verdict_snapshot` (НЕ в `RequestHash`)
Хеш категории (C): `classifier_version`, coverage-конфиг+`coverageGateVersion`, `postcheck_gate`,
`memoryPostcheckVersion` + decl-контент инъектируемых записей при gate ON (§6). Префикс
`tm-verdict-v1`. В ключ вызова НЕ входит. Хранится в `chunk_status` как триггер бесплатного
re-classify на fast-path (§5, §8).
### 3.3 `guard_hash` — ПЕР-СТАДИЙНЫЙ, хранится в `chunk_status`, гейт fast-path (НЕ в `RequestHash`)
Хеш ВСЕГО wire stage-плана для чанка×стадии — суперсет того, что fast-path обязан проверить перед
доверием строке (закрывает дыру (a), §5):
`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`; всё остальное
как в `render.go:198`. Вердикт-версии из ключа **уходят**. Прямые wire-поля остаются прямыми (были и
есть) — это и есть покрытие, которого не хватало fast-path-у.
Полный `snapshotID` (для `jobs.snapshot_id`, `snapshots`-таблицы, отчётности) сохраняется как
`H(все wireSnapshotID стадий, verdictSnapshotID)`, но перестаёт быть частью ключа вызова и
демотируется до advisory (§9).
## 4. Полный полевой маппинг снапшота (обязательно, ревью п.2)
Каждое поле, сегодня сворачиваемое в `snapshotID()` (`runner.go:263-431`) + `memoryVersion`
(`memory.go:232-259`) + coverage/classifier/postcheck, классифицировано:
**wire** (влияет на байты запроса) / **verdict** (влияет на классификацию) / **dropped-as-redundant**
(уже покрыто иначе). Колонка «Куда в v2»: `content_hash` (msgs), прямое поле `RequestHash`,
`wireSnapshotID`, `verdictSnapshotID`, `guard_hash`, либо dropped.
| Поле (сегодня) | Класс | Куда в v2 | Почему |
|---|---|---|---|
| `brief_hash` | wire | **dropped**`content_hash` | Метаданные книги рендерятся в шаблон (`render.go:108-121`) → в `msgs`; per-chunk `content_hash` ловит правку брифа гранулярно. **Правило (v3.1-a): ЛЮБОЕ brief-поле, потребляемое ВНЕ рендера, обязано получить явную судьбу** (`content_hash` его не ловит). Назначено: `SourceLang`/`TargetLang``verdictSnapshotID` (cjk-gate/coverage-коридор — строка ниже, v3.1-a); `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-у. |
| `max_output_ratio` | wire (деривация) | **guard_hash** | Множитель `maxTokens`. То же. |
| `min_max_tokens` | wire (деривация) | **guard_hash** | Пол `maxTokens`. То же. |
| `context_assembly.glossary_injection` | wire | **dropped**`content_hash` | Режим инъекции меняет наличие/содержимое инъекц-сообщения → `msgs`. |
| `context_assembly.glossary_token_budget` | wire | **dropped**`content_hash` | Бюджет → eviction → инъектнутые строки → `msgs` (§10). |
| `context_assembly.stm_depth` | wire | **dropped**`content_hash` | STM входит в `msgs`. |
| `context_assembly.overlap_tokens` | wire | **dropped**`content_hash` | Overlap меняет `ch.Text``msgs`. |
| `context_assembly.cache_ttl` (`Context.CacheTTL`) | advisory | **dropped (v3.1-b)** | ⚠ **Правка v3.1-b: wire-ИНЕРТЕН**`clients.go:38` передаёт клиенту `prov.CacheTTL`, а `Context.CacheTTL` (pipeline `context.cache_ttl`) НЕ доходит НИ ДО ОДНОГО клиента. Response-нейтрален, reuse безопасен → НЕ пиним (v3 клал его в `wireSnapshotID` ошибочно). |
| `prov.CacheTTL` (провайдер, Anthropic cache_control) | wire (cache-control) | **wireSnapshotID** + `guard_hash` (v3.1-b) | Провайдер-уровневый TTL РЕАЛЬНО едет на провод (`clients.go:38``provider_anthropic.go` cache_control). Пер-стадийно через резолвнутый провайдер стадии. Anthropic снят со стека → экспозиция нулевая, механизм чист. |
| `Book.SourceLang` / `Book.TargetLang` | verdict | **verdictSnapshotID (v3.1-a)** | ⚠ **Правка v3.1-a.** Языки — ВЕРДИКТ-входы вне рендера: `TargetLang`→cjk-echo (`disposition.go:196`), пара→`len_ratio`-коридор (`chunkrun.go:56`). `editor.md` НЕ рендерит `{{target_lang}}``content_hash` editor-стадии их не ловит, а вердикт от них зависит. Инцидентально они и в translator `content_hash` (рендер `{{source_lang}}`/`{{target_lang}}`), но несущая — вердикт-зависимость. Уходят из ключа вместе с `brief_hash`; вердикт-фейт назначен явно. |
| `model` (пер-стадийный) | wire | **dropped из снапшота** → прямое поле `RequestHash` + `guard_hash` | `RequestHash` уже несёт `model` вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность. |
| `temperature` (`st.Temperature`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же. Именно этого не хватало fast-path-у v1 (дыра a). |
| `reasoning` (`st.Reasoning`) | wire | **dropped из снапшота** → прямое поле + `guard_hash` | То же (low→none — дыра a). |
| `jsonOnly` | wire | прямое поле `RequestHash` + `guard_hash` | Уже прямое (сейчас всегда `false`); держим в `guard_hash` для полноты. |
| `maxTokens` (фактический) | wire | прямое поле `RequestHash`; деривация → `guard_hash` | Значение — прямое поле; версии деривации — в `guard_hash` (выше). |
| `capability` (ResolveCapability) | wire | **wireSnapshotID** + `guard_hash` | Резолвнутая wire-форма (budget-ключ, temp-режим, thinking-выключатель); не в `msgs`, не прямое поле. |
| `model_extra` (`ExtraBody`) | wire | **wireSnapshotID** + `guard_hash` | Меняет тело (GLM thinking-off и т.п.); не в `msgs`. |
| `provider_temp` (local) | wire | **wireSnapshotID** + `guard_hash` | Оверрайд поверх wire ПОСЛЕ хеша; не в `msgs`. |
| `provider_max_tok` (local) | wire | **wireSnapshotID** + `guard_hash` | То же. |
| `provider_model` (local) | wire | **wireSnapshotID** + `guard_hash` | Фактически отвечающий local-бэкенд (8b→14b); самый импактный local-оверрайд. |
| `escalate_to` | wire | **wireSnapshotID** + `guard_hash` | Фолбэк-модель: меняет wire эскал-подвызова И резолвнутую диспозицию (случился ли хоп). |
| `escalate_capability` | wire | **wireSnapshotID** + `guard_hash` | Резолвнутая wire фолбэка. |
| `escalate_extra` | wire | **wireSnapshotID** + `guard_hash` | `extra_body` фолбэка. |
| `escalate_provider_temp/max_tok/model` | wire | **wireSnapshotID** + `guard_hash` | Провайдер-оверрайды фолбэка. |
| `pipeline_core` (C0…C3) | wire | **wireSnapshotID** + `guard_hash` | Механика сборки прохода (linear vs selection/fusion); консервативный catch-all wire-плана. |
| `prompt_version` (`st.PromptVersion`) | wire | **dropped**`content_hash` | Человеческий лейбл; фактические байты промпта рендерятся в `msgs` (авторитет — рендер, не лейбл). |
| `prompt_sha256` | wire | **dropped**`content_hash` | Шаблон рендерится в system/user сообщения → `msgs`. Рендер И ЕСТЬ `msgs`. |
| stage `name` | wire | **dropped из снапшота** → прямое поле `stage` + `content_hash` | `RequestHash` несёт `stage`; структура плана отражена в `msgs` через `prev`-цепочку. |
| stage `role` | wire | **dropped из снапшота** → прямое поле `role` + `content_hash` | `role` — прямое поле; он же выбирает инъекцию (translator/editor) → `msgs`. |
| `memory_version` (матчер+инъекция часть) = `memoryInjectVersion` | wire | **dropped**`content_hash` | `memoryNormVersion` + матчер-часть `memoryMatchVersion` определяют, КАКИЕ записи инъектятся → инъекц-сообщение → `msgs`. §6. |
| `memory_version` (post-check часть) = `memoryPostcheckVersion` | verdict | **verdictSnapshotID** | Алгоритм/пороги post-check — вердикт, пересчитывается вживую (`translateChunk`). §6. **Правка 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** (ТОЛЬКО при `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` строится из
`content_hash`. Следствие: смена cache-границы/TTL честно даёт свежий `RequestHash` (свежий вызов
если дошли до attempt-цикла), но fast-path на `content_hash`/`guard_hash` НЕ рушит reuse — это
корректно, т.к. cache-control response-нейтрален (валидный старый ответ переиспользуем). `cache_ttl`
в `wireSnapshotID` — консервативный пин для свежести ключа, не гард reuse.
## 5. HOLE (a): fast-path-гард обязан покрывать ВЕСЬ wire stage-план
**Дефект v1.** Fast-path (`resumeFromChunkStatus`, короткое замыкание в `runStage:897-901` ДО
`runAttempt`) доверяет строке `chunk_status`, когда совпали `content_hash` + `wireSnapshotID` +
`verdict_snapshot`. Но `content_hash` (= `msgsContentHash`) НЕ содержит прямых wire-полей
`model`/`temperature`/`reasoning`/`maxTokens`, а `wireSnapshotID` (§3.1) их тоже не содержит (они —
прямые поля `RequestHash`, не категория B). Значит смена `temperature` 0.3→0.4, `reasoning` low→none
или деривации `maxTokens` НЕ меняет ни `content_hash`, ни `wireSnapshotID`, ни `verdict_snapshot`
v1-fast-path ХИТИТ → отдаёт stale-текст, сгенерённый на СТАРОМ wire. Это ре-открытие stale-serve F1.
Настоящий `RequestHash` эти поля включает, но fast-path замыкается ДО attempt-цикла, где `RequestHash`
считается.
**Взвешены два варианта:**
- **(1) Отдельная колонка `guard_hash`** (§3.3): хеш ВСЕГО wire stage-плана (`content_hash` +
`wireSnapshotID` + `model`/`temp`/`reasoning`/`jsonOnly` + деривация `maxTokens` + `escalate_to`
внутри `wireSnapshotID`). НЕ часть `RequestHash` — только гейт доверия fast-path. На fast-path
пересчитываем `guard_hash` из ТЕКУЩЕГО конфига и сравниваем со строкой.
- **(2) Verify-by-recompute**: на fast-path пересчитать полные wire-параметры победившего attempt и
сравнить, храня в строке всё нужное для пересчёта (модель/temp/reasoning/фактический maxTokens
победителя, capability и т.д.).
**Рекомендация — вариант (1), `guard_hash`.** Проще и строго-сильнее:
- Один детерминированный хеш вместо хранения и по-полевого сравнения ~десятка параметров (вар. 2
раздувает `chunk_status` и дублирует логику резолва).
- **Строго сильнее текущего гарда**: сегодня fast-path проверяет `snapshot_id`+`content_hash`;
`guard_hash` покрывает `content_hash` (⊇) ПЛЮС весь wire-план, включая то, что `snapshot_id` терял
при пер-стадийном расщеплении. Смена `temperature`/`reasoning`/`maxTokens`-деривации/`escalate_to`
`guard_hash` mismatch → fast-path проваливается в attempt-цикл → там `RequestHash` (с прямыми
полями) промахивается → **честный свежий вызов** (это реально другая работа, не re-pay).
- **Разделение слоёв**: `RequestHash` — ключ оплаченного ответа; `guard_hash` — гард доверия
материализованной строки-резолва. Держать их разными хешами (а не пихать всё в `RequestHash`)
сохраняет пер-стадийную гранулярность ключа и не смешивает деривац-версии (которые в ключе
избыточны) с ключом.
**Контрпример к «строго сильнее» БЕЗ 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 и
отчётности), но гейтом служит `guard_hash` (он его суперсет). При mismatch `guard_hash`
проваливаемся в attempt-цикл (свежий вызов, если wire реально другой); при mismatch только
`verdict_snapshot` (guard совпал) — проваливаемся в `runAttempt`, где checkpoint ХИТИТ по
wire-идентичности → **re-classify бесплатно** → re-upsert строки с новым `verdict_snapshot` и
диспозицией (§8).
## 6. HOLE (b): расщепление `memory_version` — ОБЯЗАТЕЛЬНО
`memoryMatchVersion` (`memory.go:31-39`) сегодня версионирует ОБА: матчер/селекцию инъекции И
алгоритм post-check (слаг буквально несёт `…+postcheck-declaware`). `computeMemoryVersion`
(`memory.go:232-259`) сворачивает `memoryNormVersion` + `memoryMatchVersion` + `gate:bool` + контент
строк (approved-only при gate OFF; ВСЕ строки, включая `decl`+`status`, при gate ON). Всё это сегодня
`MemoryVersion``snapshotID` → ключ. Правка → промах по книге. Ревью-инсайт: **post-check
пересчитывается вживую на каждом resume (`translateChunk:774-788` над финальным `prev`) и в
`chunk_status` НЕ хранится — значит `memory_version` можно убрать из ключа вызова целиком.**
**Расщепление (обязательное, не опциональное):**
- **`memoryInjectVersion`** (wire) = `memoryNormVersion` + матчер/селекция-часть `memoryMatchVersion`
+ инъектируемый контент approved-записей (`src`/`dst`/aliases — то, что рендерится в
`renderGlossaryBlock`/`renderEditorConstraintBlock`). Определяет, ЧТО и КАК инъектится →
инъекц-сообщение → `msgs``content_hash`. **Из ключа УБИРАЕТСЯ полностью; отдельным полем ключа
НЕ является** — покрыт `content_hash` пер-чанково (это и даёт гранулярный re-bill append-а, §10).
- **`memoryPostcheckVersion`** (verdict) = алгоритм+пороги post-check + `gate:bool` (`postcheck_gate`)
+ **`decl`-контент ВСЕХ инъектируемых записей при gate ON** + **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`:**
1. `memoryPostcheckVersion` (алгоритм/пороги post-check);
2. `gate:bool` — судьба `postcheck_gate` **явно**: это ВЕРДИКТ-сторона (toggle меняет резолвнутую
диспозицию `glossary_miss`, `runner.go:782-787`), кладём в `verdictSnapshotID`. Сегодняшний
двойной фолд (top-level `PostcheckGate` в `snapshotID` + `gate:` в `computeMemoryVersion`)
схлопывается в один источник — `verdictSnapshotID`;
3. **decl-контент** инъектируемых записей — судьба **явно**: `decl` НЕ инъектится в `msgs`, но при
gate ON участвует в post-check вердикте (`memory.go:225-229` — «decl edit would silently flip a
resumed chunk's disposition»). Значит `decl` — ВЕРДИКТ-сторона → в `verdictSnapshotID` (только при
gate ON, зеркаля текущее условие `gateOn` в `computeMemoryVersion:238`). При gate OFF `decl`
не влияет ни на wire, ни на вердикт → не входит никуда (dropped).
**Почему это корректно и бесплатно.** Post-check материализуется из банка (пере-материализуется
каждый прогон, `seedGlossary``materializeMemory`) и гоняется вживую в `translateChunk` над `prev`
независимо от того, пришёл `prev` из свежего вызова или из fast-path-резолва. Правка `decl`/порогов
→ новый банк → новый вердикт post-check → но **wire идентичен → $0**. `verdictSnapshotID` в
`chunk_status.verdict_snapshot` делает такую правку (а) видимой статусу (§9) и (б) триггером
пере-резолва per-stage fast-path (classifier/coverage тоже вердикт, но пересчитываются ТОЛЬКО в
`runAttempt`/`translateChunk`, не в `resumeFromChunkStatus` — потому `verdict_snapshot` busts
fast-path и заставляет пройти attempt-цикл, где всё пере-классифицируется бесплатно). Post-check-часть
в `verdict_snapshot` формально belt-and-suspenders (post-check и так live на chunk-level), но держит
`verdict_snapshot` полным отпечатком вердикт-конфига.
## 7. HOLE (c): замена loud-гейта согласия на pre-flight dry-run
**Что демонтируется.** Сегодня рассинхрон снапшота — fail-loud стоп (`runStage:865-869`;
`--resnapshot` = явное согласие, Р6-класс «согласие на трату»). Content-addressed resume демотирует
job-snapshot-mismatch из fail-loud в пер-чанковое `content_hash`/`guard_hash`-решение (§8, §9) — то
есть **удаляет этот гейт согласия**. Его нельзя просто выкинуть: Р6-класс согласия на трату должен
быть ЗАМЕНЁН, иначе тихая переоплата возвращается через заднюю дверь.
**Замена — pre-flight dry-run.** Перед `translate`, который сменил бы конфиг (и всегда в
онгоинг-режиме) прогоняем СУХОЙ проход (детерминированный, $0, без LLM):
1. **Проекция (источник — тот же пер-чанковый `content_hash`/`guard_hash`-диф, что и на fast-path).**
Для каждого чанка×стадии материализуем ТЕКУЩИЕ `msgs` (`MessagesWithInjection` — дёшево, только
строковая подстановка) и считаем `content_hash` + `guard_hash`. Сравниваем со СТРОКОЙ
`chunk_status`. Единица «будет re-bill», если её `guard_hash` отличается от хранимого И хранимая
диспозиция была оплачена (ok/flagged). Диф ловит эффекты второго порядка, чьи `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. **Порог + флаг (форма ратифицирована 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` как флаг остаётся синонимом-алиасом для обратной совместимости, но
его семантика — «прими проецируемый re-bill», а не «пере-пинуй всё».
## 8. Resume: легальность reuse и безопасность re-classify (F1)
Reuse чекпоинта легален ⟺ **полная wire-идентичность**: `guard_hash` совпал (⊇ `content_hash` +
`wireSnapshotID` + прямые wire-поля + деривация `maxTokens`). Это ровно то, что даёт новый
`RequestHash` на attempt-оси: совпал `guard_hash` ⇒ совпал wire ⇒ `RequestHash` попадёт в оплаченный
ответ (то же основание, что у сегодняшнего same-snapshot resume). **Вердикт-различие
(`verdict_snapshot`) НИКОГДА не блокирует reuse перевода** — только заставляет пере-классифицировать.
Машинерия re-classify уже есть: на checkpoint-hit `runAttempt` (`runner.go:1094`) зовёт
`classifyOutput` над текстом чекпоинта, не доверяя старому вердикту; post-check — `translateChunk`
над `prev`. Правки:
- **Fast-path `resumeFromChunkStatus`**: гейт §5 (`guard_hash` + `verdict_snapshot`). При смене
`verdict_snapshot` (guard совпал) — НЕ отдавать старую диспозицию: проваливаться в `runAttempt`
checkpoint хитит по wire → re-classify бесплатно → re-upsert `chunk_status` с новыми
`verdict_snapshot`/диспозицией. Нужны колонки `chunk_status.guard_hash`, `chunk_status.verdict_snapshot`.
- **Вердикт-флип:** `flagged→ok` (ослабили гейт) → текст чекпоинта отдаётся бесплатно. `ok→flagged`
(ужесточили) → чанк флагается; если это влечёт НОВЫЙ wire-запрос (retry на `attempt+1` или
эскалация) — честно оплачивается (реально новая работа). Ретраи/эскалация уже на своих осях ключа
(`attempt`, модель эскалации).
**Ось безопасности F1 (никакого тихого расходящегося re-pay).** Reuse — precondition на ТОЧНОЕ
совпадение wire-идентичности (`guard_hash` — сильнейший гард: точные байты промпта+инъекции ПЛЮС все
прямые wire-поля и деривация maxTokens, чего v1 не покрывал). Вердикт-смена меняет только post-settle
классификацию, НИКОГДА не запрос — потому re-classify матчнутого чекпоинта не может отдать
расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей
wire на новом конфиге был бы ДРУГИМ из-за temp/reasoning/maxTokens) — теперь исключён `guard_hash`.
**Паритетные НЕ-цели (v3.1, D22.2 — что осознанно ВНЕ хешей).** Формула суперсет-паритета не обязана
хешировать входы, которые ни wire, ни вердикт: (а) **classify-Source для editor-строк**`classify`
принимает `Source` для soft-refusal-порога и cjk-echo, но на editor-выходе источник — это черновик, а
не оригинал; это часть детерминированной классификации над `content_hash`-материалом, не отдельная
ось ключа. (б) **`Retries.RegenerateBeforeEscalate` и `Escalation.BudgetUSD`** — НЕ в снапшоте/ключе:
они управляют СКОЛЬКО попыток/хопов делать, а не байтами какого-либо вызова; их правка меняет, какие
`attempt`-оси материализуются, но каждый материализованный вызов уже уникально кейится своим `attempt`
(ретрай) или моделью эскалации (хоп) — уже покрыто. Смена `RegenerateBeforeEscalate` не пере-оплачивает
готовые чанки (их `attempt=0`-чекпоинты попадают), только разрешает/запрещает БУДУЩИЕ ретраи флагнутых.
(в) **Транспортные ретраи** (`timeouts` в models.yaml, backoff) — вне ключа by design (тот же
`RequestHash` через транспорт-ретраи и resume — F3-идемпотентность); их правка не инвалидирует ничего.
Эти три — паритетные не-цели: их отсутствие в `wireSnapshotID`/`guard_hash`/`verdictSnapshotID`
КОРРЕКТНО, а не пробел.
## 9. Судьба `chunk_status.snapshot_id` и status-ридеров
- **`chunk_status.snapshot_id`** — **сохраняется как advisory** (какой job-снапшот резолвнул строку;
отчётность), но **перестаёт быть гейтом корректности**. Первичный resume-гейт — `guard_hash` +
`verdict_snapshot` + `content_hash` (§5). Условие `cs.SnapshotID == snapID` (`runStage:899`)
снимается.
- **`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-идентичные чанки, резолвнутые в разное время). Больше не
«loud».
- **`ConfigDrift`** (`status.go:56-61,295-299`: текущий конфиг рендерит иной снапшот, чем хранимый) —
**заменяется/дополняется** проекцией dry-run (§7): вместо булева «снапшот отличается» показываем
**«проецируемый re-bill: N чанков, ~$X»** из того же пер-чанкового `guard_hash`-дифа. Равенство
снапшота остаётся грубой подсказкой; действенное число — пер-чанковая проекция (`currentSnapshotProjected`,
`status.go:347`, расширяется до пер-чанкового дифа).
- **Redrive drift-guard** (`status.go:462-480`, добавлен этой сессией — рефузит redrive при
`cs.SnapshotID != curSnap`): его резон-детр (drift → переоплата всей книги) **растворяется**
расщеплением — drift больше не пере-оплачивает неизменные чанки. Guard **эволюционирует**: грубое
сравнение snapshot-равенства заменяется на `guard_hash`-диф ТОЛЬКО целевых (сбрасываемых) чанков.
Redrive и так пере-гоняет цели под текущим wire — это его цель; поэтому жёсткий рефуз снимается,
заменяясь на pre-flight проекцию §7 (`--accept-rebill` при превышении порога) плюс сохранённый
инвариант «DispOK-стадии не сбрасываются» (D-лог п.5в). Итог: redrive не рушит флаг-телеметрию
громким отказом, а показывает проецируемую цену целей.
## 10. Blast-radius approved-term APPEND (со sticky + eviction + каскадом стадий)
**Первый порядок (главная история):** append approved-термина T re-bill-ит **только чанки, где T
ФАЕРИТСЯ**. T фаерится в чанке, если его нормализованный ключ встречается в `ch.Text` (для translator
`memory.go` Select) → T инъектится → инъекц-сообщение меняется → `content_hash` меняется → translator
этого чанка re-bill. Чанки, где T не фаерится → идентичная инъекция → идентичный `content_hash` → $0
(checkpoint-hit, re-classify free). Это FIRST-ORDER контракт.
**Второй порядок — три поправки (ловятся dry-runом §7 автоматически, т.к. он диффит `content_hash`
per chunk×stage, а не рассуждает аналитически):**
1. **Sticky-кэрри (`stickyDepth = 2`, `memory.go:70-73`).** T, фаернувший в чанке K, попадает в
`activeIDs[K]``unionSticky` (`runner.go:577-588`) несёт его в K+1 и K+2 (в пределах ГЛАВЫ;
сброс на границе главы, `runner.go:549-552`). В K+1/K+2 T инъектится через sticky, даже если его
ключ там не встречается → их translator-инъекция меняется → `content_hash` → **K+1, K+2 тоже
re-bill**. Радиус = {фаерящие чанки} {их следующие 2 внутри главы}.
2. **F2 eviction (`memory.go` бюджет `glossary_token_budget`).** Инъекция бюджет-лимитирована. В
бюджет-ПОЛНОМ чанке добавление T ВЫТЕСНЯЕТ более низкоприоритетную ранее-инъектнутую строку L. Ключевой
второпорядковый случай: T, принесённый в K+1 через sticky (где T НЕ фаерится), потребляет бюджет и
вытесняет строку L' — **меняя `msgs` K+1 сверх просто +T, хотя сам T там не фаерится**. Eviction
НЕ расширяет множество чанков за пределы {фаерящие sticky}, но увеличивает дельту внутри них
(не «+T», а «+TL»).
3. **Каскад на редактор.** Инъектируемый констрейнт-блок редактора — ПОДМНОЖЕСТВО инъекции черновика
(`editorInjection = renderEditorConstraintBlock(memSel.injected)`, `runner.go:717-719` — тот же
`memSel`). Значит: (i) T-подтверждённый попадает и в констрейнт-блок редактора → `msgs` редактора
меняются; (ii) черновик translatorа меняется (T применён) → вход `{{draft}}` редактора меняется
`content_hash` редактора меняется. **Любой re-billed translator-чанк каскадит на свою editor-стадию.**
Первопорядковое «re-bill фаерящих чанков» надо читать как «re-bill фаерящих чанков × ОБЕ стадии».
**Итоговый радиус:** `{фаерящие T} {sticky +2/глава}` × `{translator, editor}`; всё прочее — $0
(checkpoint-hit → re-classify free). Онгоинг-выигрыш D15: подтверждение пачки терминов на границе
тома не пере-покупает готовые главы — только реально затронутые чанки×стадии, а pre-flight dry-run
(§7) показывает их число и цену ДО траты.
## 11. Sequencing миграции + одноразовый re-pin
Два изменения, оба ломающие ключ/схему:
- **Формула `RequestHash`**: `snapshotID``wireSnapshotID`, бамп `tm-request-v2``tm-request-v3`
(`render.go:212`). Это смена ФОРМАТА ключа → **все существующие чекпоинты промахнутся ОДИН раз**.
- **Схема**: миграция **v8** (текущий максимум — **v7** после cheap-gate миграции этой сессии:
`migrate.go` v7 = `ALTER TABLE retrieval_state ADD COLUMN n_style_flags/style_detail`):
`ALTER TABLE chunk_status ADD COLUMN guard_hash TEXT NOT NULL DEFAULT '';`
`ALTER TABLE chunk_status ADD COLUMN verdict_snapshot TEXT NOT NULL DEFAULT '';`
(`snapshot_id`, `content_hash` — сохраняются). Существующие строки получают пустой `guard_hash`
fast-path у них всегда busts → падают в attempt-цикл, где `RequestHash` под v3 не совпадёт с v2 →
свежий вызов (тот самый одноразовый re-pin). Строки самозалечиваются с новыми хешами при первом
прогоне.
**Ограничение порядка (жёсткое).** Миграция+бамп ДОЛЖНЫ приземлиться **ДО первой онгоинг-книги
(`add-chapters`)**. Иначе: если книга уже несёт v2-чекпоинты, а миграция приходит В СЕРЕДИНЕ её
жизни, первый `add-chapters` после миграции **пере-оплатит ВЕСЬ бэклог** (все прежние главы
пере-пинятся под v3) — ровно та D15-мина, которую спека снимает. Поэтому:
- Статичную приёмочную книгу Ф1 (D15.1 / D18 蛊真人) либо завершаем под v2, либо принимаем её
**одноразовый re-pin** (громкий, расхождений нет — D15.1) на границе Ф1.5.
- **Первая онгоинг-книга РОЖДАЕТСЯ под `tm-request-v3`** (начальные главы кейятся v3), чтобы
последующие `add-chapters` находили v3-ключи и не пере-пинали бэклог.
- **Одноразовый re-pin осознан и задокументирован** (как v1→v2 сейчас): ни одна онгоинг-книга не
должна пересекать миграцию в середине жизненного цикла.
## 12. Минимальный план реализации (Ф1.5, после ратификации 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(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`.
5. **Миграция v8** (§11): колонки `guard_hash`, `verdict_snapshot`; писать/читать в
`chunkstatus.go` (Upsert/Get/ForBook). `snapshot_id` оставить (advisory).
6. **Fast-path (`runStage`)**: гейт `cs.guard_hash == guardHash(тек.) && cs.verdict_snapshot ==
verdictSnapshotID(тек.) && disposition != skipped`; иначе `runAttempt` (re-classify бесплатно, §8).
7. **Job-snapshot-mismatch**: демотировать fail-loud стоп (`runStage:865-869`) в пер-чанковое
`guard_hash`-решение.
8. **Pre-flight dry-run (§7)**: пер-чанковый `guard_hash`-диф + **правило каскада §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**/
**sanitizer**/**source_lang·target_lang** — v3.1-a) → $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-контрпример);** **(г-тер, D22.2 axis-sensitivity) флип `role`/`stage` меняет
`RequestHash` — мутация «удалить role/stage из RequestHash» роняет тест (сейчас переживает сьют);** (д)
dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис) editor-каскад: смена temperature translator'а
→ проекция считает re-bill ОБЕИХ стадий чанка, не только translator (§7.1-бис; мутация «убрать правило
каскада» роняет проекцию до ~½);** (е) миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`;
**(ж, v3.1-b) `Context.CacheTTL` вне `wireSnapshotID` (правка «5m»→«1h» не re-bill-ит); `prov.CacheTTL`
в `wireSnapshotID` (правка re-bill-ит Anthropic-стадию).**
## 13. Решённые вопросы (ответы D20.2 — вписаны в текст v3)
Все три §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-, вердикт- и
избыточные входы. Фикс: три хеша на трёх слоях — `wireSnapshotID` (пер-стадийный, в `RequestHash`),
`verdictSnapshotID` (в `chunk_status`, не в ключе), `guard_hash` (пер-стадийный суперсет wire-плана,
гейт 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).