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

16 KiB
Raw Blame History

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) вердикт $0wireSnapshotID+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-v2v3 и задокументировать как осознанный 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: snapshotIDwireSnapshotID (бамп 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 и требует ратификации.