# D15.2 — Спека: content-addressed переиспользование чекпоинтов (дизайн, НЕ код) **Статус:** проект спеки бэкенд-сессии, сдан на утверждение оркестратору. Реализация — Ф1.5, **после** ратификации. Зона: спека написана в `backend/docs/` (зона бэкенда); при утверждении оркестратор переносит/сворачивает в `docs/architecture/03-implementation-notes.md` новым разделом. **Гейтит:** онгоинг-режим (`add-chapters`, сегмент «ИИ-фабрик», Р9) — D15.2. Текущий all-or-nothing resnapshot **приемлем для статичной приёмочной книги Ф1 (D15.1) — НЕ трогать до приёмки**; эта спека — для онгоинга. --- ## 1. Проблема (верифицирована D15 / 07 §4.4) `snapshotID` вшит в `RequestHash` (`render.go` — поле `snapshotID` в length-prefixed хеше). Значит **любое** изменение снапшота (флип coverage-гейта, toggle `postcheck_gate`, бамп `classifierVersion`, append approved-терминов через `memoryVersion`, правка инертной ручки) после `--resnapshot` пере-оплачивает **всю книгу**, включая **байт-идентичные** запросы (воспроизведено: 2→4 оплаченных вызова на wire-идентичных телах). Механизм честного reuse существует (`msgsContentHash`, `render.go`), но заперт условием `cs.SnapshotID == snapID` (`runner.go`, resume fast-path). Лгущие комментарии `runner.go`/`coverage.go` про «re-classify from checkpoint for FREE» уже исправлены в этом пакете (Задача 1a): сегодня это правда ТОЛЬКО внутри неизменного снапшота. Цель спеки — **сделать это правдой и через смену снапшота**, без потери snapshot-гарантий. ## 2. Ключевое наблюдение: снапшот смешивает ДВА разных класса входов `snapshotID` сегодня сворачивает вместе: **(A) Wire-определяющие входы** — меняют БАЙТЫ, отправленные провайдеру, значит меняют сам ответ, значит требуют нового вызова: - `brief_hash`, `chunker_version`, `memory_version`, `context_assembly` — **все они уже входят в `msgsContentHash`**, потому что рендер (метаданные книги в шаблоне, нарезка → `ch.Text`, инъекция глоссария как ОТДЕЛЬНОЕ сообщение) целиком материализуется в `msgs`. Это belt-and-suspenders: фактический per-chunk рендер — источник истины. - `model`, `temperature`, `reasoning`, `jsonOnly`, `maxTokens` — уже ОТДЕЛЬНЫЕ поля `RequestHash` (не через снапшот). - `capability` (budget-поле / temp-режим / reasoning-контроль), `model_extra`, провайдер-оверрайды (local temp/max_tokens/model), эскалационные capability/оверрайды — **НЕ в `msgs` и НЕ отдельные поля**: меняют тело запроса, но сегодня заходят в ключ только через снапшот. Это единственная wire-релевантная часть снапшота, не покрытая иначе. - `estimator_version`, `max_tokens_policy` — влияют на `maxTokens`, а он уже отдельное поле; сами ВЕРСИИ избыточны (фактический `maxTokens` в ключе). **(B) Вердикт-определяющие входы** — меняют, как ответ КЛАССИФИЦИРУЕТСЯ, но НЕ байты запроса: - `classifier_version` (порядок/пороги intrinsic classify), `coverage` (enabled + пороги + `coverage_gate_version`), `postcheck_gate` (on/off; сам АЛГОРИТМ post-check — в `memory_version`, но он влияет на вердикт, не на wire… см. §6 нюанс). Сегодня (B) сидит в `RequestHash` → вердикт-only правка промахивается по всем чекпоинтам и пере-оплачивает книгу. Это и есть дефект. ## 3. Дизайн: расщепить снапшот на wire-часть и вердикт-часть - **`wireSnapshotID`** = хеш ТОЛЬКО (A)-входов, НЕ покрытых `msgs`/прямыми полями: резолвнутая `capability`, `model_extra`, провайдер-оверрайды (temp/max_tokens/model), эскалационные capability/extra/оверрайды. (Всё остальное wire-определяющее уже в `msgsContentHash` или в прямых полях `RequestHash`.) - **`verdictSnapshotID`** = хеш (B)-входов: `classifier_version`, coverage-конфиг+версия, `postcheck_gate`-состояние. **`RequestHash` (ключ чекпоинта) = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temp, reasoning, jsonOnly, maxTokens, `wireSnapshotID`, msgs).** — убираем `snapshotID`, добавляем `wireSnapshotID`. Вердикт-версии из ключа чекпоинта **уходят**. Полный `snapshotID` (для `jobs.snapshot_id`, `snapshots`-таблицы, отчётности) остаётся как `hash(wireSnapshotID, verdictSnapshotID)` — но перестаёт быть частью ключа вызова. ### Что тогда re-bill-ится, а что бесплатно (операторский контракт) | Изменение | Класс | Эффект | |---|---|---| | Флип `coverage.enabled`, правка порогов coverage; бамп `classifier_version`; toggle/пороги `postcheck_gate` | (B) вердикт | **$0** — `wireSnapshotID`+`msgs` не меняются → чекпоинт ХИТ → только **re-classify** | | Append approved-терминов | (A) через `msgs` | Re-bill **только тех чанков, где новый термин ФАЕРИТСЯ** (их инъекция → `msgs` меняются); чанки, где не фаерится — **$0** (идентичная инъекция → идентичный `msgs`) | | Правка book-brief, меняющая метаданные части глав | (A) через `msgs` | Re-bill только затронутых чанков | | Правка промпт-шаблона, смена модели/temp/reasoning, правка `capability`, ре-чанкинг, смена estimator/maxtok-политики (меняет фактический `maxTokens`) | (A) wire | Re-bill (честно: тело/ответ реально другие) | **Онгоинг-выигрыш (флагман «ИИ-фабрик»):** подтверждение пачки терминов на границе тома больше не пере-покупает готовые главы — только те чанки, где термин действительно матчится. Это прямо снимает D15-мину онгоинга. ## 4. Resume: когда байт-идентичный запрос легально переиспользуется Reuse чекпоинта легален ⟺ **полная wire-идентичность** совпадает: `msgsContentHash` И не-msgs wire-параметры (`wireSnapshotID` + прямые поля model/temp/reasoning/maxTokens). Это ровно то, что даёт новый `RequestHash` — совпал ключ ⇒ совпал wire ⇒ переиспользуем оплаченный ответ (то же основание, что у сегодняшнего same-snapshot resume; провайдер недетерминирован при temp>0, но чекпоинт — валидный ответ на ЭТОТ wire). **Вердикт-различие НИКОГДА не блокирует reuse** — доказуемо wire-нейтрально (см. §5). ## 5. Re-classify по новым порогам (и почему это безопасно, F1) Машинерия re-classify **уже есть**: на checkpoint-hit `runAttempt` вызывает `classifyOutput` над текстом чекпоинта (не доверяет старому вердикту). Единственная правка — чтобы чекпоинт ХИТИЛ при вердикт-only смене (§3 убирает вердикт-версии из ключа). Тогда: - **Fast-path `resumeFromChunkStatus`** (короткое замыкание до `runAttempt`): доверять строке `chunk_status` только когда совпали `content_hash` И `wireSnapshotID` И `verdictSnapshotID`. При СМЕНЕ `verdictSnapshotID` — **не** отдавать старый вердикт: проваливаться в `runAttempt`, где чекпоинт хитит по wire-идентичности → re-classify бесплатно → re-upsert `chunk_status` с новым вердиктом. Нужна колонка `chunk_status.verdict_snapshot` (в дополнение к `content_hash`). - **Вердикт-флип на re-classify:** `flagged→ok` (ослабили гейт) → текст чекпоинта отдаётся бесплатно. `ok→flagged` (ужесточили) → чанк флагается; если это влечёт НОВЫЙ wire-запрос (retry на attempt+1, или эскалация на другую модель) — он честно оплачивается (это реально новая работа, не re-pay старой). Ретраи/эскалация уже сидят на своих осях ключа (`attempt`, модель эскалации). **Ось безопасности (F1 — никакого тихого расходящегося re-pay):** reuse — precondition на ТОЧНОЕ совпадение wire-идентичности (`msgsContentHash` — сильнейший гард: точные байты промпта+инъекции; плюс не-msgs wire-параметры). Вердикт-смена меняет только post-settle классификацию, НИКОГДА не запрос, поэтому re-classify матчнутого чекпоинта не может отдать расходящийся ПЕРЕВОД — только другой ВЕРДИКТ на том же переводе (искомый бесплатный re-verdict). Опасный кейс — reuse чекпоинта, чей wire на новом конфиге был бы ДРУГИМ — исключён требованием точного wire-match. ## 6. Нюансы и открытые вопросы к оркестратору 1. **`memory_version` содержит `memoryMatchVersion` (алгоритм матчера).** Смена алгоритма матчера меняет per-chunk ВЫБОР (какие записи инъектятся) → инъекция → `msgs` → корректно re-bill только затронутых чанков. Значит `memory_version` из wire-ключа можно убрать (полагаться на `msgs`). **НО:** post-check-алгоритм тоже версионируется `memoryMatchVersion`, а post-check — ВЕРДИКТ, не wire. Дилемма: одна версия покрывает и wire-часть матчера, и вердикт-часть post-check. Развязка: при реализации расщепить `memoryMatchVersion` на `memoryInjectVersion` (wire, покрыт `msgs`) и `memoryPostcheckVersion` (вердикт, в `verdictSnapshotID`). До расщепления — держать `memory_version` в wire-части (консервативно: лишний re-bill безопаснее тихого stale-вердикта). 2. **Гранулярность гейта.** Сегодня resnapshot-гейт — per-JOB (`jobs.snapshot_id`, глава×стадия). Для онгоинга нужен per-CHUNK (`content_hash`) первичный гейт; job-снапшот демотируется до грубого advisory. Раннер уже делает per-chunk `content_hash`-чек (`runStage`) — спека делает его ПЕРВИЧНЫМ. Job-снапшот-mismatch перестаёт быть fail-loud стопом; заменяется на per-chunk решение. 3. **Экономика показа (D5 retro-patch).** Append-термин, фаерящийся в старых главах, ЗАКОННО их re-bill-ит (термин должен примениться) — но оператору показать стоимость (`tmctl status` projected-дельта или dry-run «сколько чанков затронет этот append»). Это UI-хвост, не F1. 4. **Миграция ключей.** Смена формулы `RequestHash` (убрать `snapshotID`, добавить `wireSnapshotID`) — это смена ФОРМАТА ключа (как v1→v2 сейчас). Все существующие чекпоинты промахнутся ОДИН раз при переходе на новую формулу. Приемлемо (одноразово, на границе Ф1.5); зафиксировать бампом `tm-request-v2`→`v3` и задокументировать как осознанный one-time re-pin. 5. **`AMBIGUOUS-BILLED` / F3.** Ортогонально этой спеке (F3 — техдолг at-most-once); content-addressed reuse не меняет F3-инвариант. ## 7. Минимальный план реализации (Ф1.5, после утверждения) 1. Расщепить `snapshotID()` → `wireSnapshotID()` + `verdictSnapshotID()`; `snapshotID` = их хеш. 2. `RequestHash`: `snapshotID` → `wireSnapshotID` (бамп `tm-request-v3`). 3. `chunk_status`: добавить `verdict_snapshot` (миграция), писать/читать; fast-path доверяет строке только при совпадении `content_hash`+`wireSnapshotID`+`verdict_snapshot`, иначе проваливается в `runAttempt` (re-classify бесплатно). 4. Job-снапшот-mismatch: демотировать из fail-loud стопа в per-chunk `content_hash`-решение. 5. (Опц.) расщепить `memoryMatchVersion` на inject/postcheck версии (§6.1). 6. Тесты: (а) вердикт-only смена → $0 re-classify, вердикт обновлён; (б) append-термин → re-bill ТОЛЬКО фаерящихся чанков, остальные $0; (в) wire-смена (промпт/модель/capability) → честный re-bill; (г) F1: никакой матч не отдаёт расходящийся текст (mutation: ослабить wire-match → тест ловит stale-serve). --- **Резюме для оркестратора:** дефект — `snapshotID` в ключе вызова смешивает wire- и вердикт-входы. Фикс — расщепить; ключ чекпоинта держать на wire-идентичности (по факту это `msgsContentHash` + не-msgs wire-параметры), вердикт-версии убрать из ключа и re-classify-ить на resume бесплатно. Безопасность (F1) сохраняется: reuse — на точном wire-match, вердикт-смена доказуемо wire-нейтральна. Онгоинг-мина D15 снимается: append-термин re-bill-ит только реально затронутые чанки. Реализация — малая и хирургическая (машинерия re-classify уже есть), но гейтит онгоинг Ф1.5 и требует ратификации.