160 lines
16 KiB
Markdown
160 lines
16 KiB
Markdown
# 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 и требует ратификации.
|