16 KiB
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-upsertchunk_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. Нюансы и открытые вопросы к оркестратору
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-вердикта).- Гранулярность гейта. Сегодня resnapshot-гейт — per-JOB (
jobs.snapshot_id, глава×стадия). Для онгоинга нужен per-CHUNK (content_hash) первичный гейт; job-снапшот демотируется до грубого advisory. Раннер уже делает per-chunkcontent_hash-чек (runStage) — спека делает его ПЕРВИЧНЫМ. Job-снапшот-mismatch перестаёт быть fail-loud стопом; заменяется на per-chunk решение. - Экономика показа (D5 retro-patch). Append-термин, фаерящийся в старых главах, ЗАКОННО их
re-bill-ит (термин должен примениться) — но оператору показать стоимость (
tmctl statusprojected-дельта или dry-run «сколько чанков затронет этот append»). Это UI-хвост, не F1. - Миграция ключей. Смена формулы
RequestHash(убратьsnapshotID, добавитьwireSnapshotID) — это смена ФОРМАТА ключа (как v1→v2 сейчас). Все существующие чекпоинты промахнутся ОДИН раз при переходе на новую формулу. Приемлемо (одноразово, на границе Ф1.5); зафиксировать бампомtm-request-v2→v3и задокументировать как осознанный one-time re-pin. AMBIGUOUS-BILLED/ F3. Ортогонально этой спеке (F3 — техдолг at-most-once); content-addressed reuse не меняет F3-инвариант.
7. Минимальный план реализации (Ф1.5, после утверждения)
- Расщепить
snapshotID()→wireSnapshotID()+verdictSnapshotID();snapshotID= их хеш. RequestHash:snapshotID→wireSnapshotID(бампtm-request-v3).chunk_status: добавитьverdict_snapshot(миграция), писать/читать; fast-path доверяет строке только при совпаденииcontent_hash+wireSnapshotID+verdict_snapshot, иначе проваливается вrunAttempt(re-classify бесплатно).- Job-снапшот-mismatch: демотировать из fail-loud стопа в per-chunk
content_hash-решение. - (Опц.) расщепить
memoryMatchVersionна inject/postcheck версии (§6.1). - Тесты: (а) вердикт-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 и требует ратификации.