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

160 lines
16 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 — Спека: 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 и требует ратификации.