diff --git a/backend/cmd/tmctl/invocation.go b/backend/cmd/tmctl/invocation.go index 646d261a..d64ab4a2 100644 --- a/backend/cmd/tmctl/invocation.go +++ b/backend/cmd/tmctl/invocation.go @@ -93,7 +93,11 @@ func (v *rebillConsentValue) IsBoolFlag() bool { return true } // main()'s default branch → exit 1, leaving 2 exclusive to flagged chunks. func parseInvocation(args []string, flagOut io.Writer) (invocation, error) { if len(args) < 1 { - return invocation{}, fmt.Errorf("usage: tmctl --config book.yaml") + // The command list is the dispatch switch's, in full (row 176): `backup` and `migrate` are + // deploy-step commands and `seed-lint` is the $0 seed validator, and a usage line that omits them + // tells the operator they do not exist. seed-lint is on its own clause because it takes --seed + // and no --config — folding it into the first clause would be the same class of lie. + return invocation{}, fmt.Errorf("usage: tmctl --config book.yaml | tmctl seed-lint --seed glossary.yaml") } cmd, rest := args[0], args[1:] diff --git a/backend/cmd/tmctl/invocation_test.go b/backend/cmd/tmctl/invocation_test.go index d25bae9a..b8ed9ecc 100644 --- a/backend/cmd/tmctl/invocation_test.go +++ b/backend/cmd/tmctl/invocation_test.go @@ -4,7 +4,11 @@ import ( "bytes" "errors" "fmt" + "go/ast" + "go/parser" + "go/token" "io" + "strconv" "strings" "testing" @@ -17,12 +21,21 @@ import ( func TestParseNoArgsUsage(t *testing.T) { _, err := parseInvocation(nil, &bytes.Buffer{}) - // The command list gained `export` (D39 слой 6 read-only surface) and then `manifest` (backlog row 100 - // — the $0 producer of the chapter/chunk manifest); both are deliberate contract extensions and the - // rest of the usage text stays frozen. - if err == nil || err.Error() != "usage: tmctl --config book.yaml" { + // The command list gained `export` (D39 слой 6 read-only surface), then `manifest` (backlog row 100 + // — the $0 producer of the chapter/chunk manifest), and then the three the line had silently never + // learned: `backup`/`migrate` (the deploy step, row 174) and `seed-lint`, which gets its own clause + // because it takes --seed rather than --config (row 176, sanctioned by D39.134 п.3). Every one is a + // deliberate contract extension and the rest of the usage text stays frozen. + if err == nil || err.Error() != "usage: tmctl --config book.yaml | tmctl seed-lint --seed glossary.yaml" { t.Fatalf("usage error text is frozen, got: %v", err) } + // The line and the dispatch switch are the same list: a command reachable in run() and missing here + // is exactly the defect row 176 recorded, and it grew back three times. + for _, cmd := range dispatchCommands { + if !strings.Contains(err.Error(), cmd) { + t.Errorf("the usage line must name every dispatchable command; %q is missing from: %s", cmd, err.Error()) + } + } } func TestParseMissingConfig(t *testing.T) { @@ -237,3 +250,50 @@ func TestParseCeilingUSD(t *testing.T) { } } } + +// TestDispatchCommandsCoversTheSwitch closes the direction the usage-line test cannot see. That test +// walks dispatchCommands and checks each name appears in the usage string, so DROPPING a command from +// the list keeps it green while the usage line silently stops mentioning a command tmctl still runs — +// exactly the defect of row 176, which grew back three times. This reads the dispatch switch itself. +func TestDispatchCommandsCoversTheSwitch(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "main.go", nil, 0) + if err != nil { + t.Fatal(err) + } + inList := map[string]bool{} + for _, c := range dispatchCommands { + inList[c] = true + } + found := 0 + ast.Inspect(f, func(n ast.Node) bool { + sw, ok := n.(*ast.SwitchStmt) + if !ok { + return true + } + sel, ok := sw.Tag.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "cmd" { + return true + } + for _, stmt := range sw.Body.List { + for _, expr := range stmt.(*ast.CaseClause).List { + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + name, err := strconv.Unquote(lit.Value) + if err != nil { + t.Fatal(err) + } + found++ + if !inList[name] { + t.Errorf("run() dispatches %q but dispatchCommands does not list it — the usage line will not name it", name) + } + } + } + return false + }) + if found != len(dispatchCommands) { + t.Errorf("the dispatch switch has %d commands, dispatchCommands lists %d — the two must be one list", found, len(dispatchCommands)) + } +} diff --git a/backend/cmd/tmctl/main.go b/backend/cmd/tmctl/main.go index 4ed4113c..c16a7af5 100644 --- a/backend/cmd/tmctl/main.go +++ b/backend/cmd/tmctl/main.go @@ -1,5 +1,5 @@ // tmctl is the TextMachine CLI: translate / report / status / export / redrive / manifest / backup / -// migrate. +// migrate / seed-lint (dispatchCommands). // main.go — thin wiring (package №4): argument parsing — invocation.go, // output renderers — render.go, .env — dotenv.go; here just the // «parse → env → ctx → fetch → render» wiring and exit-code mapping. @@ -9,9 +9,11 @@ import ( "context" "errors" "fmt" + "io" "os" "os/signal" "path/filepath" + "strings" "syscall" "textmachine/backend/internal/membank" @@ -21,11 +23,29 @@ import ( ) func main() { - err := run() + os.Exit(exitOf(os.Stderr, run)) +} + +// exitOf runs the command behind a recover so a panic of the MAIN goroutine leaves through the exit +// contract rather than the runtime's handler (obs.PanicError). Worker goroutines carry their own recover +// at the wave seam (pipeline.runWave); this covers everything else, from parsing to the renderers. +// +// `diag` is the diagnostics sink (main passes os.Stderr, tests a buffer) for the same reason +// parseInvocation takes one: what reaches stderr on a crash is the contract, so it is asserted rather +// than assumed. os.Exit is the CALLER's — it skips deferred functions, including this recover. +func exitOf(diag io.Writer, body func() error) (code int) { + defer func() { + if p := recover(); p != nil { + err := obs.NewPanicError("tmctl", p) + fmt.Fprintln(diag, "tmctl:", err) + code = exitCode(err) + } + }() + err := body() if err != nil { - fmt.Fprintln(os.Stderr, "tmctl:", err) + fmt.Fprintln(diag, "tmctl:", err) } - os.Exit(exitCode(err)) + return exitCode(err) } // The refusal band. A code in [refusalFirst, refusalLast] means the invocation was TURNED DOWN before @@ -61,7 +81,8 @@ var refusalExit = map[pipeline.RefusalClass]int{ // exitCode maps a run() error onto the ratified shell contract (Milestone 2 / R1-FL-A): // // 0 clean -// 1 infra failure and everything else, including a flag-parse error +// 1 infra failure and everything else, including a flag-parse error and a recovered PANIC +// (*obs.PanicError, row 176) // 2 completed-with-flags (acceptance allows N flags; the typed *pipeline.CompletedWithFlags, // which survives %w-wraps via errors.As) // 3 bank-mining signature stop (*pipeline.WaveSignatureStop — the run paused before the edit wave @@ -89,9 +110,15 @@ func exitCode(err error) int { var sigStop *pipeline.WaveSignatureStop var ceiling *pipeline.CeilingHalt var refusal *pipeline.Refusal + var panicked *obs.PanicError switch { case err == nil: return 0 + case errors.As(err, &panicked): + // A crash is 1, not a new number: both bands are frozen seams and a fresh code would be a word + // added to a ratified dictionary. Checked FIRST so a panic cannot be read as one of the + // deliberate stops below even if a layer joined it with one. + return 1 case errors.As(err, &sigStop): return 3 case errors.As(err, &flagged): @@ -141,6 +168,13 @@ func traceID() string { return v } +// dispatchCommands is the list the switch in run() dispatches, in usage order. It exists as ONE list +// because the usage line and the unknown-command hint were two hand-maintained copies of it, and the +// usage line fell three commands behind (row 176) while the hint stayed current. +var dispatchCommands = []string{ + "translate", "report", "status", "export", "redrive", "manifest", "backup", "migrate", "seed-lint", +} + func run() error { inv, err := parseInvocation(os.Args[1:], os.Stderr) if err != nil { @@ -191,7 +225,7 @@ func run() error { case "seed-lint": return seedLint(inv.seedPath) default: - return fmt.Errorf("unknown command %q (want translate|report|status|export|redrive|manifest|backup|migrate|seed-lint)", inv.cmd) + return fmt.Errorf("unknown command %q (want %s)", inv.cmd, strings.Join(dispatchCommands, "|")) } } diff --git a/backend/cmd/tmctl/panic_exit_test.go b/backend/cmd/tmctl/panic_exit_test.go new file mode 100644 index 00000000..7e0340fd --- /dev/null +++ b/backend/cmd/tmctl/panic_exit_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "strings" + "testing" + + "textmachine/backend/internal/obs" + "textmachine/backend/internal/pipeline" +) + +// panic_exit_test.go: a crash must leave through a FAILING exit code (row 176). Unrecovered, a Go panic +// is the runtime's own exit 2 — the number this contract gives to "completed with flags" — and the +// platform's intake reads 2 as a finished run with flagged chapters, so a process that died mid-book was +// recorded as `ready` with its money read from a `status --json` that knows nothing about the crash. + +func TestPanicOnTheMainGoroutineExitsAsFailure(t *testing.T) { + var diag bytes.Buffer + code := exitOf(&diag, func() error { panic("something ate a nil map") }) + if code != 1 { + t.Fatalf("a panic must exit 1 (infra failure), got %d", code) + } + // 2 is the one number that would be actively harmful, so it gets its own sentence. + if code == 2 { + t.Fatal("a panic must never exit 2 — the platform records that as completed-with-flags") + } + out := diag.String() + for _, want := range []string{"tmctl:", "PANIC in tmctl", "something ate a nil map", "goroutine"} { + if !strings.Contains(out, want) { + t.Errorf("the panic and its stack must reach the diagnostics sink; %q missing from: %s", want, out) + } + } +} + +func TestPanicFromAWaveWorkerExitsAsFailure(t *testing.T) { + // The shape the driver actually returns: the wave's error, wrapped on its way up through translate(). + err := fmt.Errorf("pipeline: draft wave: %w", obs.NewPanicError("wave worker", "index out of range")) + if code := exitCode(err); code != 1 { + t.Fatalf("a wrapped worker panic must exit 1, got %d", code) + } + var diag bytes.Buffer + if code := exitOf(&diag, func() error { return err }); code != 1 { + t.Fatalf("exitOf must map it the same way, got %d", code) + } + if !strings.Contains(diag.String(), "goroutine") { + t.Errorf("the worker's stack must reach the diagnostics sink; got: %s", diag.String()) + } +} + +// TestPanicTakesNoNonFailingCodeOfTheDictionary is the trap: the fix moves no number, so the only way a +// panic could still be read as success is by MATCHING one of the typed sentinels the dictionary is read +// through. Each case panics with the very sentinel it must not become. +func TestPanicTakesNoNonFailingCodeOfTheDictionary(t *testing.T) { + for _, tc := range []struct { + name string + value any + notCode int + }{ + {"completed with flags", &pipeline.CompletedWithFlags{Flagged: 1, Total: 3}, 2}, + {"signature stop", &pipeline.WaveSignatureStop{Terms: 2}, 3}, + {"context cancellation", context.Canceled, 5}, + } { + t.Run(tc.name, func(t *testing.T) { + err := obs.NewPanicError("wave worker", tc.value) + code := exitCode(err) + if code == tc.notCode { + t.Fatalf("a panic carrying %s leaked into exit %d", tc.name, code) + } + if code != 1 { + t.Fatalf("a panic must exit 1, got %d", code) + } + }) + } + // And the refusal band, whose promise is the opposite of a crash: "nothing reached a provider, + // nothing was spent, nothing was written". + code := exitCode(obs.NewPanicError("wave worker", pipeline.RefuseConfig(errors.New("bad")))) + if code >= refusalFirst && code <= refusalLast { + t.Fatalf("a panic must never land in the refusal band, got %d", code) + } +} + +// TestPanicWinsWhenJoinedWithASentinel pins the ORDER of the switch, not just its content: errors.Join +// puts a panic and a sentinel in one chain, and both errors.As calls would match. The panic must win, or +// a crash joined with a ceiling halt would leave as exit 4 — which the platform records as `paused`. +func TestPanicWinsWhenJoinedWithASentinel(t *testing.T) { + for _, sentinel := range []error{ + &pipeline.CompletedWithFlags{Flagged: 1, Total: 2}, + &pipeline.WaveSignatureStop{Terms: 1}, + context.Canceled, + pipeline.RefuseConfig(errors.New("bad config")), + } { + joined := errors.Join(sentinel, obs.NewPanicError("wave worker", "boom")) + if code := exitCode(joined); code != 1 { + t.Errorf("a panic joined with %T must still exit 1, got %d", sentinel, code) + } + } +} diff --git a/backend/docs/D15.2-content-addressed-resume-spec.md b/backend/docs/D15.2-content-addressed-resume-spec.md index cc0a6154..bdf851df 100644 --- a/backend/docs/D15.2-content-addressed-resume-spec.md +++ b/backend/docs/D15.2-content-addressed-resume-spec.md @@ -398,6 +398,10 @@ job-snapshot-mismatch из fail-loud в пер-чанковое `content_hash`/` 2. **Стоимость.** Проецируемый $ = Σ по изменившимся единицам их хранимого `chunk_status.cost_usd` (честная прошлая фактическая цена; для каскадных downstream-единиц без прошлой цены — оценка `ledger.EstimateUSD` над новыми `msgs`). Выдаём «**N чанков будет пере-оплачено, ~$X**». + ⚠ **ЭРРАТА 17.08 (строка 181, пак честности):** «прошлая фактическая цена» перестала быть честной, + когда провайдер сменил таблицу (D39.137): единица, купленная по старым ценам, при пере-покупке стоит + до ×4.4 больше. Реализация теперь пере-прайсит ЗАПИСАННЫЕ ТОКЕНЫ текущей таблицей — тем же швом, + которым считает резерв и сеттл (`internal/pipeline/reprice.go`). 3. **Порог + флаг (форма ратифицирована D20.2-Q2).** Порог `rebill_consent_usd` (конфиг книги) с дефолтом **`min($0.50, 5% × ProjectedBookUSD)`**. **Fallback-флор при `ProjectedBookUSD=0`** (книга ещё не считалась / нет обработанных чанков → 5%-ветка даёт $0 и заблокировала бы даже центовый diff --git a/backend/internal/membank/memory.go b/backend/internal/membank/memory.go index ae204ffd..a25785e6 100644 --- a/backend/internal/membank/memory.go +++ b/backend/internal/membank/memory.go @@ -358,16 +358,12 @@ func (b *Bank) Voices() []store.VoiceProfile { return b.voices } func (b *Bank) Pairs() []store.AddressPair { return b.pairs } // ComputeVersion is the F1 content-hash: the frozen rows (ORDER BY-stable) plus the -// normalization and matcher algorithm versions. Approved-only per D8/§8 when the post-check -// hard gate is OFF (auto/draft injected-content changes are caught at the per-chunk -// content_hash level; their decl changes affect only the recomputed retrieval-state). -// When the gate is ON, the resolved chunk disposition depends on the decl forms of ALL -// injected records (approved AND auto/draft), and those are in NEITHER content_hash (decl -// is not injected) NOR the approved-only hash — so a decl edit would silently flip a -// resumed chunk's disposition (self-review #4). So gateOn folds every row's content -// (incl. decl + status) into the hash, making any such edit a loud --resnapshot. The -// glossary.id autoincrement is deliberately EXCLUDED (fresh each replace); only content -// columns are hashed. +// normalization and matcher algorithm versions. EVERY row folds whatever its status, and +// its whole content (incl. decl and status) with it — see the loop below for why the +// approved-only fold of D8/§8 was retired by pack-20 (D39.42 п.3). gateOn is folded as a +// FIELD rather than as a scope switch: it changes how the same rows resolve a chunk's +// disposition, so the two gate settings must not share a hash. The glossary.id +// autoincrement is deliberately EXCLUDED (fresh each replace); only content columns are hashed. func ComputeVersion(rows []store.GlossaryEntry, gateOn bool) string { return ComputeVersionScoped(rows, gateOn, false) } diff --git a/backend/internal/obs/panic.go b/backend/internal/obs/panic.go new file mode 100644 index 00000000..97ab5332 --- /dev/null +++ b/backend/internal/obs/panic.go @@ -0,0 +1,38 @@ +package obs + +import ( + "fmt" + "runtime/debug" +) + +// panic.go is SafeGo's opposite number, and the contrast is the point: SafeGo swallows so a background +// telemetry write dies alone, while on the PAID path a swallowed crash is how a run that died mid-book +// gets reported as a success. THE defect (row 176): unrecovered, a panic leaves through the Go runtime's +// handler, which exits 2 — the number tmctl's contract gives to "completed with flags" — and the +// supervisor records the crashed run as `ready`. Here a panic becomes an ordinary error instead. + +// PanicError is a recovered runtime panic, carrying the stack of the goroutine that panicked. +// +// It deliberately has NO Unwrap: the recovered value may itself be an error (a panicked context.Canceled, +// a *pipeline.CeilingHalt thrown by accident), and exposing it to errors.Is/As would let a crash match a +// sentinel of the exit-code dictionary and read as a deliberate stop. +type PanicError struct { + // Where names the goroutine that died, since the message is often read without the stack. + Where string + Value any + Stack []byte +} + +// Error renders the panic WITH its stack. The stack is part of the message rather than something a +// caller has to fish out with errors.As, because the one guaranteed reader is `tmctl: ` on stderr, +// and the wrapping between here and there is not under this type's control: a crash whose diagnosis +// depends on nobody using %v on the way up is a diagnosis that will be missing when it is needed. +func (e *PanicError) Error() string { + return fmt.Sprintf("PANIC in %s: %v\n%s", e.Where, e.Value, e.Stack) +} + +// NewPanicError converts a recover() value into an error. Call it only when recover() returned non-nil; +// `where` names the goroutine ("wave worker", "tmctl"). +func NewPanicError(where string, v any) *PanicError { + return &PanicError{Where: where, Value: v, Stack: debug.Stack()} +} diff --git a/backend/internal/pipeline/live_reprobe_test.go b/backend/internal/pipeline/live_reprobe_test.go index 2ceba8be..7aa37738 100644 --- a/backend/internal/pipeline/live_reprobe_test.go +++ b/backend/internal/pipeline/live_reprobe_test.go @@ -99,11 +99,23 @@ func TestLiveClassifierHarmSet(t *testing.T) { // Default 5, not 1. The comment above condemns n=1 and the first version of this rig then DEFAULTED to // it — so the next person to run it would have got exactly the reading it warns against. A run costs // ~$0.0002-0.0009, so five samples are free at the scale of any probe that would bother running this. - runs := 5 + runs := classify6SampleN if v := os.Getenv("TM_CLASSIFY6_N"); v != "" { - if n, cerr := strconv.Atoi(v); cerr == nil && n > 0 { - runs = n + // A malformed override is REFUSED, not ignored: silently falling back to the ratified 5 would buy + // five paid calls for an operator who believes they asked for something else, one line above the + // guard that exists to stop exactly that. + n, cerr := strconv.Atoi(v) + if cerr != nil || n <= 0 { + t.Fatalf("TM_CLASSIFY6_N=%q is not a positive integer — refusing rather than quietly running the default N=%d and billing for it", v, classify6SampleN) } + runs = n + } + // The size guard fires BEFORE the first paid call, not after: an override the threshold does not + // cover must cost nothing. The refusal is LOUD rather than a skip — a watch run that quietly did not + // measure is indistinguishable from one that passed. + if ok, why := classify6SampleRatified(runs); !ok { + t.Fatalf("TM_CLASSIFY6_N=%d: %s. Nothing was called and nothing was billed. Run the ratified N=%d, or ask the owner for a threshold at your N", + runs, why, classify6SampleN) } effort := st.Reasoning if effort == "" { @@ -177,16 +189,16 @@ func TestLiveClassifierHarmSet(t *testing.T) { t.Errorf("persist probe evidence: %v", werr) } - // ⚠ THE THRESHOLD AND THE SAMPLE DISAGREE, and that is an OWNER decision, not something to soften here. - // D39.69 §2 ratified "6/6" against a SINGLE call; nobody defined it over a sample. Measured 02.08 on - // live 0731 weights: effort `low` reaches 6/6 on 4 of 5 runs, `high` on 5 of 5 — a difference n=5 cannot - // separate — while `high` costs 2.30x per call. So the level this engine now recommends for the bank - // roles makes this gate RED. Reporting that honestly is the point; relaxing the gate to fit the - // recommendation would be fitting the acceptance criterion to the result. - if full != runs { - t.Fatalf("the ratified acceptance threshold is 6/6 term (D39.69 §2) and it was reached on %d of %d runs "+ - "at effort %q — see classifier-6of6-%s.json. ⚠ The threshold was defined for ONE call, not for a "+ - "sample: decide with the owner whether it means \"every run\" or \"the median run\" before treating "+ - "this as a regression.", full, runs, effort, effort) + // The threshold is the SAMPLED form ratified by D39.136 п.6б (row 116 closed by delegation): «≥4 of 5 + // runs at N=5 reach 6/6». It replaced D39.69 §2's "6/6", which had been defined against a SINGLE call + // and which this rig used to apply per run — the open question the rig declared here is CLOSED, and + // the closure was the owner's, not a softening by whoever ran the probe. What the sampled form buys: + // the 02.08 measurement on live 0731 weights (effort `low` 4 of 5, `high` 5 of 5, a difference n=5 + // cannot separate, `high` 2.30x per call) is a PASS for the level the engine actually recommends for + // the bank roles, instead of a red gate that says nothing about the model. + if ok, why := classify6Accepted(runs, full); !ok { + t.Fatalf("classifier acceptance FAILED: %s — 6/6 was reached on %d of %d runs at effort %q "+ + "(ratified threshold D39.136 п.6б: ≥%d of %d). Raw evidence: classifier-6of6-%s.json", + why, full, runs, effort, classify6RunsAtFullOK, classify6SampleN, effort) } } diff --git a/backend/internal/pipeline/live_reprobe_threshold_test.go b/backend/internal/pipeline/live_reprobe_threshold_test.go new file mode 100644 index 00000000..e61482a9 --- /dev/null +++ b/backend/internal/pipeline/live_reprobe_threshold_test.go @@ -0,0 +1,66 @@ +package pipeline + +import "testing" + +// live_reprobe_threshold_test.go: the ARITHMETIC of the classifier acceptance threshold, lifted out of +// the `live` build tag so it is exercised by the ordinary battery. The rig that spends money on it lives +// in live_reprobe_test.go; this file carries no build tag on purpose, so both tag sets compile it and the +// paid rig and the free unit test can never disagree about what "accepted" means. + +// The ratified acceptance threshold of the §2 type-classifier (D39.136 п.6б, closing row 116): the +// SAMPLED form — «≥4 of 5 runs at N=5 reach 6/6» — replacing D39.69 §2's "6/6" read over a single call. +// +// The form is ratified for N=5 ONLY. A different sample size has no ratified threshold at all, and +// DERIVING one (a ratio, a binomial bound) would be a session inventing an acceptance criterion — the +// quiet bypass the rig exists to prevent — so classify6Accepted refuses it instead of scaling. +const ( + classify6SampleN = 5 + classify6RunsAtFullOK = 4 +) + +// classify6SampleRatified reports whether the threshold covers a sample of this size at all. It is a +// separate question from the verdict because it must be answerable BEFORE any call is bought. +func classify6SampleRatified(runs int) (ok bool, why string) { + if runs != classify6SampleN { + return false, "the ratified threshold (D39.136 п.6б) is defined ONLY for N=5 — a sample of another size has no acceptance criterion, and deriving one here would be inventing the gate rather than applying it" + } + return true, "" +} + +// classify6Accepted resolves a finished probe run against the ratified threshold. `ok` is the verdict; +// `why` is the operator-facing reason, non-empty whenever ok is false. +func classify6Accepted(runs, runsAtFull int) (ok bool, why string) { + if ok, why := classify6SampleRatified(runs); !ok { + return false, why + } + if runsAtFull < classify6RunsAtFullOK { + return false, "fewer than 4 of the 5 runs reached 6/6" + } + return true, "" +} + +func TestClassify6ThresholdIsTheRatifiedSampledForm(t *testing.T) { + // 4/5 is the case the pre-ratification rig failed on: D39.136 п.6б exists precisely because effort + // `low` measured 4-of-5 on 02.08 and the "every run" form called that a regression. + for _, c := range []struct { + runs, atFull int + want bool + }{ + {5, 5, true}, + {5, 4, true}, + {5, 3, false}, + {5, 0, false}, + {4, 4, false}, // N≠5: refused, never scaled to "4/4 is also ≥80%" + {10, 9, false}, + {1, 1, false}, + {6, 6, false}, // not even a SUPERSET of the ratified sample is derived from it + } { + got, why := classify6Accepted(c.runs, c.atFull) + if got != c.want { + t.Errorf("classify6Accepted(runs=%d, atFull=%d) = %v (%s), want %v", c.runs, c.atFull, got, why, c.want) + } + if !got && why == "" { + t.Errorf("a refusal must carry its reason (runs=%d, atFull=%d)", c.runs, c.atFull) + } + } +} diff --git a/backend/internal/pipeline/miningstop_join_test.go b/backend/internal/pipeline/miningstop_join_test.go index 41b58197..196ba10e 100644 --- a/backend/internal/pipeline/miningstop_join_test.go +++ b/backend/internal/pipeline/miningstop_join_test.go @@ -1595,7 +1595,7 @@ func TestPointwiseReEditOnlyPaysForTheUnitsTheTermTouches(t *testing.T) { if err != nil { t.Fatal(err) } - proj, err := rp.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }) + proj, err := rp.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, newRepricerT(t, rp)) if err != nil { t.Fatal(err) } diff --git a/backend/internal/pipeline/rebill.go b/backend/internal/pipeline/rebill.go index cc3245f3..3e2864c3 100644 --- a/backend/internal/pipeline/rebill.go +++ b/backend/internal/pipeline/rebill.go @@ -17,7 +17,8 @@ import ( // or is not the current one) and a snapshot move misses EVERY checkpoint of its wave. So consent is // built at the granularity the engine actually re-pays at — the SNAPSHOT — and the projection is the // honest reading of that: "the units already paid for under a superseded snapshot will be paid for -// again; the sum of their stored chunk_status.cost_usd is $X". +// again; their recorded tokens at TODAY'S price table come to $X" (reprice.go, row 181 — until then it +// summed the historical cost_usd, which is a different table the moment a vendor moves its prices). // // Two consequences of that granularity, both deliberate: // - the spec's §7.1-бис editor-cascade fix ("a stage strictly below a re-billed unit is itself @@ -25,8 +26,8 @@ import ( // cascade rule adds nothing. Its ratified ordering ("the cascade lands BEFORE the flag semantics") // is therefore not violated but inapplicable; // - the spec's EstimateUSD fallback ("for cascade units with no past price") has no subject either: -// every unit counted here HAS a stored price, because it is counted precisely for having been paid. -// A paid row whose stored cost is $0 is a genuinely $0 model (local/priced-zero), so 0 is its honest +// every unit counted here HAS stored usage, because it is counted precisely for having been paid. +// A paid row that re-prices to $0 is a genuinely $0 model (local/priced-zero), so 0 is its honest // contribution rather than a gap to estimate around. // // WHAT IS NOT PROJECTED (documented, not silent): the CONTENT axis. A source edit that leaves the chunk @@ -52,10 +53,16 @@ type RebillConsent struct { } // RebillProjection is what a run would re-pay: the chunk×stage units resolved under a superseded -// snapshot, and the sum of what they cost the first time. +// snapshot, and what buying them again would cost AT THE CURRENT PRICE TABLE (reprice.go). type RebillProjection struct { Rows int USD float64 + // HistoricalRows counts the units inside USD that could not be fully re-priced and carry the amount + // they were billed at instead: a billed-decode row with no usage on file, a row whose calls no longer + // account for its money, or one whose newest calls are provably not its own (reprice.go). It exists so + // the operator-facing text can name what the number is made of rather than claim a re-pricing it did + // not achieve. + HistoricalRows int // Repinned counts the units the run will serve for $0 despite a moved snapshot (pack-20 point 5): the // move was bank-only and their rendered bytes are unchanged. It is not part of the amount — it is the // number that makes the amount believable, because before pack-20 every one of these was counted as a @@ -79,7 +86,9 @@ type RebillProjection struct { // chunk, an edit row at its unit's leader chunk — so one membership test covers both. // // Over-estimating is the safe direction for a consent gate, but a number the operator is asked to -// approve and then not charged is exactly what makes such a number stop being read. +// approve and then not charged is exactly what makes such a number stop being read. Which is why the +// amount is not a safety margin either way: it is the stored tokens re-priced through the same seam the +// reservation will use, so the two answer the same question with the same table. // // CONTENT AWARENESS (pack-20 point 5, closing the phase-1 P3 finding). Snapshot divergence alone // over-counts: since the bank is folded per wave, signing ONE term marked the whole edit wave as re-billed @@ -99,7 +108,7 @@ type RebillProjection struct { // re-render injected bytes and therefore genuinely needs the text. Splitting the two is what lets a // status read skip a 1.4 s re-chunk it almost never needs, without ever computing a content hash over an // empty string. -func (r *Runner) projectRebill(statuses []store.ChunkStatus, manifest []chunk.Chunk, withText func() ([]chunk.Chunk, error)) (RebillProjection, error) { +func (r *Runner) projectRebill(statuses []store.ChunkStatus, manifest []chunk.Chunk, withText func() ([]chunk.Chunk, error), rp *repricer) (RebillProjection, error) { var p RebillProjection decider := newRepinDecider(r) var contentHashes map[chunkKey]map[string]string @@ -163,29 +172,44 @@ func (r *Runner) projectRebill(statuses []store.ChunkStatus, manifest []chunk.Ch } } p.Rows++ - p.USD += cs.CostUSD + usd, fromHistory := rp.usd(cs) + p.USD += usd + if fromHistory { + p.HistoricalRows++ + } } return p, nil } -// projectBookUSD extrapolates the book's total cost from the units already FULLY attempted (done or -// flagged), which is the base of the 5% consent threshold. It is the SINGLE definition of the number -// `tmctl status` reports as projected_book_usd — status used to compute it inline, and a threshold -// computed from a second, drifting definition of the same quantity is exactly the class of bug the -// memberDrops helper was extracted to remove. +// projectBookUSD extrapolates what the WHOLE book costs, from the units already FULLY attempted (done or +// flagged), at the CURRENT price table. It is the SINGLE definition of the number `tmctl status` reports +// as projected_book_usd — status used to compute it inline, and a threshold computed from a second, +// drifting definition of the same quantity is exactly the class of bug the memberDrops helper was +// extracted to remove. +// +// It re-prices for the same reason the re-payment amount does (row 181), and specifically so the consent +// gate cannot end up half-historical: the threshold is 5% of THIS, so leaving the base in old money +// while the amount moves to the new table would compare two different currencies and quietly change how +// often the operator is asked at all. // // It deliberately does NOT use book-committed spend: committed also carries partial spend on -// IN-PROGRESS units, which are outside the denominator and would over-estimate the book. -func projectBookUSD(units []editUnit, byChunk map[chunkKey][]store.ChunkStatus, nDraftStages, nEditStages int) float64 { +// IN-PROGRESS units, which are outside the denominator and would over-estimate the book. Nor does it +// re-use resolveChunkState's CostUSD, which is the HISTORICAL sum the per-chapter passports report — +// what a chapter has cost is a fact, not a projection. +func projectBookUSD(units []editUnit, byChunk map[chunkKey][]store.ChunkStatus, nDraftStages, nEditStages int, rp *repricer) float64 { var processedCost float64 processed := 0 for _, u := range units { expected := len(u.Members)*nDraftStages + nEditStages - res := resolveChunkState(unitRows(u, byChunk), expected) + rows := unitRows(u, byChunk) + res := resolveChunkState(rows, expected) if res.State != ChunkDone && res.State != ChunkFlagged { continue } - processedCost += res.CostUSD + for _, cs := range rows { + usd, _ := rp.usd(cs) + processedCost += usd + } processed++ } if processed == 0 { @@ -210,6 +234,18 @@ func (r *Runner) rebillConsentThreshold(projectedBookUSD float64) (usd float64, return rebillConsentFloorUSD, fmt.Sprintf("the $0.50 cap, under 5%% of the projected book cost $%.6f", projectedBookUSD) } +// projectionBasis says, inside the consent text, what the amount is made of. The parenthetical used to +// read "the sum of their stored cost_usd" and became a lie the day the projection stopped summing it +// (row 181) — in the single most visible place, the sentence the operator's consent is given to. It +// names the un-re-priced remainder rather than rounding it away: an amount partly quoted in last +// season's money is still a fact about the number, and hiding it is how the previous text went stale. +func projectionBasis(p RebillProjection) string { + if p.HistoricalRows > 0 { + return fmt.Sprintf("their recorded tokens at the CURRENT price table; %d of the %d unit(s) carry, wholly or in part, the amount they were originally billed at — that much of their usage could not be re-priced", p.HistoricalRows, p.Rows) + } + return "their recorded tokens at the CURRENT price table" +} + // checkRebillConsent is the gate: it refuses BEFORE any reservation when the run would re-pay more than // the book's consent threshold and the operator has not consented to that amount. // @@ -228,9 +264,13 @@ func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) e if err != nil { return fmt.Errorf("pipeline: read chunk_status for the re-bill projection: %w", err) } + rp, err := r.newRepricer() + if err != nil { + return err + } // A write path already holds the real split, so the lazy provider just hands it back — no second // ingest, and no branch where the consent gate could see text-free chunks. - proj, err := r.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }) + proj, err := r.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, rp) if err != nil { return err } @@ -243,7 +283,7 @@ func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) e byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs) } bookUSD := projectBookUSD(r.outputUnits(chunks), byChunk, - len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit))) + len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit)), rp) threshold, source := r.rebillConsentThreshold(bookUSD) // A NAMED ceiling is an instruction, not merely a consent form: it is honoured even below the @@ -256,6 +296,7 @@ func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) e if r.AcceptRebill.Given { r.Log.WarnContext(ctx, "accepting a projected re-payment of already-billed work (--accept-rebill)", "rebill_units", proj.Rows, "rebill_usd", fmt.Sprintf("%.6f", proj.USD), + "units_not_repriced", proj.HistoricalRows, "repinned_free", proj.Repinned, "threshold_usd", fmt.Sprintf("%.6f", threshold)) return nil } @@ -265,6 +306,7 @@ func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) e // money and it goes to the log. r.Log.InfoContext(ctx, "re-paying already-billed work under the consent threshold; continuing without asking", "rebill_units", proj.Rows, "rebill_usd", fmt.Sprintf("%.6f", proj.USD), + "units_not_repriced", proj.HistoricalRows, "repinned_free", proj.Repinned, "threshold_usd", fmt.Sprintf("%.6f", threshold)) return nil } @@ -277,6 +319,6 @@ func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) e if proj.Repinned > 0 { repin = fmt.Sprintf(" (%d further unit(s) are re-pinned for $0 — the bank moved but their injected bytes did not)", proj.Repinned) } - return fmt.Errorf("pipeline: this run would RE-PAY for work already billed: %d chunk×stage unit(s) are resolved under a superseded snapshot and would be paid for again, ~$%.6f (the sum of their stored cost_usd)%s. That is over this book's consent threshold $%.6f (%s), and Р6 requires consent to a CONCRETE spend, not a blanket one (D20.2-Q2). NOTHING was reserved and no row was touched. Re-run with --accept-rebill to accept the whole projected amount, or --accept-rebill= to accept it only up to a ceiling (a ceiling below the projection refuses).%s", - proj.Rows, proj.USD, repin, threshold, source, hint) + return fmt.Errorf("pipeline: this run would RE-PAY for work already billed: %d chunk×stage unit(s) are resolved under a superseded snapshot and would be paid for again, ~$%.6f (%s)%s. That is over this book's consent threshold $%.6f (%s), and Р6 requires consent to a CONCRETE spend, not a blanket one (D20.2-Q2). NOTHING was reserved and no row was touched. Re-run with --accept-rebill to accept the whole projected amount, or --accept-rebill= to accept it only up to a ceiling (a ceiling below the projection refuses).%s", + proj.Rows, proj.USD, projectionBasis(proj), repin, threshold, source, hint) } diff --git a/backend/internal/pipeline/rebill_test.go b/backend/internal/pipeline/rebill_test.go index 6bc31525..e31012cf 100644 --- a/backend/internal/pipeline/rebill_test.go +++ b/backend/internal/pipeline/rebill_test.go @@ -290,7 +290,7 @@ func TestRebillProjectionIsPerWave(t *testing.T) { if err != nil { t.Fatal(err) } - if proj, err := r1.projectRebill(statusesBefore, chunksOf(t, r1), func() ([]chunk.Chunk, error) { return chunksOf(t, r1), nil }); err != nil || proj.Rows != 0 { + if proj, err := r1.projectRebill(statusesBefore, chunksOf(t, r1), func() ([]chunk.Chunk, error) { return chunksOf(t, r1), nil }, newRepricerT(t, r1)); err != nil || proj.Rows != 0 { t.Fatalf("a freshly-run book projects no re-bill; each row must be judged against ITS OWN wave, got %+v (err=%v)", proj, err) } r1.Close() @@ -312,7 +312,7 @@ func TestRebillProjectionIsPerWave(t *testing.T) { if err != nil { t.Fatal(err) } - proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }) + proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }, newRepricerT(t, r2)) if err != nil { t.Fatal(err) } @@ -358,7 +358,7 @@ func TestRebillProjectionExcludesSkippedAndUnchanged(t *testing.T) { t.Fatal("setup: the refusal fixture must leave a skipped edit row") } // No drift yet: nothing is superseded, so nothing is projected. - if proj, err := r1.projectRebill(statuses, chunksOf(t, r1), func() ([]chunk.Chunk, error) { return chunksOf(t, r1), nil }); err != nil || proj.Rows != 0 || proj.USD != 0 { + if proj, err := r1.projectRebill(statuses, chunksOf(t, r1), func() ([]chunk.Chunk, error) { return chunksOf(t, r1), nil }, newRepricerT(t, r1)); err != nil || proj.Rows != 0 || proj.USD != 0 { t.Fatalf("an undrifted book must project no re-bill, got %+v (err=%v)", proj, err) } r1.Close() @@ -370,7 +370,7 @@ func TestRebillProjectionExcludesSkippedAndUnchanged(t *testing.T) { if err := r2.seedGlossary(ctx); err != nil { t.Fatal(err) } - proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }) + proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }, newRepricerT(t, r2)) if err != nil { t.Fatal(err) } @@ -408,7 +408,7 @@ func TestRebillProjectionIgnoresRetiredStages(t *testing.T) { if err != nil { t.Fatal(err) } - proj, err := r.projectRebill(statuses, chunksOf(t, r), func() ([]chunk.Chunk, error) { return chunksOf(t, r), nil }) + proj, err := r.projectRebill(statuses, chunksOf(t, r), func() ([]chunk.Chunk, error) { return chunksOf(t, r), nil }, newRepricerT(t, r)) if err != nil { t.Fatal(err) } @@ -450,7 +450,7 @@ func TestRebillProjectionIgnoresVanishedChunks(t *testing.T) { if err := r2.seedGlossary(ctx); err != nil { t.Fatal(err) } - proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }) + proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }, newRepricerT(t, r2)) if err != nil { t.Fatal(err) } @@ -608,13 +608,16 @@ func TestProjectBookUSDExtrapolates(t *testing.T) { {Stage: "edit", Disposition: string(DispOK), CostUSD: 1.0}, }, } + // An empty repricer holds no checkpoint usage, so every row falls back to its stored amount — which + // is exactly the arithmetic this test is about (the re-pricing itself is reprice_test.go's subject). + noUsage := &repricer{} // $2.00 over 1 processed unit × 3 units in the book = $6.00. - if got := projectBookUSD(units, byChunk, 1, 1); got != 6.0 { + if got := projectBookUSD(units, byChunk, 1, 1, noUsage); got != 6.0 { t.Fatalf("projected book cost = %v, want 6.0 ($2.00/processed unit × 3 units)", got) } // Nothing processed → no basis to extrapolate from → $0 (which is what makes the threshold fall back // to its $0.50 floor rather than to 5%×0 = $0). - if got := projectBookUSD(units, map[chunkKey][]store.ChunkStatus{}, 1, 1); got != 0 { + if got := projectBookUSD(units, map[chunkKey][]store.ChunkStatus{}, 1, 1, noUsage); got != 0 { t.Fatalf("with nothing processed the projection is 0, got %v", got) } } @@ -650,7 +653,7 @@ func TestProjectBookUSDMatchesStatus(t *testing.T) { byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs) } got := projectBookUSD(r.outputUnits(chunks), byChunk, - len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit))) + len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit)), newRepricerT(t, r)) if got != rep.ProjectedBookUSD { t.Fatalf("projectBookUSD = %v but status reports %v — the threshold base and the published projection diverged", got, rep.ProjectedBookUSD) } diff --git a/backend/internal/pipeline/reprice.go b/backend/internal/pipeline/reprice.go new file mode 100644 index 00000000..05d545c0 --- /dev/null +++ b/backend/internal/pipeline/reprice.go @@ -0,0 +1,135 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + + "textmachine/backend/internal/ledger" + "textmachine/backend/internal/llm" + "textmachine/backend/internal/store" +) + +// reprice.go: what already-billed work would cost IF BOUGHT AGAIN TODAY (row 181). A projection of +// FUTURE spend that adds up past `cost_usd` quotes a price list that no longer exists — DeepSeek's table +// moved 16.08.2026 (D39.137) — while reserve and settle price with the current one (stagerun.go). +// +// Scope: only spend that has NOT happened — the re-payment amount, its consent threshold and +// projected_book_usd. Committed/reserved and the per-chapter passport costs are historical fact. +// +// The recorded TOKENS are re-priced through settle's own seam (ledger.CostUSD + Pricer.PriceForResponse) +// rather than re-estimated with ledger.EstimateUSD: the estimate reserves the whole max_tokens budget and +// would overshoot by multiples, it needs rendered messages the read path deliberately does not have, and +// going through the settle seam is what keeps the quote and the booking from drifting apart. +// +// NOT projected (like the content axis in rebill.go): ROUTING. A row is priced by the model that +// ANSWERED, not by the one its stage resolves to now — predicting the token mix of a model that has never +// seen these chunks is a different question, and it is why an escalation hop is priced at its own model. + +// repricedCall is one stored provider call: what it was billed, and what the same tokens cost today. +type repricedCall struct { + then float64 + now float64 + // priced is false when the checkpoint carries no usable token count, and `now` is the billed amount. + priced bool +} + +// repricer answers "what would this stored row cost today" for every disposition row of a book. A cell +// holds its calls OLDEST FIRST, because which of them still back the row is decided from the newest end +// (see usd). +type repricer struct { + cells map[chunkKey]map[string][]repricedCall +} + +// newRepricer reads the book's checkpoint usage once. It is EAGER rather than lazy on purpose: both +// callers (the consent gate and the status read-model) need it for the amounts they publish, and a +// lazily-loading variant would have to smuggle a store error out of an arithmetic helper. +func (r *Runner) newRepricer() (*repricer, error) { + rows, err := r.Store.CheckpointUsageForBook(r.Book.BookID) + if err != nil { + return nil, fmt.Errorf("pipeline: read checkpoint usage for the re-pricing of already-billed work: %w", err) + } + rp := &repricer{cells: make(map[chunkKey]map[string][]repricedCall)} + for _, cu := range rows { + usd, priced := r.repriceCheckpoint(cu) + key := chunkKey{cu.Chapter, cu.ChunkIdx} + byStage := rp.cells[key] + if byStage == nil { + byStage = map[string][]repricedCall{} + rp.cells[key] = byStage + } + byStage[cu.Stage] = append(byStage[cu.Stage], repricedCall{then: cu.CostUSD, now: usd, priced: priced}) + } + return rp, nil +} + +// repriceCheckpoint prices one stored call at today's table. `priced` is false when the checkpoint +// carries no usable token count and the historical amount is returned instead — the real case being the +// billed-decode-failure checkpoint, which settles the RESERVATION ESTIMATE against a `{}` usage +// (stagerun.go): re-pricing that to $0 would quietly delete money from the consent number. +func (r *Runner) repriceCheckpoint(cu store.CheckpointUsage) (usd float64, priced bool) { + var u llm.Usage + if err := json.Unmarshal([]byte(cu.UsageJSON), &u); err != nil { + return cu.CostUSD, false + } + if u == (llm.Usage{}) { + // No tokens to price. A genuinely $0 row (a local model, a $0 derived export checkpoint) is + // re-priced to the same $0 and is not a gap; only a row that COST something is. The test is on + // the whole struct rather than on selected fields, so a new usage axis cannot make it stale. + if cu.CostUSD == 0 { + return 0, true + } + return cu.CostUSD, false + } + return ledger.CostUSD(r.Pricer.PriceForResponse(cu.ModelRequested, cu.ModelActual), u), true +} + +// usd is what re-buying this disposition row would cost at the current price table; `fromHistory` says +// the answer is partly the amount it was billed at instead. +// +// GENERATION MEMBERSHIP, and why it has to be DERIVED (this is an inference, not an identity — the one +// place in here worth distrusting). Checkpoints are append-only for the life of the book and carry no +// snapshot: re-buying a unit, or editing the source under it, leaves the old call on file beside the new +// one. chunk_status.cost_usd is the opposite — OVERWRITTEN each run with the CURRENT generation's cost. +// So pricing every call of a position answers "what has this position ever cost" in place of the question +// the operator is consenting to. The row's own money is the only per-generation authority in the store, +// so membership is taken from it: walk the calls NEWEST FIRST, keeping them while they fit inside +// cs.CostUSD. +// +// TWO PRECONDITIONS, and what happens when each fails: +// - the generation's calls SUM to the row's cost. Short of it means calls were lost (a restore, a +// legacy row) — the remainder is carried at its billed value, the conservative direction and the +// answer the projection gave before re-pricing existed. Not a routine branch: the one path that +// deletes checkpoints, ResetChunkStages, deletes the disposition row with them in one transaction. +// - they are the NEWEST calls of the cell. A config REVERT breaks this: the re-run addresses an old +// request_hash, the settle is a no-op (ON CONFLICT DO NOTHING, store/ledger.go) and the row is +// rewritten with an OLDER call's cost while newer, superseded ones stay on file. The walk then +// overshoots — which is detectable, so it is detected, and the row falls back to its billed amount +// marked NOT re-priced. That quotes stale money, but it quotes it out loud, which is the whole +// point of the row this file closes. +// +// Recovering the revert case EXACTLY needs a generation marker on `checkpoints` (they carry neither +// snapshot_id nor run id) — a money-table schema change, and a question for ratification rather than a +// third patch here. +func (rp *repricer) usd(cs store.ChunkStatus) (usd float64, fromHistory bool) { + calls := rp.cells[chunkKey{cs.Chapter, cs.ChunkIdx}][cs.Stage] + var accounted float64 + for i := len(calls) - 1; i >= 0 && accounted < cs.CostUSD-residueEpsilonUSD; i-- { + accounted += calls[i].then + usd += calls[i].now + if !calls[i].priced { + fromHistory = true + } + } + if accounted > cs.CostUSD+residueEpsilonUSD { + return cs.CostUSD, true // the newest calls are not this row's — see the revert case above + } + if residue := cs.CostUSD - accounted; residue > residueEpsilonUSD { + usd += residue + fromHistory = true + } + return usd, fromHistory +} + +// residueEpsilonUSD is far below any amount this engine prints (%.6f): it separates money a row really +// lost from the float noise of adding the same terms in a different order. +const residueEpsilonUSD = 1e-9 diff --git a/backend/internal/pipeline/reprice_test.go b/backend/internal/pipeline/reprice_test.go new file mode 100644 index 00000000..78ae03c1 --- /dev/null +++ b/backend/internal/pipeline/reprice_test.go @@ -0,0 +1,498 @@ +package pipeline + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "textmachine/backend/internal/chunk" + "textmachine/backend/internal/obs" + "textmachine/backend/internal/store" +) + +// reprice_test.go: the consent number after a VENDOR PRICE CHANGE (row 181). The scenario is the one +// that made the row material — DeepSeek's table moved on 16.08.2026 and the projection was still adding +// up what the units had cost before it, so the operator consented to a figure the reservation would beat +// by multiples. Every assertion here is on money the operator is SHOWN, not on money that moves: the +// gate still refuses before any reservation, and the ledger is untouched. + +func newRepricerT(t *testing.T, r *Runner) *repricer { + t.Helper() + rp, err := r.newRepricer() + if err != nil { + t.Fatal(err) + } + return rp +} + +// bumpModelPrices multiplies every price in the fixture models.yaml by factor — the vendor moving its +// table under a book that is already paid for. +func bumpModelPrices(t *testing.T, bookPath string, factor float64) { + t.Helper() + path := filepath.Join(filepath.Dir(bookPath), "models.yaml") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + old := "price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }" + next := fmt.Sprintf("price: { input_per_m: %g, cached_per_m: %g, cache_write_per_m: 0, output_per_m: %g }", + 1.0*factor, 0.1*factor, 2.0*factor) + changed := strings.ReplaceAll(string(raw), old, next) + if changed == string(raw) { + t.Fatal("setup: the fixture price line was not found in models.yaml") + } + writeFile(t, path, changed) +} + +// TestRebillProjectionUsesTheCurrentPriceTable is the row-181 headline: units bought under the old table +// must be quoted at the NEW one, because that is the table the reservation and the settle will use. +func TestRebillProjectionUsesTheCurrentPriceTable(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) + defer srv.Close() + bookPath := setupProject(t, srv.URL) + ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + committedBefore, _, err := r1.Store.SpentUSD("test-book") + if err != nil { + t.Fatal(err) + } + r1.Close() + if committedBefore <= 0 { + t.Fatalf("setup: the first run must bill something, got %v", committedBefore) + } + + const factor = 4.0 // DeepSeek's own move was ×3.0-4.7 per axis (D39.137) + bumpModelPrices(t, bookPath, factor) + driftPipelineVersion(t, bookPath) + + r2 := newRunner(t, bookPath) + defer r2.Close() + statuses, err := r2.Store.ChunkStatusesForBook("test-book") + if err != nil { + t.Fatal(err) + } + chunks := chunksOf(t, r2) + proj, err := r2.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, newRepricerT(t, r2)) + if err != nil { + t.Fatal(err) + } + if proj.Rows != 2 { + t.Fatalf("setup: both stages must be superseded, got %d row(s)", proj.Rows) + } + if proj.HistoricalRows != 0 { + t.Errorf("every unit here has its usage on file; %d were quoted from history instead", proj.HistoricalRows) + } + // The whole point: the projection is what the units cost TODAY, not what they cost when bought. + if want := factor * committedBefore; !nearUSD(proj.USD, want) { + t.Fatalf("re-payment projected at $%.6f but the current price table makes that work $%.6f — the operator would consent to the old table's number (row 181)", + proj.USD, want) + } + + // The threshold's base moves with it, or the gate goes half-historical: 5% of a book costed in last + // season's money against an amount costed in this one. + rep, err := r2.Status(ctx) + if err != nil { + t.Fatal(err) + } + if want := factor * committedBefore; !nearUSD(rep.ProjectedBookUSD, want) { + t.Fatalf("status projected_book_usd = $%.6f, want $%.6f at the current table", rep.ProjectedBookUSD, want) + } + if !nearUSD(rep.RebillUSD, proj.USD) { + t.Fatalf("status quotes $%.6f where the consent gate projects $%.6f — the two must be one number", rep.RebillUSD, proj.USD) + } + + // And the operator-facing sentence: it must name the table it used, and must not still promise a sum + // of stored cost_usd — the most visible place the old text would have gone on lying. + r2.Resnapshot = true + _, terr := r2.TranslateBook(ctx) + if terr == nil { + t.Fatal("the re-payment is over the threshold — the run must refuse") + } + msg := terr.Error() + if !strings.Contains(msg, fmt.Sprintf("~$%.6f", proj.USD)) { + t.Errorf("the refusal must quote the re-priced amount ~$%.6f; got: %s", proj.USD, msg) + } + if strings.Contains(msg, "stored cost_usd") { + t.Errorf("the consent text still describes the amount as the sum of stored cost_usd: %s", msg) + } + if !strings.Contains(msg, "CURRENT price table") { + t.Errorf("the consent text must say what the amount is priced with; got: %s", msg) + } + // The threshold printed in that same sentence is 5% of the RE-PRICED book, not of the old one. + if !strings.Contains(msg, fmt.Sprintf("projected book cost $%.6f", rep.ProjectedBookUSD)) { + t.Errorf("the threshold's base must be the re-priced book cost $%.6f; got: %s", rep.ProjectedBookUSD, msg) + } + + // Nothing was bought to learn any of this. + committedAfter, reservedAfter, err := r2.Store.SpentUSD("test-book") + if err != nil { + t.Fatal(err) + } + if committedAfter != committedBefore || reservedAfter != 0 { + t.Errorf("a refused run must move no money: committed %v → %v, reserved %v", committedBefore, committedAfter, reservedAfter) + } +} + +// driftPromptVersion moves the fixture's prompt_version from one token to another, so a test can drift +// the book a SECOND time (driftPipelineVersion only knows the first hop). +func driftPromptVersion(t *testing.T, bookPath, from, to string) { + t.Helper() + path := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + changed := strings.ReplaceAll(string(raw), "prompt_version: "+from, "prompt_version: "+to) + if changed == string(raw) { + t.Fatalf("setup: prompt_version %q not found in the fixture pipeline", from) + } + writeFile(t, path, changed) +} + +// TestRebillProjectionAfterAReBuyQuotesOneGeneration is the end-to-end form of the regression: a book +// that has ALREADY been re-bought once (the very flow the consent gate governs) must still be quoted the +// cost of ONE re-payment, not the sum of everything it has ever been billed. Prices are never touched +// here, so the projection has to equal the historical per-generation cost exactly. +func TestRebillProjectionAfterAReBuyQuotesOneGeneration(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) + defer srv.Close() + bookPath := setupProject(t, srv.URL) + ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + r1.Close() + + // Generation 2: drift both waves and consent to the re-payment, so the book now carries two + // checkpoints per unit while each chunk_status row records only the newer one's cost. + driftPromptVersion(t, bookPath, "v-test", "v-test-g2") + r2 := newRunner(t, bookPath) + r2.Resnapshot = true + r2.AcceptRebill = RebillConsent{Given: true} + if _, err := r2.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + committed, _, err := r2.Store.SpentUSD("test-book") + if err != nil { + t.Fatal(err) + } + r2.Close() + if !nearUSD(committed, 4*fakeCallUSD) { + t.Fatalf("setup: two generations of draft+edit must bill 4 calls, got $%.6f", committed) + } + + // Generation 3 is only PROJECTED: what would one more re-payment cost? + driftPromptVersion(t, bookPath, "v-test-g2", "v-test-g3") + r3 := newRunner(t, bookPath) + defer r3.Close() + statuses, err := r3.Store.ChunkStatusesForBook("test-book") + if err != nil { + t.Fatal(err) + } + chunks := chunksOf(t, r3) + proj, err := r3.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, newRepricerT(t, r3)) + if err != nil { + t.Fatal(err) + } + if want := 2 * fakeCallUSD; !nearUSD(proj.USD, want) { + t.Errorf("a re-bought book projects the cost of ONE more re-payment ($%.6f), got $%.6f — the projection is counting superseded generations", want, proj.USD) + } + if proj.HistoricalRows != 0 { + t.Errorf("every row here has its usage on file; %d were quoted from history", proj.HistoricalRows) + } + rep, err := r3.Status(ctx) + if err != nil { + t.Fatal(err) + } + if want := 2 * fakeCallUSD; !nearUSD(rep.ProjectedBookUSD, want) { + t.Errorf("projected_book_usd is what the book costs ONCE ($%.6f), got $%.6f", want, rep.ProjectedBookUSD) + } +} + +// addModel appends a model to the fixture catalogue at `factor` times the fixture price. +func addModel(t *testing.T, bookPath, name string, factor float64) { + t.Helper() + path := filepath.Join(filepath.Dir(bookPath), "models.yaml") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + writeFile(t, path, string(raw)+fmt.Sprintf(` + %s: + provider: fake + price: { input_per_m: %g, cached_per_m: %g, cache_write_per_m: 0, output_per_m: %g } +`, name, 1.0*factor, 0.1*factor, 2.0*factor)) +} + +// TestRepriceCheckpointFallsBackToTheBilledAmount pins the one place where re-pricing is impossible and +// the honest answer is the historical figure: the billed-decode checkpoint, which settles the +// RESERVATION ESTIMATE against a `{}` usage (stagerun.go). Re-pricing that to $0 would delete real money +// from the consent number. +func TestRepriceCheckpointFallsBackToTheBilledAmount(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) + defer srv.Close() + bookPath := setupProject(t, srv.URL) + addModel(t, bookPath, "fake-dear", 10.0) // a second, dearer model so "priced by which one" is decidable + r := newRunner(t, bookPath) + defer r.Close() + + for _, tc := range []struct { + name string + cu store.CheckpointUsage + wantUSD float64 + wantPriced bool + }{ + { + name: "usage on file is re-priced", + cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `{"PromptTokens":1000,"CachedTokens":200,"CompletionTokens":500}`, CostUSD: 0.01}, + wantUSD: fakeCallUSD, + wantPriced: true, + }, + { + name: "billed 2xx with an unreadable body keeps its settled estimate", + cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `{}`, CostUSD: 0.0042}, + wantUSD: 0.0042, + wantPriced: false, + }, + { + // Money is priced by the model that ANSWERED, not the one that was asked — the same + // PriceForResponse ordering settle uses, and the axis an escalation hop rides. + name: "the model that answered sets the price", + cu: store.CheckpointUsage{ModelRequested: "fake-model", ModelActual: "fake-dear", UsageJSON: `{"PromptTokens":1000,"CachedTokens":200,"CompletionTokens":500}`, CostUSD: 0.01}, + wantUSD: 10 * fakeCallUSD, + wantPriced: true, + }, + { + name: "a genuinely $0 row is not a gap", + cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `{}`, CostUSD: 0}, + wantUSD: 0, + wantPriced: true, + }, + { + name: "unparseable usage keeps its billed amount", + cu: store.CheckpointUsage{ModelRequested: "fake-model", UsageJSON: `not json`, CostUSD: 0.0007}, + wantUSD: 0.0007, + wantPriced: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + usd, priced := r.repriceCheckpoint(tc.cu) + if !nearUSD(usd, tc.wantUSD) || priced != tc.wantPriced { + t.Fatalf("repriceCheckpoint = ($%.6f, priced=%v), want ($%.6f, priced=%v)", usd, priced, tc.wantUSD, tc.wantPriced) + } + }) + } +} + +// TestRepricerCountsOnlyTheCurrentGeneration is the regression an adversarial review of this pack found. +// Checkpoints are append-only across a book's whole life and carry no snapshot: a re-bought or +// source-edited unit accumulates a checkpoint per generation, while chunk_status.cost_usd is OVERWRITTEN +// with the current generation's cost alone. Re-pricing every checkpoint of the cell therefore quoted the +// operator a number that grew by a whole extra generation with each re-payment — the failure the pack +// exists to remove, reintroduced by its own fix. +func TestRepricerCountsOnlyTheCurrentGenerationOfCheckpoints(t *testing.T) { + rp := &repricer{cells: map[chunkKey]map[string][]repricedCall{ + {chapter: 1, chunkIdx: 0}: { + // Two generations, oldest first, DELIBERATELY ASYMMETRIC: an expensive first generation and a + // cheap second one, so walking the wrong end gives a different answer instead of the same one. + "draft": {{then: 0.60, now: 2.40, priced: true}, {then: 0.05, now: 0.20, priced: true}}, + // One generation, two attempts. + "edit": {{then: 0.01, now: 0.04, priced: true}, {then: 0.02, now: 0.08, priced: true}}, + }, + }} + for _, tc := range []struct { + name string + cs store.ChunkStatus + wantUSD float64 + wantFromHistory bool + }{ + { + // Walking from the oldest end would answer $2.40 here — the whole inference is the direction. + name: "only the newest generation is priced", + cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0.05}, + wantUSD: 0.20, + }, + { + // A config REVERT rewrites the row with an OLDER call's cost while the newer, superseded call + // stays on file. The walk then overshoots the row's money, which proves the newest calls are + // not this row's — the amount falls back to what was billed, and says so. + name: "a reverted row overshoots and is quoted as billed, disclosed", + cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0.60}, + wantUSD: 0.60, + wantFromHistory: true, + }, + { + name: "every attempt of the current generation counts", + cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "edit", CostUSD: 0.03}, + wantUSD: 0.12, + }, + { + // A skipped row never reached a provider and costs $0 — even where the same position was + // billed under an earlier generation whose checkpoints are still on file. + name: "a $0 row takes nothing from the orphans at its position", + cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft", CostUSD: 0}, + wantUSD: 0, + }, + { + // Money the surviving checkpoints do not account for is carried at its billed value rather + // than dropped: the conservative direction, and what the projection answered before re-pricing. + name: "unexplained money is carried as billed", + cs: store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "edit", CostUSD: 0.05}, + wantUSD: 0.12 + 0.02, + wantFromHistory: true, + }, + { + name: "a row with no checkpoint at all keeps its billed amount", + cs: store.ChunkStatus{Chapter: 9, ChunkIdx: 0, Stage: "edit", CostUSD: 0.03}, + wantUSD: 0.03, + wantFromHistory: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + usd, fromHistory := rp.usd(tc.cs) + if !nearUSD(usd, tc.wantUSD) || fromHistory != tc.wantFromHistory { + t.Fatalf("usd = ($%.6f, fromHistory=%v), want ($%.6f, fromHistory=%v)", usd, fromHistory, tc.wantUSD, tc.wantFromHistory) + } + }) + } +} + +func nearUSD(got, want float64) bool { + d := got - want + return d < 1e-9 && d > -1e-9 +} + +// TestProjectionBasisNamesWhatTheAmountIsMadeOf pins the operator-facing sentence itself: the parenthetical +// the consent is given to must say which table priced the amount, and must disclose any part of it that +// could not be re-priced instead of quietly rounding that away. +func TestProjectionBasisNamesWhatTheAmountIsMadeOf(t *testing.T) { + clean := projectionBasis(RebillProjection{Rows: 4}) + if !strings.Contains(clean, "CURRENT price table") || strings.Contains(clean, "originally billed") { + t.Errorf("a fully re-priced amount names the table and claims nothing else; got: %s", clean) + } + mixed := projectionBasis(RebillProjection{Rows: 4, HistoricalRows: 1}) + if !strings.Contains(mixed, "CURRENT price table") || !strings.Contains(mixed, "1 of the 4") { + t.Errorf("a partly un-re-priced amount must disclose how much of it is old money; got: %s", mixed) + } +} + +// TestCheckpointsOfOneGenerationSumToTheRowsCost pins the PREMISE the generation rule stands on: within a +// single generation, a disposition row's cost_usd is exactly the sum of its own checkpoints' costs. The +// rule reads membership off that identity — walk the newest calls while they fit inside the row's money — +// so a future path that bills into cost_usd without a checkpoint, or checkpoints money the row never +// counts, would not break loudly; the projection would just start quoting the wrong number. +// +// The shapes it runs are a plain draft+edit book and an escalation hop (a different model billed under the +// SAME stage name, so both calls land in one cell). It does NOT build a retried attempt, a repair call or +// a billed-decode row — those are covered at unit level in repriceCheckpoint's own table. +func TestCheckpointsOfOneGenerationSumToTheRowsCost(t *testing.T) { + for _, tc := range []struct { + name string + setup func(t *testing.T, url string) string + reply func(body string) (string, string) + }{ + {"plain draft+edit", func(t *testing.T, url string) string { return setupProject(t, url) }, draftEdit}, + {"an escalation hop under the same stage name", func(t *testing.T, url string) string { + return setupEscalationProject(t, url, 1.0, nil) + }, echoOrClean}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, tc.reply) + defer srv.Close() + bookPath := tc.setup(t, srv.URL) + r := newRunner(t, bookPath) + defer r.Close() + if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil { + t.Fatal(err) + } + statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID) + if err != nil { + t.Fatal(err) + } + if len(statuses) == 0 { + t.Fatal("setup: the run wrote no disposition rows") + } + usage, err := r.Store.CheckpointUsageForBook(r.Book.BookID) + if err != nil { + t.Fatal(err) + } + stored := map[chunkKey]map[string]float64{} + for _, cu := range usage { + k := chunkKey{cu.Chapter, cu.ChunkIdx} + if stored[k] == nil { + stored[k] = map[string]float64{} + } + stored[k][cu.Stage] += cu.CostUSD + } + rp := newRepricerT(t, r) + for _, cs := range statuses { + if cs.Disposition == string(DispSkipped) { + continue // never reached a provider, so it has no checkpoints to sum + } + if got := stored[chunkKey{cs.Chapter, cs.ChunkIdx}][cs.Stage]; !nearUSD(got, cs.CostUSD) { + t.Errorf("ch%d/chunk%d/%s: checkpoints sum to $%.6f but the row records $%.6f — the generation rule reads membership off this identity", + cs.Chapter, cs.ChunkIdx, cs.Stage, got, cs.CostUSD) + } + // And the rule consumes all of them: a residue here means the walk stopped early. + if _, fromHistory := rp.usd(cs); fromHistory { + t.Errorf("ch%d/chunk%d/%s: a single-generation row must be fully re-priced, not partly carried from history", + cs.Chapter, cs.ChunkIdx, cs.Stage) + } + } + }) + } +} + +// TestProjectionCountsRowsItCouldNotRePrice wires the disclosure end to end: rp.usd reporting an +// un-re-priced row must reach RebillProjection.HistoricalRows and therefore the operator's sentence. +// Without this the increment can be deleted and the whole suite stays green. +func TestProjectionCountsRowsItCouldNotRePrice(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) + defer srv.Close() + bookPath := setupProject(t, srv.URL) + ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) + + r1 := newRunner(t, bookPath) + if _, err := r1.TranslateBook(ctx); err != nil { + t.Fatal(err) + } + r1.Close() + driftPipelineVersion(t, bookPath) + + r2 := newRunner(t, bookPath) + defer r2.Close() + statuses, err := r2.Store.ChunkStatusesForBook("test-book") + if err != nil { + t.Fatal(err) + } + chunks := chunksOf(t, r2) + // An EMPTY repricer is the store that lost its calls: every counted row falls back to what it was + // billed, and the projection has to say so rather than present the total as freshly re-priced. + proj, err := r2.projectRebill(statuses, chunks, func() ([]chunk.Chunk, error) { return chunks, nil }, &repricer{}) + if err != nil { + t.Fatal(err) + } + if proj.Rows == 0 { + t.Fatal("setup: the drift must supersede both stages") + } + if proj.HistoricalRows != proj.Rows { + t.Fatalf("every row was quoted from history; the projection discloses %d of %d", proj.HistoricalRows, proj.Rows) + } + if basis := projectionBasis(proj); !strings.Contains(basis, fmt.Sprintf("%d of the %d", proj.Rows, proj.Rows)) { + t.Errorf("the consent sentence must disclose the un-re-priced part; got: %s", basis) + } +} diff --git a/backend/internal/pipeline/snapshot.go b/backend/internal/pipeline/snapshot.go index 274bd7d2..b8083383 100644 --- a/backend/internal/pipeline/snapshot.go +++ b/backend/internal/pipeline/snapshot.go @@ -177,15 +177,16 @@ func (r *Runner) repairSnapshot() (*repairSnap, error) { } // memoryVersion is the content-hash of the deterministically materialized injected -// memory (the frozen APPROVED glossary rows + the normalization/matcher algorithm +// memory (the frozen glossary rows + the normalization/matcher algorithm // versions), the memory component of the snapshot (D5.2/D8, F1 CLOSED). It is the -// Version() of the bank materialized once before the loop, so a change to the approved +// Version() of the bank materialized once before the loop, so a change to the // glossary — or to the deterministic machinery (trad→simp table, matcher) — fires the // resnapshot gate LOUDLY instead of a stale checkpoint re-paying a diverged translation. // nil bank (report path / a book with no glossary) → the empty-materialization hash, a -// stable constant. STM is excluded (rebuilt from checkpoints, §3.2). Auto/draft rows are -// excluded per D8 (their injected-content changes are caught at the per-chunk -// content_hash level; a future autopopulation milestone revisits this). +// stable constant. STM is excluded (rebuilt from checkpoints, §3.2). EVERY row folds +// whatever its status since pack-20 (D39.42 п.3 — an auto/draft row changes the injection +// and hence the re-payment, which the per-chunk content_hash alone never reached); +// the approved-only fold this comment used to describe is gone. See membank.ComputeVersion. func (r *Runner) memoryVersion() string { if r.memory != nil { return r.memory.Version() diff --git a/backend/internal/pipeline/status.go b/backend/internal/pipeline/status.go index d42b0bab..cace6f71 100644 --- a/backend/internal/pipeline/status.go +++ b/backend/internal/pipeline/status.go @@ -150,9 +150,9 @@ type StatusReport struct { // RebillUnits/RebillUSD turn the drift BOOLEAN into the number the operator actually decides on // (spec D15.2 §9, taken 25.07): "the config drifted" says nothing about whether continuing costs a // cent or the whole book — these say "N chunk×stage units already billed under a superseded snapshot - // would be paid for again, ~$X". Same projection the consent gate refuses on (projectRebill), so - // status can never quote a different number than the one translate enforces. Both omitempty: a book - // with no drift keeps its exact prior bytes. + // would be paid for again, ~$X at the CURRENT price table" (row 181). Same projection the consent + // gate refuses on (projectRebill), so status can never quote a different number than the one + // translate enforces. Both omitempty: a book with no drift keeps its exact prior bytes. RebillUnits int `json:"rebill_units,omitempty"` RebillUSD float64 `json:"rebill_usd,omitempty"` @@ -188,11 +188,14 @@ type StatusReport struct { // disposition — a nonzero count is "attention worth a human glance", not a failed chunk. StyleFlags int `json:"style_flags"` - CommittedUSD float64 `json:"committed_usd"` - ReservedUSD float64 `json:"reserved_usd"` - BookCeilingUSD float64 `json:"book_ceiling_usd,omitempty"` - CeilingPct float64 `json:"ceiling_pct,omitempty"` // 100·(committed+reserved)/book_ceiling - ProjectedBookUSD float64 `json:"projected_book_usd"` // committed extrapolated over the whole book + CommittedUSD float64 `json:"committed_usd"` + ReservedUSD float64 `json:"reserved_usd"` + BookCeilingUSD float64 `json:"book_ceiling_usd,omitempty"` + CeilingPct float64 `json:"ceiling_pct,omitempty"` // 100·(committed+reserved)/book_ceiling + // ProjectedBookUSD extrapolates the per-processed-unit cost over the whole book at TODAY'S price + // table — deliberately NOT the book's committed spend, which is historical money and also carries + // partial spend on in-progress units (projectBookUSD). + ProjectedBookUSD float64 `json:"projected_book_usd"` ETASeconds float64 `json:"eta_seconds,omitempty"` // secondary: mean fresh-call throughput × remaining // ContentLabels / Routing are the content-label PROVENANCE (B6): what the book declares and which @@ -603,12 +606,19 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) { } } + // One re-pricing read serves both money projections below (reprice.go): the re-payment amount and + // projected_book_usd, which is also the base of the consent threshold — computing them from two + // price tables is precisely how the gate would go half-historical. + rp, err := r.newRepricer() + if err != nil { + return nil, err + } // Re-payment projection (spec §9): what the drift above would COST. Computed whenever rows exist — // not only under ConfigDrift — because SnapshotDrift (rows split across snapshots within one wave) // re-bills too, and that is precisely the case the boolean pair leaves unpriced. $0 and read-only: - // projectRebill only re-renders the wave snapshots and sums stored costs. + // projectRebill re-renders the wave snapshots and re-prices stored usage; it reaches no provider. if len(statuses) > 0 { - if proj, perr := r.projectRebill(statuses, chunks, withText); perr != nil { + if proj, perr := r.projectRebill(statuses, chunks, withText, rp); perr != nil { // Same discipline as the drift check above: a failed projection is reported, never // silently rendered as "nothing to re-pay". r.Log.WarnContext(ctx, "re-bill projection failed; the re-payment cost of the drift is unknown (reported as none)", "err", perr) @@ -631,12 +641,14 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) { rep.CeilingPct = 100 * (committed + reserved) / rep.BookCeilingUSD } // Projected book cost: extrapolate the per-PROCESSED-unit average over the whole book (done + - // flagged = a unit fully attempted). NOT book committed: committed also carries partial spend on - // IN-PROGRESS units (excluded from the denominator), which would over-estimate (finding #7). The - // arithmetic lives in projectBookUSD (rebill.go) because the consent threshold is 5% of THIS number - // — one definition, so the threshold can never be computed from a drifted copy of it. + // flagged = a unit fully attempted), AT TODAY'S PRICES (row 181). NOT book committed: committed also + // carries partial spend on IN-PROGRESS units (excluded from the denominator), which would + // over-estimate (finding #7) — and committed is historical money besides, which is the other half of + // why this number is not it. The arithmetic lives in projectBookUSD (rebill.go) because the consent + // threshold is 5% of THIS number — one definition, so the threshold can never be computed from a + // drifted copy of it. processed := rep.Done + rep.Flagged - rep.ProjectedBookUSD = projectBookUSD(units, byChunk, len(draftStages), len(editStages)) + rep.ProjectedBookUSD = projectBookUSD(units, byChunk, len(draftStages), len(editStages), rp) // ETA (secondary): mean fresh-call throughput × remaining processing. No synthetic bar. // DEVIATION from D12 (which ratified an EWMA) — minor 1d, made explicit: this is a plain diff --git a/backend/internal/pipeline/wavepanic_test.go b/backend/internal/pipeline/wavepanic_test.go new file mode 100644 index 00000000..fa9d78d2 --- /dev/null +++ b/backend/internal/pipeline/wavepanic_test.go @@ -0,0 +1,158 @@ +package pipeline + +import ( + "context" + "errors" + "fmt" + "strings" + "sync/atomic" + "testing" + + "textmachine/backend/internal/obs" +) + +// wavepanic_test.go: a panic of a WAVE WORKER must leave the engine as a failing error, never as the +// process-level exit 2 the Go runtime gives an unrecovered panic — which is the code the shell contract +// reserves for "completed with flags", so a run that died mid-book was recorded as `ready` (row 176). +// +// The wave seam is where this has to be tested: the paid path runs inside runWave's goroutine pool +// (waverun.go), and a recover on the main goroutine alone would leave every real crash uncovered. + +func TestWaveWorkerPanicSurfacesAsAFailingError(t *testing.T) { + const workers = 4 + var cancelled atomic.Int32 + inFlight := make(chan struct{}, workers-1) + r := &Runner{} + // The siblings do what real stage work does — wait on the wave context — so this also pins the + // masking trap: the panic cancels the wave, every other worker then fails with context.Canceled, and + // if THAT became the wave's error the run would leave as exit 5, a graceful stop. The panicking + // worker waits until the others are in flight, so the outcome does not depend on the scheduler. + err := r.runWave(context.Background(), workers, workers, func(ctx context.Context, i int) error { + if i == 0 { + for k := 0; k < workers-1; k++ { + <-inFlight + } + panic("worker exploded mid-chunk") + } + inFlight <- struct{}{} + <-ctx.Done() + cancelled.Add(1) + return ctx.Err() + }) + if err == nil { + t.Fatal("a panicking worker must fail the wave; a nil error here is the run reported as success") + } + var panicked *obs.PanicError + if !errors.As(err, &panicked) { + t.Fatalf("the wave error must be a *obs.PanicError, got %T: %v", err, err) + } + if panicked.Where != "wave worker" { + t.Errorf("the panic must name the goroutine it died in, got %q", panicked.Where) + } + // The stack has to survive to whoever prints the error: on the paid path the only guaranteed reader + // is `tmctl: ` on stderr, and diagnosis of a crash mid-book is worth the verbosity. + msg := err.Error() + for _, want := range []string{"worker exploded mid-chunk", "goroutine", "runWave"} { + if !strings.Contains(msg, want) { + t.Errorf("the panic error must carry %q so the stack reaches stderr; got: %s", want, msg) + } + } + if errors.Is(err, context.Canceled) { + t.Error("the panic must not be masked by the cancellation it caused (that reads as exit 5, a graceful stop)") + } + // The crash stops the wave rather than letting it grind on: every sibling it cancelled did return. + if got := cancelled.Load(); got != workers-1 { + t.Errorf("a panicking worker must cancel the wave: %d of %d siblings saw the cancellation", got, workers-1) + } +} + +// TestWaveWorkerPanicOutranksAnEarlierWaveError is the race an adversarial review of this pack found: +// the first error to arrive cancels the wave, and a panic in code nobody expected to run under +// cancellation arrives SECOND. Routed through first-wins it was discarded with its stack, and the run +// departed as the sibling's error — a ceiling halt (exit 4, which the platform records as `paused`) or a +// cancellation (exit 5). A process that crashed is neither. +func TestWaveWorkerPanicOutranksAnEarlierWaveError(t *testing.T) { + for _, tc := range []struct { + name string + first error + }{ + {"ceiling halt (exit 4 = paused)", &CeilingHalt{Scope: "book", err: errors.New("book USD ceiling reached")}}, + {"cancellation (exit 5 = graceful stop)", context.Canceled}, + {"an ordinary infra error", errors.New("provider unreachable")}, + } { + t.Run(tc.name, func(t *testing.T) { + const workers = 2 + inFlight := make(chan struct{}) + r := &Runner{} + // Item 0 is dispatched first, so the panicking worker is parked and holding its item before + // the sibling fails — otherwise the wave's cancellation would drop item 1 and no panic would + // happen at all. It then waits for ctx.Done(), which runWave fires only AFTER recording the + // sibling's error, so the panic is strictly second without a sleep. + err := r.runWave(context.Background(), workers, workers, func(ctx context.Context, i int) error { + if i == 0 { + close(inFlight) + <-ctx.Done() + panic("worker exploded under cancellation") + } + <-inFlight + return tc.first + }) + var panicked *obs.PanicError + if !errors.As(err, &panicked) { + t.Fatalf("the crash must outrank the earlier wave error; wave returned %T: %v", err, err) + } + if !strings.Contains(err.Error(), "goroutine") { + t.Errorf("the stack must survive the race; got: %s", err.Error()) + } + }) + } +} + +// TestPanicErrorMatchesNoExitSentinel is the trap the fix is one line away from: an error born of +// recover that matched a dictionary sentinel would leave through a NON-failing exit code with the +// dictionary formally untouched — 2 flags, 3 signature stop, 4 ceiling (the platform reads `paused`), +// 5 graceful stop, 10-19 refusal ("nothing was spent"). The panicked VALUE is deliberately one of those +// types in each case, because that is the shape that would slip through an Unwrap. +func TestPanicErrorMatchesNoExitSentinel(t *testing.T) { + for _, tc := range []struct { + name string + value any + }{ + {"plain string", "boom"}, + {"runtime error", fmt.Errorf("nil map write")}, + {"context.Canceled", context.Canceled}, + {"CompletedWithFlags", &CompletedWithFlags{Flagged: 1, Total: 2}}, + {"WaveSignatureStop", &WaveSignatureStop{Terms: 3}}, + {"CeilingHalt", &CeilingHalt{Scope: "book", err: errors.New("ceiling")}}, + {"Refusal", refuse(RefusalBadConfig, errors.New("bad config"))}, + } { + t.Run(tc.name, func(t *testing.T) { + // Wrapped, because that is how it reaches main: through the wave, the driver and translate(). + err := fmt.Errorf("pipeline: wave draft: %w", obs.NewPanicError("wave worker", tc.value)) + + var flagged *CompletedWithFlags + var sigStop *WaveSignatureStop + var ceiling *CeilingHalt + var refusal *Refusal + if errors.As(err, &flagged) { + t.Error("a panic must not read as completed-with-flags (exit 2)") + } + if errors.As(err, &sigStop) { + t.Error("a panic must not read as a signature stop (exit 3)") + } + if errors.As(err, &ceiling) { + t.Error("a panic must not read as a ceiling halt (exit 4 — the platform records `paused`)") + } + if errors.As(err, &refusal) { + t.Error("a panic must not read as a refusal (10-19 — the band promises nothing was spent)") + } + if errors.Is(err, context.Canceled) { + t.Error("a panic must not read as a graceful stop (exit 5)") + } + var panicked *obs.PanicError + if !errors.As(err, &panicked) { + t.Error("the panic must stay recognisable through the wrap") + } + }) + } +} diff --git a/backend/internal/pipeline/waverun.go b/backend/internal/pipeline/waverun.go index 7888e4b7..1b11e7d2 100644 --- a/backend/internal/pipeline/waverun.go +++ b/backend/internal/pipeline/waverun.go @@ -12,6 +12,7 @@ import ( "textmachine/backend/internal/config" "textmachine/backend/internal/lang" "textmachine/backend/internal/membank" + "textmachine/backend/internal/obs" "textmachine/backend/internal/runevents" "textmachine/backend/internal/store" ) @@ -247,6 +248,15 @@ func (r *Runner) runWave(parent context.Context, workers, n int, work func(ctx c var wg sync.WaitGroup var mu sync.Mutex var firstErr error + // A crash gets its OWN slot rather than competing for firstErr, and outranks it on the way out. Both + // halves are load-bearing. Unrecovered, a worker panic takes the process down through the runtime's + // handler, which exits 2 — the code the shell contract reserves for "completed with flags", so a run + // that died mid-book was recorded as `ready` (row 176). But routing it through first-wins would only + // move the lie: a sibling that already failed cancels the wave, and a panic in the code nobody expected + // to run under cancellation would then be discarded with its stack, leaving the run to depart as the + // sibling's ceiling halt (exit 4, `paused`) or cancellation (exit 5). A process that crashed is not + // paused and did not stop gracefully. + var panicErr error fail := func(err error) { mu.Lock() if firstErr == nil { @@ -255,10 +265,24 @@ func (r *Runner) runWave(parent context.Context, workers, n int, work func(ctx c } mu.Unlock() } + failPanic := func(err error) { + mu.Lock() + if panicErr == nil { + panicErr = err + } + mu.Unlock() + cancel() + } for w := 0; w < workers; w++ { wg.Add(1) go func() { defer wg.Done() + // NOT obs.SafeGo: this recover does not let the run continue, it makes the run fail loudly. + defer func() { + if p := recover(); p != nil { + failPanic(obs.NewPanicError("wave worker", p)) + } + }() for i := range idxCh { if err := work(ctx, i); err != nil { fail(err) @@ -275,6 +299,9 @@ func (r *Runner) runWave(parent context.Context, workers, n int, work func(ctx c } close(idxCh) wg.Wait() + if panicErr != nil { + return panicErr + } if firstErr != nil { return firstErr } diff --git a/backend/internal/store/ledger.go b/backend/internal/store/ledger.go index f6b44aa3..ab9314fc 100644 --- a/backend/internal/store/ledger.go +++ b/backend/internal/store/ledger.go @@ -339,6 +339,45 @@ func (s *Store) RoleResponsesForBook(bookID, role, mustContain string) ([]RoleRe }, args...) } +// CheckpointUsage is one billed call's TOKENS and routing, addressed the way chunk_status is — +// (chapter, chunk, stage) — rather than by request_hash. +// +// It is the bridge a re-payment projection needs (row 181). chunk_status carries only a summed +// cost_usd in the currency of the day it was billed, and final_hash reaches a single checkpoint only on +// the ok path, so a flagged row has no route back to its own usage at all. The job join has one: jobs are +// (book, chapter, stage) and checkpoints carry chunk_idx, which is exactly the chunk_status key, for +// every attempt of every disposition. +type CheckpointUsage struct { + Chapter int + ChunkIdx int + Stage string + ModelRequested string + ModelActual string + UsageJSON string + CostUSD float64 // what it was billed AT THE TIME — the historical figure, kept for the fallback +} + +// CheckpointUsageForBook returns every checkpoint's usage for a book in INSERTION order. The order is +// load-bearing, not cosmetic: the table is append-only for the life of the book, so a position +// accumulates one call per re-purchase, and the newest can be told from a superseded one only by when it +// was written. `attempt` cannot do it — it restarts at 0 every run — so the ordering rides rowid. +// +// response_text is deliberately NOT selected: it is the megabytes of the table and no pricing question +// needs a byte of it. +func (s *Store) CheckpointUsageForBook(bookID string) ([]CheckpointUsage, error) { + return queryAll(s.r, ` + SELECT j.chapter, c.chunk_idx, c.stage, c.model_requested, c.model_actual, c.usage_json, c.cost_usd + FROM checkpoints c JOIN jobs j ON j.id = c.job_id + WHERE j.book_id = ? + ORDER BY c.rowid`, + func(rows *sql.Rows) (CheckpointUsage, error) { + var u CheckpointUsage + err := rows.Scan(&u.Chapter, &u.ChunkIdx, &u.Stage, &u.ModelRequested, &u.ModelActual, + &u.UsageJSON, &u.CostUSD) + return u, err + }, bookID) +} + // SpentUSD reports (committed, reserved) for a book across all days. func (s *Store) SpentUSD(bookID string) (committed, reserved float64, err error) { ctx, cancel := opContext() diff --git a/backend/internal/store/ledger_test.go b/backend/internal/store/ledger_test.go new file mode 100644 index 00000000..1de9e399 --- /dev/null +++ b/backend/internal/store/ledger_test.go @@ -0,0 +1,51 @@ +package store + +import "testing" + +// TestCheckpointUsageForBookReturnsInsertionOrder pins the ordering the re-payment projection reads +// generation membership from (pipeline/reprice.go). Checkpoints are append-only for the life of a book, +// so telling this run's calls from a superseded run's is possible only by WHEN each was written. +// `attempt` cannot do it — it restarts at 0 every run — which is exactly what this fixture makes visible: +// the attempt numbers run 0,1,0,1 while the insertion order is g1a0, g1a1, g2a0, g2a1. +func TestCheckpointUsageForBookReturnsInsertionOrder(t *testing.T) { + s, _ := openTemp(t) + job := mustSnapshotAndJob(t, s) + + for _, cp := range []struct { + hash string + attempt int + cost float64 + }{ + {"g1a0", 0, 0.01}, {"g1a1", 1, 0.02}, // generation 1 + {"g2a0", 0, 0.03}, {"g2a1", 1, 0.04}, // generation 2, written later, attempts restart + } { + res, _, err := s.Reserve("book", cp.cost, Ceilings{BookUSD: 100, DayUSD: 100}) + if err != nil { + t.Fatal(err) + } + if err := s.SettleWithCheckpoint(res, cp.cost, Checkpoint{ + RequestHash: cp.hash, JobID: job.ID, ChunkIdx: 0, Attempt: cp.attempt, Stage: "draft", + Role: "translator", ModelRequested: "m", ModelActual: "m", UsageJSON: "{}", CostUSD: cp.cost, + }, nil); err != nil { + t.Fatal(err) + } + } + + rows, err := s.CheckpointUsageForBook("book") + if err != nil { + t.Fatal(err) + } + var got []float64 + for _, r := range rows { + got = append(got, r.CostUSD) + } + want := []float64{0.01, 0.02, 0.03, 0.04} // ordering by `attempt` would give 0.01, 0.03, 0.02, 0.04 + if len(got) != len(want) { + t.Fatalf("got %d rows, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("checkpoint order = %v, want insertion order %v", got, want) + } + } +} diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index fc15be85..9f03b943 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-17, голова D39.148 — КУРС: движок и платформа до «работает и отдаёт результат», фронт морозится ДАЛЬШЕ лендинга P7; гейт доков усилен линтом якорей в хуке (D39.148). ОЧЕРЕДЬ №18 (единственный носитель — здесь): (а) приёмка живого P7 → лендинг **БЕЗ S5 и без разморозки** (D39.147: P7 зону не размораживает) · (а2) промт бэкенд-пака честности ВЫДАН — `BACKEND_HONESTY_PACK_SESSION_PROMPT.md` (176 · 181 · 172-г · 187; п.5 живой вахты — только при названной владельцем сумме), запуск по слову владельца; рубежи D39.141 закрыты двумя проходами опровергателя + сверкой вливания (⚠ урок в норму: первый опровергатель САМ принёс ложную атрибуцию кодов 0–3 ноте D39.131, поймал её автор сверкой с телом ноты — рубежи не заменяют друг друга, они ловят разное) · (б) лендинг петель полигона — ⚠ в дереве НЕзадокументированная работа 16.08 (`eval/dovodka/PLAN-16-08.md`, `ja6.py`, `naklon.py`, свежие фризы) без записи в журнале, состав выяснить у полигона · (в) пере-съём классификаторного гейта с порогом 116 в риге — санкция владельца 17.08, потолок $0.20, исполняется п.5 пака честности; ⚠ весов **pro** он НЕ проверяет и строку 172-г не закрывает (разбор — в её теле) · (г) свободные бэкенд: 160 Этап 0, 181 (дедлайн — первый пере-прогон) · (д) строка 148 после фраз владельца; лента нот эры — реестр `architecture/05-decisions-index.md`). Оркестраторов ДВА (решение владельца 07.08): этот — движок/платформа/фронт/доки; параллельный (РОЛЬЮ, без номера — счётчик один, D39.112 п.6) — приёмка полигона. Одновременно не запускаются; CURRENT-STATE ведут оба, чужие строки не трогают. Норма изоляции панелей после инцидента отката чужих файлов — D39.113, гардрейлы в CLAUDE.md. +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-17, голова D39.149 — КУРС: движок и платформа до «работает и отдаёт результат», фронт морозится ДАЛЬШЕ лендинга P7; гейт доков усилен линтом якорей в хуке (D39.148). ОЧЕРЕДЬ №18 (единственный носитель — здесь): (а) приёмка живого P7 → лендинг **БЕЗ S5 и без разморозки** (D39.147: P7 зону не размораживает) · (а2) пак честности **ИСПОЛНЕН, ПРИНЯТ С ФИКС-ЛИСТОМ и ЗАЛЕНДЕН** (D39.149; строки 176/181/187 закрыты, 172-г НЕ закрыта — риг меряет flash); открыто: фикс-лист ФЧ-1…ФЧ-8 (носитель — запись приёмки в секции «Бэкенд») и два решения владельца — ось МОДЕЛИ в числе согласия (ФЧ-5) и маркер поколения на `checkpoints` (строка 195); рубежи выдачи промта D39.141 были закрыты двумя проходами опровергателя + сверкой вливания (⚠ урок в норму: первый опровергатель САМ принёс ложную атрибуцию кодов 0–3 ноте D39.131, поймал её автор сверкой с телом ноты — рубежи не заменяют друг друга, они ловят разное) · (б) лендинг петель полигона — ⚠ в дереве НЕзадокументированная работа 16.08 (`eval/dovodka/PLAN-16-08.md`, `ja6.py`, `naklon.py`, свежие фризы) без записи в журнале, состав выяснить у полигона · (в) пере-съём классификаторного гейта с порогом 116 в риге — санкция владельца 17.08, потолок $0.20, исполняется п.5 пака честности; ⚠ весов **pro** он НЕ проверяет и строку 172-г не закрывает (разбор — в её теле) · (г) свободные бэкенд: 160 Этап 0, 181 (дедлайн — первый пере-прогон) · (д) строка 148 после фраз владельца; лента нот эры — реестр `architecture/05-decisions-index.md`). Оркестраторов ДВА (решение владельца 07.08): этот — движок/платформа/фронт/доки; параллельный (РОЛЬЮ, без номера — счётчик один, D39.112 п.6) — приёмка полигона. Одновременно не запускаются; CURRENT-STATE ведут оба, чужие строки не трогают. Норма изоляции панелей после инцидента отката чужих файлов — D39.113, гардрейлы в CLAUDE.md. > - **Эра №15 закрыта — семь приёмок, все ПРИНЯТЫ и залендены**; лента, коммиты и разборы — D39.109–123 (D-лог) и реестр нот `architecture/05-decisions-index.md`; снимок прежних бюллетеней этой шапки — архив-слайс `-08-02-04`. > - **ЖИВОЕ:** полигон — **фаза Д ИДЁТ** (заказ 10.08; деньги санкционированы 15.08 напрямую полигону — ⚠ числа потолка в носителях расходятся, фактическую цепь сверить при лендинге петель; ja-книга `enkan_no_hate_ja`; при ратификации фазы Д в D-ноту: декой-правило + обязательный кросс-семейный опровергатель приёмки — одобрены 10.08; свежие фриз-коммиты полигона в дереве — НЕ трогать) · **платформа — P7 ЗАПУЩЕН 16.08** (промт `platform/docs/PLATFORM_P7_SESSION_PROMPT.md`, строит по канону 0.3.0; приёмка — очередь №18; первым шагом пака — рантбук migrate end-to-end, предусловие выката) · **фронт ЗАМОРОЖЕН** (D39.136 п.2 + **D39.147: морозится ДАЛЬШЕ лендинга P7 — слово владельца 17.08; P7 зону НЕ размораживает, S5-промт не выдаётся, разморозка отдельным словом по достижении сквозного пути**; перечень первого касания зоны — зеркало 0.3.0 + перегенерация типов + моки + гейт утечки конвейера + ФС-1..12 + Ф-63/Ф-28 + фразы В-11 по словарю кодов — не отменён, ждёт разморозки) · **контракт 0.3.0 ФИНАЛЕН в каноне** (батч+дофикс D39.142/143 · модель подписи «один ОК» D39.144 · дочистка D39.145; зеркало фронта отстаёт ратифицированно) · закрытые стройки эры — лентой нот: эмиттер D39.131 · P5/P6 D39.130/132 · migrate D39.134 · S4+0.2.3 D39.135 · DeepSeek-репин D39.137 (тела — D-лог и слайсы). > - **Открыто на владельце:** **развязка git с origin** (локальная линия ИСТИННА, force-push его рукой; не пуллить) · Приложение А контракта (148: фразы — по словарю кодов 0.3.0, структура готова) · продуктовое слово «остановлена: лимиты» (В-3) · В-4/Ф-30 (глава без заголовка — движковая половина строка 160) · мини-проба флора 44 (одобрена, промт не выдан; число — после ре-пробы 188) · подпись денежного шага 46 · лист В-3+К-6 · авто-резюм paused (вопрос платформы) · **схема time-based DeepSeek** (доклад — архив-слайс `PROGRESS-2026-08-14-15`; рекомендация: пик оставить + операционное правило «прогоны в долины», scheduler не начинать без ответа вендора об отметке тарификации) · **вход ратификации фазы Д:** посылка «dspro дешевле glm» в пике ПЕРЕВЁРНУТА (×1.26 дороже, D39.137 п.4) · **возражение Sol по проходу `tier` эксп-23** (пере-судить починенным ригом или оставить с записанным возражением — секция «Полигон») · фронт-вопросы зонного журнала: В-7 (плотность; держит Ф-54) · В-8 (слово состояния в дереве) · В-9 (языки интерфейса — механизм готов, ПТ-36) · Ф-38 (вкладка «Замечания»). Закрытые пункты листа (PD-104 · В-10 · В-11-форма · Ф-56/57/61/62/63 · Ф-28 · ПТ-33-граница · 116 · 126 · 172-пин · PD-203 · санкции фазы Д · дизайн 160/161 · title) — в нотах D39.136–145, здесь не держатся. @@ -8,7 +8,7 @@ > - **Курс:** ОБЩНОСТЬ ✅ → КАЧЕСТВО БАНКА ✅ → ПАКЕТ-ЧЕКЕРОВ ✅ (D39.59–78) → **ФРОНТ-ЭРА** (D39.81–100: зоны живые, контракт API ратифицирован) → **шов/платформа/движковые блокеры построены** (D39.106–123). Хвосты курса живут строками: 16 (edit-волна не гонялась НИ РАЗУ — держит и оси голоса 13б/24) · 46 (дизайн заморожен D39.92/93, промт ждёт выдачи) · coldrun-b фаза C заморожена чекпойнтом легитимно (D39.86; эталон денег/поведения — coldrun-a, read-only); развилка 0731 решена и исполнена (D39.87/91, код `553f1a3`). > - **Горизонт (D39.62/67, освежён D39.95):** **ДОБОР ИДЕАЛА** (первым прогоном: оси голоса 24 · авто-режим · цена 16; жильцы ролей решаются ДО прогона эксп-22 — строка 149 · веса K1–K12 13а · вне-претрейн чекпоинт 55; остаток арбитража банка = рецензент спорных кластеров при ре-пробе 74 — D39.102) → ВТОРАЯ ПАРА живьём (ja→ru; преп 81) → МАСШТАБ → пилот Ф2.5 (гейт резюме-строки 80; строки 62–68, 85) → Ф3 ридер-IDE (69–71). **Стоячие:** ToS-триггер 25.10 · Ш-2 до go1.27 (⚠ + x/text Unicode 17 тем же тулчейном — строка 119, реестр §Б-108 справочника якорей) · платные прогоны разблокированы (проба провода — D39.97, конфиги 112 залендены). > - **Стек (полная карта роль→модель→конфиг→квирки — [STACK.md](STACK.md), D39.126):** draft deepseek-v4-flash thinking-ON `low` **⚠0731** → терминолог (та же модель) → editor deepseek-v4-pro БИЛИНГВ ИНТЕРИМ (топология ПОДТВЕРЖДЕНА при неразличимости жильцов — D39.117; закон-блок ОБЯЗАТЕЛЕН — строка 134; glm-5 резерв; вахта маппинга эффорта pro — §Б-108) → судья gemini (Ф2, в движке НЕ построен — строка 33); канал B Mistral+grok; ~$0.85/ранобэ (D30.4, пере-калибровка при следующем платном прогоне). ⚠ **Вендор-факты 13–15.08 (пере-пин ИСПОЛНЕН и ПРИНЯТ, D39.137):** таблица цен запинена ПИКОМ (flash 0.44/1.32 · pro 1.32/3.96/кэш-хит 0.044 за 1M; счёт шиппинг-c1 = 100% DeepSeek ⇒ ×4.2–4.4 в пике / ×2.1–2.2 в долине — замер по трём прогонам); у pro эффорт стал настраиваемым low/high/max (квирк 3а устарел — вахта §Б-108 сработала); ⚠ ВЕСА pro сменились под тем же слагом (V4-Pro-0813, класс D39.61) — вахта-риг готов (остаток 172); ⚠ посылка интерим-редактора «dspro дешевле glm» в пике ПЕРЕВЁРНУТА (×1.26 дороже — вход ратификации фазы Д, D39.137 п.4); покупки фазы Д на deepseek до 16.08 16:00 UTC — по старым ценам. ⚠ **ВЕСЬ банковый контур (банкнота+терминолог+классификатор) в shipping-c1 НЕ включён** — жив ран-локальным конфигом книги (строка 140; сверка STACK.md 09.08 — факт шире прежней декларации «одна банкнота»); эскалация в shipping за `budget_usd: 0` (STACK.md §примечания). -> - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). **СЧЁТ ОЧЕРЕДИ на 17.08 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **150** строк · зона бэкенд **73** строго / **104** широко (175 пере-скоуплена D39.134; 176/177 заведены 15.08; 178 — D39.136; 179 ЗАКРЫТА D39.138; 180/181 — D39.137; 182 — wire-батч, аудит 15.08; 183–187 — контракт-ревью 28, D39.138; 188–190 — аудит бэклога, D39.140; 191/192 — модель подписи банка и пост-ридинговый цикл, D39.144; 193 — молчащие дыры выдачи, D39.147) (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0**, платные прогоны разблокированы · «скоро» **36** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строки эксп-22: 55·153 (плюс 150 — руки владельца); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже). +> - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). **СЧЁТ ОЧЕРЕДИ на 17.08 (скриптом по таблице — `python3 docs/scripts/counts.py`; обновлять при каждом лендинге):** всего **153** строк · зона бэкенд **74** строго / **107** широко (175 пере-скоуплена D39.134; 176/177 заведены 15.08; 178 — D39.136; 179 ЗАКРЫТА D39.138; 180/181 — D39.137; 182 — wire-батч, аудит 15.08; 183–187 — контракт-ревью 28, D39.138; 188–190 — аудит бэклога, D39.140; 191/192 — модель подписи банка и пост-ридинговый цикл, D39.144; 193 — молчащие дыры выдачи, D39.147; 194–196 — приёмка пака честности, D39.149) (⚠ колонки счётчик читает С КОНЦА — испр. D39.116) · **блокеров очереди 0**, платные прогоны разблокированы · «скоро» **38** (перечень — грепом по таблице, рукописный список снят D39.126) · гейт-строки эксп-22: 55·153 (плюс 150 — руки владельца); строка 5 — остаток гейчен ре-пробой 74, носитель события теперь строка 188 (аудит D39.140); остальное «когда-нибудь». ⚠ Счёт — НИЖНЯЯ граница долга, не потолок (разбор — легенда таблицы ниже). > - Архивы хроники: `archive/PROGRESS-2026-07-04-10.md` (D31) · `-10-13` (D39.6-гигиена) · `-13-25` (стройка паков 11–16, rerun2) · **`-25-31` (паки 17–20 · мини-прогон · полигон-пакеты 5–8 · ToS · холодный прогон; D39.26–58)** · **`-08-01-02` (сессии №9/№10: общность · качество банка · coldrun-b · открытие фронта/платформы; D39.59–90, срез D39.105)** · **`-08-02-04` (сессии №11–№13: ручки эффорта · стандарты · контракт API · платформа P0 · банк-арбитраж; D39.91–105 + снимок шапки эры №15)** · `-08-04-09` (пинги закрытых паков эры №15) · **`-08-14-15` (закрытые бэкенд-записи №16–17: эмиттер шва · migrate · пере-пин DeepSeek; вынесено D39.139)**. Записи ниже — живой хвост (№16+, эра D39.124+; подрезка D39.139). ## Бэклог (ЕДИНЫЙ, собран 26.07, актуализация 04.08 D39.99/D39.101; правки — только через оркестратора) @@ -135,6 +135,9 @@ | 191 | **Подпись банка = ОДИН ОК всему банку (модель владельца 16.08, D39.144)** — пер-термный полный набор решений НЕ гейт нигде: (а) контракт поправлен (409-полноты снят с resume, `pending_decisions` информационный — исполнено D39.144); (б) **движок**: verify_bank-пауза снимается командой resume с решениями «как есть», нерешённое едет авто-строками с пометкой — авто-провод D39.42 п.3 УЖЕ так работает, проверить/ослабить только границу самой паузы; (в) **платформа**: канал решений P7 строится накопительным БЕЗ гейта полноты + проводка «resume = снятие стопа» до движка; (г) пост-ридинговый цикл правок — выселен строкой **192** (носитель думания) | бэкенд+платформа | скоро (**P7 запущен** — (в) исполняется им; (б) сверка границы паузы движка — внутри P7 чтением кода, правка движка при расхождении = пинг №18) | (б) малое касание движка · (в) внутри P7 | слово владельца 16.08, D39.144, D39.42 п.3 | | 192 | **Пост-ридинговый цикл правок банка — «наше подумать» владельца 16.08 (записано, чтобы не потерялось; точную механику «не продумал ещё никто и нигде» — его слово).** UX-модель целиком: юзер нажал перевод → черновая волна + майнинг вместе → на фронте появляются ЧЕРНОВОЙ перевод и ЧЕРНОВОЙ банк · подписывается ВЕСЬ банк одним ОК (D39.144) → перевод уходит в доработку · дальше юзер ЧИТАЕТ, и его опции: поправить термин в банке ЛИБО добавить свой → кнопка «поправить» → пере-генерация, которая по ВХОЖДЕНИЯМ правит запрошенное. Открытые вопросы дизайна: что именно перегенерируется (вхождения ключа · затронутые юниты · со сметой и согласием?) · нужен ли движковый ТОЧЕЧНЫЙ РЕДАКТОР (полигон над этим работает — фаза Д) · форма в контракте (ручки нет) и на экране. Уже лежит под ногами: движковый re-edit по ключу банка со сметой и $0-пере-пином ПОСТРОЕН (строка 49: `repin.go`/`rebill.go`, D39.42 п.5/D39.45) · пометка неподписанных строк · дельта-чтение банка (0.3.0). НЕ проектировать до полигонных итогов; затем дизайн-пак на их результатах → слово владельца → стройка | владелец+полигон → бэкенд/контракт/фронт | когда-нибудь (триггер: итоги фазы Д по точечному редактору) | дизайн-пак после полигона → слово владельца → стройка | слово владельца 16.08, D39.144 п.1 | | 193 | **Дыры выдачи МОЛЧАТ в самом тексте книги** (инвентарь №18, всё грунтовано кодом): выпавший c-lite-член юнита исчезает из отгруженного текста БЕЗ маркера — читатель получает склейку без пропущенного куска и признака в тексте нет (`internal/pipeline/export.go:211-223`) · флагнутый юнит отдаётся пустым `final_text` (`:324-329`) · платформа поверх сознательно срезает `flag_reason`/`detail` до трёх значений (`platform/internal/ingest/export.go:21-24`) ⇒ на читательской поверхности дыра есть, а причины нет · и ни один гейт не отказывается выдать дырявую книгу: `export` возвращает успех даже при 100% pending (`cmd/tmctl/render.go:474-475` — «export is an audit projection, not a run verdict»), лестницы `run_complete/structurally_complete/publishable` в коде 0 вхождений. Родня: 141 (сноска без слова-заголовка уезжает дырой) и «политика красных» строки 49 — брать ОДНИМ касанием с ассемблером, врозь не имеет смысла | бэкенд (+платформа) | скоро (вместе с 49) | маркер дыры в тексте + причина на провод + отказ отдавать дырявое как готовое | инвентарь №18, **D39.147** п.2б | +| 194 | **Деньги банковых ролей вне контура согласия** (приёмка №18, клейм сессии подтверждён двумя линзами и признан ПРЕЕXISTING): `UpsertChunkStatus` зовётся только волновыми путями, а терминолог и классификатор чекпоинтятся под стадией `terminology` в главе 0 и строк `chunk_status` НЕ пишут ⇒ их деньги не входят НИ в число согласия, НИ в `projected_book_usd`, при этом на сдвиге снапшота они реально пере-покупаются. Дыра ровно того же класса, что чинила строка 181, и это вторая половина дыры Р6 (родня 160/185, 38). Паком не введено и в его заказ не входило | бэкенд | скоро (следующее касание денежного пути) | расширение проекции на банковые роли + тест | приёмка №18, отчёт сессии 17.08 | +| 195 | **Маркер поколения на `checkpoints` — СХЕМНОЕ решение, не третья заплата** (вопрос сессии оркестратору, приёмка №18): членство чекпоинта в текущем поколении сегодня ВЫВОДИТСЯ из денег строки (обход с нового конца, пока вызовы помещаются в `cs.CostUSD`) — это вывод, а не тождество, и он ломается на откате конфига (детектор перебора ловит только одну сторону — ФЧ-2). Точное решение = `snapshot_id`/`run_id` на денежной таблице: миграция + ратификация. ⚠ До неё каждая правка репрайсера — заплата поверх вывода | владелец → бэкенд | скоро (перед следующим касанием репрайсера) | ратификация схемы → миграция | приёмка №18 | +| 196 | **Остаточный класс строки 176: неперехватываемые `fatal error` Go** (конкурентная запись в map, OOM, стек-оверфлоу) по-прежнему дают exit 2, то есть читаются платформой как «завершено с флагами» — `recover` их не ловит по устройству языка (подтверждено линзой шва исполнением). Строка 176 закрыта для ПАНИК, не для fatal. Лечение — вне exit-контракта: сторож процесса у платформы либо маркер живости в артефакте прогона | бэкенд/платформа | когда-нибудь | отдельное решение | приёмка №18, линза шва | | 49 | **Этапы Б+В спеки D15.2** (content-addressed resume / `guard_hash` — D39.31 сознательно не строил) ; этап В = tmctl export-контракт D29.1 (annot-v1 · цвет-мап+rollup · политика красных fail-closed) + операторский протокол-документ релиза гендер-твиста (D5.1): сам механизм УЖЕ построен — until_ch-правка → точечный re-edit со сметой и $0-пере-пином (`repin.go`/`rebill.go`, D39.42 п.5/D39.45) | бэкенд | скоро (ПОДТЯНУТА D39.81: annot-v1 = источник замечаний для фронта — критический путь подключения) | отдельное решение (annot-половина — по контракту 14, D39.99) | D39.34(4), D33 п.5, D39.81 | | 49а | **ALTER-шаги миграций v8–v14 не идемпотентны вопреки шапке `migrate.go:9-11`** (полу-применённая БД не сходится; счётчик версий скрывает) — находка критика полноты пака-19 | бэкенд | когда-нибудь | отдельное решение | D39.56, PACK19_BUILD §6.9 | | 50 | F3-остаток идемпотентности | бэкенд | когда-нибудь | отдельное решение | D39.34(4) | @@ -192,6 +195,67 @@ **⟶ Состояние бэкенда — CURRENT-STATE (один носитель, D39.80); здесь только пинги живых сессий.** +### Пак «честные числа и статусы» (строки 181 · 176 · 172-г · 187) — ИСПОЛНЕН, дерево не закоммичено (17.08) + +**Записка-план (пункт заказа → где).** 1) 181 — `internal/pipeline/reprice.go` (нов.) + `store/ledger.go` (`CheckpointUsageForBook`) + `rebill.go`/`status.go`; тест `reprice_test.go`. 2) 176 — `internal/obs/panic.go` (нов.), recover воркера `waverun.go:260-268`, `exitOf` в `cmd/tmctl/main.go`, ветка `*obs.PanicError`→1 в `exitCode`; usage-строка `invocation.go:96` + её замороженный тест; тесты `wavepanic_test.go`, `panic_exit_test.go`. 3) 172-г — `live_reprobe_threshold_test.go` (нов., без тега) + риг `live_reprobe_test.go`. 4) 187 — `snapshot.go:179-190`, `membank/memory.go:359-365`. 5) живой прогон — исполнен, $0.005582. + +**181, развилка источника чисел (решение).** Пере-прайс идёт ФАКТИЧЕСКИМИ токенами чекпоинтов через тот же шов, что и сеттл (`Pricer.PriceForResponse` + `ledger.CostUSD`), а не оценкой `EstimateUSD`. Причины: оценка резервирует весь `max_tokens` + reasoning-буфер и завысила бы кратно (лечим «число врёт вниз» числом, врущим вверх); оценке нужны отрендеренные сообщения, которых read-путь сознательно не имеет; через шов сеттла проекция не может разъехаться с тем, что забронирует леджер. Мост к usage — НЕ `FinalHash` (он только на ok-пути, как и предупреждал заказ), а джойн `checkpoints ⋈ jobs` по (chapter, chunk_idx, stage): он достаёт usage и для flagged-строк, и для всех попыток. **Обе половины гейта починены вместе:** `projectBookUSD` (база 5%-порога и `projected_book_usd`) пере-прайсится тем же репрайсером — иначе порог сравнивал бы две разные валюты. Историческими остаются `committed/reserved` и `CostUSD` глав-паспортов: потраченное — факт, а не проекция. **Не проецируется (задокументировано, как и контент-ось):** РОУТИНГ — строка прайсится моделью, которая ОТВЕТИЛА, а не той, в которую стадия резолвится сейчас. `HistoricalRows` считает строки, чей usage недостижим (чекпоинт снесён redrive'ом; billed-decode с `{}`-usage) — они несут историческую сумму, и операторский текст это НАЗЫВАЕТ. + +**176, выбор кода.** Паника (любой горутины) → **1**, «infra failure and everything else». Не новый номер: обе полосы — замороженный шов, а слово в ратифицированном словаре не заводится сессией. Ветка `*obs.PanicError` стоит ПЕРВОЙ в `exitCode`. `obs.PanicError` намеренно БЕЗ `Unwrap`: паника значением `context.Canceled`/`*CeilingHalt` иначе уехала бы в 5/4 при формально нетронутом словаре (покрыто тестом на 7 значений). `obs.SafeGo` НЕ применён — он глотает. Стек едет в `Error()`, значит на stderr при любой обёртке. + +**Команды и результаты.** `make battery` → EXIT=0; SKIP ровно два: `TestHelperEventsRun`, `TestHelperKillLoop` — оба helper-процессы подпроцессных харнессов (`t.Skip("helper process only")` без env-ключа), не пропуск покрытия. Корпусные четыре (`TestMinerFullBookParity`, `TestCheckerLabels*`, `TestK6LabelsBaseline`) на этом стенде ПРОШЛИ, не скипнулись. Голден: `go test ./internal/pipeline/ -run Golden -count=1` → ok, бит-в-бит. `go vet -tags live ./...` → чисто. `grep -rh '^func Test' backend --include='*_test.go' | wc -l` = 819; в диффе `^-func Test` = 0 удалённых, +9 добавленных. ⚠ **Испр. приёмкой №18:** финальное число — **827** (пере-считано командой отчёта после последних правок; 819/823 — промежуточные снимки), удалённых имён 0 подтверждено. + +**Красное до фикса — исполнением** (откат в рабочем дереве, без git): 181 — `re-payment projected at $0.003640 but the current price table makes that work $0.014560`; отдельно откат только базы порога — `status projected_book_usd = $0.003640, want $0.014560`. 176 — при снятом recover тест-бинарь падает паникой (`panic: worker exploded mid-chunk`), а реальный бинарь при непойманной панике выходит **2** (замерено: собранная программа-однострочник, `exit=2`). Usage-строка — `usage error text is frozen, got: usage: tmctl --config book.yaml`. Правок тестов ради зелени нет: изменены только `invocation_test.go` (санкция тела 176 + D39.134 п.3) и механические сайты вызова после смены сигнатур `projectRebill`/`projectBookUSD` — утверждения не тронуты. + +**Пункт 5, живой прогон (санкция владельца 17.08, потолок $0.20).** Стенд `~/books/gu-zhenren/coldrun-b/reprobe/classify6/` был на схеме v14 против v15 — `status` дал ратифицированный `exit 13 schema_mismatch`, прогнан `tmctl migrate` (v14→v15, restore point взят). Замер: **4/5 прогонов достигли 6/6**, `finish=stop` на всех пяти, `bad_lines=0`, модель `deepseek-v4-flash`, `max_tokens=8000`, эффорт `unset`. **Итог: ГЕЙТ ПРОЙДЕН по ратифицированной форме — и это ровно тот 4/5, на котором старая форма «6/6 на КАЖДОМ» упала бы ложно.** Трата **$0.005582424**; леджер пробы 0.00038997→0.00597239, дельта совпала с числом рига до последнего знака, `reserved_usd=0`. Ревью исполнением: запросы прочитаны глазами (дамп `classifierMessages` в `classifier-request.json` — `reasoning:""`, то есть reasoning-off НЕ шлётся и thinking у DeepSeek остаётся ON, эхо-мина не взведена), сырые ответы прочитаны из `classifier-6of6-unset.json`, стоимость двух прогонов пересчитана вручную по новой таблице flash и сошлась (run0 = 1143·0.44 + 869·1.32 за 1M = $0.001650). ⚠ **Строку 172-г НЕ закрываю:** риг меряет классификатор на flash, а сменившиеся веса — у pro (редактор). + +**Диспозиция строки 164 — ОСТАВЛЕНА ОТКРЫТОЙ, и она по-прежнему верна.** Сверено кодом: `Status()` (`status.go:388`) не зовёт `seedGlossary`, поэтому `r.baseMemory` на read-пути nil (`seeding.go:157-159` — единственное место присвоения), `precomputeSticky` даёт пустую инъекцию и `renderedContentHashes` не опознаёт repin (`rebill.go:166`). Пак этого не двигает: он чинит ЦЕНУ строки, а 164 — про то, какие строки вообще попадают в счёт. Разделение честнее, потому что лечение 164 — материализация банка на КАЖДОМ `status` (его платформа зовёт перед каждым спавном), то есть решение о цене read-пути, а не правка текста. Пере-прайс её не маскирует — он масштабирует завышение той же таблицей. + +**Находки вне заказа (не чинил, оркестратору на завод строк).** (1) Промт §5 называет `LOG_LLM_BODIES=1`+`LOG_LEVEL=debug` носителем тел запросов для рига — **не работает**: ключ читается из `obs.ReqInfo` контекста (`cmd/tmctl/main.go:196-200` → гейт `obs/logging.go:77`), а `live_reprobe_test.go:119` строит ctx без `WithReqInfo`, поэтому тела не логируются ни при каких env. Использован второй разрешённый носитель (дамп). (2) `docs/experiments/00-provider-quirks.md` до сих пор пишет «цены DeepSeek не изменились (flash $0.14/$0.28)» — протухло со сменой 16.08 (D39.137), зона не моя. (3) Якоря строки 181 ПРОВЕРЕНЫ и оказались точными (не расхождение, называю потому что сверял): в HEAD `rebill.go:166` = `p.USD += cs.CostUSD`, `:252` = сравнение с потолком согласия, `stagerun.go:431/:613` = `EstimateUSD(PriceFor(...))` и `PriceForResponse(...)`. (4) Прежний артефакт стенда `classifier-6of6.json` — старого одно-сэмплового формата; текущий риг пишет `classifier-6of6-.json`, старый файл рядом никем не перезаписывается и вводит в заблуждение. (5) Список команд usage-строки и подсказки `unknown command` были двумя ручными копиями; сведены в один `dispatchCommands` (байты подсказки не изменились). (6) Строки 146/173/177 по пути видел — не трогал (подписанный пропуск заказа). + +**Адверсариальное ревью §4 — ПРОВЕДЕНО (17.08, воркфлоу `wf_6f671334-f69`: 23 агента, 0 ошибок, 1.95M токенов, 37 мин).** Три независимые линзы (fable — деньги/алгоритм/контракт; opus — ремесло/проза/идиоматика; opus — шов exit-кодов + верность §3/§4), по скептику-опровергателю на каждую находку, критик полноты финалом. 20 находок → **11 опровергнуто**, 8 выжило, +5 от критика. + +| Находка (линзы) | Вердикт | Диспозиция | +|---|---|---| +| **Репрайсер суммирует чекпоинты ВСЕХ поколений**: `checkpoints` append-only на всю жизнь книги и не несут снапшота, а `chunk_status.cost_usd` ПЕРЕЗАПИСЫВАЕТСЯ текущим поколением ⇒ после первой же пере-покупки число согласия и `projected_book_usd` завышены ×N | CONFIRMED ×3 линзы, воспроизведено исполнением | **ПРИНЯТА — регресс МОЕГО же фикса, исправлен.** Членство поколения выводится из единственного пер-поколенного авторитета в сторе — денег самой строки: обход вызовов С НОВОГО КОНЦА, пока они помещаются в `cs.CostUSD` (`reprice.go`). Потребовало порядка вставки: `ORDER BY c.rowid` (по `attempt` нельзя — он перезапускается каждый прогон) | +| **Пропущенные (`skipped`) строки прайсились по осиротевшим чекпоинтам** своей позиции — фантомные деньги в базе порога, без всякого `--resnapshot` | PARTIAL (тот же корень) | **ПРИНЯТА, закрыта тем же фиксом:** у `skipped`-строки `cs.CostUSD=0` ⇒ обход не берёт ни одного вызова | +| **Паника воркера, проигравшая гонку другой ошибке волны, исчезала бесследно** — `fail` first-wins, и прогон уходил кодом сиблинга: 4 (платформа пишет `paused`) или 5 (graceful stop), стек уничтожался | PARTIAL, воспроизведено на трёх видах первой ошибки | **ПРИНЯТА, исправлена.** У краха свой слот `panicErr`, возвращается ПРЕИМУЩЕСТВЕННО перед `firstErr`; first-wins для обычных ошибок не тронут. Разошёлся со вторым скептиком, советовавшим только печатать стек: упавший процесс не «пауза» — врать в пользу возобновляемости хуже | +| **Обоснование правила остатка ложно**: докстринг ссылался на redrive, а `ResetChunkStages` сносит строку диспозиции ВМЕСТЕ с чекпоинтами в одной транзакции — сценарий недостижим | CONFIRMED / PARTIAL | **ПРИНЯТА:** остаток пере-описан честно (пол для стора, потерявшего вызовы — рестор, легаси), тест пере-написан | +| **Спека-проект `backend/docs/D15.2-*.md:398` всё ещё определяет проекцию как Σ хранимого `cost_usd`** (критик; моя зона, заказ требовал «правда или прочь») | найдено критиком | **ПРИНЯТА:** эррата в теле спеки | +| Три непокрытые мутации (цена по ОТВЕТИВШЕЙ модели · раскрытие непере-прайсенной части в тексте согласия · порядок panic-first при `errors.Join`) | найдено критиком | **ПРИНЯТЫ:** три пина добавлены; последний показан красным (перенос ветки паники ниже сентинелов даёт 2/3/10) | +| Проза: история про exit 2 пересказана в шести блоках; 33-строчная шапка `reprice.go` | ОПРОВЕРГНУТЫ по метрике (плотность файлов сдвинулась на 1–2 пункта, норма репо такая же) | **ЧАСТИЧНО ПРИНЯТЫ ВОПРЕКИ ОПРОВЕРЖЕНИЮ** — слово владельца о лаконичности выше довода «дом так пишет»: шапка `reprice.go` 33→14 строк. ⚠ **Испр. по раунду 2 (находка F6): моё утверждение «история про exit 2 рассказана ОДИН раз» было НЕВЕРНО** — она изложена на четырёх сайтах (`obs/panic.go`, `waverun.go`, `wavepanic_test.go`, `panic_exit_test.go`) и упомянута ещё на двух в `cmd/tmctl`. Ревьюер рекомендовал НЕ резать: каждый текст говорит про свой шов и все четыре фактически верны; исправлено утверждение, не код | +| `status` жёстко падает при ошибке загрузки репрайсера · `dispatchCommands` не выводится из switch · порядок в `CheckpointUsageForBook` избыточен · `TM_CLASSIFY6_N` мусорный игнорируется · плоская карта вместо вложенной · и ещё 6 | ОПРОВЕРГНУТЫ исполнением | Действий нет. По одной опровергнутой находке действие всё же сделал: `EXPLAIN QUERY PLAN` показал лишнюю temp-B-tree сортировку по колонкам, которых логика не использует → `ORDER BY c.rowid` | + +**РАУНД 2 адверсариального ревью — ПРОВЕДЁН по дельте первого (воркфлоу `wf_bb22ee8a-a4b`: 15 агентов, 0 ошибок, 1.22M токенов, 70 мин).** Скоуп узкий и намеренный: свежий, никем не смотренный фикс. Линзы — fable по правилу членства поколения (с прямым указанием, что это ВЫВОД, а не тождество), opus по слоту `panicErr` + **мутационная проверка тестов**. 12 находок → 5 опровергнуто, 7 выжило, +3 от критика. **Вердикт критика: HAND OVER WITH NAMED CAVEATS.** + +| Находка | Вердикт | Диспозиция | +|---|---|---| +| **R2-1: обход ломается на ОТКАТЕ конфига.** Правило считает, что вызовы текущего поколения — самые НОВЫЕ в ячейке. Откат промпт-конфига адресуется старым `request_hash`, сеттл становится no-op (`ON CONFLICT DO NOTHING`), строка пере-записывается стоимостью СТАРОГО вызова, а новый остаётся на файле. Воспроизведено сквозным прогоном: тихое ×2 завышение при `HistoricalRows=0` | PARTIAL major. ⚠ Скептик снял главное обвинение: **это НЕ регресс моего фикса** — до-фиксный код даёт то же число в этом потоке; направление «занижение» ново, но показано только синтетически | **ПРИНЯТА.** Добавлен детектор: перебор `accounted > cs.CostUSD` **доказывает**, что новейшие вызовы не принадлежат строке ⇒ откат к сумме счёта с `fromHistory=true`. Тихое ×2 меняется на устаревшую цену, названную вслух. Точное решение требует маркера поколения на `checkpoints` (схема денежной таблицы) — **вопрос на ратификацию, а не третья заплата** | +| **F1 (blocker): направление обхода НЕ ПРИБИТО** — переворот на «от старых» переживал всю зелень: мой фикстур был симметричен (два одинаковых поколения) | PARTIAL | **ПРИНЯТА:** поколения сделаны асимметричными ($0.60 против $0.05); мутация теперь убита | +| **F2: `ORDER BY c.rowid` не прибит** — и `ORDER BY c.attempt`, и полное удаление переживали зелень. Плюс: планировщик показывает, что клауза temp-B-tree не убирает, а ДОБАВЛЯЕТ | PARTIAL major | **ПРИНЯТА:** store-тест с немонотонными `attempt` (0,1,0,1 против порядка вставки). ⚠ Моё обоснование через `EXPLAIN QUERY PLAN` в записи выше было неверно: клауза нужна для КОРРЕКТНОСТИ, а не ради плана | +| **F4: счётчик раскрытия `HistoricalRows` не прибит** — удаление инкремента переживало зелень | PARTIAL minor | **ПРИНЯТА:** тест ведёт флаг из `rp.usd` через реальную `projectRebill` до текста согласия | +| **F8/R2-2: докстринг guard-теста называл формы, которых фикстуры не строят** (ретрай, производные экспорты) — дрейф док-против-кода в паке про честность | PARTIAL | **ПРИНЯТА:** комментарий говорит ровно то, что тест гоняет | +| **C2 (критик): текст согласия звал ЧАСТИЧНО пере-прайсенную единицу целиком исторической** («their usage is no longer on file») | найдено критиком | **ПРИНЯТА:** формулировка исправлена | +| **C1 (критик): `dispatchCommands` прибит не в ту сторону** — выброс команды ИЗ списка оставлял зелень | найдено критиком | **ПРИНЯТА:** AST-тест читает сам switch `run()` и сверяет со списком в обе стороны | +| **C3 (критик): мусорный `TM_CLASSIFY6_N` тихо игнорировался** и проба покупала 5 вызовов — прямо над громким гейтом | найдено критиком | **ПРИНЯТА:** нечисловой/неположительный — громкий отказ до первого вызова | +| F6 (проза), и 5 опровергнутых: $0-строка не пере-прайсится вверх · $0-вызов теряется · при победе паники теряется ошибка сиблинга · `Status` жёстко падает · «крах не может уйти кодом 2» неверно для `fatal error` | ОПРОВЕРГНУТЫ | Кода не трогал. Последнее — честное уточнение: неперехватываемые `fatal error` (конкурентная запись в map, OOM) по-прежнему дают 2; `recover` их не ловит по устройству Go. **Назвать оркестратору как остаточный класс** | + +**Мутационная приёмка исполнением.** Четыре мутации, которые у ревьюера ПЕРЕЖИЛИ зелень, теперь убиты: обход от старых → `TestRepricerCountsOnlyTheCurrentGenerationOfCheckpoints`; `ORDER BY c.attempt` → `TestCheckpointUsageForBookReturnsInsertionOrder`; снятие `HistoricalRows++` → `TestProjectionCountsRowsItCouldNotRePrice`; снятие детектора перебора → тот же генерационный тест. + +**Что критик проверил САМ и подтвердил** (не мои клеймы, его прогоны): батарея EXIT=0 с ровно двумя SKIP; голден сверяется с ФАЙЛОМ `testdata/golden/capture.golden`, не сам с собой, и файл не в diff; wire-нейтральность доказана трижды (голден · оба хеш-несущих файла в дельте изменены ТОЛЬКО в комментариях · под `prompts/`/`configs/`/`langpacks/`/`testdata/` нет ни одного изменения); ноль удалённых имён тестов; оба регресса раунда 1 красные на до-фиксном коде; ни `platform/`, ни `frontend/`, ни контракт 14 не читают `projected_book_usd`/`rebill_usd`; **новое чтение замерено на синтетическом сторе 800 глав / 4800 чекпоинтов / 64 МБ — 39–43 мс** против 10-секундного бюджета операции. Мой счёт тестов был на единицу меньше фактического. + +**Перепроверка после ревью — командами.** `make battery` → EXIT=0, те же два SKIP. Голден → ok. `go vet -tags live ./...` → чисто. `^func Test` = 823 (было 819), удалённых имён 0. Красное до фикса показано исполнением для ОБОИХ регрессов: `projected $0.007280` против настоящих `$0.003640` (пере-купленная книга) и `wave returned *pipeline.CeilingHalt` / `*errors.errorString: context canceled` вместо паники. Замер стоимости нового чтения: на самом большом реальном проекте (coldrun-a) 149 чекпоинтов, план — два индексных поиска, 2.13 мс. + +**Obstacle.** (а) Интервальная самопроверка субагентом в СЕРЕДИНЕ работы не проводилась — ревью запускалось финалом по слову владельца; оба регресса нашлись бы раньше, промежуточный прогон линз стоил бы дешевле. (в) Живой прогон стартовал ~00:58 UTC и захватил границу пикового окна 01–04 UTC (правило-рекомендация «не стартовать в пик»): при трате $0.0056 против потолка $0.20 ожидание трёх часов было бы несоразмерно, но факт называю. (г) `git status` показывает чужой ИНДЕКС (полигон: `eval/`, `docs/experiments/`) — не трогал и не прибирал; своё не стейджил. + +**Запись оркестратора №18, 17.08 — ПРИЁМКА ПАКА «ЧЕСТНЫЕ ЧИСЛА И СТАТУСЫ»: ПРИНЯТ С ФИКС-ЛИСТОМ, залендено.** Панель 5 линз (слепая по заказу+диффу ДО отчёта · деньги · шов · вне карты · собственные мутации в копии) + пере-раны оркестратора. **Своей рукой пере-проверено исполнением:** `make battery` EXIT=0, SKIP ровно два и оба helper-процессы · голден `-run Golden` ok · `^func Test` = **827**, удалённых имён 0 · **деньги пробы ДВУМЯ независимыми путями — сумма чекпоинтов минус вызов 02.08 и пере-счёт из сырого `usage_json` по запинённой таблице `models.yaml` — оба дали $0.005582424**, до последнего знака · вердикт гейта из сырого артефакта: `runs: 5, runs_at_threshold: 4`, `finish=stop` ×5 ⇒ PASS по ратифицированной форме и ложное падение по прежней. Регрессов против HEAD панель не нашла: три места, где движок врал, действительно перестали врать. + +**Фикс-лист ФЧ-1…ФЧ-8** (ни один не блокирует лендинг; все — «заявленное не прибито» либо «текст обещает больше числа»). **ФЧ-1 (HIGH, три неприбитые гарантии, мутации ПЕРЕЖИЛИ зелень):** снятие фильтра по книге в `CheckpointUsageForBook` (`store/ledger.go:371`) не валит ни store-, ни денежные pipeline-тесты — **воспроизведено оркестратором лично**, а это единственная защита от того, чтобы деньги ЧУЖОЙ книги вошли в число согласия · ключ ячейки можно лишить `chunk_idx`, и обход начнёт съедать деньги соседнего чанка (во всех фикстурах глава = один чанк) · ветка пометки «строка не пере-оценена» не ловится на продовом пути. **ФЧ-2 (MED):** два докстринга `reprice.go` описывают НЕ то, что делает код (детекция перебора односторонняя — суффикс вытесненных вызовов с точной суммой проходит молча; «остаток несётся по счёту» — код сперва съедает вызовы прошлых поколений) — в паке про честность это тот же класс, что чинился. **ФЧ-3 (MED):** заказ 187 исполнен наполовину — снятый закон approved-only продолжает утверждаться в докстринге теста и в `backend/docs/D15.2-*.md:317`. **ФЧ-4 (MED):** раскрытие «часть суммы — старые деньги» не доезжает до `status`/`--json` (`HistoricalRows` теряется на границе `status.go`), а второй операторский текст отказа (`--accept-rebill`) цитирует пере-прайсенное число без basis. **ФЧ-5 (MED, деньги — решение владельца):** пере-прайс чинит ось ЦЕНЫ, но не ось МОДЕЛИ — при смене модели стадии (модель входит в снапшот) число считается ценой ОТСТАВЛЕННОЙ модели, замер линзы: $0.003640 против честных $0.036400 при `HistoricalRows=0`, и фраза согласия при этом заявляет «current price table» без оговорки. Не регресс, но обещание §0 промта для этого триггера остаётся живым. **ФЧ-6 (LOW):** `Status()` жёстко падает при ошибке репрайсера — в трёх строках над сознательной политикой «ошибка проекции репортится, но отчёт не валит»; это канал сеттла платформы. **ФЧ-7 (MED, шов):** паника воркера ПОВЕРХ пойманного потолка уничтожает факт потолка на обоих каналах — прогон, остановленный ceiling'ом, платформа запишет `failed`, что PD-113 прямо запрещает (не регресс; до пака терялось иначе). **ФЧ-8 (LOW):** докстринг `TranslateBook` про «каждый выход через `terminal()`» неверен для паники вне волны · комментарий `dispatchCommands` («список теперь один») — usage-строка осталась второй копией · шапка рига ссылается на заменённую ратификацию. + +**Подтверждено панелью как ВЕРНОЕ (клеймы сессии, пере-проверенные независимо):** словарь кодов побайтно не двинут, добавлен ровно один `return 1`; «паника → 1» читается платформой как `failed` во всех пяти точках потребления (инвентарь: `ingest/exit.go` · `ingest/supervisor.go` · `runner/engine.go` · `runner/marker.go` · `runs/reconcile.go` · `books/parse.go`), тогда как ДО пака упавший посреди книги прогон записывался `ready` · остаточный класс подтверждён: непойманные `fatal error` (конкурентная запись в map, OOM) по-прежнему дают 2, `recover` их не ловит · wire-нейтральность: оба хеш-несущих файла изменены ТОЛЬКО комментариями · строка 164 после пака по-прежнему верна · клейм сессии про банковые роли верен И это преexisting — заведена строка **194**. + +**Что НЕ проверено:** мутации ФЧ-1 (2) и (3) приняты со слов мутационной линзы — лично воспроизведён только фильтр книги · старый вызов 02.08 в леджере пробы не сошёлся ни с одной из двух ценовых таблиц (вне скоупа пробы, не разбирался) · перф нового чтения на боевой книге — замер только синтетический и со слов сессии. + *(Пинги закрытых паков — фикс-пак банка D39.118, блокеры контракта D39.122 — в [archive/PROGRESS-2026-08-04-09.md](archive/PROGRESS-2026-08-04-09.md), D39.125.)* *(Закрытые бэкенд-записи 01–02.08 — общность фаза 2 · качество банка (оба этапа) · фикс-пак банка · finding-1 · пакет-чекеров · мелкая пачка · coldrun-b · ручка эффорта — в [archive/PROGRESS-2026-08-01-02.md](archive/PROGRESS-2026-08-01-02.md).)* diff --git a/docs/architecture/05-decisions-index.md b/docs/architecture/05-decisions-index.md index 05cf2f09..9b7f823c 100644 --- a/docs/architecture/05-decisions-index.md +++ b/docs/architecture/05-decisions-index.md @@ -1,4 +1,4 @@ -# Реестр D-нот — карта актуальности v2 (D1–D39.148; строка 167; титул — носитель головы, бампать при каждом аппенде — испр. оркестратором №17 15.08 по аудиту: отставал на четыре ноты) +# Реестр D-нот — карта актуальности v2 (D1–D39.149; строка 167; титул — носитель головы, бампать при каждом аппенде — испр. оркестратором №17 15.08 по аудиту: отставал на четыре ноты) > Одна строка на КАЖДУЮ ноту журнала решений: № · дата · суть · статус · где тело · темы. Ведёт оркестратор при лендингах: новая нота = новая строка ТЕМ ЖЕ коммитом (полноту сторожит `docs/scripts/counts.py --check`). Суть и статус здесь — НАВИГАЦИЯ, не контракт: при конфликте побеждает ТЕЛО ноты (живой [`05-decisions-log.md`](05-decisions-log.md) → слайсы `../archive/architecture/05-decisions-*.md`). Несущие эрраты к фактам старых нот живут в шапке живого D-лога — их читать ОБЯЗАТЕЛЬНО, реестр их не дублирует. Темы (для грепа оси «какой закон по X»): деньги · шов · банк · промпт · судья · гейты · контракт · платформа · фронт · полигон · ToS · общность · процесс · нарезка · голос · инфра · модели. > Родословная: прозаическая карта D1–D39.28 (ревизии D31–D38.2) пересобрана в реестр докс-паком 167 (09.08.2026, №16): извлечение 6+6 агентов по worksheet `../archive/reports/DOC_AUDIT_INVENTORY_2026-08-09.md`, полнота сверена скриптом против заголовков всех шести файлов. «тело: жив» = живой D-лог; «слайс N» = `../archive/architecture/05-decisions-N.md`. @@ -201,3 +201,4 @@ | D39.146 | 16.08 | Закрытие сессии №17, передача №18: лента D39.134–145 (приёмки migrate/S4/DeepSeek, контракт-ревью+батч+дофикс — канон 0.3.0 финален, модель подписи один ОК, аудит бэклога потерь 0, реструктуризация роли); очередь №18 — приёмка запущенного P7 → S5+разморозка фронта, лендинг петель фазы Д, вахта весов с порогом 116, свободные 160/181; CURRENT-STATE ужат, доки проверены двумя аудиторами готовности | ЖИВОЕ: очередь — CURRENT-STATE | жив | хендофф оркестратор | | D39.147 | 17.08 | Слово владельца напрямую №18: фронт морозится ДАЛЬШЕ лендинга P7 (условие разморозки D39.136 п.2 амендировано — P7 зону не размораживает, S5 не выдаётся, разморозка отдельным словом), курс — движок и платформа до «работает и отдаёт результат», моки фронта при разморозке уступают дев-стенду платформы; инвентарь трёх аудитов кодом: ассемблера книги нет нигде и export всегда exit 0 (строка 49), дыры выдачи молчат в тексте (новая 193), боевой конфиг без банкового контура = 140 внутри wire-батча 182, беспагинационные чтения стора под 10s (дописка 54), платформа после P7 читаема но без экспорта и эскроу (136/137/П-18), блокирующих строк движка 18 из 103 и два тяжелейших не про код (54, 16) | ЖИВОЕ: курс и условие разморозки | жив | процесс фронт платформа владелец | | D39.148 | 17.08 | Ревизия D39.126: `--lint` якорей вплетён в pre-commit хук доков (warn-only, `--from-index`), триггер линта — любой док или CLAUDE.md, `--check` остаётся на D-логе/PROGRESS; дизайн против ложных тревог — сканируемые доки по содержимому коммита, цели якорей из дерева, состав коммита одним вызовом; верификация реальными тест-коммитами поймала два дефекта (падение на отсутствующем файле, пустое тело предупреждения); метод-урок: хук судится тест-коммитом, а не ручным прогоном — git отдаёт хуку временный индекс через GIT_INDEX_FILE | ЖИВОЕ: действующая форма гейта доков | жив | процесс инфра доки | +| D39.149 | 17.08 | Приёмка пака «честные числа и статусы» (панель 5 линз + пере-раны оркестратора): ПРИНЯТ С ФИКС-ЛИСТОМ ФЧ-1…ФЧ-8, регрессов нет; ратифицированы паника→exit 1 (PD-212 со стороны движка закрыт, полосы не двинуты), PanicError без Unwrap, пере-прайс фактическими токенами через шов сеттла с починкой обеих половин гейта, выборочный порог в риге (4/5 PASS); деньги пробы $0.005582424 сошлись двумя независимыми путями; строки 176/181/187 закрыты, 172-г запрещено закрывать этим прогоном, новые 194 (деньги банковых ролей вне согласия) · 195 (маркер поколения — схема, на владельце) · 196 (fatal error residual); владельцу — ось МОДЕЛИ в числе согласия | ЖИВОЕ: фикс-лист ФЧ и два решения владельца | жив | деньги приёмка шов бэкенд | diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index c65c07fe..48807ceb 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1,4 +1,4 @@ -# Журнал решений оркестратора — контракт D1–D39.148 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`) +# Журнал решений оркестратора — контракт D1–D39.149 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`) > **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Работая с контрактом (греп номера: живой файл → слайсы, целиком НЕ читать — D39.125), держи под рукой, что чем перекрыто: > ⚠ **Эррата 09.08 (D39.125):** D39.111 п.1 предписывал промту S3 «максимум = баланс МИНУС открытые холды» — формула ОШИБОЧНА (вычитание дважды), исправлена D39.115 п.2(а): максимум = Balance КАК ЕСТЬ; тело D39.111 живёт ниже в этом файле (голова D39.106+). @@ -483,3 +483,15 @@ **4. Метод-урок (записан, потому что куплен ошибкой оркестратора №18 в тот же день).** Хук нельзя судить, запуская его руками в шелле: при частичном коммите (`git commit -- <пути>`, наша каноническая форма) git строит ВРЕМЕННЫЙ индекс и отдаёт его хуку через `GIT_INDEX_FILE`, поэтому `git diff --cached` и `git show :файл` ВНУТРИ хука видят состав коммита, а ручной прогон — настоящий индекс с чужим стейджем. №18 на этом основании объявил хук мёртвым для наших коммитов; диагноз опровергнут экспериментом параллельного ревьюера и пере-воспроизведён №18. ⚠ Опровергающая улика лежала в его же наблюдении (фронтовый фрагмент того же диспетчера отрабатывал на pathspec-коммитах) и была объяснена, а не проверена — «связность вместо истинности» из дисциплины ревьюера. Проверка хука = тест-коммит, не вызов скрипта. **5. Носители:** докстринг `docs/scripts/counts.py` (строка «--lint руками» переписана), шапка `docs/scripts/githooks/pre-commit`, `docs/README.md`. (17.08.2026, оркестратор №18) ✅ + +## D39.149 — ПРИЁМКА БЭКЕНД-ПАКА «ЧЕСТНЫЕ ЧИСЛА И СТАТУСЫ» ПРИНЯТА С ФИКС-ЛИСТОМ И ЗАЛЕНДЕНА: строки 176/181/187 закрыты, 172-г НЕ закрыта, новые 194–196 (17.08). ✅ + +**1. Приёмка исполнением (панель 5 линз в изолированных копиях + пере-раны оркестратора; $0, только агент-токены).** Линзы: слепая (заказ+дифф ДО отчёта) · деньги · шов · вне карты · собственные мутации в копии дерева. Все пять — ACCEPT_WITH_FIXES, ни одного REJECT, регрессов против HEAD нет. **Пере-проверено МОЕЙ рукой:** батарея EXIT=0 с ровно двумя SKIP (оба helper-процессы) · голден бит-в-бит · `^func Test` = 827, удалённых имён 0 (числа отчёта 819/823 — промежуточные снимки, поправлено на месте) · **деньги живой пробы ДВУМЯ независимыми путями** (сумма чекпоинтов минус вызов 02.08 · пере-счёт из сырого `usage_json` по запинённой таблице `models.yaml`) — оба дали **$0.005582424**, знак в знак с заявленным · вердикт гейта из СЫРОГО артефакта: `runs: 5, runs_at_threshold: 4`, `finish=stop` ×5. Механика, фикс-лист ФЧ-1…ФЧ-8 и «что не проверено» — запись приёмки в PROGRESS «Бэкенд» (состав там). + +**2. Что ратифицируется этой нотой.** (а) **Паника любой горутины движка → exit 1** принято: номера полос не двинуты (добавлен ровно один `return 1`), платформа читает 1 как `failed` во всех ПЯТИ точках потребления, тогда как ДО пака упавший посреди книги прогон записывался `ready` — это закрытие PD-212 со стороны движка. (б) **`obs.PanicError` намеренно без `Unwrap`** — ратифицировано: иначе паника значением `context.Canceled`/`*CeilingHalt` уехала бы в 5/4 при формально нетронутом словаре. (в) **Пере-прайс проекции идёт ФАКТИЧЕСКИМИ токенами чекпоинтов через шов сеттла**, а не оценкой; обе половины гейта (числитель и база 5%-порога) починены вместе — ратифицировано как единственная форма, при которой порог не сравнивает две валюты. (г) **Выборочный порог D39.136 п.6б внесён в риг**; прогон дал 4/5 — гейт ПРОЙДЕН, и это ровно тот случай, на котором прежняя форма «6/6 на каждом» упала бы ложно. + +**3. Строки.** **176 ЗАКРЫТА** для паник (остаточный класс `fatal error` — новая **196**). **181 ЗАКРЫТА** (число согласия и `projected_book_usd` считаются текущей таблицей; ось МОДЕЛИ — открытый хвост ФЧ-5). **187 ЗАКРЫТА** в коде (тот же снятый закон в докстринге теста и `D15.2:317` — ФЧ-3). **172-г НЕ ЗАКРЫТА и закрывать по этому прогону запрещено** (риг меряет классификатор на flash, сменившиеся веса — у pro-редактора; дописка в теле строки). Новые: **194** деньги банковых ролей вне контура согласия (преexisting, подтверждён двумя линзами) · **195** маркер поколения на `checkpoints` — схемное решение владельца, а не третья заплата поверх ВЫВОДА членства · **196** остаточный класс `fatal error`. + +**4. Владельцу (не решается этой нотой).** (а) **Ось МОДЕЛИ в числе согласия (ФЧ-5):** при смене модели стадии проекция считает ценой отставленной модели — замер линзы $0.003640 против честных $0.036400, и текст согласия при этом заявляет «current price table» без оговорки. Развилка: прайсить по модели, которую стадия резолвит СЕЙЧАС, либо оставить и назвать вслух в тексте согласия. Это деньги ⇒ слово владельца. (б) **Строка 195** — заводить ли `snapshot_id`/`run_id` на денежной таблице. + +**5. Метод-заметка приёмки.** Три HIGH фикс-листа — не дефекты поведения, а НЕПРИБИТЫЕ гарантии: мутации переживали зелёную батарею. Самую тяжёлую (снятие фильтра по книге в `CheckpointUsageForBook`) оркестратор воспроизвёл лично — ни store-, ни денежные pipeline-тесты не краснеют, то есть единственная защита от денег ЧУЖОЙ книги в числе согласия держится не тестом. Сессия сама провела два раунда адверсариального ревью (38 агентов) и нашла в своей работе два настоящих регресса при зелёной самопроверке — это правильное поведение, и оно же показывает, что финальная панель приёмки не заменяется самопроверкой. (17.08.2026, оркестратор №18) ✅