diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go
index bf699ad..c5a2515 100644
--- a/backend/internal/config/config_test.go
+++ b/backend/internal/config/config_test.go
@@ -62,23 +62,25 @@ func TestCheckKeysOnlyUsedNonLocalModels(t *testing.T) {
if err := m.CheckKeys(cloudPipe); err != nil {
t.Fatalf("present key must pass: %v", err)
}
- // Эскалационные ключи НЕ требуются, пока гейты выключены (эскалация
- // недостижима — Фаза 0): local-стадия + облачная цепочка при gates off.
+ // Эскалационные ключи НЕ требуются, пока эскалация выключена (budget_usd=0,
+ // opt-in). Включённый coverage-гейт НЕ делает эскалацию достижимой — гейт
+ // моделей не зовёт (self-review: иначе включение гейта блокирует валидный
+ // deepseek+glm прогон из-за отсутствующего Kimi-ключа заглушки-цепочки).
t.Setenv("TEST_CLOUD_KEY", "")
escOffPipe := &Pipeline{
Stages: []Stage{{Model: "local-model"}},
Escal: Escalation{Chains: map[string][]string{"default": {"cloud-model"}}},
+ Gates: Gates{Coverage: CoverageGate{Enabled: true}}, // gate ON, budget 0
}
if err := m.CheckKeys(escOffPipe); err != nil {
- t.Fatalf("escalation keys must not be required while gates are off: %v", err)
+ t.Fatalf("enabling the coverage gate must NOT require escalation keys at budget 0: %v", err)
}
- // С включённым гейтом эскалация достижима → ключ цепочки обязателен.
+ // При budget_usd>0 эскалация достижима → ключ цепочки обязателен.
escOnPipe := &Pipeline{
Stages: []Stage{{Model: "local-model"}},
- Escal: Escalation{Chains: map[string][]string{"default": {"cloud-model"}}},
- Gates: Gates{Coverage: CoverageGate{Enabled: true}},
+ Escal: Escalation{Chains: map[string][]string{"default": {"cloud-model"}}, BudgetUSD: 1.0},
}
if err := m.CheckKeys(escOnPipe); err == nil {
- t.Fatal("escalation chain model without a key must fail-fast when gates are enabled")
+ t.Fatal("escalation chain model without a key must fail-fast when budget_usd>0")
}
}
diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go
index 80b7ff1..554d0ca 100644
--- a/backend/internal/config/models.go
+++ b/backend/internal/config/models.go
@@ -398,17 +398,18 @@ func (m *Models) CheckKeys(pipe *Pipeline) error {
for _, st := range pipe.Stages {
needed[st.Model] = struct{}{}
}
- if pipe.gatesEnabled() {
+ // Escalation models — named chains (шаг 7) and single-hop fallbacks (Веха 2.5) —
+ // are reachable ONLY once an escalation budget is set (opt-in), NOT when a gate is
+ // enabled: the coverage gate is a deterministic runner-side check that calls no
+ // model, so enabling it must not demand escalation keys (self-review finding —
+ // otherwise turning on the gate blocks a valid deepseek+glm run on a missing Kimi
+ // key from an unused chain stub).
+ if pipe.Escal.BudgetUSD > 0 {
for _, chain := range pipe.Escal.Chains {
for _, mdl := range chain {
needed[mdl] = struct{}{}
}
}
- }
- // 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{}{}
diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go
index e268590..2ce103a 100644
--- a/backend/internal/config/pipeline.go
+++ b/backend/internal/config/pipeline.go
@@ -242,17 +242,19 @@ func LoadPipeline(path string, models *Models) (*Pipeline, error) {
// Coverage QA-gate thresholds (шаг 6): an ENABLED gate must be able to flag
// something, otherwise it is a silent no-op that passes every chunk (against Р7).
if cov := p.Gates.Coverage; cov.Enabled {
- if cov.SentCovMin < 0 || cov.SentCovMin > 1 {
- bad("gates.coverage.sent_cov_min must be within [0,1], got %v", cov.SentCovMin)
+ // sent_cov_min must be a LIVE threshold in (0,1]: it is the pair-INDEPENDENT
+ // half of the gate, so requiring it guarantees the gate can never silently pass
+ // every chunk for a language pair that lacks a len_ratio corridor (self-review:
+ // the old "both empty" check missed the per-pair gap — a non-empty len_ratio map
+ // that just lacks the book's pair, with sent_cov_min=0, passed everything).
+ if cov.SentCovMin <= 0 || cov.SentCovMin > 1 {
+ bad("gates.coverage.sent_cov_min must be within (0, 1] when the gate is enabled, got %v (else the gate silently passes any language pair without a len_ratio corridor)", cov.SentCovMin)
}
for pair, b := range cov.LenRatio {
if len(b) != 2 || b[0] <= 0 || b[1] < b[0] {
bad("gates.coverage.len_ratio_bounds[%q] must be [low, high] with 0 < low <= high, got %v", pair, b)
}
}
- if cov.SentCovMin <= 0 && len(cov.LenRatio) == 0 {
- bad("gates.coverage.enabled is true but neither sent_cov_min nor len_ratio_bounds is set — the gate would silently pass every chunk")
- }
}
if len(problems) > 0 {
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
diff --git a/backend/internal/pipeline/coverage.go b/backend/internal/pipeline/coverage.go
index 020b68b..5cf0d9d 100644
--- a/backend/internal/pipeline/coverage.go
+++ b/backend/internal/pipeline/coverage.go
@@ -55,6 +55,20 @@ func isCJKSentenceTerminator(r rune) bool {
return false
}
+// isOracleSpace matches Python's whitespace notion used by the oracle — `re.\s`
+// (splitting) and `str.isspace()`/`.strip()` (trimming & the non-space count). It is
+// unicode.IsSpace PLUS the C0 information separators U+001C–U+001F, which Python
+// classifies as whitespace but Go's unicode.IsSpace (Unicode White_Space) does not.
+// These survive EPUB/PDF extraction, so matching Python here is required for the
+// bit-parity contract (D12 Q1), not cosmetic (self-review finding).
+func isOracleSpace(r rune) bool {
+ return unicode.IsSpace(r) || (r >= 0x1C && r <= 0x1F)
+}
+
+// trimOracleSpace is strings.TrimSpace with the oracle's whitespace set (mirrors the
+// Python `.strip()` in split_sentences).
+func trimOracleSpace(s string) string { return strings.TrimFunc(s, isOracleSpace) }
+
// splitSentences ports refusal_bench.py::split_sentences 1:1. Go's RE2 has no
// lookbehind, so the regex `(?<=[.!?…。!?])\s+|(?<=[。!?])` is reproduced by a manual
// left-to-right scan (branch-1 before branch-2, matching Python's alternation order):
@@ -69,7 +83,7 @@ func splitSentences(text string) []string {
runes := []rune(text)
var segs []string
emit := func(a, b int) {
- if s := strings.TrimSpace(string(runes[a:b])); s != "" {
+ if s := trimOracleSpace(string(runes[a:b])); s != "" {
segs = append(segs, s)
}
}
@@ -77,10 +91,10 @@ func splitSentences(text string) []string {
for i := 1; i < len(runes); i++ {
prev := runes[i-1]
// Branch 1: any terminator, then a whitespace run (the run is consumed).
- if isSentenceTerminator(prev) && unicode.IsSpace(runes[i]) {
+ if isSentenceTerminator(prev) && isOracleSpace(runes[i]) {
emit(segStart, i)
j := i
- for j < len(runes) && unicode.IsSpace(runes[j]) {
+ for j < len(runes) && isOracleSpace(runes[j]) {
j++
}
segStart = j
@@ -103,7 +117,7 @@ func splitSentences(text string) []string {
func nonSpaceCount(s string) int {
n := 0
for _, r := range s {
- if !unicode.IsSpace(r) {
+ if !isOracleSpace(r) {
n++
}
}
@@ -132,6 +146,11 @@ type coverageResult struct {
// 500–2500-char fragments and short chunks' ratios are noise (§3.7). Pure &
// deterministic. Returns excision_suspect (lower-bound only) or ok.
func coverageCheck(cfg config.CoverageGate, src, out, srcLang, dstLang string) coverageResult {
+ // Strip a leaked … block BEFORE measuring, exactly as classify()
+ // and the Python oracle do (refusal_bench: re.sub then .strip()). Without this a
+ // reasoning block leaked into content inflates the sentence/char counts and MASKS
+ // a real excision — a false negative, and a break of oracle parity (self-review).
+ out = trimOracleSpace(stripThink(out))
srcChars := nonSpaceCount(src)
if srcChars < cfg.MinChunkChars {
return coverageResult{cls: classification{reasonOK, ""}, applied: false}
diff --git a/backend/internal/pipeline/coverage_test.go b/backend/internal/pipeline/coverage_test.go
index 35b7218..9fbb99c 100644
--- a/backend/internal/pipeline/coverage_test.go
+++ b/backend/internal/pipeline/coverage_test.go
@@ -47,6 +47,9 @@ var oracleSplit = []struct {
{" leading and trailing. ", []string{"leading and trailing."}},
{"第一。\n\n第二。", []string{"第一。", "第二。"}},
{"Mr. Smith went. He left.", []string{"Mr.", "Smith went.", "He left."}}, // naive over-split (known)
+ // U+001F (unit separator) is whitespace to Python (\s / str.isspace) but NOT to
+ // Go's unicode.IsSpace — isOracleSpace bridges the gap for oracle parity.
+ {"A.B.", []string{"A.", "B."}},
}
func TestSplitSentencesOracleParity(t *testing.T) {
@@ -93,6 +96,32 @@ func boevoyGate(minChars int) config.CoverageGate {
}
}
+// TestOracleSpaceControlChars pins isOracleSpace to the Python oracle on the C0
+// information separators U+001C–U+001F, which Python treats as whitespace (\s /
+// str.isspace) but Go's unicode.IsSpace does not — a real bit-parity gap (self-review).
+func TestOracleSpaceControlChars(t *testing.T) {
+ us := "" // U+001F unit separator (survives EPUB/PDF extraction)
+ if got := splitSentences("A." + us + "B."); len(got) != 2 || got[0] != "A." || got[1] != "B." {
+ t.Fatalf("U+001F after a terminator must split like the oracle, got %#v", got)
+ }
+ if got := nonSpaceCount("A" + us + "B"); got != 2 {
+ t.Fatalf("U+001F must count as whitespace (oracle parity), got %d", got)
+ }
+}
+
+// TestCoverageCheckStripsThink guards that a leaked … block is stripped
+// BEFORE measuring (parity with classify() and the oracle), so a reasoning block does
+// not inflate the counts and MASK a real excision (self-review finding). Mutation:
+// drop the stripThink in coverageCheck and this goes green-to-red.
+func TestCoverageCheckStripsThink(t *testing.T) {
+ src := strings.Repeat("这是一个相当长的测试句子。", 20) // 20 sentences
+ leaked := "" + strings.Repeat("Долгое рассуждение о выборе слова. ", 40) + "Короткий огрызок."
+ res := coverageCheck(boevoyGate(0), src, leaked, "zh", "ru")
+ if res.cls.Reason != FlagExcisionSuspect {
+ t.Fatalf("a block must be stripped so the excision behind it is still caught, got %+v", res)
+ }
+}
+
func TestCoverageCheckExcision(t *testing.T) {
// Mirrors the three classify_output cases captured from the live oracle.
zhExcised := coverageCheck(boevoyGate(0), strings.Repeat("这是一个测试。", 20), "Короткий перевод.", "zh", "ru")
diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go
index 4c33837..a4f6cb6 100644
--- a/backend/internal/pipeline/runner.go
+++ b/backend/internal/pipeline/runner.go
@@ -29,6 +29,17 @@ import (
// retryable length/empty subset, up to the regenerate cap, then flag).
// Escalation and the real linguistic chunker are later steps of Фаза 1.
+// roleTranslator is the stage role whose output is a direct source→target
+// translation — the only role the excision coverage-gate is meaningful on.
+const roleTranslator = "translator"
+
+// errReserveCeiling is wrapped into the error runAttempt returns when a book/day USD
+// ceiling denies a reservation. The PRIMARY path propagates it (the book durably
+// pauses and resumes once the ceiling is raised, D4); the OPTIONAL escalation hop
+// catches it and degrades to keeping the primary flag instead of aborting the whole
+// book on every run (self-review finding).
+var errReserveCeiling = errors.New("reserve ceiling reached")
+
// Runner executes a book's pipeline.
type Runner struct {
Book *config.Book
@@ -168,11 +179,20 @@ func (r *Runner) coverageSnapshot() coverageSnap {
// gate part under the same coverage config (folded into the snapshot, so a gate change
// re-pins loudly). The gate compares the output against the ORIGINAL source (ch.Text)
// at EVERY stage, so excision introduced by the draft OR the editor is caught.
-func (r *Runner) classifyOutput(source, output, finish string) classification {
+func (r *Runner) classifyOutput(role, source, output, finish string) classification {
cls := classify(classifyInput{Source: source, Output: output, Finish: finish, TargetLang: r.Book.TargetLang})
if !cls.ok() || !r.Pipeline.Gates.Coverage.Enabled {
return cls
}
+ // The coverage gate compares the output against the ORIGINAL source, which is a
+ // source→target translation only for the TRANSLATOR role. A monolingual editor
+ // legitimately restructures/merges sentences, so gating ITS output against the
+ // source with the translation corridor false-flags a correct edit as excision
+ // (self-review finding). Editor/other-role fidelity is a Phase-2 concern (a
+ // bilingual judge over the draft, §04 mode 2), not this excision gate.
+ if role != roleTranslator {
+ return cls
+ }
if cov := coverageCheck(r.Pipeline.Gates.Coverage, source, output, r.Book.SourceLang, r.Book.TargetLang); !cov.cls.ok() {
return cov.cls
}
@@ -183,8 +203,10 @@ func (r *Runner) classifyOutput(source, output, finish string) classification {
// 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.
+// durable, resume-safe). A PRE-HOP soft cap: the gate admits a hop while spent <
+// budget and does NOT pre-estimate the hop's own cost, so a single hop may overshoot
+// the budget by up to its full cost; the NEXT chunk's escalation is then denied. Size
+// the budget with that worst case in mind — it bounds TOTAL escalation, not per-hop.
func (r *Runner) escalationBudgetRemains() (bool, error) {
budget := r.Pipeline.Escal.BudgetUSD
if budget <= 0 {
@@ -252,6 +274,16 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
// stages were folded, escalation models were not, until the cycle landed).
EscalateTo string `json:"escalate_to,omitempty"`
EscalateCapability json.RawMessage `json:"escalate_capability,omitempty"`
+ // The fallback model's OTHER wire-affecting inputs, mirroring the primary
+ // fold above: its top-level extra_body (merged into the escalation wire body)
+ // and its provider-level temperature/max_tokens overrides (local kind). Without
+ // these, editing the fallback's extra_body / provider knobs would change the
+ // escalation wire but leave the snapshot identical → a silent false-hit on a
+ // resumed escalated chunk (self-review: the primary path guards this, escalation
+ // did not).
+ EscalateExtra json.RawMessage `json:"escalate_extra,omitempty"`
+ EscalateProviderTemp float64 `json:"escalate_provider_temp,omitempty"`
+ EscalateProviderMaxTok int `json:"escalate_provider_max_tok,omitempty"`
}
snap := struct {
BriefHash string `json:"brief_hash"`
@@ -337,6 +369,16 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
return "", "", eerr
}
ss.EscalateCapability = escCap
+ if prov, ok := r.Models.Providers[r.Models.Models[st.EscalateTo].Provider]; ok {
+ ss.EscalateProviderTemp, ss.EscalateProviderMaxTok = prov.Temperature, prov.MaxTokens
+ }
+ if extra := r.Models.Models[st.EscalateTo].ExtraBody; len(extra) > 0 {
+ raw, merr := json.Marshal(extra)
+ if merr != nil {
+ return "", "", merr
+ }
+ ss.EscalateExtra = raw
+ }
}
snap.Stages = append(snap.Stages, ss)
}
@@ -629,28 +671,50 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
// 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()
+ // Idempotency (self-review): if the fallback hop already happened its
+ // checkpoint exists and was already paid — REPLAY it for free regardless of the
+ // budget, so a crash AFTER the hop settled but BEFORE chunk_status was written
+ // re-serves it on resume rather than discarding a paid, successful translation
+ // and flipping the verdict OK→flagged. Only a FRESH hop is budget-gated. The
+ // hash mirrors runAttempt's for (model=EscalateTo, attempt=0, maxTokens=base).
+ fbHash := RequestHash(r.Book.BookID, ch.Chapter, ch.ChunkIdx, 0, st.Name, st.Role, st.EscalateTo,
+ st.Temperature, st.Reasoning, false, baseMaxTokens, snapID, msgs)
+ fbExists, err := r.Store.GetCheckpoint(fbHash)
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 {
+ mayHop := fbExists != nil
+ if !mayHop {
+ if mayHop, err = r.escalationBudgetRemains(); 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
+ }
+ if mayHop {
+ fb, err := r.runAttempt(ctx, st, st.EscalateTo, snapID, ch, job, 0, baseMaxTokens, msgs, true)
+ if err != nil {
+ // An OPTIONAL hop that trips a USD ceiling must NOT abort the whole book
+ // (and re-abort on every resume): the chunk is already flagged, so keep
+ // the primary flag and continue. Any other infra error still aborts.
+ if !errors.Is(err, errReserveCeiling) {
+ return nil, err
+ }
+ r.Log.WarnContext(ctx, "escalation hop denied by a USD ceiling; keeping the primary flag",
+ "stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx, "reason", string(last.cls.Reason))
+ } else {
+ 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).
}
- // else: the fallback also failed → keep the primary flag (last unchanged);
- // the fallback call is billed and counted, the chunk stays flagged (1 hop).
}
}
@@ -732,7 +796,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
att.finish = cp.FinishReason
att.modelActual = cp.ModelActual
att.cumCost = cp.CostUSD
- att.cls = r.classifyOutput(ch.Text, cp.ResponseText, cp.FinishReason)
+ att.cls = r.classifyOutput(st.Role, 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: model, ModelActual: cp.ModelActual,
@@ -763,10 +827,11 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
switch verdict {
case store.ReserveDeniedBook:
// Ceiling is a hard, book-wide stop (not a per-chunk flag): the job stays
- // 'pending' and resume continues once the ceiling is raised.
- return att, fmt.Errorf("pipeline: book USD ceiling reached (%.2f$) — raise ceilings.book_usd or stop", r.Book.Ceilings.BookUSD)
+ // 'pending' and resume continues once the ceiling is raised. Wrapped so an
+ // optional escalation hop can degrade instead of aborting the book.
+ return att, fmt.Errorf("pipeline: book USD ceiling reached (%.2f$) — raise ceilings.book_usd or stop: %w", r.Book.Ceilings.BookUSD, errReserveCeiling)
case store.ReserveDeniedDay:
- return att, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD)
+ return att, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$): %w", r.Book.Ceilings.DayUSD, errReserveCeiling)
}
client, err := r.client(model)
@@ -878,7 +943,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID
// non-empty truncated length draft is now classified (flagged/retried), never
// silently passed downstream as OK; an empty completion is flagged too, not a
// run-crash.
- att.cls = r.classifyOutput(ch.Text, resp.Text, resp.FinishReason)
+ att.cls = r.classifyOutput(st.Role, ch.Text, resp.Text, resp.FinishReason)
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
@@ -926,6 +991,12 @@ func (r *Runner) resumeFromChunkStatus(ctx context.Context, st config.Stage, ch
sr.Model = cp.ModelActual
sr.FinishReason = cp.FinishReason
sr.Text = cp.ResponseText
+ // Preserve escalation attribution across resume (self-review): a chunk resolved
+ // via a fallback checkpoint is still an escalation, so a resumed run reports it.
+ if cp.Escalation {
+ sr.Escalated = true
+ sr.EscalationModel = cp.ModelRequested
+ }
}
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
diff --git a/backend/internal/pipeline/runner_test.go b/backend/internal/pipeline/runner_test.go
index c59a54c..ea9e725 100644
--- a/backend/internal/pipeline/runner_test.go
+++ b/backend/internal/pipeline/runner_test.go
@@ -1171,6 +1171,153 @@ func TestRunnerEscalationResumeServesFallback(t *testing.T) {
if res.Chunks[0].FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("resume text differs: %q", res.Chunks[0].FinalText)
}
+ // Escalation attribution survives resume (self-review [11/14]).
+ if d := res.Chunks[0].Stages[0]; !d.Escalated || d.EscalationModel != "fake-fallback" {
+ t.Fatalf("resume must preserve escalation attribution, got %+v", d)
+ }
+}
+
+// Only the TRANSLATOR stage is coverage-gated (self-review [6]): a monolingual editor
+// that legitimately merges/tightens sentences (low sent_cov vs the CJK source) must
+// NOT be flagged as excision. Mutation: gate every stage and the short edit flags.
+func TestRunnerCoverageGateOnlyTranslator(t *testing.T) {
+ rec := &reqRec{}
+ srv := newJSONProvider(rec, func(body string) (string, string) {
+ if isEditBody(body) {
+ return "Одно.", "stop" // a very short "polish" — would flag if the editor were gated
+ }
+ return strings.Repeat("Это предложение перевода. ", 30), "stop" // faithful translator draft
+ })
+ defer srv.Close()
+ bookPath := setupProjectOpts(t, srv.URL, projectOpts{
+ source: strings.Repeat("これは長い文である。", 30), regenerate: 0, gatesYAML: coverageGateBlock,
+ })
+
+ r := newRunner(t, bookPath)
+ defer r.Close()
+ res, err := r.TranslateBook(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.Flagged != 0 || res.ExitCode() != 0 || res.Chunks[0].Disposition != DispOK {
+ t.Fatalf("a short monolingual edit must NOT be excision-flagged (editor is not gated), got %+v", res.Chunks[0])
+ }
+}
+
+// A successful, already-PAID escalation must be resume-idempotent even when the budget
+// no longer admits a fresh hop (self-review [2/9/13]): a crash between the fallback
+// settle and the chunk_status write must REPLAY the paid checkpoint, not discard it and
+// flip the chunk OK→flagged. Mutation: drop the fbExists check and the chunk flips.
+func TestRunnerEscalationResumeIdempotentUnderBudget(t *testing.T) {
+ rec := &reqRec{}
+ srv := newJSONProvider(rec, echoOrClean)
+ defer srv.Close()
+ // budget < one hop cost: after the first hop, EscalationSpentUSD ≥ budget, so a
+ // FRESH hop would be denied on resume — the paid hop must replay regardless.
+ bookPath := setupEscalationProject(t, srv.URL, 0.001, false, "")
+ ctx := context.Background()
+
+ r1 := newRunner(t, bookPath)
+ res1, err := r1.TranslateBook(ctx)
+ if err != nil || res1.Chunks[0].Disposition != DispOK {
+ t.Fatalf("run1 must escalate to OK, got %+v (err %v)", res1.Chunks[0], err)
+ }
+ dbPath := r1.Book.ProjectDB
+ r1.Close()
+ callsAfterRun1 := rec.count()
+
+ // Simulate the crash window: chunk_status lost, checkpoints (incl. escalation) survive.
+ db, err := sql.Open("sqlite", "file:"+dbPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := db.Exec(`DELETE FROM chunk_status WHERE stage = 'draft'`); err != nil {
+ t.Fatal(err)
+ }
+ db.Close()
+
+ r2 := newRunner(t, bookPath)
+ defer r2.Close()
+ res2, err := r2.TranslateBook(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rec.count() != callsAfterRun1 {
+ t.Fatalf("resume must REPLAY the paid escalation checkpoint, not re-call: calls %d -> %d", callsAfterRun1, rec.count())
+ }
+ if res2.Chunks[0].Disposition != DispOK {
+ t.Fatalf("resume must re-serve the escalated OK chunk, not flip to flagged (budget must not orphan a paid hop), got %+v", res2.Chunks[0])
+ }
+}
+
+// An OPTIONAL escalation hop that trips the book USD ceiling must degrade to keeping
+// the primary flag, NOT abort the whole book (and re-abort on every resume) — self-
+// review [10]. Mutation: propagate the ceiling error and TranslateBook returns non-nil.
+func TestRunnerEscalationCeilingDegrades(t *testing.T) {
+ rec := &reqRec{}
+ srv := newJSONProvider(rec, echoOrClean)
+ defer srv.Close()
+ bookPath := setupEscalationProject(t, srv.URL, 1.0, false, "")
+
+ // Lower the book ceiling so the primary draft call fits but the escalation hop does not.
+ bp := filepath.Join(filepath.Dir(bookPath), "book.yaml")
+ raw, err := os.ReadFile(bp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ writeFile(t, bp, strings.Replace(string(raw), "book_usd: 5.0", "book_usd: 0.0025", 1))
+
+ r := newRunner(t, bookPath)
+ defer r.Close()
+ res, err := r.TranslateBook(context.Background())
+ if err != nil {
+ t.Fatalf("an escalation-hop ceiling denial must NOT abort the book, got: %v", err)
+ }
+ ch := res.Chunks[0]
+ if ch.Disposition != DispFlagged || ch.FlagReason != FlagCJKArtifact {
+ t.Fatalf("the chunk must keep its primary flag when escalation is ceiling-denied, got %+v", ch)
+ }
+ if ch.Stages[0].Escalated {
+ t.Fatalf("a ceiling-denied hop must not be recorded as an escalation, got %+v", ch.Stages[0])
+ }
+}
+
+// The fallback model's top-level extra_body is folded into the snapshot (self-review
+// [1]): editing it changes the escalation wire, so resume must fail loud until
+// --resnapshot, never silently serve a stale escalated checkpoint.
+func TestRunnerEscalationFoldsFallbackExtraBody(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()
+
+ // Add extra_body to the FALLBACK model only — it changes the escalation wire body.
+ mp := filepath.Join(filepath.Dir(bookPath), "models.yaml")
+ raw, err := os.ReadFile(mp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ patched := strings.Replace(string(raw),
+ " fake-fallback:\n provider: fake",
+ " fake-fallback:\n provider: fake\n extra_body: { top_p: 0.5 }", 1)
+ if patched == string(raw) {
+ t.Fatal("failed to inject extra_body into fake-fallback")
+ }
+ writeFile(t, mp, patched)
+
+ r2 := newRunner(t, bookPath)
+ _, err = r2.TranslateBook(ctx)
+ r2.Close()
+ if err == nil || !strings.Contains(err.Error(), "resnapshot") {
+ t.Fatalf("editing the fallback's extra_body must fail loud mentioning --resnapshot, got: %v", err)
+ }
}
// The fallback model is folded into the snapshot: removing escalate_to changes the
diff --git a/backend/internal/store/ledger.go b/backend/internal/store/ledger.go
index 4ae9479..0789c2c 100644
--- a/backend/internal/store/ledger.go
+++ b/backend/internal/store/ledger.go
@@ -178,10 +178,10 @@ func (s *Store) GetCheckpoint(requestHash string) (*Checkpoint, error) {
cp := Checkpoint{RequestHash: requestHash}
err := s.r.QueryRowContext(ctx, `
SELECT job_id, chunk_idx, attempt, stage, role, model_requested, model_actual,
- response_text, usage_json, cost_usd, finish_reason, provider_request_id
+ response_text, usage_json, cost_usd, finish_reason, provider_request_id, escalation
FROM checkpoints WHERE request_hash = ?`, requestHash).Scan(
&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.ResponseText, &cp.UsageJSON, &cp.CostUSD, &cp.FinishReason, &cp.ProviderRequestID, &cp.Escalation)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md
index ff9df78..7fab4f4 100644
--- a/docs/PROGRESS.md
+++ b/docs/PROGRESS.md
@@ -340,7 +340,15 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор
**Live-валидация (1 целевой вызов deepseek, ~$0.00004):** фрагмент Лу Синя «祝福» (эхо-репро-семья) → `cjk_share=0.00`, `verdict=ok`, `finish=stop`, `reasoning_tokens=0`, чистый русский перевод. **DeepSeek-мина подтверждённо обезврежена на реальном проводе** — `reasoning:"off"` no-op держит thinking ON → перевод, не эхо. Адаптер читает `content`, слаг = `deepseek-v4-flash` (канонизация чистая). grok закомментирован в наборе (гигиена clean-prod xAI — включать осознанно).
-**Веха 2.5 завершена (A+B+C+D + §2-гейт).** Коммиты: `2c1c38d` (эхо-мина гейт), `2acfdf2` (coverage-гейт), `d97f832` (Kimi base_url), `659cce0` (single-hop эскалация), + этот. Дальше — агентское селфревью на вехе.
+**Веха 2.5 (A+B+C+D + §2-гейт) реализована.** Коммиты: `2c1c38d` (эхо-мина гейт), `2acfdf2` (coverage-гейт), `d97f832` (Kimi base_url), `659cce0` (single-hop эскалация), `a844765` (live-harness). Дальше — агентское селфревью на вехе.
+
+### 2026-07-05 — Веха 2.5: агентское адверсариальное селфревью + фиксы
+
+Многоагентный Workflow (6 дименсий: детерминизм / деньги / корректность coverage / корректность эскалации / эхо-мина+конфиг / нейтральность+хаки → независимая верификация КАЖДОЙ находки CONFIRMED/REFUTED с конкретным сценарием инпут→неверный-выход). 25 агентов, **14 подтверждённых** (5 опровергнуто). Все исправлены; ключевые фиксы мутационно-проверены (сломай → тест падает).
+
+**Детерминизм/snapshot:** [1/3 HIGH] `escalate_to`-модель шлёт `extra_body`/provider-оверрайды на провод, но они НЕ были в snapshot → тихий false-hit на resume (примари-путь гейтил, эскалация нет) → свёрнуты `EscalateExtra`/`EscalateProviderTemp/MaxTok`. **Эскалация:** [2/9/13 HIGH] бюджет-гейт неидемпотентен — краш между settle фолбэка и chunk_status выбрасывал уже-оплаченный успешный фолбэк и флипал OK→flagged → **checkpoint-first replay** (существующий фолбэк-чекпоинт реиграется бесплатно, минуя бюджет-гейт); [10] эскалация, пробившая book-потолок, роняла всю книгу → `errReserveCeiling`-деградация (оставить примари-флаг, книга продолжает); [11/14] телеметрия эскалации терялась на resume → `GetCheckpoint` отдаёт флаг escalation, `resumeFromChunkStatus` восстанавливает. **Coverage:** [5 HIGH] гейт НЕ стрипал `` (в отличие от `classify`/оракула) → утёкший reasoning маскировал вырезание → `stripThink` в `coverageCheck`; [6] гейт на КАЖДОЙ стадии ложно флагал легитимную монолингвальную правку → **только translator-роль** (верность editor — Ф2, билингв-судья §04); [7] `sent_cov_min=0`+нет коридора пары → молча пропускал всё → требуем `sent_cov_min∈(0,1]` при enabled; [8] Go `unicode.IsSpace` расходился с оракулом на U+001C–001F → `isOracleSpace` (паритет). **Конфиг:** [12] включение coverage-гейта требовало ключи недостижимых chain-заглушек → префлайт ключей эскалации отвязан от `gatesEnabled` → `budget_usd>0`; [4] честный doc soft-cap. Регресс-тесты добавлены; полный набор зелёный `-race`, `tmctl report` $0, live-harness компилируется под `-tags live`.
+
+**Веха 2.5 сдана на внешнее ревью.** Открытые для владельца/оркестратора: боевой флип `gates.coverage.enabled` (после валидации precision), цена `deepseek-v4-pro` (в /models слаг есть, цены нет), формула `escalation.budget_usd`, F3-idempotency-key + `tmctl status` (D12 — отдельные задачи).
## Полигон
(секция параллельной сессии — записи добавлять сюда)
@@ -457,3 +465,5 @@ keep-alive (Ф1–2), инъекция глоссария (`selective`), пор
> **[СЕССИЯ ЗАКРЫТА, 04.07]** Выходы: research/13 (+§«Честные слабые места»), architecture/06, `eval/memory_hotpath.py` (спека), `eval/retrieval_bench.py` (эмбеддинги). Эксп-05 снят как низкосигнальный. **Перед кодом реализующей сессии — прочитать research/13 §«Честные слабые места»:** F1 подан как решённый, но там развилка (snapshot материализует байты vs store версионирует глоссарий); post-check в русской морфологии — несущий, но невалидированный (померить до доверия как гейту); «пусто=безопасно» — размен на ложный-пропуск, который тоже тихий; ставка на детерминированный no-LLM путь — cost-driven, не evidence-driven (DelTA валидирует LLM-в-цикле). Гигиена: фоновых процессов нет, ollama полигона не тронут, `eval/.venv` дополнен (torch-cpu/sentence-transformers/openai/dotenv — переиспользуемо).
> **[ДОБАВЛЕНО после закрытия] Локалка × банк памяти — экстракция терминов, ВЕРИФИЦИРОВАННЫЙ бенчмарк (дыра G1)** → [experiments/06-local-extraction.md](experiments/06-local-extraction.md), `eval/extract_bench.py`. 13 моделей × 12 кейсов (zh/ja/en + эдж: катакана-запад, буддийские имена, алиасы), запросы через `refusal_bench.call_provider`+`providers.json` (квирки верны: deepseek thinking-ON+max_tokens 8000 — прошлый пустой был МОЯ ошибка бюджета, не свойство модели), **каждый ответ health-верифицирован** (пустой/echo не засчитан как recall 0). Итог: **(1) СПОТ сущностей решён у всех, локаль вкл.** (recall ~0.98 на всех языках, 0 галлюцинаций). **(2) РЕНДЕР dst — разрыв с ЯЗЫКОВЫМ градиентом у локали:** en 0.82 / ja 0.53 / **zh 0.26** (阿Q→«Ах-Чу», 赵太爷→«Тяо Тай-Я» — Палладий-мусор → **эмпирика для B6**); облако-топ zh 0.75/ja 0.98/en 1.0. **(3) бо́льшая локаль не помогает** (30b render 0.55≈8b, ×3 медленнее). **(4) deepseek thinking помогает рендеру** (0.85 vs 0.58 off; на экстракции thinking-off здоров — echo бьёт перевод, не JSON). Рендер-value: **gemini-2.5-flash** (0.92/1.4с). **Дизайн-следствие (уточняет Р4/G1):** спот=дешёвая 8b (все языки); рендер dst язык-зависим — zh обязательно облако/человек + таблица Палладия (B6), en локаль потянет. ruadapt ненадёжен (5/12 health).
+
+> **[В ОЧЕРЕДИ, 05.07] Исследование «Адаптивный/обучающийся слой памяти»** — промт `docs/ADAPTIVE_MEMORY_RESEARCH_PROMPT.md` (подготовлен сессией валидации по запросу владельца). Вопрос: стоит ли гибрид «детерминированный банк + слой, обучающийся по ходу книги», и как смёржить под контур (локаль-first, дешёвое API ок, флагман без логитов → kNN-MT/frontier-LoRA заблокированы; обучаемое — только на локали, роль support, не переводчик). Центр — арбитраж «кто побеждает» (логика + VRAM 8ГБ). БАР: обязан бить eval-валидированный детерминированный базис на zh/ja→ru, иначе не мёржить. Тайминг: Фаза 2+, целить в реальные дыры (после in-house eval банка). Запускает владелец отдельной сессией.