Add single-hop escalation routing a deterministic content-failure to a named fallback model once, re-gated, budget-capped, with channel-B isolation by type

This commit is contained in:
Claude (backend session) 2026-07-04 23:56:50 +03:00
parent 6dc8f13c6c
commit 5cbf75cae8
8 changed files with 497 additions and 37 deletions

View file

@ -49,10 +49,17 @@ type Provider struct {
// thinking:disabled is fine because its input is a Russian draft (no CJK to
// echo): GLM's provider carries no such flag. Validation-only metadata — it
// never reaches the wire, so it is NOT part of the snapshot (no --resnapshot).
EchoesWhenThinkingOff bool `yaml:"echoes_when_thinking_off"`
CacheTTL string `yaml:"cache_ttl"` // anthropic kind: "", "5m", "1h"
Model string `yaml:"model"` // local kind: the backend's own tag
MaxTokens int `yaml:"max_tokens"`
EchoesWhenThinkingOff bool `yaml:"echoes_when_thinking_off"`
// Permissive marks a provider allowed to serve the 18+ channel B (the most
// permissive large APIs: xAI/Grok, DeepSeek official, local abliterated — D3).
// It is the TYPE that enforces channel-B isolation (D4.1): a channel=adult stage
// may only run on, and escalate to, a permissive provider — a structural gate,
// not a comment, so an SFW-only provider can never silently serve/answer a
// permissive chunk. Validation-only metadata (never on the wire, not in snapshot).
Permissive bool `yaml:"permissive"`
CacheTTL string `yaml:"cache_ttl"` // anthropic kind: "", "5m", "1h"
Model string `yaml:"model"` // local kind: the backend's own tag
MaxTokens int `yaml:"max_tokens"`
// Temperature (local kind): override запроса — ollama чтит request-
// температуру поверх Modelfile, и температура облачной роли молча
// расстроила бы локальную модель. 0 = наследовать запрос.
@ -229,6 +236,17 @@ func (m *Models) Prices() (*ledger.Pricer, error) {
return ledger.NewPricer(prices, m.Models[m.DefaultModel].Price.ToLedger())
}
// providerPermissive reports whether a model's provider is marked permissive
// (channel-B-eligible, D4.1). Unknown model → false (fail closed: an undefined
// model is never treated as permissive).
func (m *Models) providerPermissive(modelName string) bool {
mod, ok := m.Models[modelName]
if !ok {
return false
}
return m.Providers[mod.Provider].Permissive
}
// APIKey resolves a provider's key from the environment ("" if the provider
// declares no env var — the local backend).
func (p Provider) APIKey() string {
@ -387,6 +405,16 @@ func (m *Models) CheckKeys(pipe *Pipeline) error {
}
}
}
// Single-hop fallback models are reachable only once an escalation budget is set
// (opt-in); preflight their keys then, so an escalation does not 401 mid-book
// after the primary already flagged.
if pipe.Escal.BudgetUSD > 0 {
for _, st := range pipe.Stages {
if st.EscalateTo != "" {
needed[st.EscalateTo] = struct{}{}
}
}
}
var problems []string
seen := map[string]bool{} // dedupe per provider
for name := range needed {

View file

@ -64,6 +64,18 @@ type Stage struct {
PromptVersion string `yaml:"prompt_version"`
Temperature float64 `yaml:"temperature"`
Reasoning string `yaml:"reasoning"` // "", off, low, medium, high
// EscalateTo is the SINGLE-HOP fallback model tried ONCE when this stage's
// output is a deterministic content-failure another model might fix (echo /
// excision / refusal — D12). Empty = no escalation (e.g. the editor is pinned
// per book — its style must not drift to a foreign model, D12/2605.13368). The
// fallback is its OWN model, which is the request_hash axis (a distinct
// checkpoint, never the failed primary's). Gated by escalation.budget_usd.
EscalateTo string `yaml:"escalate_to"`
// Channel selects the safety channel: "" | sfw (default) | adult (18+). An
// adult stage is isolated by TYPE (D4.1): its model AND its escalate_to must be
// on permissive providers, enforced at load — never a fall-through to a refusing
// SFW provider.
Channel string `yaml:"channel"`
}
// Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).
@ -197,6 +209,28 @@ func LoadPipeline(path string, models *Models) (*Pipeline, error) {
default:
bad("stage %q: reasoning must be off|low|medium|high, got %q", st.Name, st.Reasoning)
}
switch st.Channel {
case "", "sfw", "adult":
default:
bad("stage %q: channel must be sfw|adult, got %q", st.Name, st.Channel)
}
if st.EscalateTo != "" {
if _, ok := models.Models[st.EscalateTo]; !ok {
bad("stage %q: escalate_to model %q is not defined in models.yaml", st.Name, st.EscalateTo)
} else if st.EscalateTo == st.Model {
bad("stage %q: escalate_to must differ from the primary model %q (a same-model hop is a guaranteed repeat)", st.Name, st.Model)
}
}
// D4.1: channel-B (18+) isolation is enforced by TYPE — a permissive stage
// may only run on, and escalate to, a permissive provider.
if st.Channel == "adult" {
if !models.providerPermissive(st.Model) {
bad("stage %q: channel=adult requires model %q on a permissive provider (D4.1)", st.Name, st.Model)
}
if st.EscalateTo != "" && !models.providerPermissive(st.EscalateTo) {
bad("stage %q: channel=adult escalate_to %q must be on a permissive provider (D4.1 — no fall-through to a refusing SFW channel)", st.Name, st.EscalateTo)
}
}
}
for chain, ms := range p.Escal.Chains {
for _, m := range ms {

View file

@ -88,6 +88,23 @@ func (r FlagReason) retryable() bool {
return r == FlagLength || r == FlagEmpty
}
// escalatable reports whether a flag is a DETERMINISTIC content-failure that a
// DIFFERENT model might fix — the single-hop escalation trigger (D12, the
// deterministic-content-failure class). These arrive as HTTP 200 (the provider
// billed a "translation" that is echo / excision / a refusal), so a same-model
// retry just re-produces them (which is why they are non-retryable) — escalation
// routes them to another model ONCE. length/empty are excluded (retryable on the
// same model with a bigger budget); decode_error is transport-shaped (a billed
// unreadable body), not a content verdict another model would reliably avoid.
func (r FlagReason) escalatable() bool {
switch r {
case FlagCJKArtifact, FlagExcisionSuspect, FlagLoopDegenerate,
FlagHardRefusal, FlagSoftRefusal, FlagContentFilter:
return true
}
return false
}
// disposition maps a reason to the resolved chunk×stage state.
func (r FlagReason) disposition() Disposition {
if r == reasonOK {

View file

@ -179,6 +179,24 @@ func (r *Runner) classifyOutput(source, output, finish string) classification {
return cls
}
// escalationBudgetRemains reports whether the book may still spend on a single-hop
// fallback draft: escalation is OPT-IN via escalation.budget_usd (0 = disabled, the
// boevoy default — a stage's escalate_to is inert until a premium budget is set), and
// capped at that budget summed over the book's escalation checkpoints (money-path
// durable, resume-safe). A soft cap: the hop already in flight may cross it slightly,
// but the NEXT chunk's escalation is then denied.
func (r *Runner) escalationBudgetRemains() (bool, error) {
budget := r.Pipeline.Escal.BudgetUSD
if budget <= 0 {
return false, nil
}
spent, err := r.Store.EscalationSpentUSD(r.Book.BookID)
if err != nil {
return false, err
}
return spent < budget, nil
}
// memoryVersion is the content-hash of the deterministically materialized
// injected memory (approved glossary + summaries + series-bible), the memory
// component of the snapshot (D5.2/D8). Until the memory bank v2 migration lands
@ -226,6 +244,14 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
// тель), поэтому обязана входить в снапшот — иначе правка каппы молча
// false-хитит чекпоинты (тот же класс D5.2, что и payload ниже).
Capability json.RawMessage `json:"capability,omitempty"`
// EscalateTo + EscalateCapability — the single-hop fallback model and its
// resolved wire shape (Веха 2.5). The fallback's request_hash uses ITS model,
// and its wire body uses ITS capability — both must be in the snapshot, or
// changing the escalation model / its caps would silently false-hit an
// escalated chunk's checkpoint (closes the D5.2 escalation-capability techdebt:
// stages were folded, escalation models were not, until the cycle landed).
EscalateTo string `json:"escalate_to,omitempty"`
EscalateCapability json.RawMessage `json:"escalate_capability,omitempty"`
}
snap := struct {
BriefHash string `json:"brief_hash"`
@ -301,6 +327,17 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
return "", "", cerr
}
ss.Capability = capRaw
// Fold the single-hop fallback model + its resolved capability (Веха 2.5):
// both are wire-affecting inputs of an escalated call, so changing them is a
// loud --resnapshot, not a silent false-hit on an escalated checkpoint.
if st.EscalateTo != "" {
ss.EscalateTo = st.EscalateTo
escCap, eerr := json.Marshal(r.Models.ResolveCapability(st.EscalateTo))
if eerr != nil {
return "", "", eerr
}
ss.EscalateCapability = escCap
}
snap.Stages = append(snap.Stages, ss)
}
data, err := json.Marshal(snap)
@ -327,6 +364,10 @@ type StageResult struct {
FlagReason FlagReason // "" when ok
Detail string
Attempts int
// Escalated — a single-hop fallback draft was tried this stage (D12); when the
// fallback passed the re-gate, Model above is the fallback (it answered).
Escalated bool
EscalationModel string // the fallback model when Escalated ("" otherwise)
}
// ChunkOutcome is one chunk's result across the stage list.
@ -553,7 +594,7 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
attemptsMade := 0
for attempt := 0; ; attempt++ {
maxTokens := maxTokensForAttempt(baseMaxTokens, attempt)
att, err := r.runAttempt(ctx, st, snapID, ch, job, attempt, maxTokens, msgs)
att, err := r.runAttempt(ctx, st, st.Model, snapID, ch, job, attempt, maxTokens, msgs, false)
if err != nil {
return nil, err // infra failure
}
@ -577,6 +618,42 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
break
}
// Single-hop escalation (D12 deterministic-content-failure class): a flag that a
// DIFFERENT model might fix (echo / excision / refusal) is routed ONCE to the
// stage's named fallback, under the book's escalation.budget_usd, and RE-GATED
// (classifyOutput runs on the fallback output too — §3.8). The editor declares no
// escalate_to → pinned per book (its style must not drift to a foreign model,
// D12/2605.13368). The fallback uses its OWN model (the request_hash axis), so its
// checkpoint never collides with the failed primary's, and it is exactly ONE call
// (not a fresh retry budget): total calls/chunk = primary attempts + 1 hop, never
// reset on the fallback (the LiteLLM #19985 retry×fallback blow-up guard).
escalated, escModel := false, ""
if last.cls.Reason.escalatable() && st.EscalateTo != "" {
remains, err := r.escalationBudgetRemains()
if err != nil {
return nil, err
}
if remains {
fb, err := r.runAttempt(ctx, st, st.EscalateTo, snapID, ch, job, 0, baseMaxTokens, msgs, true)
if err != nil {
return nil, err
}
cumCost += fb.cumCost
runCost += fb.runCost
anyFresh = anyFresh || fb.freshCall
escalated, escModel = true, st.EscalateTo
r.Log.WarnContext(ctx, "stage escalated to a fallback model",
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx,
"primary_reason", string(last.cls.Reason), "fallback", st.EscalateTo,
"fallback_disposition", string(fb.cls.Reason.disposition()))
if fb.cls.ok() {
last = fb // the fallback draft passed the re-gate → it is authoritative
}
// else: the fallback also failed → keep the primary flag (last unchanged);
// the fallback call is billed and counted, the chunk stays flagged (1 hop).
}
}
disposition := last.cls.Reason.disposition()
finalHash := ""
if disposition == DispOK {
@ -595,16 +672,18 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
sr := &StageResult{
Stage: st.Name, Role: st.Role, Model: last.modelActual,
FromResume: !anyFresh,
Usage: last.usage,
CostUSD: runCost,
CumCostUSD: cumCost,
LatencyMS: last.latency,
FinishReason: last.finish,
Disposition: disposition,
FlagReason: last.cls.Reason,
Detail: last.cls.Detail,
Attempts: attemptsMade,
FromResume: !anyFresh,
Usage: last.usage,
CostUSD: runCost,
CumCostUSD: cumCost,
LatencyMS: last.latency,
FinishReason: last.finish,
Disposition: disposition,
FlagReason: last.cls.Reason,
Detail: last.cls.Detail,
Attempts: attemptsMade,
Escalated: escalated,
EscalationModel: escModel,
}
if disposition == DispOK {
sr.Text = last.text
@ -635,10 +714,10 @@ type stageAttempt struct {
// checkpoints — no re-billing), otherwise a fresh reserve → call → settle+
// checkpoint. It returns an error only on an infra failure; a bad completion
// comes back as a classification on the attempt.
func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string, ch Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message) (stageAttempt, error) {
reqHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, attempt, st.Name, st.Role, st.Model,
func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID string, ch Chunk, job *store.Job, attempt, maxTokens int, msgs []llm.Message, escalation bool) (stageAttempt, error) {
reqHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, attempt, st.Name, st.Role, model,
st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
att := stageAttempt{reqHash: reqHash, modelActual: st.Model}
att := stageAttempt{reqHash: reqHash, modelActual: model}
// Resume on the attempt axis: a checkpoint means THIS attempt already happened
// and was billed — classify its text and never re-bill (kill -9 loses ≤1 call;
@ -656,7 +735,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
att.cls = r.classifyOutput(ch.Text, cp.ResponseText, cp.FinishReason)
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: cp.ModelActual,
Stage: st.Name, Role: st.Role, ModelRequested: model, ModelActual: cp.ModelActual,
RequestHash: reqHash, TMHit: true, OK: att.cls.ok(), FinishReason: cp.FinishReason,
Degraded: degradedTag(att.cls),
})
@ -667,7 +746,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
}
// Fresh call: reserve → call → settle+checkpoint (atomic, §3.3).
price := r.Pricer.PriceFor(st.Model)
price := r.Pricer.PriceFor(model)
promptEst := 0
for _, m := range msgs {
promptEst += EstimateTokens(m.Content)
@ -690,7 +769,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
return att, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD)
}
client, err := r.client(st.Model)
client, err := r.client(model)
if err != nil {
_ = r.Store.Release(resv)
_ = r.Store.SetJobStatus(job.ID, "failed")
@ -704,7 +783,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
start := time.Now()
resp, err := client.Complete(ctx, llm.LLMRequest{
Model: st.Model,
Model: model,
Messages: msgs,
MaxTokens: maxTokens,
Temperature: st.Temperature,
@ -720,8 +799,9 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
// not an infra crash). A settle failure here is a real infra fault.
if serr := r.Store.SettleWithCheckpoint(resv, estimate, store.Checkpoint{
RequestHash: reqHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: attempt,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: st.Model,
Stage: st.Name, Role: st.Role, ModelRequested: model, ModelActual: model,
ResponseText: "", UsageJSON: "{}", CostUSD: estimate, FinishReason: decodeErrorFinish,
Escalation: escalation,
}); serr != nil {
return att, fmt.Errorf("pipeline: settle after billed decode failure: %w", serr)
}
@ -730,7 +810,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
att.cls = classification{FlagDecodeError, "billed 2xx with an unreadable body: " + err.Error()}
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model,
Stage: st.Name, Role: st.Role, ModelRequested: model,
RequestHash: reqHash, CostUSD: estimate, LatencyMS: att.latency,
Degraded: "billed_2xx_decode_failed", Err: err.Error(), OK: false, FinishReason: decodeErrorFinish,
})
@ -745,7 +825,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
}
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model,
Stage: st.Name, Role: st.Role, ModelRequested: model,
RequestHash: reqHash, LatencyMS: att.latency, Err: err.Error(), OK: false,
})
_ = r.Store.SetJobStatus(job.ID, "failed")
@ -754,7 +834,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
modelActual := resp.Model
if modelActual == "" {
modelActual = st.Model
modelActual = model
}
att.modelActual = modelActual
att.text = resp.Text
@ -763,7 +843,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
// Цена по фактически ответившей модели, с fallback на ЗАПРОШЕННУЮ (не на
// дешёвый глобальный якорь), если провайдер вернул канонизированный слаг.
price = r.Pricer.PriceForResponse(st.Model, modelActual)
price = r.Pricer.PriceForResponse(model, modelActual)
cost := ledger.CostUSD(price, resp.Usage)
// Платная модель (InputPerM>0) вернула 2xx с нулевым usage — $0 ослепил бы
// потолок: берём консервативную оценку. Для local ($0 цена) нулевой usage
@ -785,9 +865,9 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
if err := r.Store.SettleWithCheckpoint(resv, cost, store.Checkpoint{
RequestHash: reqHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: attempt,
Stage: st.Name, Role: st.Role,
ModelRequested: st.Model, ModelActual: modelActual, ResponseText: resp.Text,
ModelRequested: model, ModelActual: modelActual, ResponseText: resp.Text,
UsageJSON: string(usageJSON), CostUSD: cost, FinishReason: resp.FinishReason,
ProviderRequestID: resp.ProviderRequestID,
ProviderRequestID: resp.ProviderRequestID, Escalation: escalation,
}); err != nil {
// The provider HAS billed this 2xx; failing to persist means the money
// state is behind reality — fail loud, never continue on top.
@ -802,7 +882,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, snapID string,
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: modelActual,
Stage: st.Name, Role: st.Role, ModelRequested: model, ModelActual: modelActual,
RequestHash: reqHash,
PromptTokens: resp.Usage.PromptTokens, CachedTokens: resp.Usage.CachedTokens,
CacheCreationTokens: resp.Usage.CacheCreationTokens,

View file

@ -961,3 +961,266 @@ func TestRunnerRejectsCoverageGateWithoutThresholds(t *testing.T) {
t.Fatalf("a rejected config must not reach the provider, calls=%d", rec.count())
}
}
// --- Веха 2.5: single-hop escalation (B, D12) -------------------------------
// setupEscalationProject wires a ja→ru book whose draft stage escalates to a second
// model (fake-fallback) on a deterministic content-failure, under escalation
// budget_usd. Both models sit on the same fake provider; the mock distinguishes them
// by the "model" field in the request body. Optional permissive marks the provider
// permissive and channel=adult on the draft (D4.1 isolation test).
func setupEscalationProject(t *testing.T, providerURL string, budgetUSD float64, permissive bool, channel string) string {
t.Helper()
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "prompts", "translator.md"),
"Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}")
writeFile(t, filepath.Join(dir, "prompts", "editor.md"),
"Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}")
permLine := ""
if permissive {
permLine = "\n permissive: true"
}
writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: fake-model
providers:
fake:
kind: openai
base_url: %q%s
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
models:
fake-model:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
fake-fallback:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
`, time.Now().UTC().Format("2006-01-02"), providerURL, permLine))
chanLine := ""
if channel != "" {
chanLine = ", channel: " + channel
}
writeFile(t, filepath.Join(dir, "pipeline.yaml"), fmt.Sprintf(`
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
retries: { regenerate_before_escalate: 0 }
stages:
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off", escalate_to: fake-fallback%s }
- { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" }
escalation: { budget_usd: %g }
`, chanLine, budgetUSD))
writeFile(t, filepath.Join(dir, "source.txt"), "静かな図書館の朝。")
writeFile(t, filepath.Join(dir, "book.yaml"), `
book_id: test-book
title: Тест
source_lang: ja
target_lang: ru
genre: ранобэ
audience: тест
venuti: 0.5
honorifics: keep
transcription: polivanov
footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 5.0, day_usd: 10.0 }
`)
return filepath.Join(dir, "book.yaml")
}
// echoOrClean is the escalation mock: the primary (fake-model) ECHOES the CJK source
// (cjk_artifact); the fallback (fake-fallback) returns a clean Russian translation.
func echoOrClean(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
if strings.Contains(body, "fake-fallback") {
return "Тихое утро в библиотеке.", "stop"
}
return "静かな図書館の朝。", "stop" // echo of the CJK source → cjk_artifact
}
// A deterministic echo (cjk_artifact) is escalated ONCE to the fallback model, which
// translates cleanly → the chunk is OK, billed to the fallback, and the edit runs.
func TestRunnerEscalationFixesEcho(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, echoOrClean)
defer srv.Close()
bookPath := setupEscalationProject(t, srv.URL, 1.0, false, "")
ctx := context.Background()
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
// draft(fake-model)=echo → escalate draft(fake-fallback)=clean → edit = 3 calls.
if rec.count() != 3 {
t.Fatalf("want 3 calls (primary + fallback + edit), got %d", rec.count())
}
ch := res.Chunks[0]
if ch.Disposition != DispOK || res.Flagged != 0 || res.ExitCode() != 0 {
t.Fatalf("escalated chunk must resolve OK, got %+v (flagged=%d)", ch, res.Flagged)
}
draft := ch.Stages[0]
// The mock canonicalizes every response model to "fake-model", so Model reflects
// that; the escalation is proven by Escalated + EscalationModel + the fallback's text.
if !draft.Escalated || draft.EscalationModel != "fake-fallback" || draft.Text != "Тихое утро в библиотеке." {
t.Fatalf("draft must record the successful escalation to fake-fallback, got %+v", draft)
}
if ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("final text must come from the edit over the fallback draft, got %q", ch.FinalText)
}
esc, err := r.Store.EscalationSpentUSD("test-book")
if err != nil || esc <= 0 {
t.Fatalf("the escalation call must be tagged and summable, got %v (err %v)", esc, err)
}
}
// If the fallback ALSO fails the re-gate, the chunk stays flagged (primary reason),
// the edit is skipped, and it was exactly ONE hop (primary + fallback, no more).
func TestRunnerEscalationFallbackAlsoFailsFlags(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ред", "stop"
}
return "静かな図書館の朝。", "stop" // BOTH models echo → cjk_artifact
})
defer srv.Close()
bookPath := setupEscalationProject(t, srv.URL, 1.0, false, "")
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if rec.count() != 2 {
t.Fatalf("want exactly 2 calls (primary + 1 fallback hop, edit skipped), got %d", rec.count())
}
ch := res.Chunks[0]
if ch.Disposition != DispFlagged || ch.FlagReason != FlagCJKArtifact || res.ExitCode() != 2 {
t.Fatalf("a failed escalation must keep the primary flag, got %+v", ch)
}
if !ch.Stages[0].Escalated {
t.Fatalf("the draft must record that escalation was tried, got %+v", ch.Stages[0])
}
if ch.Stages[1].Disposition != DispSkipped {
t.Fatalf("edit must be skipped after a failed escalation, got %+v", ch.Stages[1])
}
}
// escalation.budget_usd = 0 disables escalation (opt-in): the deterministic flag
// stays a flag, no fallback call is made.
func TestRunnerEscalationBudgetZeroDisables(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, echoOrClean)
defer srv.Close()
bookPath := setupEscalationProject(t, srv.URL, 0, false, "") // budget 0
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if rec.count() != 1 {
t.Fatalf("budget=0 must not make a fallback call, got %d calls", rec.count())
}
ch := res.Chunks[0]
if ch.Disposition != DispFlagged || ch.FlagReason != FlagCJKArtifact || ch.Stages[0].Escalated {
t.Fatalf("with no budget the echo must stay flagged, unescalated, got %+v", ch.Stages[0])
}
}
// A successful escalation is resume-safe: the chunk resolves from chunk_status and
// its final_hash points at the FALLBACK checkpoint, served at $0 with no re-call.
func TestRunnerEscalationResumeServesFallback(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, echoOrClean)
defer srv.Close()
bookPath := setupEscalationProject(t, srv.URL, 1.0, false, "")
ctx := context.Background()
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
callsAfterRun1 := rec.count()
r2 := newRunner(t, bookPath)
defer r2.Close()
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("resume must not re-call after a successful escalation, calls %d -> %d", callsAfterRun1, rec.count())
}
if res.TotalUSD != 0 || res.Chunks[0].Disposition != DispOK {
t.Fatalf("resume must serve the escalated chunk at $0, got %+v", res.Chunks[0])
}
if res.Chunks[0].FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("resume text differs: %q", res.Chunks[0].FinalText)
}
}
// The fallback model is folded into the snapshot: removing escalate_to changes the
// snapshot, so resume fails loud until --resnapshot (D5.2 escalation-capability closure).
func TestRunnerEscalationEntersSnapshot(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, echoOrClean)
defer srv.Close()
bookPath := setupEscalationProject(t, srv.URL, 1.0, false, "")
ctx := context.Background()
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pipePath)
if err != nil {
t.Fatal(err)
}
writeFile(t, pipePath, strings.Replace(string(raw), ", escalate_to: fake-fallback", "", 1))
r2 := newRunner(t, bookPath)
_, err = r2.TranslateBook(ctx)
r2.Close()
if err == nil || !strings.Contains(err.Error(), "resnapshot") {
t.Fatalf("changing the fallback model must fail loud mentioning --resnapshot, got: %v", err)
}
}
// D4.1: channel-B (18+) isolation is enforced by TYPE — a channel=adult stage on a
// NON-permissive provider is rejected at load, never a silent fall-through.
func TestRunnerChannelBRequiresPermissive(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, echoOrClean)
defer srv.Close()
// channel=adult, provider NOT permissive → NewRunner must fail.
nonPerm := setupEscalationProject(t, srv.URL, 1.0, false, "adult")
if _, err := NewRunner(nonPerm, obs.NewLogger()); err == nil || !strings.Contains(err.Error(), "permissive") {
t.Fatalf("channel=adult on a non-permissive provider must fail-fast, got: %v", err)
}
// channel=adult, provider permissive → accepted.
perm := setupEscalationProject(t, srv.URL, 1.0, true, "adult")
r, err := NewRunner(perm, obs.NewLogger())
if err != nil {
t.Fatalf("channel=adult on a permissive provider must load, got: %v", err)
}
r.Close()
}

View file

@ -17,9 +17,9 @@ import (
type ReserveResult int
const (
ReserveOK ReserveResult = iota
ReserveDeniedBook // per-book USD ceiling hit
ReserveDeniedDay // daily USD ceiling hit
ReserveOK ReserveResult = iota
ReserveDeniedBook // per-book USD ceiling hit
ReserveDeniedDay // daily USD ceiling hit
)
// Reservation is the handle Settle/Release need to undo the admission.
@ -109,6 +109,9 @@ type Checkpoint struct {
CostUSD float64
FinishReason string
ProviderRequestID string
// Escalation marks a single-hop fallback-draft checkpoint (D12): summed against
// escalation.budget_usd, independent of the primary translation spend.
Escalation bool
}
// SettleWithCheckpoint atomically (ONE transaction, one file) converts the
@ -135,12 +138,12 @@ func (s *Store) SettleWithCheckpoint(res Reservation, cost float64, cp Checkpoin
INSERT INTO checkpoints (
request_hash, job_id, chunk_idx, attempt, stage, role,
model_requested, model_actual, response_text, usage_json,
cost_usd, finish_reason, provider_request_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
cost_usd, finish_reason, provider_request_id, escalation
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (request_hash) DO NOTHING`,
cp.RequestHash, cp.JobID, cp.ChunkIdx, cp.Attempt, cp.Stage, cp.Role,
cp.ModelRequested, cp.ModelActual, cp.ResponseText, cp.UsageJSON,
cp.CostUSD, cp.FinishReason, cp.ProviderRequestID)
cp.CostUSD, cp.FinishReason, cp.ProviderRequestID, cp.Escalation)
if err != nil {
return fmt.Errorf("store: checkpoint insert: %w", err)
}
@ -197,3 +200,19 @@ func (s *Store) SpentUSD(bookID string) (committed, reserved float64, err error)
bookID).Scan(&committed, &reserved)
return
}
// EscalationSpentUSD sums the settled cost of a book's escalation (fallback-draft)
// checkpoints — the money-path figure the runner gates against escalation.budget_usd
// (D12). It joins checkpoints→jobs by book_id and counts only escalation=1 rows, so
// it is durable across resume and never double-counts a checkpoint-hit (a resumed
// hop re-serves its checkpoint without a new settle).
func (s *Store) EscalationSpentUSD(bookID string) (float64, error) {
ctx, cancel := opContext()
defer cancel()
var sum float64
err := s.r.QueryRowContext(ctx, `
SELECT COALESCE(SUM(c.cost_usd), 0)
FROM checkpoints c JOIN jobs j ON c.job_id = j.id
WHERE j.book_id = ? AND c.escalation = 1`, bookID).Scan(&sum)
return sum, err
}

View file

@ -126,6 +126,13 @@ var migrations = []string{
);
CREATE INDEX IF NOT EXISTS chunk_status_book_idx ON chunk_status (book_id, disposition);
`,
// v3: escalation flag on checkpoints (Веха 2.5, D12 single-hop). The fallback
// draft (a deterministic content-failure routed to another model ONCE) is tagged
// so the book's escalation.budget_usd can be enforced by summing ONLY escalation
// spend, independent of the primary translation cost. On the checkpoint (written
// in the same tx as the settle), NOT request_log, so the sum is durable across
// resume / kill -9 (telemetry is fire-and-forget; money-path budgeting is not).
`ALTER TABLE checkpoints ADD COLUMN escalation INTEGER NOT NULL DEFAULT 0;`,
}
// migrate runs all pending migrations on the write pool, one transaction per

View file

@ -322,7 +322,19 @@ keep-alive (Ф12), инъекция глоссария (`selective`), пор
`reasoning_content`-vs-`content`, cache-поля, `content_filter`-эмиссия требуют **платного** completion → не гоняю здесь; проверит live-conformance харнесс (C) при ручном прогоне оператором. Код уже корректен по докам (`httpllm.go` читает `content`, парсит оба cache-варианта DeepSeek).
Дальше: B (single-hop эскалация) → C (live-conformance).
### 2026-07-04 — Веха 2.5 (B): single-hop эскалация детерминированных провалов
Реализован класс «детерминированный контент-провал» из D12: флаг, который другая модель может починить (`cjk_artifact`/`excision_suspect`/`loop_degenerate`/`hard_refusal`/`soft_refusal`/`content_filter``FlagReason.escalatable()`), эскалирует **на другую модель ОДИН раз**, а не флажок.
**Механика (раннер):** после primary-attempt-loop, если вердикт escalatable И у стадии задан `escalate_to` И бюджет остаётся — один вызов `runAttempt(escalate_to, escalation=true)`, результат **ре-гейтится** (`classifyOutput` гоняется и на фолбэке). Прошёл → фолбэк авторитетный (его чекпоинт = `final_hash`); не прошёл → primary-флаг остаётся, фолбэк оплачен и посчитан. **Ось эскалации = модель в `request_hash`** (не attempt): чекпоинт фолбэка `(model=escalate_to, attempt=0)` не коллизится с провалившимся primary. **Ровно 1 хоп** (не свежий retry-бюджет): вызовов/чанк = primary attempts + 1 (guard LiteLLM #19985). `runAttempt` параметризован `model`+`escalation`.
**Бюджет (D12):** `escalation.budget_usd` — opt-in (0 = выкл, боевой дефолт). Money-path-durable: миграция v3 добавляет колонку `escalation` на checkpoints (пишется в той же tx, что settle), `EscalationSpentUSD` суммирует только escalation-чекпоинты (resume-safe, не двойной счёт на checkpoint-hit). Soft cap.
**Канал B (D4.1) типом, не комментарием:** `Provider.permissive` + `Stage.channel` (sfw|adult); `LoadPipeline` валит `channel=adult`, если primary или `escalate_to` НЕ на пермиссив-провайдере. **Editor pinned per book:** editor не имеет `escalate_to` → не эскалирует (стиль не плывёт на чужую модель, 2605.13368). **D5.2-закрытие:** `escalate_to` + его резолвнутая каппа свёрнуты в `stageSnap` — смена эскалационной модели/каппы = громкий `--resnapshot`. `CheckKeys` префлайтит ключи `escalate_to` при budget>0. `escalate_to == primary` запрещён валидацией.
Тесты (мок-провайдер, два fake-модели): эхо→фолбэк-починка→OK+edit; фолбэк тоже эхнул→primary-флаг+skip (ровно 2 вызова); budget=0→не эскалирует; resume подаёт фолбэк-чекпоинт за $0; смена fallback→`--resnapshot`; channel=adult без permissive→fail-fast. Всё зелёное `-race`, `tmctl report` $0.
Дальше: C (live-conformance харнесс, build-tag `live`).
## Полигон
(секция параллельной сессии — записи добавлять сюда)