Land the honesty pack: consent numbers repriced at the current table, a panicking engine no longer reads as success, and the classifier gate re-based
This commit is contained in:
parent
0ef049f492
commit
f840867327
23 changed files with 1446 additions and 89 deletions
|
|
@ -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 <translate|report|status|export|redrive|manifest> --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 <translate|report|status|export|redrive|manifest|backup|migrate> --config book.yaml | tmctl seed-lint --seed glossary.yaml")
|
||||
}
|
||||
cmd, rest := args[0], args[1:]
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <translate|report|status|export|redrive|manifest> --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 <translate|report|status|export|redrive|manifest|backup|migrate> --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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, "|"))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
100
backend/cmd/tmctl/panic_exit_test.go
Normal file
100
backend/cmd/tmctl/panic_exit_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 и заблокировала бы даже центовый
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
38
backend/internal/obs/panic.go
Normal file
38
backend/internal/obs/panic.go
Normal file
|
|
@ -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: <err>` 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()}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
backend/internal/pipeline/live_reprobe_threshold_test.go
Normal file
66
backend/internal/pipeline/live_reprobe_threshold_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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=<usd> 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=<usd> 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
135
backend/internal/pipeline/reprice.go
Normal file
135
backend/internal/pipeline/reprice.go
Normal file
|
|
@ -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
|
||||
498
backend/internal/pipeline/reprice_test.go
Normal file
498
backend/internal/pipeline/reprice_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
158
backend/internal/pipeline/wavepanic_test.go
Normal file
158
backend/internal/pipeline/wavepanic_test.go
Normal file
|
|
@ -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: <err>` 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
51
backend/internal/store/ledger_test.go
Normal file
51
backend/internal/store/ledger_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <translate|report|status|export|redrive|manifest> --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-<effort>.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).)*
|
||||
|
|
|
|||
|
|
@ -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); владельцу — ось МОДЕЛИ в числе согласия | ЖИВОЕ: фикс-лист ФЧ и два решения владельца | жив | деньги приёмка шов бэкенд |
|
||||
|
|
|
|||
|
|
@ -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) ✅
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue