455 lines
51 KiB
Markdown
455 lines
51 KiB
Markdown
# 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` (`classifyOutput` → `classify`, `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-v2`→`tm-request-v3`
|
||
`RequestHash = f(bookID, chapter, chunkIdx, attempt, stage, role, model, temperature, reasoning,
|
||
jsonOnly, maxTokens, wireSnapshotID, msgs)`. Замена: `snapshotID` → `wireSnapshotID`; всё остальное
|
||
как в `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 | **dropped** → `content_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 | **dropped** → `content_hash` | Правила чанкера дают `ch.Text` → `msgs`. Ре-чанкинг меняет границы/число чанков → `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 | **dropped** → `content_hash` | Режим инъекции меняет наличие/содержимое инъекц-сообщения → `msgs`. |
|
||
| `context_assembly.glossary_token_budget` | wire | **dropped** → `content_hash` | Бюджет → eviction → инъектнутые строки → `msgs` (§10). |
|
||
| `context_assembly.stm_depth` | wire | **dropped** → `content_hash` | STM входит в `msgs`. |
|
||
| `context_assembly.overlap_tokens` | wire | **dropped** → `content_hash` | Overlap меняет `ch.Text` → `msgs`. |
|
||
| `context_assembly.cache_ttl` | wire (cache-control) | **wireSnapshotID** | НЕ в `content_hash` (в `msgs` только `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 | **dropped** → `content_hash` | Человеческий лейбл; фактические байты промпта рендерятся в `msgs` (авторитет — рендер, не лейбл). |
|
||
| `prompt_sha256` | wire | **dropped** → `content_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 | **dropped** → `content_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_to`
|
||
→ `guard_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). Всё это сегодня
|
||
→ `MemoryVersion` → `snapshotID` → ключ. Правка → промах по книге. Ревью-инсайт: **post-check
|
||
пересчитывается вживую на каждом resume (`translateChunk:774-788` над финальным `prev`) и в
|
||
`chunk_status` НЕ хранится — значит `memory_version` можно убрать из ключа вызова целиком.**
|
||
|
||
**Расщепление (обязательное, не опциональное):**
|
||
|
||
- **`memoryInjectVersion`** (wire) = `memoryNormVersion` + матчер/селекция-часть `memoryMatchVersion`
|
||
+ инъектируемый контент approved-записей (`src`/`dst`/aliases — то, что рендерится в
|
||
`renderGlossaryBlock`/`renderEditorConstraintBlock`). Определяет, ЧТО и КАК инъектится →
|
||
инъекц-сообщение → `msgs` → `content_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 материализуется из банка (пере-материализуется
|
||
каждый прогон, `seedGlossary`→`materializeMemory`) и гоняется вживую в `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_hash` → **K+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», а «+T−L»).
|
||
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`**: `snapshotID`→`wireSnapshotID`, бамп `tm-request-v2`→`tm-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`**: `snapshotID`→`wireSnapshotID`, бамп `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`),
|
||
`verdictSnapshotID` (в `chunk_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**.
|