Add chapter/chunk book loop with per-chunk disposition flags, attempt-axis retries, content-hashed resume, and F4/D2.5 fixes

This commit is contained in:
Claude (backend session) 2026-07-04 22:00:30 +03:00
parent 0dbcce7e97
commit 357dde296b
12 changed files with 1965 additions and 309 deletions

View file

@ -4,6 +4,7 @@ package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"os"
@ -16,8 +17,20 @@ import (
"textmachine/backend/internal/pipeline"
)
// Exit codes (Веха 2): 0 clean · 2 completed-with-flags (приёмка допускает N
// флагов) · 1 infra failure. The exit-2 case is carried up as a typed sentinel
// (*pipeline.CompletedWithFlags) so a "clean run, attention needed" never
// masquerades as an infra crash and vice-versa.
func main() {
if err := run(); err != nil {
err := run()
var flagged *pipeline.CompletedWithFlags
switch {
case err == nil:
return
case errors.As(err, &flagged):
fmt.Fprintln(os.Stderr, "tmctl:", err)
os.Exit(2)
default:
fmt.Fprintln(os.Stderr, "tmctl:", err)
os.Exit(1)
}
@ -29,7 +42,11 @@ func run() error {
}
cmd, args := os.Args[1], os.Args[2:]
fs := flag.NewFlagSet(cmd, flag.ExitOnError)
// ContinueOnError (not ExitOnError): the stdlib's ExitOnError calls os.Exit(2)
// on a parse failure, which would COLLIDE with exit code 2 (completed-with-
// flags). Returning the error routes a bad flag to main()'s default branch →
// exit 1, keeping 2 exclusive to the flagged-chunks case (self-review Веха 2).
fs := flag.NewFlagSet(cmd, flag.ContinueOnError)
cfgPath := fs.String("config", "", "path to book.yaml")
resnapshot := fs.Bool("resnapshot", false, "re-pin existing jobs to the current config snapshot (пере-перевод оплаченных чанков — явное согласие)")
if err := fs.Parse(args); err != nil {
@ -69,35 +86,58 @@ func translate(ctx context.Context, cfgPath string, resnapshot bool) error {
defer r.Close()
r.Resnapshot = resnapshot
res, err := r.TranslateOneChunk(ctx)
res, err := r.TranslateBook(ctx)
if err != nil {
return err
}
fmt.Println("=== ПЕРЕВОД ===")
fmt.Println(res.FinalText)
fmt.Println()
fmt.Println("=== СТОИМОСТЬ ПО СТАДИЯМ ===")
for _, st := range res.Stages {
src := "call"
if st.FromResume {
src = "checkpoint"
for _, ch := range res.Chunks {
fmt.Printf("=== ГЛАВА %d ЧАНК %d — %s%s ===\n", ch.Chapter, ch.ChunkIdx, ch.Disposition, flagSuffix(ch.FlagReason))
if ch.Disposition == pipeline.DispOK {
fmt.Println(ch.FinalText)
} else {
fmt.Printf("[ФЛАГ %s] чанк не переведён — черновик/редактура непригодны, помечен для человека\n", ch.FlagReason)
}
fmt.Printf("%-8s %-22s %-10s $%.6f in=%d (cached=%d, cache_write=%d) out=%d+%d %dms finish=%s\n",
st.Stage, st.Model, src, st.CostUSD,
st.Usage.PromptTokens, st.Usage.CachedTokens, st.Usage.CacheCreationTokens,
st.Usage.CompletionTokens, st.Usage.ReasoningTokens, st.LatencyMS, st.FinishReason)
for _, st := range ch.Stages {
how := "call"
switch {
case st.Disposition == pipeline.DispSkipped:
how = "skipped"
case st.FromResume:
how = "resume"
}
fmt.Printf(" %-8s %-22s %-8s %-8s%s $%.6f (cum $%.6f) in=%d (cached=%d) out=%d+%d att=%d %dms finish=%s\n",
st.Stage, st.Model, how, st.Disposition, flagSuffix(st.FlagReason),
st.CostUSD, st.CumCostUSD,
st.Usage.PromptTokens, st.Usage.CachedTokens,
st.Usage.CompletionTokens, st.Usage.ReasoningTokens, st.Attempts, st.LatencyMS, st.FinishReason)
}
fmt.Println()
}
fmt.Printf("ИТОГО (этот запуск): $%.6f\n", res.TotalUSD)
fmt.Printf("ИТОГО (этот запуск): $%.6f — чанков %d, флагов %d\n", res.TotalUSD, len(res.Chunks), res.Flagged)
committed, reserved, err := r.Store.SpentUSD(res.BookID)
if err != nil {
return err
}
fmt.Printf("Ledger книги: committed=$%.6f reserved=$%.6f\n", committed, reserved)
// Exit code 2 is a typed sentinel, not an infra error: the report above is
// already on stdout; main() maps this to a non-zero exit for the operator.
if res.Flagged > 0 {
return &pipeline.CompletedWithFlags{Flagged: res.Flagged, Total: len(res.Chunks)}
}
return nil
}
// flagSuffix renders a non-empty flag reason as "(reason)".
func flagSuffix(r pipeline.FlagReason) string {
if r == "" {
return ""
}
return "(" + string(r) + ")"
}
func report(cfgPath string) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
@ -119,6 +159,27 @@ func report(cfgPath string) error {
row.LatencyMS, row.FinishReason, row.TMHit, row.OK)
}
// Flag section (Веха 2): every chunk×stage whose disposition ≠ ok — the
// "флаг редактору" the plan requires (02-mvp Фаза-1 приёмка допускает N).
flags, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
return err
}
headerPrinted := false
for _, f := range flags {
if f.Disposition == "ok" {
continue
}
if !headerPrinted {
fmt.Printf("\n=== ФЛАГИ (disposition ≠ ok) ===\n")
fmt.Printf("%-4s %-6s %-8s %-9s %-16s %5s %10s %s\n",
"ch", "chunk", "stage", "disp", "reason", "att", "cost_usd", "detail")
headerPrinted = true
}
fmt.Printf("%-4d %-6d %-8s %-9s %-16s %5d %10.6f %s\n",
f.Chapter, f.ChunkIdx, f.Stage, f.Disposition, f.FlagReason, f.Attempts, f.CostUSD, f.Detail)
}
committed, reserved, err := r.Store.SpentUSD(r.Book.BookID)
if err != nil {
return err

View file

@ -0,0 +1,88 @@
package pipeline
import "strings"
// chunker.go: the Веха-2 segmenter SEAM. Its only job right now is to turn a
// normalized source into an ordered list of (chapter, chunk) units so the runner
// loop, resume and disposition machinery can be exercised over MANY chunks — the
// hardcoded "chapter=1/chunk=0" of Фаза 0 is gone.
//
// This is a deliberate PLACEHOLDER. The real linguistic chunker of шаг 3
// (sentence segmentation, read-only overlap, ruby name-reading extraction for
// ja) replaces SplitChunks WITHOUT touching the loop — it only has to keep the
// Chunk contract and bump chunkerVersion. chunkerVersion is folded into the
// snapshot (render.go), so changing the segmentation is an explicit
// re-translation (a loud --resnapshot), never a silent cache miss (R6/D5.2).
// Chunk is one ordered unit of translation work.
type Chunk struct {
Chapter int // 1-based
ChunkIdx int // 0-based within its chapter
Text string
}
// chapterSep splits the source into chapters. Form feed (U+000C) is the ASCII
// "page/section break" — semantically a chapter boundary, invisible in prose,
// and untouched by NormalizeSource (BOM/CRLF/NFC/outer-trim leave an internal
// \f intact). A source with no \f is a single chapter (Фаза-0 backward compat:
// the one-file example stays one chapter).
const chapterSep = "\f"
// targetChunkChars is the placeholder chunk size (≈ the plan's "12k tokens";
// for a CJK source ~1 char ≈ 1 token). Paragraphs are packed up to this size and
// never split, so the 264-char example remains a single chunk.
const targetChunkChars = 1500
// SplitChunks segments a NORMALIZED source (post-NormalizeSource) into an ordered
// chunk list. Rule: split on \f into 1-based chapters; within a chapter pack
// blank-line paragraphs greedily into chunks of ≤ targetChunkChars, never
// splitting a paragraph (a paragraph larger than the target is its own chunk);
// drop empty pieces. Fully deterministic.
func SplitChunks(source string) []Chunk {
var out []Chunk
chapterNo := 0
for _, chapRaw := range strings.Split(source, chapterSep) {
paras := splitParagraphs(chapRaw)
if len(paras) == 0 {
continue // an empty chapter block does not consume a chapter number
}
chapterNo++
chunkIdx := 0
var buf strings.Builder
flush := func() {
if buf.Len() == 0 {
return
}
out = append(out, Chunk{Chapter: chapterNo, ChunkIdx: chunkIdx, Text: buf.String()})
chunkIdx++
buf.Reset()
}
for _, p := range paras {
// Start a new chunk if adding this paragraph would exceed the target
// and the current chunk is non-empty (never split a paragraph).
if buf.Len() > 0 && buf.Len()+len("\n\n")+len(p) > targetChunkChars {
flush()
}
if buf.Len() > 0 {
buf.WriteString("\n\n")
}
buf.WriteString(p)
}
flush()
}
return out
}
// splitParagraphs breaks a chapter block on blank lines and trims each paragraph,
// dropping empties. Deterministic; whitespace-only paragraphs vanish.
func splitParagraphs(block string) []string {
var paras []string
for _, raw := range strings.Split(block, "\n\n") {
// Collapse a run of blank lines: a "paragraph" that is itself only blank
// lines (from 3+ consecutive newlines) trims to "".
if p := strings.TrimSpace(raw); p != "" {
paras = append(paras, p)
}
}
return paras
}

View file

@ -0,0 +1,96 @@
package pipeline
import (
"reflect"
"strings"
"testing"
)
func TestSplitChunksSingleParagraph(t *testing.T) {
got := SplitChunks("静かな図書館の朝。")
want := []Chunk{{Chapter: 1, ChunkIdx: 0, Text: "静かな図書館の朝。"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("single paragraph = %+v, want %+v", got, want)
}
}
func TestSplitChunksChapters(t *testing.T) {
src := "Глава один." + chapterSep + "Глава два." + chapterSep + "Глава три."
got := SplitChunks(src)
want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: "Глава один."},
{Chapter: 2, ChunkIdx: 0, Text: "Глава два."},
{Chapter: 3, ChunkIdx: 0, Text: "Глава три."},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("chapters = %+v, want %+v", got, want)
}
}
func TestSplitChunksPacksToTarget(t *testing.T) {
// Two paragraphs that together exceed the target must split into two chunks,
// each never splitting a paragraph.
p1 := strings.Repeat("a", 800)
p2 := strings.Repeat("b", 800)
got := SplitChunks(p1 + "\n\n" + p2)
want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: p1},
{Chapter: 1, ChunkIdx: 1, Text: p2},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("packing = chapters/chunks %d, want 2 chunks; got %+v", len(got), summarize(got))
}
}
func TestSplitChunksPacksSmallParagraphsTogether(t *testing.T) {
// Small paragraphs pack into one chunk (under the target), joined by a blank line.
got := SplitChunks("Абзац один.\n\nАбзац два.\n\nАбзац три.")
if len(got) != 1 {
t.Fatalf("small paragraphs must pack into one chunk, got %d: %+v", len(got), summarize(got))
}
if got[0].Text != "Абзац один.\n\nАбзац два.\n\nАбзац три." {
t.Fatalf("packed text = %q", got[0].Text)
}
}
func TestSplitChunksOversizeParagraphIsOwnChunk(t *testing.T) {
big := strings.Repeat("ы", 2000) // > targetChunkChars but never split
got := SplitChunks(big)
if len(got) != 1 || got[0].Text != big {
t.Fatalf("an oversize paragraph must be one un-split chunk, got %+v", summarize(got))
}
}
func TestSplitChunksDropsEmpties(t *testing.T) {
// Empty chapter blocks and whitespace-only paragraphs vanish; chapter numbers
// only advance for non-empty chapters.
src := "A" + chapterSep + " \n\n " + chapterSep + "B"
got := SplitChunks(src)
want := []Chunk{
{Chapter: 1, ChunkIdx: 0, Text: "A"},
{Chapter: 2, ChunkIdx: 0, Text: "B"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("empties = %+v, want %+v", got, want)
}
}
func TestSplitChunksDeterministic(t *testing.T) {
src := "Абзац.\n\nЕщё абзац." + chapterSep + strings.Repeat("длинный ", 300)
a, b := SplitChunks(src), SplitChunks(src)
if !reflect.DeepEqual(a, b) {
t.Fatal("SplitChunks must be deterministic")
}
}
func summarize(cs []Chunk) []string {
out := make([]string, len(cs))
for i, c := range cs {
txt := c.Text
if len(txt) > 12 {
txt = txt[:12] + "…"
}
out[i] = strings.TrimSpace(txt)
}
return out
}

View file

@ -0,0 +1,286 @@
package pipeline
import (
"fmt"
"regexp"
"strings"
"unicode"
"unicode/utf8"
"textmachine/backend/internal/llm"
)
// disposition.go: the per-chunk×stage verdict machinery of the Веха-2 runner
// (D2). It answers three questions about a completion — is it usable, and if not
// WHY, and may we re-attack it — WITHOUT ever touching the wire (classify is a
// pure function of the completion text + finish reason + a little context, so a
// resume reproduces the same verdict from the same checkpoint, no re-billing).
//
// This is deliberately SEPARATE from the configurable coverage QA-gate
// (Gates.Coverage, шаг 6): that gate is opt-in and threshold-driven; the
// classifier here is intrinsic runner robustness that is always on, so a bad
// chunk is flagged-and-skipped instead of poisoning the pipeline (empty draft →
// empty edit → empty export) or wedging the whole book on a single call.
// Disposition is the resolved state of a chunk×stage over its checkpoints.
type Disposition string
const (
// DispOK — a usable completion; its text feeds the next stage and is the
// authoritative checkpoint.
DispOK Disposition = "ok"
// DispFlagged — the completion is unusable (refusal / echo / truncation loop
// / empty / decode error) and retries (if any) are exhausted; the chunk is
// flagged for a human and later stages are skipped. Money is still accounted.
DispFlagged Disposition = "flagged"
// DispSkipped — this stage was NOT attempted because an earlier stage of the
// same chunk was flagged (no garbage draft → no paid edit over garbage).
DispSkipped Disposition = "skipped"
)
// FlagReason is the tagged cause of a flag — a contract enum like the Finish*
// constants (D2.2: tagging drives retry policy). The runner re-attacks ONLY the
// retryable subset; the rest are deterministic per model/channel, so a same-model
// retry would just re-refuse and re-bill (routing to channel B / escalation is
// Фаза 2). Some constants are DEFINED here for contract completeness but are not
// yet emitted by Веха 2 — see the notes — they belong to later steps.
type FlagReason string
const (
reasonOK FlagReason = "" // sentinel: not a flag
// Emitted by classify() — the retryable pair (D2: bigger max_tokens on the
// attempt axis, up to the regenerate cap, then flag).
FlagLength FlagReason = "length" // truncated at max_tokens (a genuine cut, not a loop)
FlagEmpty FlagReason = "empty" // 2xx with no usable text (thinking likely ate the whole budget)
// Emitted by classify() — deterministic, NOT retryable on the same model.
FlagLoopDegenerate FlagReason = "loop_degenerate" // repetition loop at a length cut (bigger budget just buys more loop, D2.3)
FlagHardRefusal FlagReason = "hard_refusal" // provider finish_reason=refusal
FlagSoftRefusal FlagReason = "soft_refusal" // refusal-blacklist match on a short output (the real insurance, D2.4)
FlagContentFilter FlagReason = "content_filter" // provider finish_reason=content_filter (best-effort — emission unverified across DS/GLM/Kimi/grok, D2.4)
FlagCJKArtifact FlagReason = "cjk_artifact" // output echoed the CJK source instead of translating (D3.4; фолбэк-черновик — шаг 7, пока просто флаг)
// Emitted by the runner's billed-decode path.
FlagDecodeError FlagReason = "decode_error" // 2xx with an unreadable body — billed, conservatively settled, flagged
// Reserved for later steps — DEFINED for contract stability, NOT emitted by
// Веха 2. coverage_fail / excision_suspect are verdicts of the configurable
// coverage gate (шаг 6); hard_block / upstream_not_ok are for HTTP-level
// content blocks handled when escalation lands (шаг 7).
FlagCoverageFail FlagReason = "coverage_fail"
FlagExcisionSuspect FlagReason = "excision_suspect"
FlagHardBlock FlagReason = "hard_block"
FlagUpstreamNotOK FlagReason = "upstream_not_ok"
)
// decodeErrorFinish is the finish_reason the runner stores on a billed-but-
// unreadable 2xx (BilledDecodeError). classify() recognises it so a RESUMED
// decode checkpoint re-resolves to the same FlagDecodeError verdict the live
// path assigned — live and resume must agree (determinism of the resolve).
const decodeErrorFinish = "decode_error"
// retryable reports whether a flag may be re-attacked on the SAME model along the
// attempt axis. Only length/empty: both are budget symptoms a bigger max_tokens
// can cure. Everything else is deterministic (refusal/filter/echo/loop/decode) —
// re-attacking burns money on a guaranteed repeat (D2.2/D2.3).
func (r FlagReason) retryable() bool {
return r == FlagLength || r == FlagEmpty
}
// disposition maps a reason to the resolved chunk×stage state.
func (r FlagReason) disposition() Disposition {
if r == reasonOK {
return DispOK
}
return DispFlagged
}
// classifyInput is everything classify needs. It is more than the "(text,
// finish)" shorthand because a faithful 1:1 port of eval/refusal_bench.py
// (03-implementation-notes §3.7) needs the source length (soft-refusal is a
// refusal pattern on an ANOMALOUSLY SHORT output — a long translation that
// merely quotes "I'm sorry, but…" as dialogue must NOT flag) and the target
// language (CJK-echo detection is meaningless when translating INTO a CJK
// language).
type classifyInput struct {
Source string
Output string
Finish string
TargetLang string
}
// classification is classify's verdict.
type classification struct {
Reason FlagReason
Detail string
}
func (c classification) ok() bool { return c.Reason == reasonOK }
// classify is the single verdict function. ORDER MATTERS (D2.1/D2.4): the
// deterministic hard signals (decode / content_filter / refusal finish) and the
// text refusal-blacklist / CJK-echo are checked BEFORE length/empty, so a
// truncated refusal is flagged as a refusal (deterministic, no paid re-attack)
// rather than as a length cut (retryable). Pure: no time, no randomness, no map
// iteration — resume reproduces the identical verdict from the identical
// checkpoint.
func classify(in classifyInput) classification {
// Strip a <think>…</think> block the model may have leaked into content
// (mirrors refusal_bench) before judging emptiness/length.
out := strings.TrimSpace(stripThink(in.Output))
finish := in.Finish
// 1) Deterministic finish-reason signals — never retried.
switch finish {
case decodeErrorFinish:
return classification{FlagDecodeError, "billed 2xx with an unreadable body"}
case llm.FinishContentFilter:
return classification{FlagContentFilter, "provider finish_reason=content_filter"}
case llm.FinishRefusal:
return classification{FlagHardRefusal, "provider finish_reason=refusal"}
}
// 2) Text refusal-blacklist / CJK-echo — ДО length/empty (only meaningful on
// non-empty text; a truncated refusal must not become a paid length retry).
if out != "" {
if isRefusal(out, in.Source) {
return classification{FlagSoftRefusal, "refusal-blacklist match on a short output"}
}
if !isCJKTarget(in.TargetLang) {
if share := cjkShare(out); share > cjkEchoThreshold {
return classification{FlagCJKArtifact, fmt.Sprintf("CJK share %.0f%% in output (untranslated echo)", share*100)}
}
}
}
// 3) Empty completion — retryable (thinking usually ate the entire budget).
if out == "" {
return classification{FlagEmpty, "empty completion (finish=" + finish + ")"}
}
// 4) Length cut — retryable, UNLESS the output is a degenerate repetition
// loop, in which case a bigger budget only buys more loop (D2.3): flag now.
if finish == llm.FinishLength {
if degenerateLoop(out) {
return classification{FlagLoopDegenerate, "repetition loop at a length cut"}
}
return classification{FlagLength, "truncated at max_tokens"}
}
// 5) A usable completion.
return classification{reasonOK, ""}
}
// --- refusal blacklist (en/ru/zh/ja) — ported 1:1 from eval/refusal_bench.py ---
var refusalPatterns = []string{
`I (?:can(?:'|no)t|cannot|won'?t|am (?:not able|unable)) (?:to )?(?:assist|help|translate|continue|provide|fulfill)`,
`I'?m sorry,? but`,
`against (?:my|our) (?:content )?polic`,
`content polic(?:y|ies)`,
`не могу (?:помочь|перевести|продолжить|выполнить)`,
`я не буду переводить`,
`противоречит (?:политике|правилам)`,
`としてお答えできません`,
`申し訳ありませんが`,
`我(?:不能|无法)(?:协助|翻译|继续)`,
`抱歉[,]`,
`^\s*\[?(?:REFUSED|ОТКАЗ)\]?\s*$`,
}
var refusalRE = regexp.MustCompile("(?im)" + strings.Join(refusalPatterns, "|"))
var thinkRE = regexp.MustCompile(`(?s)<think>.*?</think>\s*`)
func stripThink(s string) string { return thinkRE.ReplaceAllString(s, "") }
// isRefusal ports refusal_bench: a refusal-blacklist match AND an anomalously
// short output (shorter than max(400 runes, half the source), so a long faithful
// translation that merely contains a refusal-like phrase does not flag).
func isRefusal(out, src string) bool {
if !refusalRE.MatchString(out) {
return false
}
threshold := utf8.RuneCountInString(src) / 2
if threshold < 400 {
threshold = 400
}
return utf8.RuneCountInString(out) < threshold
}
// --- CJK-echo (untranslated source returned instead of a translation, D3.4) ---
// cjkEchoThreshold: >15% Han+kana in a non-CJK-target output means the model
// echoed the source (refusal_bench cjk_share > 0.15).
const cjkEchoThreshold = 0.15
func cjkShare(s string) float64 {
if s == "" {
return 0
}
cjk, total := 0, 0
for _, r := range s {
total++
if unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana) {
cjk++
}
}
if total == 0 {
return 0
}
return float64(cjk) / float64(total)
}
// isCJKTarget reports whether the translation TARGET is a CJK language, in which
// case CJK in the output is expected and the echo check is skipped. Phase-1
// scope is →ru, so this is normally false.
func isCJKTarget(lang string) bool {
switch strings.ToLower(strings.TrimSpace(lang)) {
case "zh", "ja", "ko", "zh-cn", "zh-tw":
return true
}
return false
}
// --- degeneration detector (n-gram loop) — runs BEFORE the length retry (D2.3) ---
// degenerateLoop reports whether the text is a repetition loop: the word-level
// trigram distinctness collapses when a phrase/sentence repeats to fill the
// budget. Pure and deterministic (only len(distinct) is used, never map order).
// Below a floor of words a length cut of genuinely short text is not judged a
// loop. CJK output (no spaces → few "words") is left to the echo check.
func degenerateLoop(text string) bool {
const n = 3
const minWords = 30
const distinctRatio = 0.25 // <25% distinct trigrams ⇒ ≥75% are repeats ⇒ loop
words := strings.Fields(text)
if len(words) < minWords {
return false
}
total := len(words) - n + 1
seen := make(map[string]struct{}, total)
for i := 0; i+n <= len(words); i++ {
seen[strings.Join(words[i:i+n], "\x00")] = struct{}{}
}
return float64(len(seen))/float64(total) < distinctRatio
}
// --- max_tokens on the attempt axis (D2.3) ---
// maxTokensForAttempt is the PURE output-token budget for a retry attempt:
// attempt 0 = base, each regeneration DOUBLES it (D2.3 remedy for a length cut —
// the previous budget was too small). Purity is load-bearing: the value enters
// request_hash, so resume must reproduce the identical per-attempt budget. Its
// FORMULA is versioned into the snapshot (maxTokensPolicyVersion) so a change is
// a loud --resnapshot, not a silent checkpoint miss on retried chunks (the same
// discipline as estimatorVersion).
func maxTokensForAttempt(base, attempt int) int {
if attempt <= 0 {
return base
}
if attempt > 20 { // defensive: never shift by a runaway amount (overflow guard)
attempt = 20
}
return base << uint(attempt)
}

View file

@ -0,0 +1,138 @@
package pipeline
import (
"strings"
"testing"
"textmachine/backend/internal/llm"
)
// classify: ORDER is the contract (D2.1/D2.4) — refusal/echo/deterministic finish
// signals are decided BEFORE length/empty, so a truncated refusal is not turned
// into a paid length retry.
func TestClassifyOrder(t *testing.T) {
const ru = "ru"
cases := []struct {
name string
in classifyInput
wantReason FlagReason
}{
{"clean stop", classifyInput{Source: "静かな朝。", Output: "Тихое утро.", Finish: llm.FinishStop, TargetLang: ru}, reasonOK},
{"empty at stop", classifyInput{Source: "静かな朝。", Output: " ", Finish: llm.FinishStop, TargetLang: ru}, FlagEmpty},
{"empty at length (thinking ate budget)", classifyInput{Source: "静かな朝。", Output: "", Finish: llm.FinishLength, TargetLang: ru}, FlagEmpty},
{"genuine length cut", classifyInput{Source: strings.Repeat("текст ", 50), Output: "Начало предложения обрывается на", Finish: llm.FinishLength, TargetLang: ru}, FlagLength},
{"length but repetition loop", classifyInput{Source: "x", Output: strings.Repeat("ло ", 60), Finish: llm.FinishLength, TargetLang: ru}, FlagLoopDegenerate},
{"truncated soft refusal (NOT length)", classifyInput{Source: "静かな朝、彼女は本を開いた。長い一日の始まりだった。", Output: "Я не могу помочь с этим запросом.", Finish: llm.FinishLength, TargetLang: ru}, FlagSoftRefusal},
{"english refusal short", classifyInput{Source: "長い長い長い長い文章です。", Output: "I'm sorry, but I cannot assist with that.", Finish: llm.FinishStop, TargetLang: ru}, FlagSoftRefusal},
{"cjk echo (untranslated)", classifyInput{Source: "彼は言った。", Output: "彼は言った。とても静かな朝だった。", Finish: llm.FinishStop, TargetLang: ru}, FlagCJKArtifact},
{"provider content_filter finish", classifyInput{Source: "x", Output: "partial", Finish: llm.FinishContentFilter, TargetLang: ru}, FlagContentFilter},
{"provider refusal finish", classifyInput{Source: "x", Output: "", Finish: llm.FinishRefusal, TargetLang: ru}, FlagHardRefusal},
{"resumed decode checkpoint", classifyInput{Source: "x", Output: "", Finish: decodeErrorFinish, TargetLang: ru}, FlagDecodeError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := classify(tc.in)
if got.Reason != tc.wantReason {
t.Fatalf("classify reason = %q, want %q (detail %q)", got.Reason, tc.wantReason, got.Detail)
}
if (got.Reason == reasonOK) != got.ok() {
t.Fatalf("ok() inconsistent for reason %q", got.Reason)
}
})
}
}
// A refusal that is NOT anomalously short (a long faithful translation that
// merely quotes a refusal-like phrase as dialogue) must NOT flag.
func TestClassifyLongOutputWithRefusalPhraseIsOK(t *testing.T) {
src := strings.Repeat("短", 100) // 100 CJK runes → threshold max(400, 50) = 400
out := "«Я не могу помочь тебе», — тихо сказал он, отводя взгляд. " + strings.Repeat("Слова растворялись в утреннем тумане, и никто их не услышал. ", 12)
got := classify(classifyInput{Source: src, Output: out, Finish: llm.FinishStop, TargetLang: "ru"})
if !got.ok() {
t.Fatalf("a long translation quoting a refusal phrase must be ok, got %q", got.Reason)
}
}
// Determinism: identical input yields the identical verdict (pure function).
func TestClassifyDeterministic(t *testing.T) {
in := classifyInput{Source: "彼は静かに歩いた。", Output: strings.Repeat("эхо ", 40), Finish: llm.FinishLength, TargetLang: "ru"}
a, b := classify(in), classify(in)
if a != b {
t.Fatalf("classify is not deterministic: %+v vs %+v", a, b)
}
}
func TestRetryableSubset(t *testing.T) {
retry := map[FlagReason]bool{FlagLength: true, FlagEmpty: true}
all := []FlagReason{
reasonOK, FlagLength, FlagEmpty, FlagLoopDegenerate, FlagHardRefusal, FlagSoftRefusal,
FlagContentFilter, FlagCJKArtifact, FlagDecodeError, FlagCoverageFail, FlagExcisionSuspect,
FlagHardBlock, FlagUpstreamNotOK,
}
for _, r := range all {
if got, want := r.retryable(), retry[r]; got != want {
t.Fatalf("%q.retryable() = %v, want %v (only length/empty are retryable)", r, got, want)
}
}
if reasonOK.disposition() != DispOK {
t.Fatal("reasonOK must resolve to DispOK")
}
if FlagLength.disposition() != DispFlagged {
t.Fatal("a flag reason must resolve to DispFlagged")
}
}
// maxTokensForAttempt is pure and doubles per attempt (D2.3), which is what puts
// a DIFFERENT max_tokens (and thus request_hash) on each regeneration.
func TestMaxTokensForAttempt(t *testing.T) {
base := 2048
for attempt, want := range map[int]int{0: base, 1: 2 * base, 2: 4 * base, 3: 8 * base} {
if got := maxTokensForAttempt(base, attempt); got != want {
t.Fatalf("maxTokensForAttempt(%d, %d) = %d, want %d", base, attempt, got, want)
}
}
// Overflow guard: an absurd attempt is clamped, never a negative shift.
if got := maxTokensForAttempt(base, 999); got <= 0 {
t.Fatalf("maxTokensForAttempt overflow guard failed: %d", got)
}
}
func TestDegenerateLoop(t *testing.T) {
if !degenerateLoop(strings.Repeat("одно и то же ", 40)) {
t.Fatal("a repeated phrase must be a degenerate loop")
}
healthy := "Она открыла книгу и начала читать первую главу, где юный герой впервые покидал родную деревню навстречу далёким горам и неизвестной судьбе, полной опасностей."
if degenerateLoop(healthy) {
t.Fatal("healthy prose must NOT be a degenerate loop")
}
if degenerateLoop("короткий обрыв на пол") {
t.Fatal("a short length cut is not enough evidence for a loop")
}
}
func TestIsRefusalShortVsLong(t *testing.T) {
if !isRefusal("Извините, я не могу перевести это.", "abc") {
t.Fatal("a short refusal must be detected")
}
long := strings.Repeat("Это обычный длинный перевод без всякого отказа. ", 30)
if isRefusal(long, "abc") {
t.Fatal("a long non-refusal must not be flagged")
}
}
func TestCJKShareAndTarget(t *testing.T) {
if s := cjkShare("彼は言った"); s < 0.9 {
t.Fatalf("all-CJK text share = %.2f, want ~1", s)
}
if s := cjkShare("Полностью русский текст"); s > 0.01 {
t.Fatalf("russian text share = %.2f, want ~0", s)
}
if !isCJKTarget("ja") || !isCJKTarget("ZH") || isCJKTarget("ru") {
t.Fatal("isCJKTarget classification wrong")
}
// Into a CJK target, CJK output is expected — no echo flag.
got := classify(classifyInput{Source: "Утро.", Output: "静かな朝。", Finish: llm.FinishStop, TargetLang: "ja"})
if got.Reason == FlagCJKArtifact {
t.Fatal("CJK output must not be an echo artifact when the target IS CJK")
}
}

View file

@ -31,8 +31,11 @@ import (
// chunkerVersion versions the segmentation/ingestion rules (включая
// нормализацию источника NormalizeSource); it is part of the snapshot, so
// re-chunking a book is an explicit re-translation, not a silent cache miss
// (§3.4 — [НУЖНО РЕШЕНИЕ] п.2в).
const chunkerVersion = "chunker-v1-wholefile-nfc"
// (§3.4 — [НУЖНО РЕШЕНИЕ] п.2в). Веха 2 replaced the whole-file "one chunk" of
// Фаза 0 with the chapter/chunk splitter (chunker.go), so the version bumps:
// any Фаза-0 project DB re-pins loudly via the --resnapshot gate (a re-chunk is
// a deliberate re-translation), never a silent divergent re-pay.
const chunkerVersion = "chunker-v2-ff-para-pack"
// estimatorVersion versions EstimateTokens: его выход входит в max_tokens и
// через него в request-hash, поэтому перекалибровка весов — тоже явная
@ -40,6 +43,15 @@ const chunkerVersion = "chunker-v1-wholefile-nfc"
// ревью: code-only правка эстиматора пере-оплатила бы полкниги).
const estimatorVersion = "estimator-v0"
// maxTokensPolicyVersion versions the attempt→max_tokens scaling
// (maxTokensForAttempt, disposition.go). attempt-0 budget is already covered by
// estimatorVersion + defaults, but a change to the RETRY scaling would silently
// shift the request_hash of every attempt≥1 (a missed checkpoint → re-pay on
// retried chunks). Folding this version into the snapshot makes such a change a
// loud --resnapshot instead — the same discipline as estimatorVersion, applied
// to the regeneration axis (Веха 2).
const maxTokensPolicyVersion = "maxtok-v1-double-per-attempt"
// userSeparator splits a prompt template file into the system part and the
// user part. Обе части — версионируемые шаблоны в prompts/ («Редакция» позже
// правит файлы, не код).
@ -181,6 +193,33 @@ func RequestHash(bookID string, chapter, chunkIdx, attempt int, stage, role, mod
return hex.EncodeToString(h.Sum(nil))
}
// msgsContentHash is a content signature of the rendered messages, INDEPENDENT
// of attempt/max_tokens. It guards the chunk_status resume fast-path (runner.go):
// the source bytes are NOT folded into the snapshot (only the chunker RULES and
// the semantic brief are), so a positional chunk_status row must be re-validated
// against the current rendered content — otherwise an edited source would serve
// a stale, divergent translation with no cache miss (the determinism invariant's
// named failure; self-review Веха 2). Length-prefixed like RequestHash so
// content with NUL bytes cannot forge field/message boundaries. The template is
// already snapshot-pinned, so this only has to catch source/draft edits.
func msgsContentHash(msgs []llm.Message) string {
h := sha256.New()
var lb [8]byte
w := func(s string) {
binary.LittleEndian.PutUint64(lb[:], uint64(len(s)))
h.Write(lb[:])
h.Write([]byte(s))
}
w("tm-content-v1")
binary.LittleEndian.PutUint64(lb[:], uint64(len(msgs)))
h.Write(lb[:])
for _, m := range msgs {
w(m.Role)
w(m.Content)
}
return hex.EncodeToString(h.Sum(nil))
}
// NormalizeSource canonicalizes the source chunk so the request-hash is stable
// across editors and operating systems (находка ревью): strip a UTF-8 BOM,
// CRLF/CR → LF, Unicode NFC, trim surrounding whitespace. Без этого файл,

View file

@ -19,11 +19,15 @@ import (
"textmachine/backend/internal/store"
)
// runner.go: the Phase-0 mini-runner — one chunk through the configured stage
// list (C1: draft→edit) with full money discipline: reserve → call → settle+
// checkpoint (одна транзакция) → request_log. Resume: найденный чекпоинт =
// вызов не повторяется и не оплачивается. Гейты/эскалация/циклы по главам —
// Фаза 1, сюда же (код раннера).
// runner.go: the Веха-2 book runner — every (chapter, chunk) from the chunker
// through the configured stage list (C1: draft→edit) with full money discipline:
// reserve → call → settle+checkpoint (одна транзакция) → request_log. A bad
// chunk is FLAGGED (disposition, chunk_status) and the loop continues (D2), not
// a run-crash; only an infra failure aborts. Resume reads chunk_status BEFORE
// rendering, so a done or terminally-flagged chunk is never re-attacked or
// re-billed. Retries walk the `attempt` axis (bigger max_tokens for the
// retryable length/empty subset, up to the regenerate cap, then flag).
// Escalation and the real linguistic chunker are later steps of Фаза 1.
// Runner executes a book's pipeline.
type Runner struct {
@ -175,10 +179,15 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
Capability json.RawMessage `json:"capability,omitempty"`
}
snap := struct {
BriefHash string `json:"brief_hash"`
ChunkerVersion string `json:"chunker_version"`
EstimatorVersion string `json:"estimator_version"`
PipelineCore string `json:"pipeline_core"`
BriefHash string `json:"brief_hash"`
ChunkerVersion string `json:"chunker_version"`
EstimatorVersion string `json:"estimator_version"`
// MaxTokensPolicy versions the attempt→max_tokens scaling (Веха 2): a
// change to the retry doubling shifts attempt≥1 request_hashes, so it
// belongs in the snapshot as a loud invalidation (same class as
// estimator_version, applied to the regeneration axis).
MaxTokensPolicy string `json:"max_tokens_policy"`
PipelineCore string `json:"pipeline_core"`
// Defaults влияют на maxTokens, а тот входит в request-hash: без них
// правка max_output_ratio молча инвалидировала бы все чекпоинты в
// обход snapshot-гейта (находка ревью).
@ -201,6 +210,7 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
BriefHash: r.Book.BriefHash(),
ChunkerVersion: chunkerVersion,
EstimatorVersion: estimatorVersion,
MaxTokensPolicy: maxTokensPolicyVersion,
PipelineCore: r.Pipeline.Core,
MaxOutputRatio: r.Pipeline.Defaults.MaxOutputRatio,
MinMaxTokens: r.Pipeline.Defaults.MinMaxTokens,
@ -247,33 +257,72 @@ func (r *Runner) snapshotID() (id, payload string, err error) {
return hex.EncodeToString(sum[:]), string(data), nil
}
// StageResult reports one executed stage.
// StageResult reports one executed stage of a chunk.
type StageResult struct {
Stage string
Role string
Model string // фактически ответившая модель
FromResume bool // served from a checkpoint, no call made
FromResume bool // no provider call was made THIS run (fully served from checkpoints)
Usage llm.Usage
CostUSD float64
CostUSD float64 // THIS run's spend (0 when fully served from checkpoints)
CumCostUSD float64 // sum across ALL attempts of this chunk×stage (F3-honest, incl. retries)
LatencyMS int
FinishReason string
Text string
Text string // the usable output (only on an ok disposition)
Disposition Disposition
FlagReason FlagReason // "" when ok
Detail string
Attempts int
}
// RunResult is the whole chunk's outcome.
type RunResult struct {
BookID string
Chapter int
ChunkIdx int
Stages []StageResult
FinalText string
TotalUSD float64
// ChunkOutcome is one chunk's result across the stage list.
type ChunkOutcome struct {
Chapter int
ChunkIdx int
Stages []StageResult
FinalText string // the last stage's output; "" when the chunk is flagged
Disposition Disposition // ok | flagged (a chunk has no "skipped" — that is a per-later-stage state)
FlagReason FlagReason // the flagging stage's reason ("" when ok)
CostUSD float64 // THIS run's spend on this chunk
}
// TranslateOneChunk runs the configured stages over the book's source file as
// a single chunk (Фаза 0). Chapter/chunk are fixed at 1/0 until the chunker
// lands in Phase 1.
func (r *Runner) TranslateOneChunk(ctx context.Context) (*RunResult, error) {
// BookResult aggregates a whole run over every chapter×chunk.
type BookResult struct {
BookID string
Chunks []ChunkOutcome
TotalUSD float64 // THIS run's spend across all chunks
Flagged int // number of flagged chunks (acceptance допускает N)
}
// ExitCode is the run's shell disposition: 0 clean, 2 completed-with-flags
// (acceptance допускает N флагов — приёмка это разрешает). An infra failure is
// an error from TranslateBook, which the CLI maps to 1.
func (b *BookResult) ExitCode() int {
if b.Flagged > 0 {
return 2
}
return 0
}
// CompletedWithFlags is the typed sentinel the CLI maps to exit code 2: the book
// finished end-to-end but N chunks were flagged for a human. It is NOT an infra
// failure (a plain error → exit 1); it is a "clean run, attention needed" signal
// carried up through `func run() error` in the idiomatic Go way.
type CompletedWithFlags struct {
Flagged int
Total int
}
func (e *CompletedWithFlags) Error() string {
return fmt.Sprintf("completed with %d/%d chunk(s) flagged for review", e.Flagged, e.Total)
}
// TranslateBook runs the whole book: split the normalized source into
// chapter/chunk units (chunker.go) and drive each chunk through the stage list.
// A bad chunk is flagged and the loop CONTINUES (D2); only an infra failure
// (ceiling, config change without --resnapshot, unbilled call failure, store
// error) aborts with an error, from which resume continues.
func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
srcRaw, err := os.ReadFile(r.Book.SourceFile)
if err != nil {
return nil, fmt.Errorf("pipeline: read source: %w", err)
@ -284,6 +333,8 @@ func (r *Runner) TranslateOneChunk(ctx context.Context) (*RunResult, error) {
return nil, fmt.Errorf("pipeline: source file %s is empty", r.Book.SourceFile)
}
// Snapshot is book-level (brief + stage plan + memory), computed once and
// upserted before the loop; every job pins to it.
snapID, snapPayload, err := r.snapshotID()
if err != nil {
return nil, err
@ -292,96 +343,276 @@ func (r *Runner) TranslateOneChunk(ctx context.Context) (*RunResult, error) {
return nil, err
}
const chapter, chunkIdx = 1, 0
res := &RunResult{BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx}
prev := ""
chunks := SplitChunks(source)
if len(chunks) == 0 {
return nil, fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile)
}
for _, st := range r.Pipeline.Stages {
sr, err := r.runStage(ctx, st, snapID, chapter, chunkIdx, source, prev)
res := &BookResult{BookID: r.Book.BookID}
for _, ch := range chunks {
outcome, err := r.translateChunk(ctx, snapID, ch)
if err != nil {
// Infra failure: abort. Chunks already committed are checkpointed;
// resume continues from here at $0 for the done work.
return res, err
}
res.Stages = append(res.Stages, *sr)
res.TotalUSD += sr.CostUSD
prev = sr.Text
res.Chunks = append(res.Chunks, *outcome)
res.TotalUSD += outcome.CostUSD
if outcome.Disposition == DispFlagged {
res.Flagged++
}
}
res.FinalText = prev
return res, nil
}
func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, chapter, chunkIdx int, source, prev string) (*StageResult, error) {
job, err := r.Store.EnsureJob(r.Book.BookID, chapter, st.Name, snapID)
// translateChunk drives ONE chunk through the stage list. Stages run in order,
// each feeding the next; the FIRST flagged stage stops the chunk — later stages
// are recorded `skipped` (no paid edit over a garbage draft, D2). Returns an
// error only on an infra failure.
func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk) (*ChunkOutcome, error) {
out := &ChunkOutcome{Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Disposition: DispOK}
prev := ""
flagged := false
var flagReason FlagReason
for stageIdx, st := range r.Pipeline.Stages {
if flagged {
// An earlier stage flagged → this stage is not attempted or billed.
detail := fmt.Sprintf("skipped: an upstream stage was flagged (%s)", flagReason)
if err := r.Store.UpsertChunkStatus(store.ChunkStatus{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name,
SnapshotID: snapID, Disposition: string(DispSkipped), FlagReason: string(flagReason),
Detail: detail,
}); err != nil {
return out, fmt.Errorf("pipeline: record skipped chunk_status: %w", err)
}
out.Stages = append(out.Stages, StageResult{
Stage: st.Name, Role: st.Role, Model: st.Model,
Disposition: DispSkipped, FlagReason: flagReason, Detail: detail,
})
continue
}
sr, err := r.runStage(ctx, st, stageIdx, snapID, ch, prev)
if err != nil {
return out, err
}
out.Stages = append(out.Stages, *sr)
out.CostUSD += sr.CostUSD
if sr.Disposition == DispFlagged {
flagged = true
flagReason = sr.FlagReason
continue
}
prev = sr.Text
}
if flagged {
out.Disposition = DispFlagged
out.FlagReason = flagReason
out.FinalText = "" // garbage/refusal never propagates to the next chunk or export
} else {
out.FinalText = prev
}
return out, nil
}
// runStage runs ONE stage of ONE chunk to a terminal disposition. It first
// resumes a resolved verdict (chunk_status read BEFORE any render — anti-wedge
// #1), otherwise walks the attempt axis: render → per-attempt checkpoint resume
// or a fresh reserve/call/settle → classify → retry the retryable {length,empty}
// subset with a doubled budget up to the regenerate cap, then flag. Returns an
// error ONLY on an infra failure; a bad completion is a disposition, never an
// error.
func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, snapID string, ch Chunk, prev string) (*StageResult, error) {
job, err := r.Store.EnsureJob(r.Book.BookID, ch.Chapter, st.Name, snapID)
if err != nil {
return nil, err
}
// Snapshot pinning (Р6): джоба заморожена на снапшоте своего старта. Если
// конфиги/промпты с тех пор изменились — это инвалидация чекпоинтов и
// пере-оплата; выполняем её только по явной команде.
// Snapshot pinning (Р6): a job frozen on a stale snapshot is a loud stop
// unless --resnapshot explicitly accepts the re-translation.
if job.SnapshotID != snapID {
if !r.Resnapshot {
return nil, fmt.Errorf("pipeline: job %s/ch%d/%s was started under snapshot %.12s, current config renders snapshot %.12s — конфиг/промпты изменились; уже оплаченные чекпоинты станут недействительны и вызовы будут пере-оплачены; повторить с --resnapshot, чтобы принять это явно",
r.Book.BookID, chapter, st.Name, job.SnapshotID, snapID)
r.Book.BookID, ch.Chapter, st.Name, job.SnapshotID, snapID)
}
if err := r.Store.UpdateJobSnapshot(job.ID, snapID); err != nil {
return nil, err
}
r.Log.WarnContext(ctx, "job re-pinned to new snapshot (--resnapshot)", "stage", st.Name, "old", job.SnapshotID[:12], "new", snapID[:12])
}
// Статус 'running' выставляется НЕ здесь, а прямо перед вызовом провайдера:
// иначе отказ потолка/ошибка сборки клиента/промах гейта оставляли бы
// джобу навсегда в 'running' (находка ревью). Отказ потолка — retriable,
// джоба остаётся 'pending'; настоящая ошибка — 'failed'.
msgs, err := Messages(r.templates[st.Name], RenderVars{Book: r.Book, Text: source, Draft: prev})
// Render the wire messages up front. This is cheap (string substitution — the
// expensive part is the LLM call, still gated below) and it lets the resume
// fast-path be CONTENT-VERIFIED: the source bytes are not in the snapshot, so
// a positional chunk_status row must be checked against the current rendered
// content, else an edited source would serve a stale, divergent translation.
msgs, err := Messages(r.templates[st.Name], RenderVars{Book: r.Book, Text: ch.Text, Draft: prev})
if err != nil {
return nil, err
}
contentHash := msgsContentHash(msgs)
// max_tokens: калибровка полигона — русский выход ≈1.9× входа в токенах;
// ratio из конфига, floor защищает короткие чанки. Входит в request-hash
// (меняет вызов), поэтому считается детерминированно от исходника.
inputEst := EstimateTokens(source)
maxTokens := int(float64(inputEst) * r.Pipeline.Defaults.MaxOutputRatio)
if maxTokens < r.Pipeline.Defaults.MinMaxTokens {
maxTokens = r.Pipeline.Defaults.MinMaxTokens
// Anti-wedge #1 + content-guard (self-review): resume resolves from
// chunk_status BEFORE any LLM call. A flagged chunk is not in TM (garbage
// never commits), so absence-in-TM ≠ "not done" — the disposition row is the
// resume authority, and a terminally-flagged chunk is NOT re-attacked (no
// infinite paid loop, even if the regenerate budget was raised). The row is
// trusted ONLY when snapshot AND content both match: a stale-snapshot row
// (post --resnapshot) or a stale-content row (source edited, not in the
// snapshot) is ignored → the attempt loop below re-derives it content-safely
// (a source edit becomes a silent per-chunk re-translate, §3.4, not a stale
// serve). A `skipped` row is ignored too: translateChunk re-derives the skip.
if cs, err := r.Store.GetChunkStatus(r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name); err != nil {
return nil, err
} else if cs != nil && cs.SnapshotID == snapID && cs.ContentHash == contentHash && cs.Disposition != string(DispSkipped) {
return r.resumeFromChunkStatus(ctx, st, ch, cs)
}
// attempt 0: измерение регенераций подключит гейт-цикл Фазы 1.
const attempt = 0
reqHash := RequestHash(r.Book.BookID, chapter, chunkIdx, attempt, st.Name, st.Role, st.Model,
st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
// max_tokens base is sized from the text THIS stage processes: the source for
// the translator, the prior draft for later stages (D2.5 — the monolingual
// editor works over the Russian draft ≈1.9× the CJK source, so sizing edit
// from source under-budgets and false-triggers a length retry).
sizingText := ch.Text
if stageIdx > 0 {
sizingText = prev
}
baseMaxTokens := int(float64(EstimateTokens(sizingText)) * r.Pipeline.Defaults.MaxOutputRatio)
if baseMaxTokens < r.Pipeline.Defaults.MinMaxTokens {
baseMaxTokens = r.Pipeline.Defaults.MinMaxTokens
}
// Дополняем ReqInfo, СОХРАНЯЯ решения допуска (LogBodies) — перезапись с
// нуля отрезала бы задокументированный debug-канал (находка ревью).
// Дополняем ReqInfo, СОХРАНЯЯ решения допуска (LogBodies) — перезапись с нуля
// отрезала бы задокументированный debug-канал (находка ревью).
ri, _ := obs.ReqInfoFromContext(ctx)
ri.Book, ri.Chapter, ri.Chunk, ri.Stage, ri.Role = r.Book.BookID, chapter, chunkIdx, st.Name, st.Role
ri.Book, ri.Chapter, ri.Chunk, ri.Stage, ri.Role = r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, st.Role
ctx = obs.WithReqInfo(ctx, ri)
// Resume path: a checkpoint means the call already happened and was paid —
// serve it, log the hit, never re-bill (приёмка: kill -9 теряет ≤1 вызов).
// Пустой чекпоинт (оплаченный отказ/обрезка/нечитаемое тело) НЕ подаётся
// дальше молча: черновик="" отравил бы редактуру и экспорт (находка ревью).
if cp, err := r.Store.GetCheckpoint(reqHash); err != nil {
return nil, err
} else if cp != nil {
if strings.TrimSpace(cp.ResponseText) == "" {
return nil, fmt.Errorf("pipeline: stage %s has a BILLED but empty checkpoint (finish=%s, hash %.12s) — вызов был оплачен, но пригодного текста нет; до регенераций Фазы 1: удалить строку checkpoints вручную или дождаться механизма attempt", st.Name, cp.FinishReason, reqHash)
}
var usage llm.Usage
_ = json.Unmarshal([]byte(cp.UsageJSON), &usage)
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: cp.ModelActual,
RequestHash: reqHash, TMHit: true, OK: true, FinishReason: cp.FinishReason,
})
if err := r.Store.SetJobStatus(job.ID, "done"); err != nil {
return nil, err
}
r.Log.InfoContext(ctx, "stage served from checkpoint", "stage", st.Name, "hash", reqHash[:12])
return &StageResult{Stage: st.Name, Role: st.Role, Model: cp.ModelActual,
FromResume: true, Usage: usage, CostUSD: 0, FinishReason: cp.FinishReason, Text: cp.ResponseText}, nil
maxRegen := r.Pipeline.Retries.RegenerateBeforeEscalate
if maxRegen < 0 {
maxRegen = 0
}
var cumCost, runCost float64
var last stageAttempt
anyFresh := false
attemptsMade := 0
for attempt := 0; ; attempt++ {
maxTokens := maxTokensForAttempt(baseMaxTokens, attempt)
att, err := r.runAttempt(ctx, st, snapID, ch, job, attempt, maxTokens, msgs)
if err != nil {
return nil, err // infra failure
}
attemptsMade = attempt + 1
cumCost += att.cumCost
runCost += att.runCost
anyFresh = anyFresh || att.freshCall
last = att
if att.cls.ok() {
break
}
// Flagged: re-attack only the retryable subset, only while regenerations
// remain (a bigger budget on the attempt axis, D2.3). Everything else is
// deterministic — a same-model retry would re-refuse and re-bill (D2.2).
if att.cls.Reason.retryable() && attempt < maxRegen {
r.Log.WarnContext(ctx, "stage flagged, regenerating with a larger budget",
"stage", st.Name, "chapter", ch.Chapter, "chunk", ch.ChunkIdx,
"attempt", attempt, "reason", string(att.cls.Reason), "next_max_tokens", maxTokensForAttempt(baseMaxTokens, attempt+1))
continue
}
break
}
disposition := last.cls.Reason.disposition()
finalHash := ""
if disposition == DispOK {
finalHash = last.reqHash
}
// chunk_status is a RESOLVE over the checkpoints: cost_usd sums every attempt
// (F3-honest — retries are counted), final_hash points at the authoritative
// checkpoint the ok path serves on resume.
if err := r.Store.UpsertChunkStatus(store.ChunkStatus{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name,
SnapshotID: snapID, ContentHash: contentHash, Disposition: string(disposition), FlagReason: string(last.cls.Reason),
Attempts: attemptsMade, FinalHash: finalHash, CostUSD: cumCost, Detail: last.cls.Detail,
}); err != nil {
return nil, fmt.Errorf("pipeline: record chunk_status: %w", err)
}
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,
}
if disposition == DispOK {
sr.Text = last.text
} else {
r.Log.WarnContext(ctx, "stage flagged", "stage", st.Name, "chapter", ch.Chapter,
"chunk", ch.ChunkIdx, "reason", string(last.cls.Reason), "attempts", attemptsMade,
"cum_cost_usd", fmt.Sprintf("%.6f", cumCost))
}
return sr, nil
}
// stageAttempt is the result of one attempt (a checkpoint hit or a fresh call).
type stageAttempt struct {
reqHash string
cls classification
text string
usage llm.Usage
finish string
modelActual string
latency int
runCost float64 // billed THIS run (0 on a checkpoint hit)
cumCost float64 // this attempt's cost (checkpoint cost on a hit, fresh cost on a call)
freshCall bool // a provider call was made this run
}
// runAttempt executes exactly one attempt on the request-hash axis: a checkpoint
// hit is classified for free (self-heal, incl. legacy Фаза-0 empty/decode
// 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,
st.Temperature, st.Reasoning, false, maxTokens, snapID, msgs)
att := stageAttempt{reqHash: reqHash, modelActual: st.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;
// legacy empty/decode checkpoints self-heal by classification, not a crash).
if cp, err := r.Store.GetCheckpoint(reqHash); err != nil {
return att, err
} else if cp != nil {
var usage llm.Usage
_ = json.Unmarshal([]byte(cp.UsageJSON), &usage)
att.usage = usage
att.text = cp.ResponseText
att.finish = cp.FinishReason
att.modelActual = cp.ModelActual
att.cumCost = cp.CostUSD
att.cls = classify(classifyInput{Source: ch.Text, Output: cp.ResponseText, Finish: cp.FinishReason, TargetLang: r.Book.TargetLang})
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,
RequestHash: reqHash, TMHit: true, OK: att.cls.ok(), FinishReason: cp.FinishReason,
Degraded: degradedTag(att.cls),
})
_ = r.Store.SetJobStatus(job.ID, "done")
r.Log.InfoContext(ctx, "attempt served from checkpoint", "stage", st.Name,
"attempt", attempt, "hash", reqHash[:12], "disposition", string(att.cls.Reason.disposition()))
return att, nil
}
// Fresh call: reserve → call → settle+checkpoint (atomic, §3.3).
price := r.Pricer.PriceFor(st.Model)
promptEst := 0
for _, m := range msgs {
@ -394,27 +625,29 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c
})
if err != nil {
_ = r.Store.SetJobStatus(job.ID, "failed")
return nil, err
return att, err
}
switch verdict {
case store.ReserveDeniedBook:
// Джоба остаётся 'pending' (не 'failed'): поднимут потолок — продолжит.
return nil, fmt.Errorf("pipeline: book USD ceiling reached (%.2f$) — raise ceilings.book_usd or stop", r.Book.Ceilings.BookUSD)
// 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)
case store.ReserveDeniedDay:
return nil, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD)
return att, fmt.Errorf("pipeline: daily USD ceiling reached (%.2f$)", r.Book.Ceilings.DayUSD)
}
client, err := r.client(st.Model)
if err != nil {
_ = r.Store.Release(resv)
_ = r.Store.SetJobStatus(job.ID, "failed")
return nil, err
return att, err
}
if err := r.Store.SetJobStatus(job.ID, "running"); err != nil {
_ = r.Store.Release(resv)
return nil, err
return att, err
}
att.freshCall = true
start := time.Now()
resp, err := client.Complete(ctx, llm.LLMRequest{
Model: st.Model,
@ -423,55 +656,64 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c
Temperature: st.Temperature,
ReasoningEffort: st.Reasoning,
})
latency := int(time.Since(start).Milliseconds())
att.latency = int(time.Since(start).Milliseconds())
if err != nil {
// 2xx с нечитаемым телом: провайдер УЖЕ списал деньги — резерв не
// возвращаем, консервативно селтлим ОЦЕНКУ с пустым чекпоинтом
// (переучёт ≤1 оценки безопаснее слепого потолка) и падаем громко.
var bde *llm.BilledDecodeError
if errors.As(err, &bde) {
// 2xx with an unreadable body: the provider ALREADY billed. Do not
// release; conservatively settle the ESTIMATE with an empty decode
// checkpoint, then FLAG (anti-wedge #2 — the loop continues, this is
// 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: chunkIdx, Attempt: attempt,
RequestHash: reqHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: attempt,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: st.Model,
ResponseText: "", UsageJSON: "{}", CostUSD: estimate, FinishReason: "decode_error",
ResponseText: "", UsageJSON: "{}", CostUSD: estimate, FinishReason: decodeErrorFinish,
}); serr != nil {
r.Log.ErrorContext(ctx, "settle after billed decode failure also failed", "err", serr)
return att, fmt.Errorf("pipeline: settle after billed decode failure: %w", serr)
}
att.finish = decodeErrorFinish
att.cumCost, att.runCost = estimate, estimate
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: chapter, ChunkIdx: chunkIdx,
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model,
RequestHash: reqHash, CostUSD: estimate, LatencyMS: latency,
Degraded: "billed_2xx_decode_failed", Err: err.Error(), OK: false,
RequestHash: reqHash, CostUSD: estimate, LatencyMS: att.latency,
Degraded: "billed_2xx_decode_failed", Err: err.Error(), OK: false, FinishReason: decodeErrorFinish,
})
_ = r.Store.SetJobStatus(job.ID, "failed")
return nil, fmt.Errorf("pipeline: stage %s: %w (оценка $%.6f заселтлена консервативно)", st.Name, err, estimate)
_ = r.Store.SetJobStatus(job.ID, "done")
return att, nil
}
// No 2xx ever arrived: nothing was billed by the provider — release the
// reservation and surface the failure with a telemetry row.
// No 2xx ever arrived: nothing was billed. Release and surface as an INFRA
// failure — a long book pauses/resumes on an outage or terminal 4xx (D4),
// rather than flag-storming every remaining chunk on a dead provider.
if rerr := r.Store.Release(resv); rerr != nil {
r.Log.ErrorContext(ctx, "release after failed call also failed", "err", rerr)
}
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx,
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model,
RequestHash: reqHash, LatencyMS: latency, Err: err.Error(), OK: false,
RequestHash: reqHash, LatencyMS: att.latency, Err: err.Error(), OK: false,
})
_ = r.Store.SetJobStatus(job.ID, "failed")
return nil, fmt.Errorf("pipeline: stage %s call: %w", st.Name, err)
return att, fmt.Errorf("pipeline: stage %s call: %w", st.Name, err)
}
modelActual := resp.Model
if modelActual == "" {
modelActual = st.Model
}
att.modelActual = modelActual
att.text = resp.Text
att.finish = resp.FinishReason
att.usage = resp.Usage
// Цена по фактически ответившей модели, с fallback на ЗАПРОШЕННУЮ (не на
// дешёвый глобальный якорь), если провайдер вернул канонизированный слаг.
price = r.Pricer.PriceForResponse(st.Model, modelActual)
cost := ledger.CostUSD(price, resp.Usage)
// Платная модель (InputPerM>0) вернула 2xx с нулевым usage — баг учёта
// провайдера/необычный ответ: $0 ослепил бы потолок, поэтому берём
// консервативную оценку и предупреждаем (находка ревью). Для local ($0
// цена) нулевой usage штатен — остаётся $0.
// Платная модель (InputPerM>0) вернула 2xx с нулевым usage — $0 ослепил бы
// потолок: берём консервативную оценку. Для local ($0 цена) нулевой usage
// штатен — остаётся $0.
if cost == 0 && price.InputPerM > 0 && resp.Usage.PromptTokens == 0 && resp.Usage.CompletionTokens == 0 {
r.Log.WarnContext(ctx, "paid 2xx with zero usage; settling the reservation estimate to keep the ceiling honest",
"stage", st.Name, "model", modelActual, "estimate_usd", fmt.Sprintf("%.6f", estimate))
@ -479,12 +721,15 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c
}
usageJSON, err := json.Marshal(resp.Usage)
if err != nil {
return nil, err
return att, err
}
att.cumCost, att.runCost = cost, cost
// Деньги: settle + сырой ответ — одна транзакция (§3.3).
// Деньги: settle + сырой ответ — одна транзакция (§3.3). ВСЕГДА, даже для
// пустого/усечённого/отказного ответа: он оплачен провайдером, чекпоинт
// хранит его до цента, а годность решает classify ПОСЛЕ (деньги ≠ вердикт).
if err := r.Store.SettleWithCheckpoint(resv, cost, store.Checkpoint{
RequestHash: reqHash, JobID: job.ID, ChunkIdx: chunkIdx, Attempt: attempt,
RequestHash: reqHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: attempt,
Stage: st.Name, Role: st.Role,
ModelRequested: st.Model, ModelActual: modelActual, ResponseText: resp.Text,
UsageJSON: string(usageJSON), CostUSD: cost, FinishReason: resp.FinishReason,
@ -492,45 +737,85 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, snapID string, c
}); err != nil {
// The provider HAS billed this 2xx; failing to persist means the money
// state is behind reality — fail loud, never continue on top.
return nil, fmt.Errorf("pipeline: settle stage %s: %w", st.Name, err)
return att, fmt.Errorf("pipeline: settle stage %s: %w", st.Name, err)
}
// Пустой оплаченный ответ (finish=length: thinking съел бюджет; фильтр):
// деньги заселтлены, чекпоинт есть — но дальше по конвейеру его НЕ подаём:
// черновик="" молча отравил бы редактуру. Громкая остановка (регенерация
// по attempt — Фаза 1).
if strings.TrimSpace(resp.Text) == "" {
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: modelActual,
RequestHash: reqHash,
PromptTokens: resp.Usage.PromptTokens, CachedTokens: resp.Usage.CachedTokens,
CacheCreationTokens: resp.Usage.CacheCreationTokens,
CompletionTokens: resp.Usage.CompletionTokens, ReasoningTokens: resp.Usage.ReasoningTokens,
CostUSD: cost, LatencyMS: latency, FinishReason: resp.FinishReason,
Degraded: "empty_completion", OK: false,
})
_ = r.Store.SetJobStatus(job.ID, "failed")
return nil, fmt.Errorf("pipeline: stage %s returned an EMPTY completion (finish=%s, billed $%.6f, checkpointed) — вероятно thinking/фильтр съел бюджет; поднимите max_tokens/смените модель и удалите строку checkpoints %.12s", st.Name, resp.FinishReason, cost, reqHash)
}
// Classify AFTER the money is durably settled+checkpointed. F4 lives here: a
// 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 = classify(classifyInput{Source: ch.Text, Output: resp.Text, Finish: resp.FinishReason, TargetLang: r.Book.TargetLang})
r.Store.LogRequest(ctx, r.Log, store.RequestLog{
BookID: r.Book.BookID, Chapter: chapter, ChunkIdx: chunkIdx,
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx,
Stage: st.Name, Role: st.Role, ModelRequested: st.Model, ModelActual: modelActual,
RequestHash: reqHash,
RequestHash: reqHash,
PromptTokens: resp.Usage.PromptTokens, CachedTokens: resp.Usage.CachedTokens,
CacheCreationTokens: resp.Usage.CacheCreationTokens,
CompletionTokens: resp.Usage.CompletionTokens, ReasoningTokens: resp.Usage.ReasoningTokens,
CostUSD: cost, LatencyMS: latency, FinishReason: resp.FinishReason, OK: true,
CostUSD: cost, LatencyMS: att.latency, FinishReason: resp.FinishReason,
OK: att.cls.ok(), Degraded: degradedTag(att.cls),
})
if err := r.Store.SetJobStatus(job.ID, "done"); err != nil {
return nil, err
}
r.Log.InfoContext(ctx, "stage completed", "stage", st.Name, "model", modelActual,
"cost_usd", fmt.Sprintf("%.6f", cost), "latency_ms", latency,
"prompt_tokens", resp.Usage.PromptTokens, "completion_tokens", resp.Usage.CompletionTokens)
return &StageResult{Stage: st.Name, Role: st.Role, Model: modelActual,
Usage: resp.Usage, CostUSD: cost, LatencyMS: latency,
FinishReason: resp.FinishReason, Text: resp.Text}, nil
_ = r.Store.SetJobStatus(job.ID, "done")
r.Log.InfoContext(ctx, "attempt completed", "stage", st.Name, "attempt", attempt, "model", modelActual,
"cost_usd", fmt.Sprintf("%.6f", cost), "latency_ms", att.latency,
"disposition", string(att.cls.Reason.disposition()), "reason", string(att.cls.Reason))
return att, nil
}
// resumeFromChunkStatus serves a chunk×stage whose disposition is already
// resolved (read before any render — no re-billing). An ok stage returns its
// authoritative checkpoint text to feed the next stage; a flagged stage returns
// the flag WITHOUT re-attacking (a terminal flag never re-enters the paid loop).
func (r *Runner) resumeFromChunkStatus(ctx context.Context, st config.Stage, ch Chunk, cs *store.ChunkStatus) (*StageResult, error) {
sr := &StageResult{
Stage: st.Name, Role: st.Role, Model: st.Model, FromResume: true,
CostUSD: 0, CumCostUSD: cs.CostUSD,
Disposition: Disposition(cs.Disposition), FlagReason: FlagReason(cs.FlagReason),
Detail: cs.Detail, Attempts: cs.Attempts,
}
if cs.Disposition == string(DispOK) {
cp, err := r.Store.GetCheckpoint(cs.FinalHash)
if err != nil {
return nil, err
}
if cp == nil || strings.TrimSpace(cp.ResponseText) == "" {
// chunk_status ok is only written AFTER its checkpoint is durably
// settled, so this is a torn/tampered store — fail loud rather than
// feed the next stage an empty draft.
return nil, fmt.Errorf("pipeline: chunk_status ok for %s/ch%d/chunk%d/%s references checkpoint %.12s which is missing/empty — inconsistent store",
r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, cs.FinalHash)
}
var usage llm.Usage
_ = json.Unmarshal([]byte(cp.UsageJSON), &usage)
sr.Usage = usage
sr.Model = cp.ModelActual
sr.FinishReason = cp.FinishReason
sr.Text = cp.ResponseText
}
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: sr.Model,
RequestHash: cs.FinalHash, TMHit: true, OK: cs.Disposition == string(DispOK),
FinishReason: sr.FinishReason, Degraded: nonOKTag(cs.Disposition, cs.FlagReason),
})
r.Log.InfoContext(ctx, "stage resolved from chunk_status", "stage", st.Name,
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "disposition", cs.Disposition, "reason", cs.FlagReason)
return sr, nil
}
// degradedTag surfaces a flag reason into the request_log `degraded` column.
func degradedTag(cls classification) string {
if cls.ok() {
return ""
}
return string(cls.Reason)
}
// nonOKTag is the request_log `degraded` value for a resumed disposition row.
func nonOKTag(disposition, reason string) string {
if disposition == string(DispOK) {
return ""
}
return reason
}

View file

@ -1,8 +1,9 @@
package pipeline
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
@ -10,16 +11,18 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"sync"
"testing"
"time"
"textmachine/backend/internal/obs"
_ "modernc.org/sqlite"
)
// e2e-тест мини-раннера: temp-проект книги против httptest-провайдера. Деньги,
// чекпоинты и resume проверяются через реальные конфиги/store — то, что
// демонстрирует приёмка Фазы 0, только на моке.
// e2e-тесты книжного раннера Вехи 2: temp-проект против httptest-провайдера.
// Деньги, чекпоинты, resume, disposition и exit-коды проверяются через реальные
// конфиги/store — то, что демонстрирует приёмка, только на моке.
func writeFile(t *testing.T, path, content string) {
t.Helper()
@ -31,25 +34,89 @@ func writeFile(t *testing.T, path, content string) {
}
}
// newFakeProvider serves OpenAI-совместимые ответы: draft-стадии отвечает
// «ЧЕРНОВИК», edit-стадии — «РЕДАКТУРА» (различает по наличию слова ЧЕРНОВИК
// в промпте редактора). Считает вызовы.
func newFakeProvider(t *testing.T, calls *atomic.Int32) *httptest.Server {
// reqRec records every provider request body (thread-safe for -race).
type reqRec struct {
mu sync.Mutex
n int
bodies []string
}
func (r *reqRec) record(body string) {
r.mu.Lock()
defer r.mu.Unlock()
r.n++
r.bodies = append(r.bodies, body)
}
func (r *reqRec) count() int { r.mu.Lock(); defer r.mu.Unlock(); return r.n }
func (r *reqRec) all() []string {
r.mu.Lock()
defer r.mu.Unlock()
return append([]string(nil), r.bodies...)
}
// newJSONProvider serves an OpenAI-compatible completion whose text+finish come
// from respond(body); usage is fixed. The default draftEdit distinguishes the
// editor by the presence of the editor-prompt marker in the body.
func newJSONProvider(rec *reqRec, respond func(body string) (text, finish string)) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
body, _ := io.ReadAll(r.Body)
text := "ЧЕРНОВИК ПЕРЕВОДА"
if bytes.Contains(body, []byte("Черновик перевода для редактуры")) {
text = "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД"
rec.record(string(body))
text, finish := respond(string(body))
if finish == "" {
finish = "stop"
}
fmt.Fprintf(w, `{"id":"fake-%d","model":"fake-model","choices":[{"message":{"content":"%s"},"finish_reason":"stop"}],
tb, _ := json.Marshal(text)
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":%q}],
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`,
calls.Load(), text)
tb, finish)
}))
}
func setupProject(t *testing.T, providerURL string) string {
func isEditBody(body string) bool {
return strings.Contains(body, "Черновик перевода для редактуры")
}
func draftEdit(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "ЧЕРНОВИК ПЕРЕВОДА", "stop"
}
func maxTokensOf(t *testing.T, body string) int {
t.Helper()
var m map[string]any
if err := json.Unmarshal([]byte(body), &m); err != nil {
t.Fatalf("bad request body: %v", err)
}
v, ok := m["max_tokens"].(float64)
if !ok {
t.Fatalf("no numeric max_tokens in body: %s", body)
}
return int(v)
}
// перстоимость fake-model на вызов: (1000-200)·1 + 200·0.1 + 500·2 за 1M.
const fakeCallUSD = (800*1.0 + 200*0.1 + 500*2.0) / 1e6
type projectOpts struct {
source string
regenerate int
minMaxTokens int
bookUSD float64
}
func setupProjectOpts(t *testing.T, providerURL string, o projectOpts) string {
t.Helper()
if o.source == "" {
o.source = "静かな図書館の朝。"
}
if o.minMaxTokens == 0 {
o.minMaxTokens = 512
}
if o.bookUSD == 0 {
o.bookUSD = 1.0
}
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "prompts", "translator.md"),
@ -71,18 +138,18 @@ models:
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))
writeFile(t, filepath.Join(dir, "pipeline.yaml"), `
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: 1 }
defaults: { max_output_ratio: 2.0, min_max_tokens: %d }
retries: { regenerate_before_escalate: %d }
stages:
- { name: draft, role: translator, model: fake-model, prompt: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: "off" }
- { name: edit, role: editor, model: fake-model, prompt: prompts/editor.md, prompt_version: v-test, temperature: 0.4, reasoning: "off" }
`)
`, o.minMaxTokens, o.regenerate))
writeFile(t, filepath.Join(dir, "source.txt"), "静かな図書館の朝。")
writeFile(t, filepath.Join(dir, "book.yaml"), `
writeFile(t, filepath.Join(dir, "source.txt"), o.source)
writeFile(t, filepath.Join(dir, "book.yaml"), fmt.Sprintf(`
book_id: test-book
title: Тест
source_lang: ja
@ -96,37 +163,50 @@ footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 1.0, day_usd: 2.0 }
`)
ceilings: { book_usd: %g, day_usd: 2.0 }
`, o.bookUSD))
return filepath.Join(dir, "book.yaml")
}
func setupProject(t *testing.T, providerURL string) string {
return setupProjectOpts(t, providerURL, projectOpts{regenerate: 1})
}
func newRunner(t *testing.T, bookPath string) *Runner {
t.Helper()
r, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
return r
}
// --- existing Фаза-0/Веха-1 guarantees, ported to the book runner -----------
func TestRunnerEndToEndWithResume(t *testing.T) {
var calls atomic.Int32
srv := newFakeProvider(t, &calls)
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// Прогон 1: два вызова (draft, edit), деньги учтены.
r1, err := NewRunner(bookPath, obs.NewLogger())
r1 := newRunner(t, bookPath)
res1, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
res1, err := r1.TranslateOneChunk(ctx)
if err != nil {
t.Fatal(err)
if rec.count() != 2 {
t.Fatalf("expected 2 provider calls, got %d", rec.count())
}
if calls.Load() != 2 {
t.Fatalf("expected 2 provider calls, got %d", calls.Load())
}
if len(res1.Stages) != 2 || res1.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
if len(res1.Chunks) != 1 || res1.Flagged != 0 || res1.ExitCode() != 0 {
t.Fatalf("run1 = %+v", res1)
}
// Стоимость по формуле: (1000-200)*1 + 200*0.1 + 500*2 за 1M — на стадию.
wantStage := (800*1.0 + 200*0.1 + 500*2.0) / 1e6
if diff := res1.TotalUSD - 2*wantStage; diff > 1e-12 || diff < -1e-12 {
t.Fatalf("total = %v, want %v", res1.TotalUSD, 2*wantStage)
ch := res1.Chunks[0]
if ch.Disposition != DispOK || ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("chunk = %+v", ch)
}
if diff := res1.TotalUSD - 2*fakeCallUSD; diff > 1e-12 || diff < -1e-12 {
t.Fatalf("total = %v, want %v", res1.TotalUSD, 2*fakeCallUSD)
}
committed, reserved, err := r1.Store.SpentUSD("test-book")
if err != nil {
@ -137,121 +217,91 @@ func TestRunnerEndToEndWithResume(t *testing.T) {
}
r1.Close()
// Прогон 2 (новый процесс — новый Runner над тем же project.db): оба
// вызова обслуживаются чекпоинтами, провайдер не трогается, доплаты нет.
r2, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
// Прогон 2 (новый процесс): обе стадии из чекпоинтов/chunk_status за $0.
r2 := newRunner(t, bookPath)
defer r2.Close()
res2, err := r2.TranslateOneChunk(ctx)
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if calls.Load() != 2 {
t.Fatalf("resume must not call the provider again, calls=%d", calls.Load())
if rec.count() != 2 {
t.Fatalf("resume must not call the provider again, calls=%d", rec.count())
}
for _, st := range res2.Stages {
if res2.TotalUSD != 0 {
t.Fatalf("resume must be $0, got %v", res2.TotalUSD)
}
for _, st := range res2.Chunks[0].Stages {
if !st.FromResume || st.CostUSD != 0 {
t.Fatalf("stage %s must be served from checkpoint at $0: %+v", st.Stage, st)
t.Fatalf("stage %s must be served from resume at $0: %+v", st.Stage, st)
}
}
if res2.FinalText != res1.FinalText {
t.Fatalf("resume text differs: %q vs %q", res2.FinalText, res1.FinalText)
}
committed2, _, err := r2.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
if res2.Chunks[0].FinalText != res1.Chunks[0].FinalText {
t.Fatalf("resume text differs: %q vs %q", res2.Chunks[0].FinalText, res1.Chunks[0].FinalText)
}
committed2, _, _ := r2.Store.SpentUSD("test-book")
if committed2 != committed {
t.Fatalf("resume must not add spend: %v -> %v", committed, committed2)
}
}
// Р6: правка конфига/промпта после старта джоб — не тихая инвалидация
// чекпоинтов, а громкая ошибка; пере-перевод только явным --resnapshot.
func TestRunnerSnapshotPinning(t *testing.T) {
var calls atomic.Int32
srv := newFakeProvider(t, &calls)
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := context.Background()
r1, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
if _, err := r1.TranslateOneChunk(ctx); err != nil {
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
if calls.Load() != 2 {
t.Fatalf("run1 calls = %d", calls.Load())
if rec.count() != 2 {
t.Fatalf("run1 calls = %d", rec.count())
}
// Меняем промпт переводчика → новый snapshot.
promptPath := filepath.Join(filepath.Dir(bookPath), "prompts", "translator.md")
writeFile(t, promptPath, "НОВЫЙ промпт с {{source_lang}}.\n---USER---\n{{text}}")
r2, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
_, err = r2.TranslateOneChunk(ctx)
r2 := newRunner(t, bookPath)
_, err := r2.TranslateBook(ctx)
r2.Close()
if err == nil || !strings.Contains(err.Error(), "resnapshot") {
t.Fatalf("changed config must fail loud mentioning --resnapshot, got: %v", err)
}
if calls.Load() != 2 {
t.Fatalf("denied resume must not call the provider, calls=%d", calls.Load())
if rec.count() != 2 {
t.Fatalf("denied resume must not call the provider, calls=%d", rec.count())
}
// Явное согласие: джобы перепривязываются, вызовы повторяются и оплачиваются.
r3, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
r3 := newRunner(t, bookPath)
defer r3.Close()
r3.Resnapshot = true
res, err := r3.TranslateOneChunk(ctx)
res, err := r3.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if calls.Load() != 4 {
t.Fatalf("resnapshot run must re-call both stages, calls=%d", calls.Load())
if rec.count() != 4 {
t.Fatalf("resnapshot run must re-call both stages, calls=%d", rec.count())
}
if res.TotalUSD <= 0 {
t.Fatal("re-translation must be billed")
}
}
// D5.2/B: правка ТОЛЬКО каппы модели (wire-форма) обязана сдвинуть snapshot и
// уронить resume громко — иначе изменённое тело запроса подалось бы из старого
// чекпоинта (тихий расходящийся ре-пэй). Гейт на то, что stageSnap реально
// сворачивает резолвнутую Capability: без `ss.Capability` этот тест бы прошёл
// resume молча (находка селфревью вехи 1).
// D5.2/B: правка ТОЛЬКО каппы модели двигает snapshot и роняет resume громко.
func TestRunnerSnapshotPinsCapability(t *testing.T) {
var calls atomic.Int32
srv := newFakeProvider(t, &calls)
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := context.Background()
r1, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
if _, err := r1.TranslateOneChunk(ctx); err != nil {
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
if calls.Load() != 2 {
t.Fatalf("run1 calls = %d", calls.Load())
}
// Меняем ТОЛЬКО каппу fake-model (temperature force) — промпт/сэмплинг/
// провайдер/цены те же. Резолвнутая Capability меняется → snapshotID обязан
// сдвинуться → resume падает на snapshot-pinning, а не подаёт чекпоинт.
modelsPath := filepath.Join(filepath.Dir(bookPath), "models.yaml")
raw, err := os.ReadFile(modelsPath)
if err != nil {
@ -265,22 +315,17 @@ func TestRunnerSnapshotPinsCapability(t *testing.T) {
}
writeFile(t, modelsPath, patched)
r2, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
_, err = r2.TranslateOneChunk(ctx)
r2 := newRunner(t, bookPath)
_, err = r2.TranslateBook(ctx)
r2.Close()
if err == nil || !strings.Contains(err.Error(), "resnapshot") {
t.Fatalf("a capability edit must fail loud mentioning --resnapshot (snapshot must fold the resolved capability), got: %v", err)
t.Fatalf("a capability edit must fail loud mentioning --resnapshot, got: %v", err)
}
if calls.Load() != 2 {
t.Fatalf("denied resume must not call the provider, calls=%d", calls.Load())
if rec.count() != 2 {
t.Fatalf("denied resume must not call the provider, calls=%d", rec.count())
}
}
// Платный 2xx с нулевым usage не должен селтлиться в $0 (иначе потолок слепнет):
// берётся консервативная оценка резерва.
func TestRunnerZeroUsagePaidSettlesEstimate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"id":"z","model":"deepseek-v4-flash","choices":[{"message":{"content":"перевод"},"finish_reason":"stop"}],
@ -289,36 +334,27 @@ func TestRunnerZeroUsagePaidSettlesEstimate(t *testing.T) {
defer srv.Close()
bookPath := setupProject(t, srv.URL)
r, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateOneChunk(context.Background())
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if res.TotalUSD <= 0 {
t.Fatalf("zero-usage paid 2xx must settle a non-zero estimate, got $%.6f", res.TotalUSD)
}
committed, _, err := r.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
committed, _, _ := r.Store.SpentUSD("test-book")
if committed != res.TotalUSD {
t.Fatalf("committed %v != total %v", committed, res.TotalUSD)
}
}
// NewRunner обязан отклонить конфиг с нереализованной механикой ДО открытия
// store и любых вызовов (guard границы «конфиг vs код»).
func TestRunnerRejectsUnrunnableConfig(t *testing.T) {
var calls atomic.Int32
srv := newFakeProvider(t, &calls)
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
// Ломаем pipeline на fanout>1 (механика Фазы 2).
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pipePath)
if err != nil {
@ -329,35 +365,449 @@ func TestRunnerRejectsUnrunnableConfig(t *testing.T) {
if _, err := NewRunner(bookPath, obs.NewLogger()); err == nil {
t.Fatal("fanout.candidates>1 must be rejected by NewRunner (Phase-2 mechanics)")
}
if calls.Load() != 0 {
t.Fatalf("rejected config must not reach the provider, calls=%d", calls.Load())
if rec.count() != 0 {
t.Fatalf("rejected config must not reach the provider, calls=%d", rec.count())
}
}
func TestRunnerCeilingDenies(t *testing.T) {
var calls atomic.Int32
srv := newFakeProvider(t, &calls)
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
bookPath := setupProjectOpts(t, srv.URL, projectOpts{bookUSD: 0.0000001})
// Потолок $0: первый же reserve обязан отказать ДО вызова провайдера.
raw, err := os.ReadFile(bookPath)
if err != nil {
t.Fatal(err)
}
patched := strings.Replace(string(raw), "book_usd: 1.0", "book_usd: 0.0000001", 1)
writeFile(t, bookPath, patched)
r, err := NewRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
r := newRunner(t, bookPath)
defer r.Close()
_, err = r.TranslateOneChunk(context.Background())
_, err := r.TranslateBook(context.Background())
if err == nil {
t.Fatal("ceiling must deny the run")
}
if calls.Load() != 0 {
t.Fatalf("denied reserve must not reach the provider, calls=%d", calls.Load())
if rec.count() != 0 {
t.Fatalf("denied reserve must not reach the provider, calls=%d", rec.count())
}
}
// --- Веха 2: chunk loop, disposition, resume, exit codes --------------------
// Multi-chunk (two chapters via form feed) end-to-end + resume at $0.
func TestRunnerMultiChunkResume(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАОДИН\fГЛАВАДВА"})
ctx := context.Background()
r1 := newRunner(t, bookPath)
res1, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
r1.Close()
if len(res1.Chunks) != 2 {
t.Fatalf("want 2 chunks (2 chapters), got %d", len(res1.Chunks))
}
if res1.Chunks[0].Chapter != 1 || res1.Chunks[1].Chapter != 2 {
t.Fatalf("chapters = %d,%d", res1.Chunks[0].Chapter, res1.Chunks[1].Chapter)
}
if rec.count() != 4 { // 2 chunks × (draft+edit)
t.Fatalf("want 4 calls, got %d", rec.count())
}
r2 := newRunner(t, bookPath)
defer r2.Close()
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != 4 {
t.Fatalf("resume must not re-call, calls=%d", rec.count())
}
if res2.TotalUSD != 0 || res2.Flagged != 0 || res2.ExitCode() != 0 {
t.Fatalf("resume run = %+v", res2)
}
}
// D2 core: a flagged chunk skips its remaining stages and the loop continues to
// the next chunk; the run completes with exit code 2, and resume does not
// re-attack the flagged chunk.
func TestRunnerFlagSkipContinueExit2(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if strings.Contains(body, "ОТКАЗНАЯГЛАВА") && !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop" // soft refusal
}
return draftEdit(body)
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗНАЯГЛАВА\fНОРМАЛЬНАЯГЛАВА"})
ctx := context.Background()
r1 := newRunner(t, bookPath)
res1, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatalf("a flagged chunk must NOT be an error (loop continues), got %v", err)
}
// draft(ch1)=refusal → edit(ch1) skipped (no call); draft(ch2)+edit(ch2) ok = 3 calls.
if rec.count() != 3 {
t.Fatalf("want 3 calls (edit of flagged chunk skipped), got %d", rec.count())
}
if res1.Flagged != 1 || res1.ExitCode() != 2 {
t.Fatalf("want 1 flagged chunk / exit 2, got flagged=%d exit=%d", res1.Flagged, res1.ExitCode())
}
c1 := res1.Chunks[0]
if c1.Disposition != DispFlagged || c1.FlagReason != FlagSoftRefusal || c1.FinalText != "" {
t.Fatalf("chunk1 = %+v (want flagged/soft_refusal/no text)", c1)
}
if c1.Stages[1].Disposition != DispSkipped {
t.Fatalf("edit of a flagged chunk must be skipped, got %+v", c1.Stages[1])
}
if res1.Chunks[1].Disposition != DispOK {
t.Fatalf("chunk2 must be ok, got %+v", res1.Chunks[1])
}
// chunk_status persisted the flag and the skip.
cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "draft")
if cs == nil || cs.Disposition != "flagged" || cs.FlagReason != "soft_refusal" {
t.Fatalf("draft chunk_status = %+v", cs)
}
skip, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "edit")
if skip == nil || skip.Disposition != "skipped" {
t.Fatalf("edit chunk_status = %+v", skip)
}
r1.Close()
// Resume: the flagged chunk is resolved from chunk_status, NOT re-attacked.
r2 := newRunner(t, bookPath)
defer r2.Close()
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != 3 {
t.Fatalf("resume must not re-attack a flagged chunk, calls=%d", rec.count())
}
if res2.Flagged != 1 || res2.TotalUSD != 0 || res2.ExitCode() != 2 {
t.Fatalf("resume run = %+v", res2)
}
}
// F4: a non-empty truncated (finish=length) draft must be FLAGGED, never passed
// downstream to the editor as OK. With no regenerations it flags immediately.
func TestRunnerF4TruncatedLengthNotPassedToEdit(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if !isEditBody(body) {
return "Обрыв на середине предложения", "length" // truncated, non-empty, not a loop
}
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
ctx := context.Background()
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatalf("a truncated draft must flag, not crash: %v", err)
}
if rec.count() != 1 {
t.Fatalf("edit must NOT run on a flagged draft, calls=%d", rec.count())
}
ch := res.Chunks[0]
if ch.Disposition != DispFlagged || ch.FlagReason != FlagLength {
t.Fatalf("chunk must be flagged length, got %+v", ch)
}
if ch.Stages[0].Disposition != DispFlagged || ch.Stages[1].Disposition != DispSkipped {
t.Fatalf("draft flagged + edit skipped expected, got %+v", ch.Stages)
}
}
// D2.3 + F3 + attempt-axis: a length cut is retried with a DOUBLED budget; the
// second attempt succeeds, the editor then runs, and cost sums both attempts.
func TestRunnerLengthRetrySucceedsDoublesBudget(t *testing.T) {
rec := &reqRec{}
src := strings.Repeat("字", 500) // EstimateTokens=500 → base max_tokens=1000
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
if maxTokensOf(t, body) <= 1000 { // attempt 0 (base)
return "Обрыв на середине", "length"
}
return "ПОЛНЫЙ ЧЕРНОВИК", "stop" // attempt 1 (doubled budget)
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, regenerate: 1, minMaxTokens: 128})
ctx := context.Background()
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
ch := res.Chunks[0]
if ch.Disposition != DispOK || ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" {
t.Fatalf("retry should succeed and edit should run, got %+v", ch)
}
draft := ch.Stages[0]
if draft.Attempts != 2 {
t.Fatalf("draft must have taken 2 attempts, got %d", draft.Attempts)
}
// F3: cost sums BOTH attempts.
if diff := draft.CumCostUSD - 2*fakeCallUSD; diff > 1e-12 || diff < -1e-12 {
t.Fatalf("draft cum cost = %v, want 2×call %v (attempts summed)", draft.CumCostUSD, 2*fakeCallUSD)
}
// The two draft attempts doubled the budget: 1000 then 2000.
var draftToks []int
for _, b := range rec.all() {
if !isEditBody(b) {
draftToks = append(draftToks, maxTokensOf(t, b))
}
}
if len(draftToks) != 2 || draftToks[0] != 1000 || draftToks[1] != 2000 {
t.Fatalf("attempt budgets = %v, want [1000 2000]", draftToks)
}
}
// D2.5: the editor's max_tokens is sized from the (long Russian) DRAFT, not the
// (short CJK) source — otherwise it under-budgets and false-triggers a length
// retry. Broken D2.5 would make the two budgets equal.
func TestRunnerEditSizedFromDraft(t *testing.T) {
rec := &reqRec{}
longDraft := strings.Repeat("Утро было тихим и ясным над рекой. ", 120) // long Russian output
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОК", "stop"
}
return longDraft, "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: strings.Repeat("字", 100), minMaxTokens: 128})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
var draftTok, editTok int
for _, b := range rec.all() {
if isEditBody(b) {
editTok = maxTokensOf(t, b)
} else {
draftTok = maxTokensOf(t, b)
}
}
if editTok <= draftTok {
t.Fatalf("edit max_tokens (%d) must exceed draft's (%d) — sized from the longer draft (D2.5)", editTok, draftTok)
}
}
// Anti-wedge #2: an empty completion is FLAGGED and the loop continues — not a
// crash (the Фаза-0 behaviour was to error out the whole run).
func TestRunnerEmptyFlaggedNotCrash(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if !isEditBody(body) {
return "", "stop" // empty draft
}
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatalf("empty completion must flag, not crash: %v", err)
}
ch := res.Chunks[0]
if ch.Disposition != DispFlagged || ch.FlagReason != FlagEmpty {
t.Fatalf("empty draft must be flagged empty, got %+v", ch)
}
if res.ExitCode() != 2 {
t.Fatalf("exit code must be 2, got %d", res.ExitCode())
}
// Money for the paid-but-empty call is still accounted.
committed, _, _ := r.Store.SpentUSD("test-book")
if committed <= 0 {
t.Fatalf("the paid empty call must still be billed, committed=%v", committed)
}
}
// Anti-wedge #2: a billed 2xx with an unreadable body → flagged decode_error
// with the estimate settled, loop continues (not a crash).
func TestRunnerDecodeErrorFlagged(t *testing.T) {
var draftCalls int
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
if !isEditBody(string(body)) {
mu.Lock()
draftCalls++
mu.Unlock()
fmt.Fprint(w, `NOT JSON AT ALL`) // 200 with a garbage body → BilledDecodeError
return
}
fmt.Fprint(w, `{"id":"e","model":"fake-model","choices":[{"message":{"content":"ред"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
}))
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatalf("a billed decode failure must flag, not crash: %v", err)
}
ch := res.Chunks[0]
if ch.Disposition != DispFlagged || ch.FlagReason != FlagDecodeError {
t.Fatalf("draft must be flagged decode_error, got %+v", ch)
}
if ch.Stages[1].Disposition != DispSkipped {
t.Fatalf("edit must be skipped after a decode flag, got %+v", ch.Stages[1])
}
if res.TotalUSD <= 0 {
t.Fatal("the conservative estimate must be settled for a billed decode failure")
}
}
// Determinism content-guard (self-review Веха 2): the source is NOT in the
// snapshot, so editing a chunk's source between runs must RE-TRANSLATE that
// position (content-addressed, §3.4), never serve the stale positional
// chunk_status row. Without the content_hash guard the fast-path would return
// the OLD translation at $0.
func TestRunnerSourceEditReTranslates(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "СТАРЫЙИСХОДНИК"})
ctx := context.Background()
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
callsAfterRun1 := rec.count()
// Edit the source in place (same position, still one chunk). No brief field
// and not the chunker changed, so the snapshot is unchanged — the ONLY thing
// that must invalidate resume is the content itself.
writeFile(t, filepath.Join(filepath.Dir(bookPath), "source.txt"), "НОВЫЙИСХОДНИК")
r2 := newRunner(t, bookPath)
defer r2.Close()
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
// Both stages re-render with the new content (the editor sees {{text}} too),
// miss their old checkpoints, and are re-translated and re-billed.
if rec.count() != callsAfterRun1+2 {
t.Fatalf("an edited source must re-translate the chunk, not serve stale: calls %d -> %d", callsAfterRun1, rec.count())
}
if res.TotalUSD <= 0 {
t.Fatalf("re-translation of an edited source must be billed, got $%.6f", res.TotalUSD)
}
}
// Anti-wedge #1: a terminally-flagged chunk is resolved from chunk_status BEFORE
// rendering, so it is NOT re-attacked even when the retry budget is later raised
// (RegenerateBeforeEscalate is not in the snapshot). Without the chunk_status
// fast-path the attempt loop would spawn NEW paid attempts up to the new cap.
func TestRunnerFlaggedNotReattackedWhenRegenRaised(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if !isEditBody(body) {
return "Обрыв на середине", "length" // always a length cut
}
return "ред", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
ctx := context.Background()
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
callsAfterRun1 := rec.count() // regen=0 → exactly 1 draft attempt, flagged length
// Raise the regeneration budget and re-run. The flagged chunk must stay put.
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), "regenerate_before_escalate: 0", "regenerate_before_escalate: 2", 1))
r2 := newRunner(t, bookPath)
defer r2.Close()
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("a terminally-flagged chunk must NOT be re-attacked on a raised budget: calls %d -> %d", callsAfterRun1, rec.count())
}
if res.Chunks[0].Disposition != DispFlagged || res.Chunks[0].FlagReason != FlagLength {
t.Fatalf("chunk must stay flagged length, got %+v", res.Chunks[0])
}
}
// Anti-wedge #3: a checkpoint present without its chunk_status row (kill -9 lost
// the resolve) self-heals — classify the checkpoint text, no re-billing, no
// crash. Proven by deleting the chunk_status row and re-running with regen=0 so
// the empty flag cannot spawn a new paid attempt.
func TestRunnerLegacyEmptyCheckpointSelfHeal(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if !isEditBody(body) {
return "", "stop" // empty draft → empty checkpoint
}
return "ред", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})
ctx := context.Background()
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
dbPath := r1.Book.ProjectDB
r1.Close()
callsAfterRun1 := rec.count()
// Simulate the resolve being lost while the (empty) checkpoint survives.
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()
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatalf("legacy empty checkpoint must self-heal, not crash: %v", err)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("self-heal must NOT re-bill the empty attempt: calls %d -> %d", callsAfterRun1, rec.count())
}
ch := res.Chunks[0]
if ch.Disposition != DispFlagged || ch.FlagReason != FlagEmpty {
t.Fatalf("self-heal must re-derive the empty flag, got %+v", ch)
}
// chunk_status was rebuilt.
cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft")
if cs == nil || cs.Disposition != "flagged" {
t.Fatalf("chunk_status must be rebuilt as flagged, got %+v", cs)
}
}

View file

@ -0,0 +1,106 @@
package store
import (
"database/sql"
"errors"
)
// chunkstatus.go: the per-chunk×stage disposition record (Веха 2, D2). It is a
// materialized RESOLVE over the append-only checkpoints (keyed by request_hash,
// which already carries `attempt`), NOT a column on checkpoints. Because it is
// derivable from the checkpoints, losing it to a kill -9 self-heals: on resume
// the runner re-classifies the checkpoint text (no re-billing) and re-upserts
// the row. `disposition` is the axis the runner loop reads BEFORE rendering, so
// a terminally-flagged chunk is never re-attacked into an infinite paid loop.
//
// The store is dumb storage here: disposition/flag_reason are plain strings; the
// typed FlagReason/Disposition constants and the classifier live in the pipeline
// package.
// ChunkStatus is one (book, chapter, chunk, stage) disposition row.
type ChunkStatus struct {
BookID string
Chapter int
ChunkIdx int
Stage string
SnapshotID string
ContentHash string // signature of the rendered msgs (the source is NOT in the snapshot); guards the resume fast-path against serving a stale translation after a source edit
Disposition string // ok | flagged | skipped
FlagReason string // "" when ok
Attempts int // number of attempts made for this chunk×stage
FinalHash string // request_hash of the authoritative checkpoint (ok path)
CostUSD float64 // sum across all attempts (F3-honest)
Detail string
}
// UpsertChunkStatus writes (or overwrites) the disposition row. Overwrite is the
// resolve semantics: re-running classify over the same checkpoints reproduces the
// same row, and a --resnapshot re-processing legitimately replaces a stale row.
func (s *Store) UpsertChunkStatus(cs ChunkStatus) error {
ctx, cancel := opContext()
defer cancel()
_, err := s.w.ExecContext(ctx, `
INSERT INTO chunk_status (
book_id, chapter, chunk_idx, stage, snapshot_id, content_hash,
disposition, flag_reason, attempts, final_hash, cost_usd, detail, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (book_id, chapter, chunk_idx, stage) DO UPDATE SET
snapshot_id = excluded.snapshot_id,
content_hash = excluded.content_hash,
disposition = excluded.disposition,
flag_reason = excluded.flag_reason,
attempts = excluded.attempts,
final_hash = excluded.final_hash,
cost_usd = excluded.cost_usd,
detail = excluded.detail,
updated_at = excluded.updated_at`,
cs.BookID, cs.Chapter, cs.ChunkIdx, cs.Stage, cs.SnapshotID, cs.ContentHash,
cs.Disposition, cs.FlagReason, cs.Attempts, cs.FinalHash, cs.CostUSD, cs.Detail)
return err
}
// GetChunkStatus returns the disposition row for a chunk×stage, or nil if none.
func (s *Store) GetChunkStatus(bookID string, chapter, chunkIdx int, stage string) (*ChunkStatus, error) {
ctx, cancel := opContext()
defer cancel()
cs := ChunkStatus{BookID: bookID, Chapter: chapter, ChunkIdx: chunkIdx, Stage: stage}
err := s.r.QueryRowContext(ctx, `
SELECT snapshot_id, content_hash, disposition, flag_reason, attempts, final_hash, cost_usd, detail
FROM chunk_status
WHERE book_id = ? AND chapter = ? AND chunk_idx = ? AND stage = ?`,
bookID, chapter, chunkIdx, stage).Scan(
&cs.SnapshotID, &cs.ContentHash, &cs.Disposition, &cs.FlagReason, &cs.Attempts, &cs.FinalHash, &cs.CostUSD, &cs.Detail)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &cs, nil
}
// ChunkStatusesForBook returns every disposition row for a book, ordered for a
// stable report (tmctl report: the flag section).
func (s *Store) ChunkStatusesForBook(bookID string) ([]ChunkStatus, error) {
ctx, cancel := opContext()
defer cancel()
rows, err := s.r.QueryContext(ctx, `
SELECT chapter, chunk_idx, stage, snapshot_id, content_hash, disposition, flag_reason,
attempts, final_hash, cost_usd, detail
FROM chunk_status WHERE book_id = ?
ORDER BY chapter, chunk_idx, stage`, bookID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ChunkStatus
for rows.Next() {
cs := ChunkStatus{BookID: bookID}
if err := rows.Scan(&cs.Chapter, &cs.ChunkIdx, &cs.Stage, &cs.SnapshotID, &cs.ContentHash,
&cs.Disposition, &cs.FlagReason, &cs.Attempts, &cs.FinalHash, &cs.CostUSD, &cs.Detail); err != nil {
return nil, err
}
out = append(out, cs)
}
return out, rows.Err()
}

View file

@ -0,0 +1,63 @@
package store
import "testing"
func TestChunkStatusUpsertGetOverwrite(t *testing.T) {
s, _ := openTemp(t)
if got, err := s.GetChunkStatus("book", 1, 0, "draft"); err != nil || got != nil {
t.Fatalf("empty store must return (nil,nil), got %+v err=%v", got, err)
}
cs := ChunkStatus{
BookID: "book", Chapter: 1, ChunkIdx: 0, Stage: "draft", SnapshotID: "snap1",
Disposition: "flagged", FlagReason: "length", Attempts: 2, FinalHash: "",
CostUSD: 0.0034, Detail: "truncated at max_tokens",
}
if err := s.UpsertChunkStatus(cs); err != nil {
t.Fatal(err)
}
got, err := s.GetChunkStatus("book", 1, 0, "draft")
if err != nil {
t.Fatal(err)
}
if got == nil || got.Disposition != "flagged" || got.FlagReason != "length" || got.Attempts != 2 || got.CostUSD != 0.0034 {
t.Fatalf("round-trip mismatch: %+v", got)
}
// Overwrite (the resolve semantics): a later verdict replaces the row.
cs.Disposition, cs.FlagReason, cs.FinalHash, cs.CostUSD = "ok", "", "hashABC", 0.0068
if err := s.UpsertChunkStatus(cs); err != nil {
t.Fatal(err)
}
got, _ = s.GetChunkStatus("book", 1, 0, "draft")
if got.Disposition != "ok" || got.FlagReason != "" || got.FinalHash != "hashABC" || got.CostUSD != 0.0068 {
t.Fatalf("overwrite failed: %+v", got)
}
}
func TestChunkStatusesForBookOrdered(t *testing.T) {
s, _ := openTemp(t)
rows := []ChunkStatus{
{BookID: "b", Chapter: 2, ChunkIdx: 0, Stage: "draft", SnapshotID: "s", Disposition: "ok"},
{BookID: "b", Chapter: 1, ChunkIdx: 1, Stage: "edit", SnapshotID: "s", Disposition: "skipped"},
{BookID: "b", Chapter: 1, ChunkIdx: 0, Stage: "draft", SnapshotID: "s", Disposition: "flagged", FlagReason: "soft_refusal"},
{BookID: "other", Chapter: 1, ChunkIdx: 0, Stage: "draft", SnapshotID: "s", Disposition: "ok"},
}
for _, r := range rows {
if err := s.UpsertChunkStatus(r); err != nil {
t.Fatal(err)
}
}
got, err := s.ChunkStatusesForBook("b")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("want 3 rows for book b (other filtered), got %d", len(got))
}
// Ordered by (chapter, chunk_idx, stage).
if got[0].Chapter != 1 || got[0].ChunkIdx != 0 || got[1].ChunkIdx != 1 || got[2].Chapter != 2 {
t.Fatalf("ordering wrong: %+v", got)
}
}

View file

@ -101,6 +101,31 @@ var migrations = []string{
);
CREATE INDEX IF NOT EXISTS request_log_ts_idx ON request_log (ts);
`,
// v2: per-chunk×stage disposition (Веха 2, D2). Резолв ПОВЕРХ append-only
// checkpoints (keyed by request_hash-с-attempt) — не колонка на checkpoints:
// на resume строка пересобирается из чекпоинтов, поэтому её потеря на kill -9
// самолечится (classify по тексту чекпоинта, без ре-биллинга). jobs.status
// контентом НЕ расширяется — failed остаётся инфра-сбоем; плохой чанк живёт
// здесь как disposition, а цикл продолжается.
`
CREATE TABLE IF NOT EXISTS chunk_status (
book_id TEXT NOT NULL,
chapter INTEGER NOT NULL,
chunk_idx INTEGER NOT NULL,
stage TEXT NOT NULL,
snapshot_id TEXT NOT NULL, -- под каким снапшотом резолвнут (stale после --resnapshot игнорируется)
content_hash TEXT NOT NULL DEFAULT '',-- сигнатура отрендеренных msgs (исходник НЕ в снапшоте): правка src делает позиционную строку недействительной, а не подаёт устаревший перевод
disposition TEXT NOT NULL, -- ok | flagged | skipped
flag_reason TEXT NOT NULL DEFAULT '', -- '' когда ok; Go-константа FlagReason
attempts INTEGER NOT NULL DEFAULT 0,
final_hash TEXT NOT NULL DEFAULT '', -- request_hash авторитетного чекпоинта (ok-путь подаёт его текст дальше)
cost_usd REAL NOT NULL DEFAULT 0, -- сумма по ВСЕМ попыткам (F3-честно: ретраи учтены)
detail TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (book_id, chapter, chunk_idx, stage)
);
CREATE INDEX IF NOT EXISTS chunk_status_book_idx ON chunk_status (book_id, disposition);
`,
}
// migrate runs all pending migrations on the write pool, one transaction per

View file

@ -257,6 +257,20 @@ keep-alive (Ф12), инъекция глоссария (`selective`), пор
**Веха 2 (следующая):** runner chapter/chunk-loop + `chunk_status`/disposition (A, D2) — `flag_reason`-константы, `classify(text,finish)` (refusal/echo до length), ось attempt, exit-коды 0/2/1, фиксы F4 (усечённый length → flagged) + edit-`max_tokens` от длины черновика.
### 2026-07-04 — Сессия 4: Веха 2 Фазы 1 — chunk-loop + disposition (A, D2) + фиксы F4/D2.5
Снят хардкод «один чанк»: прогон идёт по всей книге, плохой кусок помечается и цикл продолжается. Всё зелёное: `go build/vet/test -race`, `tmctl report` ($0). Живые LLM не звались (моки + tmctl $0, как предписано).
**Цикл по чанкам (шов под шаг 3).** `TranslateOneChunk`(chapter=1/chunk=0 хардкод) → `TranslateBook``translateChunk` (цикл стадий) → `runStage``runAttempt` (ось attempt). `RunResult``ChunkOutcome` (per-chunk) + `BookResult` (агрегат книги). Чанкер — **минимальный детерминированный плейсхолдер** (`chunker.go` `SplitChunks`: разбивка на главы по form-feed, паковка абзацев ≤1500 симв., абзац не режется); настоящий лингвистический чанкер — шаг 3, подменит `SplitChunks` не трогая цикл/disposition. `chunkerVersion` бампнут `v1-wholefile``v2-ff-para-pack` (в snapshot; ре-чанкинг = осознанный `--resnapshot`).
**Схема disposition (D2, контракт-несущее).** v2-миграция (append, IF NOT EXISTS): `chunk_status(book_id,chapter,chunk_idx,stage,snapshot_id,content_hash,disposition,flag_reason,attempts,final_hash,cost_usd,detail)`, PK (book,chapter,chunk,stage). Это **резолв поверх append-only checkpoints**, не колонка на checkpoints — на resume пересобирается из чекпоинтов. `disposition ∈ {ok,flagged,skipped}`; `jobs.status` контентом НЕ расширён (failed = только инфра). `flag_reason` — Go-константы как `Finish*` (12 шт.; эмитятся length/empty/loop_degenerate/hard_refusal/soft_refusal/content_filter/cjk_artifact/decode_error, зарезервированы coverage_fail/excision_suspect/hard_block/upstream_not_ok под шаги 67). Единый `classify(src,out,finish,target)` — порядок: refusal-блэклист (порт `refusal_bench.py` 1:1, en/ru/zh/ja) / CJK-echo **ДО** length/empty (усечённый отказ не триггерит платную переатаку); `content_filter` best-effort (реальная страховка — блэклист). n-gram loop-чек ПЕРЕД удвоением max_tokens. `maxTokensForAttempt` — чистая функция (удвоение per attempt), её версия в snapshot (`maxTokensPolicy`). Retry-flagged только {length,empty} на той же модели по оси attempt (до `regenerate_before_escalate`, потом флаг); refusal/filter/cjk/loop/decode детерминированы → не переатакуются. Три анти-веджа отработали (**mutation-verified**): resume читает `chunk_status` до вызова; empty/decode → flagged (цикл идёт, не крэш); legacy-пустой чекпоинт самолечится classify-ем без ре-биллинга. Exit: `CompletedWithFlags` sentinel → 0 чисто / 2 с флагами (N допустимо) / 1 инфра.
**Фиксы.** **F4:** усечённый `length` с непустым черновиком теперь классифицируется (flagged/retry), не течёт в edit как OK (`classify` после settle). **D2.5:** edit-`max_tokens` от длины ЧЕРНОВИКА (`prev`), не исходника — монолингвальный редактор работает по русскому черновику ≈1.9× исходника (mutation-verified). **F3 честно:** `chunk_status.cost_usd` суммирует все попытки; per-run cost отделён от кумулятивного; idempotency-ключ не делал (отложено).
**Селфревью (адверсариальный воркфлоу, 5 измерений → верификация каждой находки).** disposition-money/resume/classifier — **0 дефектов**. 2 подтверждённых исправлено (оба mutation-verified): (1) *[детерминизм, medium]* `chunk_status` fast-path резолвил ПОЗИЦИОННО и подавал устаревший перевод при правке исходника (исходник не в snapshot) — фикс: сигнатура `content_hash` отрендеренных msgs в `chunk_status`, fast-path доверяет строке только при совпадении snapshot И content (иначе — content-safe attempt-loop; правка src = тихий per-chunk ре-перевод по §3.4, не устаревший подача); стоило переноса дешёвого рендера ДО чекпоинт-резолва (LLM-вызов по-прежнему загейчен — «не переатаковать» сохранено). (2) *[CLI, low]* `flag.ExitOnError``os.Exit(2)` на кривом флаге, коллизия с exit-2 «с флагами» → `ContinueOnError` (кривой флаг = exit 1). Тесты: 12 новых (disposition/chunker/chunkstatus юниты + e2e мульти-чанк/resume/flag+skip/exit-коды/F4/D2.5/decode/legacy-self-heal/source-edit), все mutation-проверены на укус.
**Техдолг вперёд (не блокирует шаг 3):** чанкер — плейсхолдер (шаг 3 подменит `SplitChunks`, чтит контракт `Chunk` + бампает `chunkerVersion`); небилленный upstream-сбой = инфра-аболт (D4 pause/resume), `upstream_not_ok`/`hard_block`-константы под HTTP-классификацию шага 7; редкий угол: kill-9 в микро-окне (потеря `chunk_status`) ПЛЮС одновременное снижение `regenerate_before_escalate` может консервативно пере-флагнуть бывший-ok чанк (без денег/расхождения — фаст-путь чинит норму, self-heal best-effort). `jobs.status` теперь наблюдательный (ничего на нём не ветвится).
## Полигон
(секция параллельной сессии — записи добавлять сюда)
@ -299,7 +313,8 @@ keep-alive (Ф12), инъекция глоссария (`selective`), пор
- [—] **Банк памяти (задача 5 + шире)** — по решению владельца выносится в **отдельную сессию «Валидация банка памяти»** (главная точка отказа пайплайна; не бросаться в код без разбора опасных сценариев). Полигон подготовил черновик промта → `docs/MEMORY_RESEARCH_SESSION_PROMPT.md`; **оркестратору вычитать/финализировать**, затем владелец запускает. Замер эмбеддингов (bge-m3 vs Qwen3-Embedding vs LaBSE) — часть мандата той сессии.
- [ ] Довалидация токен-калибровки на 35 главах реальных вебновелл (файлы владельца).
- [ ] Задача 4 (пилот выбора ядра, Фаза 2.5): протокол BWS + панель LLM-судей.
- [ ] **Эксперимент 05 — memory-bet (индикативный), готовая спека ниже** — решает go/no-go на ставку банка памяти; runnable СЕЙЧАС без бэкенда (чистый eval).
- [x] ~~**Эксперимент 05 — memory-bet**~~**СНЯТ как низкосигнальный** (решение владельца). Прогон был, но его ось C0-vs-C1 («бредит ли модель без глоссария») предрешена, а C3/C2 лишь пере-подтвердили уже процитированную литературу (2510.00829) — тест не мог изменить ни одного решения. Артефакты (doc/скрипт/данные) удалены. Единственный actionable-вывод (post-check + disposition обязательны) уже зафиксирован в реестре `architecture/06` (A2/E1) независимо от этого прогона. **Урок для eval:** тест ставить только если его исход мог бы перевернуть решение И не установлен литературой. Ценность вместо этого — валидация самого МЕХАНИЗМА (см. ниже).
- [x] **Референс горячего пути банка памяти + self-tests**`eval/memory_hotpath.py` (11/11 PASS). Исполняемая спека механизма (не продукт): нормализация trad→simp/kana, точный матч ключей/алиасов с запретом одиночных (омографы 送灶/炎/修/气 НЕ инъектируются), спойлер-фильтр `since_ch/until_ch` (hard-reject), sticky для местоименных чанков, disposition confirmed/ambiguous/reject, post-check (ловит и утечку, и отравление). **Бэкенду:** порт в Go 1:1, тесты T1T10 → unit-тесты; `[PROD]`-замены: OpenCC, Aho-Corasick, pymorphy/`decl`.
> **[ЗАДАЧА ОРКЕСТРАТОРА → eval] Эксперимент 05: memory-bet (индикативный).** Отвечает на главный незакрытый вопрос валидации (research/13 §Вердикт): оправдан ли банк памяти vs просто длинный контекст, и вредит ли ошибочная инъекция (страх владельца). **Runnable сейчас, продукт не нужен** — это конструирование промптов + API-вызовы + метрики.
> **Метод:** один стек `deepseek-chat` (черновик) → `grok-4.3` (редактор, thinking OFF, temp 0.4 — квирки в `00-provider-quirks`). Реальные zh→ru чанки: `eval/data/samples/zh/luxun-ah-q-ch1-4.txt` (повторяющиеся имена 阿Q/赵太爷/王胡/送灶; есть русский эталон Рогова `samples/ru/luxun-ah-q-ru.txt` для якоря верности). Нарезать ~58 перекрывающихся чанков; собрать мини-глоссарий (510 записей: имена+реалии, с dst и `decl`). **4 условия на чанк:**
@ -367,3 +382,7 @@ keep-alive (Ф12), инъекция глоссария (`selective`), пор
> **[БЭКЕНДУ]** реестр `architecture/06` — твой контракт для шага 4 (миграция памяти v2). Порядок: **F1 (snapshotID под память+context-assembly) — ПЕРВЫМ** (общий гейт, держит D1/D5, независим от memory-вопроса — код отстаёт от impl-notes §3.2, там уже предписан версия-счётчик); затем схема v2 = D7 + добавления реестра (`sense`, ось редакционного времени, `UNIQUE(src,sense,window)`+обратная dst-проверка, `min_key_len`+запрет одиночных ключей, артефакт нормализации NFKC/OpenCC/kana/ruby); горячий путь = **Aho-Corasick/trie, НЕ FTS5** (impl-notes §3.6 уже так); post-check обязателен, слепой post-replace в ru запрещён; трёхсторонний disposition + retrieval-state запись. Режим инъекции держи конфигом (`selective|full_prefix`) — eval потом решит дефолт. Отложенное в v1.1/Ф2 (заложить поля/место, не реализовывать): Палладий/Поливанов B6, гейт фактичности резюме D1, эмбеддинг-слой A7, бэкап файла F4, морфо-гейт глагольного рода C3.
> **[ПОЛИГОНУ/ПИЛОТУ]** in-house memory-eval — дециждающий эксперимент, фолдится в пилот Ф2.5: WMT25-трёхрежимный (no / correct / **random** terminology — random-рука ловит тихую деградацию) на реальных zh/ja→ru чанках; **обязательный baseline «банк ON vs OFF» и «банк vs длинный контекст frontier-модели»** на том же стеке (DelTA этого не делала — это пробел, который решает go/no-go на ставку); мерить две оси раздельно (самосогласованность approved-форм + source-fidelity/omission через CometKiwi + LLM-судья-против-источника); ru-специфика (согласование рода вкл. глагол, склонение dst); предзарегистрировать пороги + допустимый flag-volume на главу. Эмбеддинг-бенчмарк уже сделан этой сессией (bge-m3 дефолт, `eval/retrieval_bench.py`) — переиспользуй.
> **[СЕССИЯ ЗАКРЫТА, 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)** → `eval/extract_bench.py`. Локалка касается банка в 2 точках: эмбеддинги (сделано, bge-m3) и экстракция терминов (write-path). Индикативно (3 кейса zh+ja): **локаль хорошо СПОТИТ сущности** (qwen3-abliterated:8b recall 1.0, 0 галлюцинаций, ~15с) но **плохо РЕНДЕРИТ dst** (阿Q→«А Цзы», 羅生門→«Россомаха») → **разделить: спот локально (дёшево) / рендер dst — облако+человек** (уточняет Р4/G1, где «экстракция=локаль» слито в одно). Побочно: deepseek-v4-flash молча вернул ПУСТОЙ ответ на части чанков (祝福 воспроизводимо) — тот empty-paid-response, ради которого существуют D2/coverage-гейт. Аггрегат deepseek по recall не репортил (контаминирован пустыми) — только per-case. Оговорка: 3 кейса, индикативно.