76 KiB
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:196isCJKTarget),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; инцидентально они и в translatorcontent_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(pipelinecontext.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_hashmismatch → 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).
- инъектируемый контент approved-записей (
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:
memoryPostcheckVersion(алгоритм/пороги post-check);gate:bool— судьбаpostcheck_gateявно: это ВЕРДИКТ-сторона (toggle меняет резолвнутую диспозициюglossary_miss,runner.go:782-787), кладём вverdictSnapshotID. Сегодняшний двойной фолд (top-levelPostcheckGateвsnapshotID+gate:вcomputeMemoryVersion) схлопывается в один источник —verdictSnapshotID;- 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 OFFdeclне влияет ни на 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):
- Проекция (источник — тот же пер-чанковый
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-upsertchunk_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), FKjobs.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, а не рассуждает аналитически):
- 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 внутри главы}. - F2 eviction (
memory.goбюджетglossary_token_budget). Инъекция бюджет-лимитирована. В бюджет-ПОЛНОМ чанке добавление T ВЫТЕСНЯЕТ более низкоприоритетную ранее-инъектнутую строку L. Ключевой второпорядковый случай: T, принесённый в K+1 через sticky (где T НЕ фаерится), потребляет бюджет и вытесняет строку L' — меняяmsgsK+1 сверх просто +T, хотя сам T там не фаерится. Eviction НЕ расширяет множество чанков за пределы {фаерящие ∪ sticky}, но увеличивает дельту внутри них (не «+T», а «+T−L»). - Каскад на редактор. Инъектируемый констрейнт-блок редактора — ПОДМНОЖЕСТВО инъекции черновика
(
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.gov7 =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)
- Расщепить
snapshotID()→wireSnapshotID(stage)(пер-стадийный, §3.1) +verdictSnapshotID()(book-global, §3.2). ПолныйsnapshotID= их хеш (advisory). RequestHash:snapshotID→wireSnapshotID, бампtm-request-v3(§3.4).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-контрпример).- Расщепить
memoryMatchVersion/computeMemoryVersion(ОБЯЗАТЕЛЬНО, §6) наmemoryInjectVersion(→msgs, из ключа убрать) иmemoryPostcheckVersion(+gate+decl-при-gateON →verdictSnapshotID). Снять двойной фолдpostcheck_gate. - Миграция v8 (§11): колонки
guard_hash,verdict_snapshot; писать/читать вchunkstatus.go(Upsert/Get/ForBook).snapshot_idоставить (advisory). - Fast-path (
runStage): гейтcs.guard_hash == guardHash(тек.) && cs.verdict_snapshot == verdictSnapshotID(тек.) && disposition != skipped; иначеrunAttempt(re-classify бесплатно, §8). - Job-snapshot-mismatch: демотировать fail-loud стоп (
runStage:865-869) в пер-чанковоеguard_hash-решение. - 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). - Тесты: (а) вердикт-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; сохранены здесь как запись контракта.
- Пер-стадийная гранулярность
wireSnapshotID/guard_hash— ПОДТВЕРЖДЕНА (D20.2-Q1). Живой довод оркестратора: вероятная смена редактора glm-5→grok-4.3 при book-global пере-оплатила бы весь translator-бэклог; поверхность мала —snapshotIDуже итерирует стадии. Взято пер-стадийно (§3.1). - Порог dry-run и флаг — ПРИНЯТЫ (D20.2-Q2) с поправками: порог
min($0.50, 5%×ProjectedBookUSD), флаг--accept-rebill[=usd](опциональный потолок суммы, отдельный от--resnapshot-алиаса), fallback-флор приProjectedBookUSD=0. ⚠ Порядок: фикс каскада §7.1-бис ПЕРВЫМ (иначе порог считается от заниженной вдвое суммы). Форма вписана в §7 п.3 / §12 п.8. 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).