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

51 KiB
Raw Blame History

D15.2 — Спека v2: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)

Статус: v2, сдан на утверждение оркестратору; реализация остаётся ЗАБЛОКИРОВАННОЙ до ратификации v2 (D-лог §Ратификации 09.07 п.2: направление ратифицировано, реализация заперта до доработки трёх дыр). Зона: спека в backend/docs/ (зона бэкенда); при утверждении оркестратор сворачивает её в docs/architecture/03-implementation-notes.md новым разделом.

Supersedes: D15.2-content-addressed-resume-spec.md (v1). Направление v1 (расщепить snapshotID на wire-часть и вердикт-часть; ключ чекпоинта держать на wire-идентичности; вердикт-смену re-classify-ить на resume бесплатно) — ратифицировано и сохранено. v2 закрывает три дизайн-дыры внешнего ревью, которые заблокировали реализацию v1:

  • (a) fast-path-гард v1 (content_hash + wireSnapshotID + verdict_snapshot) слабее текущего гарда: не покрывает ПРЯМЫЕ wire-поля RequestHash (model / temperature / reasoning / maxTokens / escalate_to). Смена temperature 0.3→0.4 или reasoning low→none тихо отдала бы stale-текст — ре-открытие дефекта stale-serve F1. → §5, отдельная колонка guard_hash.
  • (b) расщепление memory_version (wire vs verdict) в v1 было «(Опц.)». Сделано ОБЯЗАТЕЛЬНЫМ (иначе главный сценарий онгоинга — append-термина/toggle гейта — не материализуется без переоплаты книги). → §6.
  • (c) loud-гейт согласия (--resnapshot fail-loud, Р6 «согласие на трату») content-addressed resume демонтирует; v1 просто удалял его. Заменён pre-flight dry-run «N чанков, ~$X» с порогом явного флага. → §7.

Гейтит: онгоинг-режим (add-chapters, сегмент «ИИ-фабрик», Р9 / D15 п.2). Текущий all-or-nothing resnapshot остаётся приемлемым для статичной приёмочной книги Ф1 (D15.1 / D18 蛊真人) — НЕ трогать до приёмки; эта спека — для онгоинга (Ф1.5).


1. Проблема (верифицирована D15 / 07 §4.4)

snapshotID (runner.go:263-431) вшит в RequestHash как поле snapshotID (render.go:198,212-214, length-prefixed хеш). Значит любое изменение снапшота — флип coverage-гейта, toggle postcheck_gate, бамп classifierVersion, append approved-терминов через memoryVersion, правка инертной ручки — после --resnapshot пере-оплачивает всю книгу, включая байт-идентичные запросы (воспроизведено: 2→4 оплаченных вызова на wire-идентичных телах). Механизм честного reuse существует (msgsContentHash, render.go:233-249), но заперт: fast-path в runStage (runner.go:899) доверяет строке chunk_status только при cs.SnapshotID == snapID, поэтому через смену снапшота не срабатывает.

Лгущие комментарии runner.go/coverage.go про «re-classify from checkpoint for FREE» уже исправлены (пакет 09.07): сегодня это правда ТОЛЬКО внутри неизменного снапшота. Цель — сделать это правдой и через смену снапшота, без потери snapshot-гарантий и без ре-открытия stale-serve.

2. Ключевое наблюдение: снапшот смешивает ДВА класса входов + ТРЕТИЙ, избыточный

snapshotID сегодня сворачивает вместе три категории, хотя ключ вызова нуждается только в одной:

(A) Wire-определяющие, УЖЕ покрытые иначе (msgs/content_hash или прямые поля RequestHash). brief_hash, chunker_version, context_assembly (инъекционные ручки), memory_version (матчер+инъекция) — всё материализуется в msgs (метаданные книги через шаблон render.go:108-121, нарезка → ch.Text, инъекция глоссария как ОТДЕЛЬНОЕ сообщение render.go:174-190) → msgsContentHash. model, temperature, reasoning, jsonOnly, maxTokens — уже ПРЯМЫЕ поля RequestHash. estimator_version, max_tokens_policy, max_output_ratio, min_max_tokens — лишь ДЕРИВИРУЮТ maxTokens, а его фактическое значение уже прямое поле.

(B) Wire-определяющие, НЕ покрытые иначе. Резолвнутая capability, model_extra, провайдер-оверрайды (local temp/max_tokens/model), эскалационные escalate_to + capability/extra/оверрайды, pipeline_core (C0…C3 — механика сборки), cache_ttl (cache-control на проводе, не в content_hash). Меняют ТЕЛО запроса, но не заходят в ключ никак, кроме снапшота — это единственная wire-часть снапшота, которую надо сохранить в ключе.

(C) Вердикт-определяющие — меняют, как ответ КЛАССИФИЦИРУЕТСЯ, но НЕ байты запроса: classifier_version (classifyOutputclassify, runner.go:201-219), coverage (enabled+пороги+coverageGateVersion), postcheck_gate (on/off) и алгоритм/данные post-check (memoryPostcheckVersion + decl инъектнутых записей при gate ON — computeMemoryVersion, memory.go:225-229). Все они пересчитываются ВЖИВУЮ на resume и в chunk_status не хранятся (классификатор/coverage — в classifyOutput; post-check — в translateChunk над финальным prev, runner.go:774-788). Значит из ключа вызова их можно убрать целиком.

Сегодня (B) и (C) сидят в RequestHash → вердикт-only или book-global-wire правка промахивается по ВСЕМ чекпоинтам и пере-оплачивает книгу. Это и есть дефект.

3. Дизайн: три хеша вместо одного снапшота-в-ключе

Расщепляем snapshotID() и вводим отдельный fast-path-гард. Появляются ТРИ независимых хеша, каждый на своём слое:

3.1 wireSnapshotID — ПЕР-СТАДИЙНЫЙ, идёт в RequestHash

Хеш ТОЛЬКО категории (B) для КОНКРЕТНОЙ стадии: резолвнутая capability, model_extra, провайдер-оверрайды (ProviderTemp/ProviderMaxTok/ProviderModel), эскалационные EscalateTo+EscalateCapability+EscalateExtra+EscalateProvider*, pipeline_core, cache_ttl. Префикс версии tm-wire-v1.

Пер-стадийность — часть выигрыша гранулярности. Сегодня snapshotID book-global: правка capability стадии-2 (редактор) меняет RequestHash стадии-1 (черновик). В v2 RequestHash стадии-1 несёт wireSnapshotID ТОЛЬКО стадии-1 → правка редактора не пере-оплачивает черновик (каскад на редактор идёт честно через изменённый prev, §10). Book-global входы (pipeline_core, cache_ttl) кладём в wireSnapshotID каждой стадии одинаковым значением.

Смена wireSnapshotID стадии → RequestHash этой стадии по всей книге меняется → честный re-bill (эти входы реально меняют тело/ответ каждого вызова; v1 §3 это уже фиксировал).

3.2 verdictSnapshotID — book-global, идёт в chunk_status.verdict_snapshot (НЕ в RequestHash)

Хеш категории (C): classifier_version, coverage-конфиг+coverageGateVersion, postcheck_gate, memoryPostcheckVersion + decl-контент инъектируемых записей при gate ON (§6). Префикс tm-verdict-v1. В ключ вызова НЕ входит. Хранится в chunk_status как триггер бесплатного re-classify на fast-path (§5, §8).

3.3 guard_hash — ПЕР-СТАДИЙНЫЙ, хранится в chunk_status, гейт fast-path (НЕ в RequestHash)

Хеш ВСЕГО wire stage-плана для чанка×стадии — суперсет того, что fast-path обязан проверить перед доверием строке (закрывает дыру (a), §5): guard_hash = H(tm-guard-v1, content_hash, wireSnapshotID, model, temperature, reasoning, jsonOnly, estimatorVersion, maxOutputRatio, minMaxTokens, maxTokensPolicyVersion). Прямые wire-поля (model/temperature/reasoning/jsonOnly) и ДЕРИВАЦИЯ maxTokens (версии+ratio+floor; сам текст для деривации уже в content_hash через msgs) — то, чего content_hash

  • wireSnapshotID не покрывают. escalate_to и эскалационная wire уже внутри wireSnapshotID.

3.4 Итоговый RequestHash (ключ чекпоинта), бамп tm-request-v2tm-request-v3

RequestHash = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temperature, reasoning, jsonOnly, maxTokens, wireSnapshotID, msgs). Замена: snapshotIDwireSnapshotID; всё остальное как в render.go:198. Вердикт-версии из ключа уходят. Прямые wire-поля остаются прямыми (были и есть) — это и есть покрытие, которого не хватало fast-path-у.

Полный snapshotID (для jobs.snapshot_id, snapshots-таблицы, отчётности) сохраняется как H(все wireSnapshotID стадий, verdictSnapshotID), но перестаёт быть частью ключа вызова и демотируется до advisory (§9).

4. Полный полевой маппинг снапшота (обязательно, ревью п.2)

Каждое поле, сегодня сворачиваемое в snapshotID() (runner.go:263-431) + memoryVersion (memory.go:232-259) + coverage/classifier/postcheck, классифицировано: wire (влияет на байты запроса) / verdict (влияет на классификацию) / dropped-as-redundant (уже покрыто иначе). Колонка «Куда в v2»: content_hash (msgs), прямое поле RequestHash, wireSnapshotID, verdictSnapshotID, guard_hash, либо dropped.

Поле (сегодня) Класс Куда в v2 Почему
brief_hash wire droppedcontent_hash Метаданные книги рендерятся в шаблон (render.go:108-121) → в msgs; per-chunk content_hash ловит правку брифа гранулярно. Caveat: поле Adult (book.go:151,156) и НЕрендеримые плейсхолдеры не в msgs — они wire-нейтральны by construction (не уходят на провод); если Adult когда-либо станет wire/verdict-влияющим — вернуть в wireSnapshotID (или verdictSnapshotID).
chunker_version wire droppedcontent_hash Правила чанкера дают ch.Textmsgs. Ре-чанкинг меняет границы/число чанков → content_hash почти всех → честный broad re-bill, гранулярно.
estimator_version wire (деривация) guard_hash (не в RequestHash) Деривирует maxTokens, а фактический maxTokens — уже прямое поле RequestHash. В ключе избыточен; fast-path нуждается — кладём в guard_hash. Бамп busts fast-path каждого чанка; платят только те, чей maxTokens реально сменился (остальные — checkpoint-hit, re-classify $0).
max_tokens_policy wire (деривация) guard_hash Как estimator_version, для attempt≥1 (maxTokensForAttempt). Избыточен в ключе (фактический maxTokens там), нужен fast-path-у.
max_output_ratio wire (деривация) guard_hash Множитель maxTokens. То же.
min_max_tokens wire (деривация) guard_hash Пол maxTokens. То же.
context_assembly.glossary_injection wire droppedcontent_hash Режим инъекции меняет наличие/содержимое инъекц-сообщения → msgs.
context_assembly.glossary_token_budget wire droppedcontent_hash Бюджет → eviction → инъектнутые строки → msgs (§10).
context_assembly.stm_depth wire droppedcontent_hash STM входит в msgs.
context_assembly.overlap_tokens wire droppedcontent_hash Overlap меняет ch.Textmsgs.
context_assembly.cache_ttl wire (cache-control) wireSnapshotID НЕ в content_hashmsgs только Role+Content; msgsContentHash опускает CacheBoundary/TTL). Response-нейтрален → reuse безопасен; пиним консервативно, чтобы смена TTL честно триггерила свежий вызов, но fast-path на content_hash не рушится (см. примечание ниже таблицы).
model (пер-стадийный) wire dropped из снапшота → прямое поле RequestHash + guard_hash RequestHash уже несёт model вызова; снапшот-фолд всех стадий избыточен и ломал гранулярность.
temperature (st.Temperature) wire dropped из снапшота → прямое поле + guard_hash То же. Именно этого не хватало fast-path-у v1 (дыра a).
reasoning (st.Reasoning) wire dropped из снапшота → прямое поле + guard_hash То же (low→none — дыра a).
jsonOnly wire прямое поле RequestHash + guard_hash Уже прямое (сейчас всегда false); держим в guard_hash для полноты.
maxTokens (фактический) wire прямое поле RequestHash; деривация → guard_hash Значение — прямое поле; версии деривации — в guard_hash (выше).
capability (ResolveCapability) wire wireSnapshotID + guard_hash Резолвнутая wire-форма (budget-ключ, temp-режим, thinking-выключатель); не в msgs, не прямое поле.
model_extra (ExtraBody) wire wireSnapshotID + guard_hash Меняет тело (GLM thinking-off и т.п.); не в msgs.
provider_temp (local) wire wireSnapshotID + guard_hash Оверрайд поверх wire ПОСЛЕ хеша; не в msgs.
provider_max_tok (local) wire wireSnapshotID + guard_hash То же.
provider_model (local) wire wireSnapshotID + guard_hash Фактически отвечающий local-бэкенд (8b→14b); самый импактный local-оверрайд.
escalate_to wire wireSnapshotID + guard_hash Фолбэк-модель: меняет wire эскал-подвызова И резолвнутую диспозицию (случился ли хоп).
escalate_capability wire wireSnapshotID + guard_hash Резолвнутая wire фолбэка.
escalate_extra wire wireSnapshotID + guard_hash extra_body фолбэка.
escalate_provider_temp/max_tok/model wire wireSnapshotID + guard_hash Провайдер-оверрайды фолбэка.
pipeline_core (C0…C3) wire wireSnapshotID + guard_hash Механика сборки прохода (linear vs selection/fusion); консервативный catch-all wire-плана.
prompt_version (st.PromptVersion) wire droppedcontent_hash Человеческий лейбл; фактические байты промпта рендерятся в msgs (авторитет — рендер, не лейбл).
prompt_sha256 wire droppedcontent_hash Шаблон рендерится в system/user сообщения → msgs. Рендер И ЕСТЬ msgs.
stage name wire dropped из снапшота → прямое поле stage + content_hash RequestHash несёт stage; структура плана отражена в msgs через prev-цепочку.
stage role wire dropped из снапшота → прямое поле role + content_hash role — прямое поле; он же выбирает инъекцию (translator/editor) → msgs.
memory_version (матчер+инъекция часть) = memoryInjectVersion wire droppedcontent_hash memoryNormVersion + матчер-часть memoryMatchVersion определяют, КАКИЕ записи инъектятся → инъекц-сообщение → msgs. §6.
memory_version (post-check часть) = memoryPostcheckVersion verdict verdictSnapshotID Алгоритм/пороги post-check — вердикт, пересчитывается вживую (translateChunk). §6.
postcheck_gate (top-level + gate: в memory) verdict verdictSnapshotID Toggle меняет резолвнутую диспозицию; пересчёт бесплатен. Сегодня двойной фолд (top-level PostcheckGate + gate:bool в computeMemoryVersion) → в v2 один раз. §6.
decl-контент инъектнутых записей verdict (при gate ON) verdictSnapshotID Decl НЕ инъектится в msgs, но при gate ON влияет на post-check вердикт (memory.go:225-229). §6.
classifier_version verdict verdictSnapshotID Пороги/порядок classify; пересчёт classifyOutput бесплатен на checkpoint-hit.
coverage (enabled+len_ratio+sent_cov_min+min_chunk_chars+coverageGateVersion) verdict verdictSnapshotID Excision-гейт не трогает провод; coverageCheck пересчитывается вживую над текстом чекпоинта.

Примечание к cache_ttl/CacheBoundary: RequestHash включает m.CacheBoundary (render.go:219), msgsContentHash — нет (render.go:245-247), а guard_hash строится из content_hash. Следствие: смена cache-границы/TTL честно даёт свежий RequestHash (свежий вызов если дошли до attempt-цикла), но fast-path на content_hash/guard_hash НЕ рушит reuse — это корректно, т.к. cache-control response-нейтрален (валидный старый ответ переиспользуем). cache_ttl в wireSnapshotID — консервативный пин для свежести ключа, не гард reuse.

5. HOLE (a): fast-path-гард обязан покрывать ВЕСЬ wire stage-план

Дефект v1. Fast-path (resumeFromChunkStatus, короткое замыкание в runStage:897-901 ДО runAttempt) доверяет строке chunk_status, когда совпали content_hash + wireSnapshotID + verdict_snapshot. Но content_hash (= msgsContentHash) НЕ содержит прямых wire-полей model/temperature/reasoning/maxTokens, а wireSnapshotID (§3.1) их тоже не содержит (они — прямые поля RequestHash, не категория B). Значит смена temperature 0.3→0.4, reasoning low→none или деривации maxTokens НЕ меняет ни content_hash, ни wireSnapshotID, ни verdict_snapshot → v1-fast-path ХИТИТ → отдаёт stale-текст, сгенерённый на СТАРОМ wire. Это ре-открытие stale-serve F1. Настоящий RequestHash эти поля включает, но fast-path замыкается ДО attempt-цикла, где RequestHash считается.

Взвешены два варианта:

  • (1) Отдельная колонка guard_hash (§3.3): хеш ВСЕГО wire stage-плана (content_hash + wireSnapshotID + model/temp/reasoning/jsonOnly + деривация maxTokens + escalate_to внутри wireSnapshotID). НЕ часть RequestHash — только гейт доверия fast-path. На fast-path пересчитываем guard_hash из ТЕКУЩЕГО конфига и сравниваем со строкой.
  • (2) Verify-by-recompute: на fast-path пересчитать полные wire-параметры победившего attempt и сравнить, храня в строке всё нужное для пересчёта (модель/temp/reasoning/фактический maxTokens победителя, capability и т.д.).

Рекомендация — вариант (1), guard_hash. Проще и строго-сильнее:

  • Один детерминированный хеш вместо хранения и по-полевого сравнения ~десятка параметров (вар. 2 раздувает chunk_status и дублирует логику резолва).
  • Строго сильнее текущего гарда: сегодня fast-path проверяет snapshot_id+content_hash; guard_hash покрывает content_hash (⊇) ПЛЮС весь wire-план, включая то, что snapshot_id терял при пер-стадийном расщеплении. Смена temperature/reasoning/maxTokens-деривации/escalate_toguard_hash mismatch → fast-path проваливается в attempt-цикл → там RequestHash (с прямыми полями) промахивается → честный свежий вызов (это реально другая работа, не re-pay).
  • Разделение слоёв: RequestHash — ключ оплаченного ответа; guard_hash — гард доверия материализованной строки-резолва. Держать их разными хешами (а не пихать всё в RequestHash) сохраняет пер-стадийную гранулярность ключа и не смешивает деривац-версии (которые в ключе избыточны) с ключом.

Fast-path v2 (runStage): доверять строке ⟺ cs.guard_hash == guard_hash(тек.) && cs.verdict_snapshot == verdictSnapshotID(тек.) && cs.disposition != skipped. content_hash продолжаем хранить (сигнатура msgs — нужна dry-runу §7 и отчётности), но гейтом служит guard_hash (он его суперсет). При mismatch guard_hash — проваливаемся в attempt-цикл (свежий вызов, если wire реально другой); при mismatch только verdict_snapshot (guard совпал) — проваливаемся в runAttempt, где checkpoint ХИТИТ по wire-идентичности → re-classify бесплатно → re-upsert строки с новым verdict_snapshot и диспозицией (§8).

6. HOLE (b): расщепление memory_version — ОБЯЗАТЕЛЬНО

memoryMatchVersion (memory.go:31-39) сегодня версионирует ОБА: матчер/селекцию инъекции И алгоритм post-check (слаг буквально несёт …+postcheck-declaware). computeMemoryVersion (memory.go:232-259) сворачивает memoryNormVersion + memoryMatchVersion + gate:bool + контент строк (approved-only при gate OFF; ВСЕ строки, включая decl+status, при gate ON). Всё это сегодня → MemoryVersionsnapshotID → ключ. Правка → промах по книге. Ревью-инсайт: post-check пересчитывается вживую на каждом resume (translateChunk:774-788 над финальным prev) и в chunk_status НЕ хранится — значит memory_version можно убрать из ключа вызова целиком.

Расщепление (обязательное, не опциональное):

  • memoryInjectVersion (wire) = memoryNormVersion + матчер/селекция-часть memoryMatchVersion
    • инъектируемый контент approved-записей (src/dst/aliases — то, что рендерится в renderGlossaryBlock/renderEditorConstraintBlock). Определяет, ЧТО и КАК инъектится → инъекц-сообщение → msgscontent_hash. Из ключа УБИРАЕТСЯ полностью; отдельным полем ключа НЕ является — покрыт content_hash пер-чанково (это и даёт гранулярный re-bill append-а, §10).
  • memoryPostcheckVersion (verdict) = алгоритм+пороги post-check + gate:bool (postcheck_gate)
    • decl-контент ВСЕХ инъектируемых записей при gate ON. Уходит в verdictSnapshotID (§3.2), пересчитывается на resume бесплатно.

Что именно ПОКИДАЕТ wire-ключ: весь memory_version. Матчер/нормализация/инъект-контент — их эффект целиком в msgs. Что входит в verdictSnapshotID:

  1. memoryPostcheckVersion (алгоритм/пороги post-check);
  2. gate:bool — судьба postcheck_gate явно: это ВЕРДИКТ-сторона (toggle меняет резолвнутую диспозицию glossary_miss, runner.go:782-787), кладём в verdictSnapshotID. Сегодняшний двойной фолд (top-level PostcheckGate в snapshotID + gate: в computeMemoryVersion) схлопывается в один источник — verdictSnapshotID;
  3. decl-контент инъектируемых записей — судьба явно: decl НЕ инъектится в msgs, но при gate ON участвует в post-check вердикте (memory.go:225-229 — «decl edit would silently flip a resumed chunk's disposition»). Значит decl — ВЕРДИКТ-сторона → в verdictSnapshotID (только при gate ON, зеркаля текущее условие gateOn в computeMemoryVersion:238). При gate OFF decl не влияет ни на wire, ни на вердикт → не входит никуда (dropped).

Почему это корректно и бесплатно. Post-check материализуется из банка (пере-материализуется каждый прогон, seedGlossarymaterializeMemory) и гоняется вживую в translateChunk над prev независимо от того, пришёл prev из свежего вызова или из fast-path-резолва. Правка decl/порогов → новый банк → новый вердикт post-check → но wire идентичен → $0. verdictSnapshotID в chunk_status.verdict_snapshot делает такую правку (а) видимой статусу (§9) и (б) триггером пере-резолва per-stage fast-path (classifier/coverage тоже вердикт, но пересчитываются ТОЛЬКО в runAttempt/translateChunk, не в resumeFromChunkStatus — потому verdict_snapshot busts fast-path и заставляет пройти attempt-цикл, где всё пере-классифицируется бесплатно). Post-check-часть в verdict_snapshot формально belt-and-suspenders (post-check и так live на chunk-level), но держит verdict_snapshot полным отпечатком вердикт-конфига.

7. HOLE (c): замена loud-гейта согласия на pre-flight dry-run

Что демонтируется. Сегодня рассинхрон снапшота — fail-loud стоп (runStage:865-869; --resnapshot = явное согласие, Р6-класс «согласие на трату»). Content-addressed resume демотирует job-snapshot-mismatch из fail-loud в пер-чанковое content_hash/guard_hash-решение (§8, §9) — то есть удаляет этот гейт согласия. Его нельзя просто выкинуть: Р6-класс согласия на трату должен быть ЗАМЕНЁН, иначе тихая переоплата возвращается через заднюю дверь.

Замена — pre-flight dry-run. Перед translate, который сменил бы конфиг (и всегда в онгоинг-режиме) прогоняем СУХОЙ проход (детерминированный, $0, без LLM):

  1. Проекция (источник — тот же пер-чанковый content_hash/guard_hash-диф, что и на fast-path). Для каждого чанка×стадии материализуем ТЕКУЩИЕ msgs (MessagesWithInjection — дёшево, только стр__ковая подстановка) и считаем content_hash + guard_hash. Сравниваем со СТРОКОЙ chunk_status. Единица «будет re-bill», если её guard_hash отличается от хранимого И хранимая диспозиция была оплачена (ok/flagged). Этот диф естественно ловит все эффекты второго порядка (sticky, eviction, каскад на редактор — §10), потому что он пере-материализует msgs per chunk×stage, а не рассуждает о них аналитически.
  2. Стоимость. Проецируемый $ = Σ по изменившимся единицам их хранимого chunk_status.cost_usd (честная прошлая фактическая цена; для каскадных downstream-единиц без прошлой цены — оценка ledger.EstimateUSD над новыми msgs). Выдаём «N чанков будет пере-оплачено, ~$X».
  3. Порог + флаг. Порог rebill_consent_usd (конфиг книги; дефолт, напр., $0.50 ИЛИ ≤5% от ProjectedBookUSD, что меньше). Ниже порога — проходим автоматически (append-термина в 3 чанках стоит центы — без трения согласия). Выше порога — отказ без явного флага --accept-rebill (зеркалит старую семантику --resnapshot, но теперь content-scoped: гейт срабатывает только на РЕАЛЬНУЮ проецируемую трату, а не на любой вердикт-only $0-change).

Это сохраняет Р6-класс согласия на трату, убирая ложные тревоги (вердикт-only смена больше не просит согласия — она $0). --resnapshot как флаг остаётся синонимом-алиасом для обратной совместимости, но его семантика — «прими проецируемый re-bill», а не «пере-пинуй всё».

8. Resume: легальность reuse и безопасность re-classify (F1)

Reuse чекпоинта легален ⟺ полная wire-идентичность: guard_hash совпал (⊇ content_hash + wireSnapshotID + прямые wire-поля + деривация maxTokens). Это ровно то, что даёт новый RequestHash на attempt-оси: совпал guard_hash ⇒ совпал wire ⇒ RequestHash попадёт в оплаченный ответ (то же основание, что у сегодняшнего same-snapshot resume). Вердикт-различие (verdict_snapshot) НИКОГДА не блокирует reuse перевода — только заставляет пере-классифицировать.

Машинерия re-classify уже есть: на checkpoint-hit runAttempt (runner.go:1094) зовёт classifyOutput над текстом чекпоинта, не доверяя старому вердикту; post-check — translateChunk над prev. Правки:

  • Fast-path resumeFromChunkStatus: гейт §5 (guard_hash + verdict_snapshot). При смене verdict_snapshot (guard совпал) — НЕ отдавать старую диспозицию: проваливаться в runAttempt → checkpoint хитит по wire → re-classify бесплатно → re-upsert chunk_status с новыми verdict_snapshot/диспозицией. Нужны колонки chunk_status.guard_hash, chunk_status.verdict_snapshot.
  • Вердикт-флип: flagged→ok (ослабили гейт) → текст чекпоинта отдаётся бесплатно. ok→flagged (ужесточили) → чанк флагается; если это влечёт НОВЫЙ wire-запрос (retry на attempt+1 или эскалация) — честно оплачивается (реально новая работа). Ретраи/эскалация уже на своих осях ключа (attempt, модель эскалации).

Ось безопасности F1 (никакого тихого расходящегося re-pay). Reuse — precondition на ТОЧНОЕ совпадение wire-идентичности (guard_hash — сильнейший гард: точные байты промпта+инъекции ПЛЮС все прямые wire-поля и деривация maxTokens, чего v1 не покрывал). Вердикт-смена меняет только post-settle классификацию, НИКОГДА не запрос — потому re-classify матчнутого чекпоинта не может отдать расходящийся ПЕРЕВОД, только другой ВЕРДИКТ на том же переводе. Опасный кейс v1 (reuse чекпоинта, чей wire на новом конфиге был бы ДРУГИМ из-за temp/reasoning/maxTokens) — теперь исключён guard_hash.

9. Судьба chunk_status.snapshot_id и status-ридеров

  • chunk_status.snapshot_idсохраняется как advisory (какой job-снапшот резолвнул строку; отчётность), но перестаёт быть гейтом корректности. Первичный resume-гейт — guard_hash + verdict_snapshot + content_hash (§5). Условие cs.SnapshotID == snapID (runStage:899) снимается.
  • SnapshotDrift (status.go:53-55,283-284: >1 snapshot_id среди строк) — демотируется до информационного advisory, не сигнала тревоги: при content-addressed resume сосуществование строк под разными job-снапшотами НОРМАЛЬНО (wire-идентичные чанки, резолвнутые в разное время). Больше не «loud».
  • ConfigDrift (status.go:56-61,295-299: текущий конфиг рендерит иной снапшот, чем хранимый) — заменяется/дополняется проекцией dry-run (§7): вместо булева «снапшот отличается» показываем «проецируемый re-bill: N чанков, ~$X» из того же пер-чанкового guard_hash-дифа. Равенство снапшота остаётся грубой подсказкой; действенное число — пер-чанковая проекция (currentSnapshotProjected, status.go:347, расширяется до пер-чанкового дифа).
  • Redrive drift-guard (status.go:462-480, добавлен этой сессией — рефузит redrive при cs.SnapshotID != curSnap): его резон-детр (drift → переоплата всей книги) растворяется расщеплением — drift больше не пере-оплачивает неизменные чанки. Guard эволюционирует: грубое сравнение snapshot-равенства заменяется на guard_hash-диф ТОЛЬКО целевых (сбрасываемых) чанков. Redrive и так пере-гоняет цели под текущим wire — это его цель; поэтому жёсткий рефуз снимается, заменяясь на pre-flight проекцию §7 (--accept-rebill при превышении порога) плюс сохранённый инвариант «DispOK-стадии не сбрасываются» (D-лог п.5в). Итог: redrive не рушит флаг-телеметрию громким отказом, а показывает проецируемую цену целей.

10. Blast-radius approved-term APPEND (со sticky + eviction + каскадом стадий)

Первый порядок (главная история): append approved-термина T re-bill-ит только чанки, где T ФАЕРИТСЯ. T фаерится в чанке, если его нормализованный ключ встречается в ch.Text (для translator memory.go Select) → T инъектится → инъекц-сообщение меняется → content_hash меняется → translator этого чанка re-bill. Чанки, где T не фаерится → идентичная инъекция → идентичный content_hash → $0 (checkpoint-hit, re-classify free). Это FIRST-ORDER контракт.

Второй порядок — три поправки (ловятся dry-runом §7 автоматически, т.к. он диффит content_hash per chunk×stage, а не рассуждает аналитически):

  1. Sticky-кэрри (stickyDepth = 2, memory.go:70-73). T, фаернувший в чанке K, попадает в activeIDs[K]unionSticky (runner.go:577-588) несёт его в K+1 и K+2 (в пределах ГЛАВЫ; сброс на границе главы, runner.go:549-552). В K+1/K+2 T инъектится через sticky, даже если его ключ там не встречается → их translator-инъекция меняется → content_hashK+1, K+2 тоже re-bill. Радиус = {фаерящие чанки} {их следующие 2 внутри главы}.
  2. F2 eviction (memory.go бюджет glossary_token_budget). Инъекция бюджет-лимитирована. В бюджет-ПОЛНОМ чанке добавление T ВЫТЕСНЯЕТ более низкоприоритетную ранее-инъектнутую строку L. Ключевой второпорядковый случай: T, принесённый в K+1 через sticky (где T НЕ фаерится), потребляет бюджет и вытесняет строку L' — меняя msgs K+1 сверх просто +T, хотя сам T там не фаерится. Eviction НЕ расширяет множество чанков за пределы {фаерящие sticky}, но увеличивает дельту внутри них (не «+T», а «+TL»).
  3. Каскад на редактор. Инъектируемый констрейнт-блок редактора — ПОДМНОЖЕСТВО инъекции черновика (editorInjection = renderEditorConstraintBlock(memSel.injected), runner.go:717-719 — тот же memSel). Значит: (i) T-подтверждённый попадает и в констрейнт-блок редактора → msgs редактора меняются; (ii) черновик translatorа меняется (T применён) → вход {{draft}} редактора меняется → content_hash редактора меняется. Любой re-billed translator-чанк каскадит на свою editor-стадию. Первопорядковое «re-bill фаерящих чанков» надо читать как «re-bill фаерящих чанков × ОБЕ стадии».

Итоговый радиус: {фаерящие T} {sticky +2/глава} × {translator, editor}; всё прочее — $0 (checkpoint-hit → re-classify free). Онгоинг-выигрыш D15: подтверждение пачки терминов на границе тома не пере-покупает готовые главы — только реально затронутые чанки×стадии, а pre-flight dry-run (§7) показывает их число и цену ДО траты.

11. Sequencing миграции + одноразовый re-pin

Два изменения, оба ломающие ключ/схему:

  • Формула RequestHash: snapshotIDwireSnapshotID, бамп tm-request-v2tm-request-v3 (render.go:212). Это смена ФОРМАТА ключа → все существующие чекпоинты промахнутся ОДИН раз.
  • Схема: миграция v8 (текущий максимум — v7 после cheap-gate миграции этой сессии: migrate.go v7 = ALTER TABLE retrieval_state ADD COLUMN n_style_flags/style_detail): ALTER TABLE chunk_status ADD COLUMN guard_hash TEXT NOT NULL DEFAULT ''; ALTER TABLE chunk_status ADD COLUMN verdict_snapshot TEXT NOT NULL DEFAULT ''; (snapshot_id, content_hash — сохраняются). Существующие строки получают пустой guard_hash → fast-path у них всегда busts → падают в attempt-цикл, где RequestHash под v3 не совпадёт с v2 → свежий вызов (тот самый одноразовый re-pin). Строки самозалечиваются с новыми хешами при первом прогоне.

Ограничение порядка (жёсткое). Миграция+бамп ДОЛЖНЫ приземлиться ДО первой онгоинг-книги (add-chapters). Иначе: если книга уже несёт v2-чекпоинты, а миграция приходит В СЕРЕДИНЕ её жизни, первый add-chapters после миграции пере-оплатит ВЕСЬ бэклог (все прежние главы пере-пинятся под v3) — ровно та D15-мина, которую спека снимает. Поэтому:

  • Статичную приёмочную книгу Ф1 (D15.1 / D18 蛊真人) либо завершаем под v2, либо принимаем её одноразовый re-pin (громкий, расхождений нет — D15.1) на границе Ф1.5.
  • Первая онгоинг-книга РОЖДАЕТСЯ под tm-request-v3 (начальные главы кейятся v3), чтобы последующие add-chapters находили v3-ключи и не пере-пинали бэклог.
  • Одноразовый re-pin осознан и задокументирован (как v1→v2 сейчас): ни одна онгоинг-книга не должна пересекать миграцию в середине жизненного цикла.

12. Минимальный план реализации (Ф1.5, после ратификации v2)

  1. Расщепить snapshotID()wireSnapshotID(stage) (пер-стадийный, §3.1) + verdictSnapshotID() (book-global, §3.2). Полный snapshotID = их хеш (advisory).
  2. RequestHash: snapshotIDwireSnapshotID, бамп tm-request-v3 (§3.4).
  3. guard_hash (§3.3): функция guardHash(content_hash, wireSnapshotID, model, temp, reasoning, jsonOnly, estimator/ratio/floor/policy), префикс tm-guard-v1.
  4. Расщепить memoryMatchVersion/computeMemoryVersion (ОБЯЗАТЕЛЬНО, §6) на memoryInjectVersion (→ msgs, из ключа убрать) и memoryPostcheckVersion (+gate+decl-при-gateON → verdictSnapshotID). Снять двойной фолд postcheck_gate.
  5. Миграция v8 (§11): колонки guard_hash, verdict_snapshot; писать/читать в chunkstatus.go (Upsert/Get/ForBook). snapshot_id оставить (advisory).
  6. Fast-path (runStage): гейт cs.guard_hash == guardHash(тек.) && cs.verdict_snapshot == verdictSnapshotID(тек.) && disposition != skipped; иначе runAttempt (re-classify бесплатно, §8).
  7. Job-snapshot-mismatch: демотировать fail-loud стоп (runStage:865-869) в пер-чанковое guard_hash-решение.
  8. Pre-flight dry-run (§7): пер-чанковый guard_hash-диф → «N чанков, ~$X»; порог rebill_consent_usd + флаг --accept-rebill. tmctl status: заменить булев ConfigDrift на проекцию; SnapshotDrift → advisory. Redrive drift-guard → guard_hash-диф целей + проекция (§9).
  9. Тесты: (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl) → $0 re-classify, вердикт обновлён; (б) append-термина → re-bill ТОЛЬКО фаерящих чанков + sticky+2 + editor-каскад, остальные $0 (§10); (в) wire-смена (промпт/модель/temperature/reasoning/capability/ estimator) → честный re-bill; (г) F1/дыра-a мутация: ослабить guard_hash (убрать temp) → тест ловит stale-serve при temp 0.3→0.4; (д) dry-run порог: ниже — авто, выше — отказ без флага; (е) миграция: онгоинг-книга под v3 не пере-пинается на add-chapters.

13. Открытые вопросы оркестратору (решения, гейтящие ратификацию v2)

  1. Пер-стадийная гранулярность wireSnapshotID/guard_hash — это v2-уточнение над book-global snapshotID v1: правка capability/model_extra РЕДАКТОРА больше не пере-оплачивает ЧЕРНОВИК (§3.1). Выигрыш реальный, но добавляет поверхность реализации (хеш на стадию, а не на книгу). Подтвердить: берём пер-стадийную гранулярность или держим book-global (проще, но грубее)?
  2. Дефолт порога dry-run rebill_consent_usd (§7) и имя флага. Предложение: порог = min($0.50, 5% × ProjectedBookUSD); флаг --accept-rebill (отдельный от --resnapshot, который остаётся алиасом). Нужно решение владельца/оркестратора — это Р6-класс согласия на трату.
  3. Caveat Adult (book.go): дроп brief_hash опирается на content_hash, но булев Adult НЕ рендерится ни в один шаблон → wire-нейтрален by construction (§4, строка brief_hash). Если Adult когда-либо должен инвалидировать работу (напр. смена канала/цензуры) — его надо ЯВНО вернуть в wireSnapshotID или verdictSnapshotID. Подтвердить, что Adult остаётся wire/verdict-нейтральным, либо назначить ему слой.

Резюме для оркестратора. Дефект — snapshotID в ключе вызова смешивает wire-, вердикт- и избыточные входы. Фикс v2: три хеша на трёх слоях — wireSnapshotID (пер-стадийный, в RequestHash), verdictSnapshotIDchunk_status, не в ключе), guard_hash (пер-стадийный суперсет wire-плана, гейт fast-path). Три дыры закрыты: (a) guard_hash покрывает прямые wire-поля, которых fast-path v1 терял (temp/reasoning/maxTokens/escalate) → нет ре-открытия stale-serve; (b) memory_version расщеплён ОБЯЗАТЕЛЬНО — inject-часть уходит в content_hash (из ключа вон), post-check+gate+decl → в verdictSnapshotID (пересчёт бесплатен); (c) loud-гейт согласия заменён pre-flight dry-run «N чанков, ~$X» с порогом --accept-rebill (Р6 сохранён). Безопасность F1 усилена (guard_hash — сильнее прежнего гарда). Онгоинг-мина D15 снята: append re-bill-ит только реально затронутые чанки×стадии. Реализация мала и хирургична, но остаётся ЗАБЛОКИРОВАННОЙ до ратификации v2.