615 lines
76 KiB
Markdown
615 lines
76 KiB
Markdown
# D15.2 — Спека v3.1: content-addressed переиспользование чекпоинтов (дизайн, НЕ код)
|
||
|
||
**Статус (актуализация 17.07, D39.6-гигиена):** **v3.1 = дизайн-оф-рекорд; реализация ОТЛОЖЕНА** —
|
||
промт-эры D30.9 (`BACKEND_D152_SESSION_PROMPT.md`) superseded арх-ресетом D39; этап Б (three-hash/
|
||
guard_hash/verdict-split/fast-path v2) в ledger-очереди ПОСЛЕ слоя 1 (нарезка, гейтится exp15) —
|
||
resume-путь денежно-критичен, реализация всё-или-ничего. ⚠ Скоуп спеки = ТОЛЬКО content-addressed
|
||
resume: `tmctl export` сюда НЕ входит и его READ-половина уже ПОСТРОЕНА отдельно (D39.5,
|
||
`internal/pipeline/export.go`); annotation/override-половина экспорта — отдельный отложенный кусок.
|
||
Исходный статус v3.1 (для истории):** v3.1 закрывает два ОБЯЗАТЕЛЬНЫХ амендмента D22.2 (§0-бис ниже) + пре-имплементационные
|
||
пункты (axis-тест, паритетные не-цели §8, освежение line-refs). Зона: спека в `backend/docs/`
|
||
(зона бэкенда); при лендинге оркестратор сворачивает её в
|
||
`docs/architecture/03-implementation-notes.md` новым разделом.
|
||
|
||
## 0-бис. v3.1-амендменты (D22.2 два обязательных + пре-имплементационные)
|
||
|
||
Ревью D22.2 сконструировало и ИСПОЛНИЛО stale-hit, который v3 пропускал. Два обязательных
|
||
амендмента до реализации:
|
||
|
||
- **(v3.1-a) `Book.SourceLang`/`Book.TargetLang` → `verdictSnapshotID`.** Языки — ВЕРДИКТ-входы,
|
||
потребляемые ВНЕ рендера: `classify` использует `TargetLang` для cjk-echo-проверки
|
||
(`disposition.go:196` `isCJKTarget`), `coverageCheck` использует пару `SourceLang`/`TargetLang`
|
||
для коридора `len_ratio` (`chunkrun.go:56`). `editor.md` НЕ рендерит `{{target_lang}}` (после
|
||
флипа D30.1 билингв-редактор рендерит `{{source_lang}}`, но не target) → на editor-стадии
|
||
`content_hash` их НЕ ловит, а вердикт (cjk/coverage) от них зависит. Сегодня они в ключе через
|
||
`brief_hash` (book-поле), но v3 дропает `brief_hash` → `content_hash`; значит нужен ЯВНЫЙ
|
||
вердикт-фейт. **Правило §4 переформулировано: судьбу обязано получить ЛЮБОЕ brief-поле,
|
||
потребляемое ВНЕ рендера** (языки — в `verdictSnapshotID`; инцидентально они и в translator
|
||
`content_hash` через рендер, но вердикт-зависимость — несущая). §4-строка добавлена.
|
||
- **(v3.1-b) `cache_ttl`-расщепление: `Context.CacheTTL` — advisory (НЕ в `wireSnapshotID`);
|
||
`prov.CacheTTL` — в `wireSnapshotID`.** Проверено кодом: `clients.go:38` передаёт клиенту
|
||
`CacheTTL: prov.CacheTTL` (провайдер-уровень, models.yaml, Anthropic-kind cache_control) —
|
||
**`Context.CacheTTL` (pipeline `context.cache_ttl`, «5m») НЕ доходит НИ ДО ОДНОГО клиента =
|
||
wire-ИНЕРТЕН.** v3 клал в `wireSnapshotID` инертный `Context.CacheTTL` и терял wire-влияющий
|
||
`prov.CacheTTL` — задом наперёд. v3.1: `Context.CacheTTL` демотирован до advisory (response-
|
||
нейтрален, reuse безопасен; НЕ пиним); `prov.CacheTTL` (пер-стадийно через резолвнутый провайдер)
|
||
→ `wireSnapshotID` + `guard_hash` (меняет cache_control на Anthropic-проводе). Anthropic снят со
|
||
стека (models.yaml) → экспозиция нулевая, но механизм чист. §4-строки поправлены.
|
||
|
||
**Пре-имплементационные (D22.2):** (1) axis-sensitivity тест `RequestHash` — флип `role`/`stage`
|
||
меняет хеш (сейчас мутация «удалить role» пережила бы сьют) → §12.9 тест-лист (`г-тер`);
|
||
(2) абзац «паритетные не-цели» §8 (classify-Source для editor-строк; `Retries`/`Escal.BudgetUSD`
|
||
вне хешей; транспорт-ретраи вне ключа); (3) line-refs освежены — **⚠ рефактор D23 расщепил
|
||
`runner.go` на `stagerun.go`/`chunkrun.go`/`escalation.go`/`bookrun.go`/`resume.go`/`status.go`;
|
||
исторические `runner.go:NNN`-ссылки ниже ИНДИКАТИВНЫ (по классу кода), актуальные локации —
|
||
в §12-плане и по grep имени функции.**
|
||
|
||
**Supersedes:** v2 (направление v1+v2 ратифицировано D-лог §Ратификации 09.07 п.2 и D20.1: расщепить
|
||
`snapshotID` на wire/вердикт-части; ключ чекпоинта — на wire-идентичности; вердикт-смену
|
||
re-classify-ить на resume бесплатно; три дыры v1 закрыты v2). **v3 закрывает три правки D20.1**
|
||
(«направление и 90% деталей ратифицированы; реализация разблокируется после v3-правок»):
|
||
|
||
- **(a)** guard_hash **обязан нести `role` + имя стадии** — без них формула НЕ суперсет текущей
|
||
защиты: конструируемый stale-VERDICT fast-path hit при флипе роли (клейм «строго сильнее» был
|
||
ложен). → §5, контрпример + §3.3/§3.4 формула.
|
||
- **(b)** dry-run §7 нуждается в **аналитическом правиле editor-каскада**: любая стадия НИЖЕ
|
||
re-bill-юнита того же чанка сама считается re-bill (иначе консент-проекция занижает ~2× на самом
|
||
дорогом классе правок — temp/reasoning/model translator'а, чей новый вывод $0-dry-run
|
||
материализовать не может). → §7 п.1-бис.
|
||
- **(c)** полевой маппинг §4 **доукомплектован**: `style_check_version`; слой YoPolicy/StyleAllowlist;
|
||
target-часть `memoryNormVersion` (→ и в postcheck-версию); судьба `jobs.snapshot_id`; verdict-фолд
|
||
конфига гейтов только при enabled. → §4, §6, §9.
|
||
|
||
**Ответы D20.2 вписаны в текст v3:** Q1 — пер-стадийная гранулярность `wireSnapshotID`/`guard_hash`
|
||
**ПОДТВЕРЖДЕНА** (§3.1); Q2 — порог `min($0.50, 5%×ProjectedBookUSD)` + флаг `--accept-rebill[=usd]`
|
||
(опциональный потолок суммы) + fallback-флор при `ProjectedBookUSD=0` **ПРИНЯТЫ**, но фикс каскада
|
||
(b) — ПЕРВЫМ (§7); Q3 — `Adult` остаётся wire/вердикт-НЕЙТРАЛЬНЫМ, слой не назначается: вместо хеша
|
||
— **load-time adult-линт** (реализован бэкенд-пакетом №3: `Pipeline.CheckAdultChannel`; §4, §13).
|
||
|
||
**v2-контекст (сохранён):** v2 закрыл три дыры внешнего ревью — (a) fast-path-гард слабее текущего →
|
||
колонка `guard_hash`; (b) расщепление `memory_version` сделано ОБЯЗАТЕЛЬНЫМ; (c) loud-гейт согласия
|
||
заменён pre-flight dry-run. v3 доводит (a) до суперсета (role+stage), (b) до полноты (target-norm в
|
||
postcheck), (c) — маппинг §4 доукомплектован и каскад в проекции аналитический.
|
||
|
||
**Гейтит:** онгоинг-режим (`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-v2, stageName, role, 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`.
|
||
|
||
**Правка D20.1(a): `stageName` + `role` ОБЯЗАТЕЛЬНЫ, префикс `tm-guard-v1`→`tm-guard-v2`.** Оба входят
|
||
в per-стадийный компонент старого `snapshotID` (stageSnap: `Name`,`Role`,… — `runner.go:387`) И в
|
||
`RequestHash` (`f(…, stage, role, model,…)` — §3.4). Без них `guard_hash` НЕ суперсет ни старого
|
||
snapshot-гарда, ни `RequestHash`-осей `stage`/`role`, из-за чего фаст-пас может отдать stale-ВЕРДИКТ
|
||
при флипе роли на wire-нейтральном чанке (контрпример — §5). `role` — не косметика: он определяет,
|
||
применяется ли coverage-гейт (только к translator-выходу, `runner.go:198-200`), т.е. РЕЗОЛВНУТЫЙ
|
||
вердикт. `stageName` — ключ строки `chunk_status` (PK по имени) и дизамбигуатор при book-global дифе
|
||
проекции §7. С правкой guard_hash строго ⊇ (per-стадийный snapshot-компонент ∪ прямые wire-поля).
|
||
|
||
### 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` ловит правку брифа гранулярно. **Правило (v3.1-a): ЛЮБОЕ brief-поле, потребляемое ВНЕ рендера, обязано получить явную судьбу** (`content_hash` его не ловит). Назначено: `SourceLang`/`TargetLang` → `verdictSnapshotID` (cjk-gate/coverage-коридор — строка ниже, v3.1-a); `YoPolicy`/`StyleAllowlist` → `verdictSnapshotID` (кормят стиль-флаггер); `Adult` (`book.go`) остаётся **wire/вердикт-НЕЙТРАЛЬНЫМ** (D20.2-Q3): слой не назначается, вместо хеша — **load-time adult-линт** `Pipeline.CheckAdultChannel` (реализован пакетом №3: `adult:true` без `channel:adult`-стадии → fail-loud). Первый рантайм-потребитель `Adult` (если появится) обязан ЯВНО вернуть его в `wireSnapshotID`/`verdictSnapshotID` — §13-Q3. |
|
||
| `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` (`Context.CacheTTL`) | advisory | **dropped (v3.1-b)** | ⚠ **Правка v3.1-b: wire-ИНЕРТЕН** — `clients.go:38` передаёт клиенту `prov.CacheTTL`, а `Context.CacheTTL` (pipeline `context.cache_ttl`) НЕ доходит НИ ДО ОДНОГО клиента. Response-нейтрален, reuse безопасен → НЕ пиним (v3 клал его в `wireSnapshotID` ошибочно). |
|
||
| `prov.CacheTTL` (провайдер, Anthropic cache_control) | wire (cache-control) | **wireSnapshotID** + `guard_hash` (v3.1-b) | Провайдер-уровневый TTL РЕАЛЬНО едет на провод (`clients.go:38`→`provider_anthropic.go` cache_control). Пер-стадийно через резолвнутый провайдер стадии. Anthropic снят со стека → экспозиция нулевая, механизм чист. |
|
||
| `Book.SourceLang` / `Book.TargetLang` | verdict | **verdictSnapshotID (v3.1-a)** | ⚠ **Правка v3.1-a.** Языки — ВЕРДИКТ-входы вне рендера: `TargetLang`→cjk-echo (`disposition.go:196`), пара→`len_ratio`-коридор (`chunkrun.go:56`). `editor.md` НЕ рендерит `{{target_lang}}` → `content_hash` editor-стадии их не ловит, а вердикт от них зависит. Инцидентально они и в translator `content_hash` (рендер `{{source_lang}}`/`{{target_lang}}`), но несущая — вердикт-зависимость. Уходят из ключа вместе с `brief_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. **Правка D20.1(c): `memoryNormVersion` двусторонний — SOURCE-часть нормализует ключи матчера (wire, → `memoryInjectVersion` → `content_hash`), а TARGET-часть нормализует dst-формы, по которым post-check ищет перевод в выводе (вердикт). Значит `memoryNormVersion` (target-сторона) входит ТАКЖЕ в `memoryPostcheckVersion` → `verdictSnapshotID`** — иначе правка target-нормализации сменила бы post-check вердикт, но не отразилась бы в `verdict_snapshot` → stale-вердикт на resume. §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** (ТОЛЬКО при `enabled`) | Excision-гейт не трогает провод; `coverageCheck` пересчитывается вживую над текстом чекпоинта. **Правка D20.1(c): фолдить в `verdictSnapshotID` ТОЛЬКО когда гейт enabled** — зеркаля дисциплину `coverageSnapshot()` (`runner.go:181-190`: `{enabled:false}` когда off, полный конфиг+версия когда on). Иначе твик порогов ВЫКЛЮЧЕННОГО гейта форсировал бы re-verdict/bust fast-path на всей книге, хотя off-гейт вердиктов не выносит. |
|
||
| `style_check_version` (`cheapGateVersion`, cheapgates.go) | verdict | **verdictSnapshotID** | Правка D20.1(c). Версионирует 4 дешёвых стиль-флаггера (тире-диалоги/ёфикатор/транслит-междометия/万-億). Они — наблюдаемость (пост-check `runCheapGates`, пересчёт вживую, в `chunk_status` НЕ хранятся, как classifier/coverage), но правка правила сдвигает записанные счётчики стиль-флагов → это ВЕРДИКТ-версия. Из wire-ключа УБИРАЕТСЯ (провод не трогает), кладётся в `verdictSnapshotID`; на resume re-classify стиля бесплатен. Сегодня фолдится как `StyleCheckVersion` в `snapshotID` (`runner.go:383`) — переезжает в `verdictSnapshotID`. |
|
||
| `YoPolicy` (book.yaml `yo_policy`) | verdict | **verdictSnapshotID** (не через `brief_hash`) | Правка D20.1(c). Ё-политика (auto/all-yo/all-e) кормит ёфикатор — вердикт-сторона, live-recomputed (класс post-check). ⚠ Сегодня едет через `BriefHash` (book-поле), но `brief_hash` в v2 задропан → `content_hash`; **YoPolicy НЕ рендерится ни в один шаблон** (это конфиг гейта, не текст модели), значит `content_hash` его НЕ ловит. Явно кладём в `verdictSnapshotID` (иначе смена ё-политики тихо не пере-выносит стиль-вердикт на resume). |
|
||
| `StyleAllowlist` (book.yaml `style_allowlist`) | verdict | **verdictSnapshotID** (не через `brief_hash`) | Правка D20.1(c). Как YoPolicy: пер-проектный allowlist транслит-междометий кормит стиль-флаггер (вердикт), НЕ рендерится в промпт → `content_hash` не ловит → явно в `verdictSnapshotID`. |
|
||
|
||
**Примечание к `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`)
|
||
сохраняет пер-стадийную гранулярность ключа и не смешивает деривац-версии (которые в ключе
|
||
избыточны) с ключом.
|
||
|
||
**Контрпример к «строго сильнее» БЕЗ role/stage (правка D20.1(a), обоснование правки §3.3).**
|
||
Возьмём чанк, у которого инъекция ПУСТА (глоссарий не сматчился / отключён): тогда
|
||
`renderGlossaryBlock` (translator) и `renderEditorConstraintBlock` (editor) дают ОДИНАКОВОЕ пустое
|
||
инъекц-сообщение → `msgs` идентичны → `content_hash` идентичен для обеих ролей. Оператор флипает
|
||
роль стадии `editor`→`translator` (проба «translator-редактуры»), НЕ трогая model/temperature/
|
||
reasoning/maxTokens. Тогда под guard_hash БЕЗ role:
|
||
- `content_hash` — тот же (пустая инъекция в обе стороны, вход `{{draft}}`/`{{text}}` тот же текст);
|
||
- `wireSnapshotID` — тот же (capability/model_extra/escalate не менялись);
|
||
- прямые wire-поля (`model`/`temp`/`reasoning`/`jsonOnly`) + деривация `maxTokens` — те же;
|
||
- → `guard_hash` СОВПАДАЕТ. И `verdict_snapshot` совпадает (role в него не входит).
|
||
|
||
Fast-path ХИТИТ и отдаёт ХРАНИМУЮ диспозицию, посчитанную под СТАРОЙ ролью. Но флип
|
||
`editor`→`translator` ВКЛЮЧАЕТ coverage-гейт (он бежит ТОЛЬКО на translator-выходе,
|
||
`classifyOutput`/`runner.go:198-200`): корректная диспозиция могла бы стать `excision_suspect`
|
||
(flagged), а хранимая — `ok` (editor, coverage не применялся). **Fast-path отдаёт stale `ok` — тихий
|
||
неверный вердикт, ре-открытие класса F1 на вердикт-оси.** С `role` внутри `guard_hash` флип роли
|
||
меняет `guard_hash` → fast-path проваливается в attempt-цикл → там `RequestHash` (который НЕСЁТ
|
||
`role`, §3.4 / `render.go:212`) **промахивается** мимо старого (role=editor) чекпоинта → **свежий
|
||
ОПЛАЧЕННЫЙ вызов**, ре-классифицированный под НОВОЙ ролью с применённым coverage → корректный вердикт.
|
||
⚠ **Это честный re-bill, НЕ $0-переклассификация:** т.к. `role` — прямое поле `RequestHash`, флип
|
||
роли расщепляет ключ чекпоинта (два role-конфига стадии не делят чекпоинт), поэтому dry-run §7 верно
|
||
покажет это как re-bill под порогом `--accept-rebill`, а не как бесплатную смену. Ключевой ВЫИГРЫШ
|
||
правки D20.1(a) — не $0, а **устранение stale-`ok` serve** (безопасность вердикт-оси); стоимость флипа
|
||
роли честна. (В общем случае `role` wire-влияющий — инъекция translator≠editor — так что держать его в
|
||
`RequestHash` консервативно верно; вырожденный пустой-инъекция кейс даёт лишь расточительный, но
|
||
корректно СОГЛАСУЕМЫЙ через §7 re-bill. Альтернатива «убрать `role` из `RequestHash` ради free
|
||
re-classify» конфлейтила бы два role-конфига одной стадии в один чекпоинт — не берём без явного
|
||
решения ратификации.) Тот же аргумент показывает, что `guard_hash` без `role` НЕ суперсет старого
|
||
snapshot-гарда (старый book-global `snapshot_id` нёс role/name всех стадий) — потому клейм «строго
|
||
сильнее» без правки ложен.
|
||
|
||
**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** + **TARGET-часть `memoryNormVersion`
|
||
(правка D20.1(c))**. Уходит в **`verdictSnapshotID`** (§3.2), пересчитывается на resume бесплатно.
|
||
⚠ **`memoryNormVersion` двусторонний:** SOURCE-нормализация ключей матчера — wire (→
|
||
`memoryInjectVersion` → `content_hash`), но post-check ищет dst-формы в ВЫВОДЕ по TARGET-нормализации
|
||
(`memory.go` пост-check матчит нормализованный dst), значит TARGET-часть `memoryNormVersion` —
|
||
ВЕРДИКТ и ОБЯЗАНА входить в `memoryPostcheckVersion`. Если норм-версия единая (не расщепляется на
|
||
src/target внутри слага) — держать `memoryNormVersion` В ОБЕИХ (`memoryInjectVersion` И
|
||
`memoryPostcheckVersion`): дубль безопасен (inject-часть всё равно в `content_hash`), а пропуск
|
||
target-стороны из вердикта дал бы stale-вердикт post-check на 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). Диф ловит эффекты второго порядка, чьи `msgs`
|
||
МАТЕРИАЛИЗУЕМЫ из хранимого банка (sticky, eviction — §10 пп.1-2): их инъекц-сообщение
|
||
пересобирается из глоссария на $0.
|
||
|
||
1-бис. **Аналитическое правило editor-каскада (правка D20.1(b)) — ОБЯЗАТЕЛЬНО.** Диф §7.1 ловит
|
||
каскад на редактор ТОЛЬКО когда меняется ИНЪЕКЦИЯ (append-термина, §10 п.3-i: T попадает и в
|
||
editor-констрейнт-блок — материализуемо). Но для самого дорогого класса правок — **смена wire-параметра
|
||
TRANSLATOR'а (temperature/reasoning/model)** — editor меняется через §10 п.3-ii (изменённый
|
||
`{{draft}}`), а НОВЫЙ черновик $0-dry-run материализовать НЕ может (он появится только когда
|
||
translator реально пере-вызовется). В проекции вход `{{draft}}` редактора остаётся СТАРЫМ
|
||
(хранимым) → `content_hash`/`guard_hash` редактора выглядят НЕИЗМЕННЫМИ → редактор НЕ попадает в
|
||
re-bill → **проекция занижает ~2× на 2-стадийном C1** (посчитан только translator, пропущен
|
||
гарантированный каскад на editor). Фикс — правило поверх дифа: **любая стадия СТРОГО НИЖЕ re-bill-юнита
|
||
ТОГО ЖЕ чанка сама считается re-bill, независимо от её собственного `guard_hash`-дифа** (её вход
|
||
сменится, как только пере-выполнится upstream; downstream цена — из `chunk_status.cost_usd`
|
||
downstream-строки, либо `EstimateUSD` над её текущими `msgs` с ПОДСТАВЛЕННЫМ проецируемым upstream).
|
||
Правило чисто структурное (линейный C1: draft→edit), не требует материализации нового вывода.
|
||
⚠ Порядок реализации (D20.2-Q2): каскад-фикс §7.1-бис ЛОЖИТСЯ ПЕРЕД семантикой флага `--accept-rebill`
|
||
— иначе порог согласия считается от заниженной вдвое суммы.
|
||
2. **Стоимость.** Проецируемый $ = Σ по изменившимся единицам их хранимого `chunk_status.cost_usd`
|
||
(честная прошлая фактическая цена; для каскадных downstream-единиц без прошлой цены — оценка
|
||
`ledger.EstimateUSD` над новыми `msgs`). Выдаём «**N чанков будет пере-оплачено, ~$X**».
|
||
3. **Порог + флаг (форма ратифицирована D20.2-Q2).** Порог `rebill_consent_usd` (конфиг книги) с
|
||
дефолтом **`min($0.50, 5% × ProjectedBookUSD)`**. **Fallback-флор при `ProjectedBookUSD=0`** (книга
|
||
ещё не считалась / нет обработанных чанков → 5%-ветка даёт $0 и заблокировала бы даже центовый
|
||
append): при `ProjectedBookUSD=0` порог = абсолютный флор `$0.50`. Ниже порога — **проходим
|
||
автоматически** (append-термина в 3 чанках стоит центы — без трения согласия). Выше порога — **отказ
|
||
без явного флага `--accept-rebill[=usd]`**: форма с ОПЦИОНАЛЬНЫМ потолком суммы (`--accept-rebill`
|
||
принимает проецируемый re-bill целиком; `--accept-rebill=1.50` — только если проекция ≤ $1.50, иначе
|
||
стоп: Р6 = согласие на КОНКРЕТНУЮ трату, не бланкетное). Гейт content-scoped: срабатывает только на
|
||
РЕАЛЬНУЮ проецируемую трату, а не на любой вердикт-only $0-change. `--resnapshot` остаётся
|
||
алиасом-синонимом (семантика — «прими проецируемый re-bill», не «пере-пинай всё»).
|
||
|
||
Это сохраняет Р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`.
|
||
|
||
**Паритетные НЕ-цели (v3.1, D22.2 — что осознанно ВНЕ хешей).** Формула суперсет-паритета не обязана
|
||
хешировать входы, которые ни wire, ни вердикт: (а) **classify-Source для editor-строк** — `classify`
|
||
принимает `Source` для soft-refusal-порога и cjk-echo, но на editor-выходе источник — это черновик, а
|
||
не оригинал; это часть детерминированной классификации над `content_hash`-материалом, не отдельная
|
||
ось ключа. (б) **`Retries.RegenerateBeforeEscalate` и `Escalation.BudgetUSD`** — НЕ в снапшоте/ключе:
|
||
они управляют СКОЛЬКО попыток/хопов делать, а не байтами какого-либо вызова; их правка меняет, какие
|
||
`attempt`-оси материализуются, но каждый материализованный вызов уже уникально кейится своим `attempt`
|
||
(ретрай) или моделью эскалации (хоп) — уже покрыто. Смена `RegenerateBeforeEscalate` не пере-оплачивает
|
||
готовые чанки (их `attempt=0`-чекпоинты попадают), только разрешает/запрещает БУДУЩИЕ ретраи флагнутых.
|
||
(в) **Транспортные ретраи** (`timeouts` в models.yaml, backoff) — вне ключа by design (тот же
|
||
`RequestHash` через транспорт-ретраи и resume — F3-идемпотентность); их правка не инвалидирует ничего.
|
||
Эти три — паритетные не-цели: их отсутствие в `wireSnapshotID`/`guard_hash`/`verdictSnapshotID`
|
||
КОРРЕКТНО, а не пробел.
|
||
|
||
## 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`)
|
||
снимается.
|
||
- **`jobs.snapshot_id`** (правка D20.1(c), одним предложением) — следует за `chunk_status.snapshot_id`:
|
||
**демотируется до advisory** (какой job-снапшот держит стадию главы), а book-global job-mismatch
|
||
fail-loud (`runStage:865-869`/`904-908`: `job.SnapshotID != snapID` → стоп без `--resnapshot`)
|
||
**заменяется** пер-чанковым `guard_hash`-решением + dry-run-проекцией §7; `UpdateJobSnapshot`
|
||
оставляем для re-pin к текущему снапшоту (self-heal), FK `jobs.snapshot_id→snapshots` цел.
|
||
- **`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, после ратификации v3)
|
||
|
||
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(stageName, role, content_hash, wireSnapshotID, model,
|
||
temp, reasoning, jsonOnly, estimator/ratio/floor/policy)`, префикс **`tm-guard-v2`** (правка
|
||
D20.1(a): `stageName`+`role` ОБЯЗАТЕЛЬНЫ — иначе не суперсет, §3.3/§5-контрпример).
|
||
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`-диф + **правило каскада §7.1-бис (любая
|
||
стадия ниже re-bill-юнита чанка = re-bill; ЛОЖИТСЯ ПЕРВЫМ)** → «N чанков, ~$X»; порог
|
||
`rebill_consent_usd` = `min($0.50, 5%×ProjectedBookUSD)` c fallback-флором $0.50 при
|
||
`ProjectedBookUSD=0`; флаг `--accept-rebill[=usd]` (опциональный потолок). `tmctl status`: заменить
|
||
булев `ConfigDrift` на проекцию; `SnapshotDrift` → advisory. Redrive drift-guard → `guard_hash`-диф
|
||
целей + проекция (§9). Adult: слой НЕ назначать — линт `CheckAdultChannel` (уже в пакете №3).
|
||
9. **Тесты:** (а) вердикт-only смена (classifier/coverage/postcheck_gate/decl/**style_check**/**yo_policy**/
|
||
**sanitizer**/**source_lang·target_lang** — v3.1-a) → $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; **(г-бис) role-флип на
|
||
wire-нейтральном чанке (пустая инъекция) → без `role` в guard_hash fast-path отдаёт stale-`ok`, с `role` —
|
||
re-classify под coverage (§5-контрпример);** **(г-тер, D22.2 axis-sensitivity) флип `role`/`stage` меняет
|
||
`RequestHash` — мутация «удалить role/stage из RequestHash» роняет тест (сейчас переживает сьют);** (д)
|
||
dry-run порог: ниже — авто, выше — отказ без флага; **(д-бис) editor-каскад: смена temperature translator'а
|
||
→ проекция считает re-bill ОБЕИХ стадий чанка, не только translator (§7.1-бис; мутация «убрать правило
|
||
каскада» роняет проекцию до ~½);** (е) миграция: онгоинг-книга под v3 не пере-пинается на `add-chapters`;
|
||
**(ж, v3.1-b) `Context.CacheTTL` вне `wireSnapshotID` (правка «5m»→«1h» не re-bill-ит); `prov.CacheTTL`
|
||
в `wireSnapshotID` (правка re-bill-ит Anthropic-стадию).**
|
||
|
||
## 13. Решённые вопросы (ответы D20.2 — вписаны в текст v3)
|
||
|
||
Все три §13-вопроса v2 РЕШЕНЫ D20.2; сохранены здесь как запись контракта.
|
||
|
||
1. **Пер-стадийная гранулярность `wireSnapshotID`/`guard_hash` — ПОДТВЕРЖДЕНА** (D20.2-Q1). Живой
|
||
довод оркестратора: вероятная смена редактора glm-5→grok-4.3 при book-global пере-оплатила бы весь
|
||
translator-бэклог; поверхность мала — `snapshotID` уже итерирует стадии. Взято пер-стадийно (§3.1).
|
||
2. **Порог dry-run и флаг — ПРИНЯТЫ** (D20.2-Q2) с поправками: порог `min($0.50, 5%×ProjectedBookUSD)`,
|
||
флаг `--accept-rebill[=usd]` (опциональный потолок суммы, отдельный от `--resnapshot`-алиаса),
|
||
fallback-флор при `ProjectedBookUSD=0`. ⚠ Порядок: **фикс каскада §7.1-бис ПЕРВЫМ** (иначе порог
|
||
считается от заниженной вдвое суммы). Форма вписана в §7 п.3 / §12 п.8.
|
||
3. **`Adult` остаётся wire/вердикт-НЕЙТРАЛЬНЫМ, слой НЕ назначается** (D20.2-Q3). Вместо хеша —
|
||
**load-time линт консистентности**: `adult:true` без единой `channel:adult`-стадии → громко. Линт
|
||
**реализован бэкенд-пакетом №3** (`Pipeline.CheckAdultChannel`, вызывается в `NewRunner`; обратная
|
||
сторона — `channel:adult` при `adult:false` — сознательно НЕ гейтится, Adult нейтрален до первого
|
||
рантайм-потребителя, который обязан ЯВНО назначить слой). §4 (строка `brief_hash`).
|
||
|
||
---
|
||
|
||
**Резюме для оркестратора.** Дефект — `snapshotID` в ключе вызова смешивает wire-, вердикт- и
|
||
избыточные входы. Фикс: три хеша на трёх слоях — `wireSnapshotID` (пер-стадийный, в `RequestHash`),
|
||
`verdictSnapshotID` (в `chunk_status`, не в ключе), `guard_hash` (пер-стадийный суперсет wire-плана,
|
||
гейт fast-path). Три дыры v2 закрыты + **три правки v3 (D20.1)**: **(a)** `guard_hash` покрывает
|
||
прямые wire-поля (temp/reasoning/maxTokens/escalate) **И `stageName`+`role`** → строгий суперсет, нет
|
||
ре-открытия stale-serve ни на wire-, ни на вердикт-оси (контрпример с флипом роли — §5); **(b)**
|
||
`memory_version` расщеплён ОБЯЗАТЕЛЬНО — inject-часть в `content_hash`, post-check+gate+decl+**target-norm**
|
||
→ `verdictSnapshotID` (пересчёт бесплатен); dry-run §7 несёт **аналитическое правило editor-каскада**
|
||
(любая стадия ниже re-bill-юнита = re-bill, иначе консент занижен ~2×); **(c)** loud-гейт согласия
|
||
заменён pre-flight dry-run «N чанков, ~$X» с порогом `min($0.50, 5%×ProjectedBookUSD)` и флагом
|
||
`--accept-rebill[=usd]` (Р6 сохранён); маппинг §4 доукомплектован (`style_check_version`,
|
||
YoPolicy/StyleAllowlist, `jobs.snapshot_id`, coverage-фолд-при-enabled); `Adult` нейтрален — линт
|
||
`CheckAdultChannel` (уже в пакете №3). Безопасность F1 усилена. Онгоинг-мина D15 снята: append
|
||
re-bill-ит только реально затронутые чанки×стадии. Реализация мала и хирургична, но **остаётся
|
||
ЗАБЛОКИРОВАННОЙ до ратификации v3** (реализацию НЕ начинать без подписи оркестратора — D20.1).
|