textmachine/backend/internal/pipeline/volume_test.go

1516 lines
67 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"textmachine/backend/internal/chunk/chunktest"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
)
// writeFakeCompletion emits the same completion body newJSONProvider does, for the tests that need to
// control the HTTP status per request and therefore cannot use that helper.
func writeFakeCompletion(w http.ResponseWriter, text, finish string) {
tb, _ := json.Marshal(text)
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":%q}],
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`,
tb, finish)
}
// volumeBook builds an n-chapter epub. With the default two-stage pipeline each chapter is one draft
// chunk and therefore exactly one OUTPUT UNIT, so "units" and "chapters" coincide and the assertions
// below read as the purchase does.
func volumeBook(t *testing.T, providerURL string, n int) string {
t.Helper()
var eps []chunktest.Chapter
var spine []string
for i := 1; i <= n; i++ {
id := fmt.Sprintf("c%d", i)
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml", Body: fmt.Sprintf("<p>静かな図書館の朝%d。</p>", i)})
spine = append(spine, id)
}
// waveWorkers > 1 on purpose: the admission must hold under the parallel fan-out, not only in a
// sequential walk. A design that counted slots inside the workers would be racy exactly here.
return setupProjectOpts(t, providerURL, projectOpts{epub: eps, spine: spine, regenerate: 1, waveWorkers: 4})
}
// chaptersWithRows reports which chapters left any chunk_status row behind — i.e. which units the run
// actually STARTED. It is the instrument for "stopped before N+1 rather than after it": a unit that began
// and was cut off would still have written a row.
func chaptersWithRows(t *testing.T, s *store.Store, bookID string) map[int]bool {
t.Helper()
rows, err := s.ChunkStatusesForBook(bookID)
if err != nil {
t.Fatal(err)
}
out := map[int]bool{}
for _, cs := range rows {
out[cs.Chapter] = true
}
return out
}
// TestTheVolumeCeilingStopsAtWhatWasBought is the §4.2 post-hoc invariant, and all three of its clauses
// are asserted separately because each can fail on its own: the run stops AT N, it says it stopped on
// VOLUME (in a way a money stop could not be confused with), and it stops BEFORE beginning N+1 rather
// than after paying for it.
//
// The third clause is the one that matters most and the one external systems get wrong: LiteLLM's
// post-hoc token check always overshoots its ceiling by one call, because it looks after the call rather
// than before it. Here the evidence is direct — unit N+1 has no chunk_status row at all, and the provider
// call count is exactly what N units cost and not one call more.
func TestTheVolumeCeilingStopsAtWhatWasBought(t *testing.T) {
const chapters, granted = 5, 2
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, chapters)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
r.MaxUnits = granted
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatalf("a volume stop must not be an error — it is a completion: %v", err)
}
// (1) It stopped AT N.
if len(res.Chunks) != granted {
t.Fatalf("the run shipped %d unit(s), want exactly the %d that were granted", len(res.Chunks), granted)
}
if res.ExitCode() != 0 {
t.Fatalf("exit code %d: a volume stop is a COMPLETION, and the frozen exit dictionary must not gain a word", res.ExitCode())
}
// (2) It NAMES the ceiling, distinguishably.
if res.Volume == nil {
t.Fatalf("the run stopped short and said nothing about why — the silent-limit defect this pack exists to avoid")
}
if res.Volume.MaxUnits != granted || res.Volume.Delivered != granted || res.Volume.LeftFresh != chapters-granted {
t.Fatalf("the stop misreports itself: %+v (want max=%d delivered=%d left_fresh=%d)",
*res.Volume, granted, granted, chapters-granted)
}
// Nothing had ever been delivered, so nothing can be REWORK — and saying so is the point of the
// split: a purchase that delivers nothing new must not be reportable as though it did.
if res.Volume.Reworked != 0 || res.Volume.LeftRework != 0 {
t.Fatalf("a book nothing has run has nothing to re-make: %+v", *res.Volume)
}
if res.Volume.Free != 0 {
t.Fatalf("nothing had been run before, so nothing could ride free; got %d", res.Volume.Free)
}
// (3) It stopped BEFORE beginning N+1, not after.
started := chaptersWithRows(t, r.Store, "test-book")
for ch := 1; ch <= granted; ch++ {
if !started[ch] {
t.Fatalf("chapter %d was granted but never ran: %v", ch, started)
}
}
for ch := granted + 1; ch <= chapters; ch++ {
if started[ch] {
t.Fatalf("chapter %d is past the ceiling and still left a row — the ceiling was checked AFTER the unit began, which is the overshoot-by-one this design exists to prevent", ch)
}
}
// The direct money evidence: two stages per unit, so N units cost exactly 2N calls. One more would
// mean a unit past the ceiling reached a provider.
if want := granted * 2; rec.count() != want {
t.Fatalf("%d provider call(s) for %d granted unit(s); want exactly %d (draft+edit each) — anything more is work past the ceiling", rec.count(), granted, want)
}
}
// TestAVolumeStopIsNotAMoneyStop pins the distinction the two ceilings owe the operator. They are
// different answers with different remedies: money means the run was cut short mid-work and is resumable
// once there is more of it; volume means the run did precisely what was bought. Reporting them alike
// would either strand a paid-for run as "paused" or show a satisfied buyer a service error.
func TestAVolumeStopIsNotAMoneyStop(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// The MONEY ceiling: an amount too small for the book's first call.
moneyBook := volumeBook(t, srv.URL, 3)
rm := newRunner(t, moneyBook)
rm.CeilingUSD = 0.0000001
_, moneyErr := rm.TranslateBook(ctx)
rm.Close()
var halt *CeilingHalt
if !errors.As(moneyErr, &halt) {
t.Fatalf("a money ceiling must still halt through its own typed sentinel, got %v", moneyErr)
}
// The VOLUME ceiling on an equivalent book: no error at all, and a report that says which ceiling.
volBook := volumeBook(t, srv.URL, 3)
rv := newRunner(t, volBook)
defer rv.Close()
rv.MaxUnits = 1
res, err := rv.TranslateBook(ctx)
if err != nil {
t.Fatalf("a volume stop must not travel as an error: %v", err)
}
if res.Volume == nil {
t.Fatalf("the volume stop lost its name")
}
// And the money ceiling stayed out of it: nothing about the volume stop touched the ledger's semantics.
if rv.CeilingUSD != 0 {
t.Fatalf("the volume ceiling must not set a money ceiling; CeilingUSD = %v", rv.CeilingUSD)
}
}
// TestAResumeDoesNotSpendItsVolumeOnFreeUnits is the repin/resume half of the counting rule, executed.
// A second purchase of two units on a book whose first two are already done must deliver units THREE and
// FOUR — not re-walk the first two and deliver nothing. If free work counted, the buyer would pay again
// for what they already own, which is the whole defect the ceiling is meant to close.
func TestAResumeDoesNotSpendItsVolumeOnFreeUnits(t *testing.T) {
const chapters = 5
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, chapters)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
r1.MaxUnits = 2
res1, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
r1.Close()
if len(res1.Chunks) != 2 {
t.Fatalf("first purchase: want 2 units, got %d", len(res1.Chunks))
}
callsAfterFirst := rec.count()
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.MaxUnits = 2
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if res2.Volume == nil {
t.Fatalf("the second purchase also stops on the ceiling and must say so")
}
// The two already-owned units rode along for free and did NOT consume the new grant.
if res2.Volume.Free != 2 {
t.Fatalf("%d unit(s) rode free, want the 2 already paid for — a $0 resume must not consume the ceiling", res2.Volume.Free)
}
if res2.Volume.Delivered != 2 || res2.Volume.Reworked != 0 {
t.Fatalf("the second purchase must DELIVER 2 new units and re-make none: %+v", *res2.Volume)
}
if res2.Volume.LeftFresh != 1 || res2.Volume.LeftRework != 0 {
t.Fatalf("1 of the 5 was never delivered and nothing is awaiting a re-make: %+v", *res2.Volume)
}
started := chaptersWithRows(t, r2.Store, "test-book")
for ch := 1; ch <= 4; ch++ {
if !started[ch] {
t.Fatalf("chapter %d should exist after two purchases of two: %v", ch, started)
}
}
if started[5] {
t.Fatalf("chapter 5 was never bought and must not have run")
}
// The second run paid for exactly two units and replayed the first two for nothing.
if fresh := rec.count() - callsAfterFirst; fresh != 4 {
t.Fatalf("the second purchase made %d provider call(s), want 4 (two units × two stages) — the free units must cost none", fresh)
}
}
// TestRetriesDoNotBurnTheVolumeCeiling is the other half of the counting rule. A unit that stutters and
// is retried is still ONE unit of book. If a retry consumed the ceiling, the buyer of two chapters would
// receive one because the engine's own quality tail — measured swinging between 1.5% and 23% of calls —
// happened to land on their purchase. That tail is a fact about MONEY and is bounded by the money ceiling.
func TestRetriesDoNotBurnTheVolumeCeiling(t *testing.T) {
var mu sync.Mutex
stuttered := false
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if !isEditBody(body) {
mu.Lock()
first := !stuttered
stuttered = true
mu.Unlock()
if first {
// A truncated draft: `length` is in the retryable set, so runStage retries the same
// chunk×stage with a bigger budget and still writes ONE row for it.
return "ЧЕРНОВИК", "length"
}
}
return draftEdit(body)
})
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 4)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
r.MaxUnits = 2
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if !stuttered {
t.Fatalf("the fixture never produced a retry, so it proves nothing")
}
if len(res.Chunks) != 2 {
t.Fatalf("a retried unit is still one unit: want 2 shipped, got %d", len(res.Chunks))
}
if res.Volume == nil || res.Volume.Delivered != 2 {
t.Fatalf("the retry must not have consumed a unit of the grant: %+v", res.Volume)
}
// The extra provider call is real and paid for — it is simply not a unit of BOOK.
if rec.count() <= 4 {
t.Fatalf("precondition: the retry should have cost an extra call, got %d", rec.count())
}
}
// TestNoCeilingChangesNothing pins the default. A run without --max-units must behave exactly as it did
// before this existed — including not paying for the scope computation, which is why planVolume returns a
// nil scope rather than an all-admitting one.
func TestNoCeilingChangesNothing(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 3)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
scope, err := r.planVolume(context.Background(), nil, nil)
if err != nil || scope != nil {
t.Fatalf("an unset ceiling must plan nothing at all, got scope=%v err=%v", scope, err)
}
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 3 {
t.Fatalf("an unbounded run must translate the whole book, got %d units", len(res.Chunks))
}
if res.Volume != nil {
t.Fatalf("an unbounded run must not claim it was cut short: %+v", res.Volume)
}
}
// TestAGrantLargerThanTheBookIsAnOrdinaryCompletion pins the boundary the `bound()` guard exists for: a
// run granted more than there is left has not been cut short, and must not report as though it had.
func TestAGrantLargerThanTheBookIsAnOrdinaryCompletion(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 2)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
r.MaxUnits = 50
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 2 {
t.Fatalf("the whole book should have run, got %d units", len(res.Chunks))
}
if res.Volume != nil {
t.Fatalf("a grant larger than the book is not a volume stop: %+v", res.Volume)
}
}
// TestABoundedRunCannotOutrunTheConsentThreshold pins the correction to a defect this pack shipped: the
// consent threshold must not be defeatable by splitting the run.
//
// The shipped version scoped the re-payment AMOUNT to the admitted units and left the THRESHOLD
// book-wide. That reads as symmetrical and is not, because with a volume ceiling the CALLER chooses how
// small each run is: `--resnapshot --max-units 1` in a loop re-pays the whole book while every single run
// stays under the threshold, and consent is never asked once. That is exactly the silent re-purchase Р6
// exists to prevent, and "the change is inert without the flag" — the argument the narrowing was accepted
// on, mine — is true and beside the point, since with the flag it opens the door the gate IS.
//
// The correction splits the two questions by what each one is ABOUT. "Must a human be asked at all" is
// the book's policy and is judged against the book's whole drift, which a small purchase cannot shrink.
// "--accept-rebill=X", by contrast, is the caller's instruction about SPEND, so it is measured against
// what this run will actually be charged — the platform funds that cap from the run's own hold, and
// measuring it against work the run will not do would refuse a caller whose money fully covers the bill.
func TestABoundedRunCannotOutrunTheConsentThreshold(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
var eps []chunktest.Chapter
var spine []string
for i := 1; i <= 4; i++ {
id := fmt.Sprintf("c%d", i)
// 静か occurs in every chapter, so the signed term changes EVERY unit's injected bytes: all four
// units genuinely re-pay rather than re-pin, which is what makes the arithmetic below discriminate.
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml", Body: fmt.Sprintf("<p>静かな図書館の朝%d。</p>", i)})
spine = append(spine, id)
}
// One unit re-pays one edit row ≈ $0.00182; four units ≈ $0.00728. The threshold sits between them,
// which is precisely the gap a splitting caller would walk through.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
epub: eps, spine: spine, regenerate: 1, waveWorkers: 4, rebillConsentUSD: 0.005,
})
dir := filepath.Dir(bookPath)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatalf("the baseline run must complete: %v", err)
}
r1.Close()
doc := decisionsDocFor(t, dir, "test-book", membank.Decision{
Action: membank.ActionApprove, Src: "静か", Dst: "тихий"})
if _, err := ApplyBankDecisions(ctx, bookPath, doc, false); err != nil {
t.Fatalf("bank-apply: %v", err)
}
// THE ATTACK: a purchase small enough that its own slice is far under the threshold. It must still be
// stopped, because the BOOK's drift is over it.
callsBefore := rec.count()
rA := newRunner(t, bookPath)
rA.Resnapshot = true
rA.MaxUnits = 1
_, errA := rA.TranslateBook(ctx)
rA.Close()
if errA == nil {
t.Fatalf("a run bounded to one unit walked past the consent gate; repeated four times it re-buys the whole book without ever asking")
}
if !strings.Contains(errA.Error(), "RE-PAY") {
t.Fatalf("want the re-payment gate's refusal, got: %v", errA)
}
// The refusal has to show BOTH numbers, or it teaches the operator the wrong thing: the book's total
// is what the threshold judged, and the run's slice is what they would actually be charged.
if !strings.Contains(errA.Error(), "THIS run, bounded by --max-units") {
t.Fatalf("the refusal must separate the book's drift from this run's slice, got: %v", errA)
}
if fresh := rec.count() - callsBefore; fresh != 0 {
t.Fatalf("the gate refuses BEFORE any reservation; %d call(s) were made", fresh)
}
// AND THE OTHER DIRECTION, which is why the narrowing was attempted at all: a NAMED cap is an
// instruction about spend, so it is measured against what THIS run re-pays (~$0.00182) and not against
// the book's $0.00728. A caller whose money covers their own bill is not refused.
rB := newRunner(t, bookPath)
defer rB.Close()
rB.Resnapshot = true
rB.MaxUnits = 1
rB.AcceptRebill = RebillConsent{Given: true, Capped: true, CapUSD: 0.002}
resB, errB := rB.TranslateBook(ctx)
if errB != nil {
t.Fatalf("a cap of $0.002 covers this run's own re-payment of ~$0.00182 and must be honoured; measuring it against the whole book would refuse a caller who can pay: %v", errB)
}
// ⚠ AND THE SPLIT EARNS ITSELF HERE. Every unit of this book was delivered by the baseline run, so
// this purchase delivers NOTHING new — it re-makes one chapter the reader already has. Reported as a
// single "granted" count it would have read exactly like a delivery of one chapter.
if resB.Volume == nil || resB.Volume.Reworked != 1 || resB.Volume.Delivered != 0 {
t.Fatalf("this purchase re-makes an already-delivered unit and delivers nothing new: %+v", resB.Volume)
}
if resB.Volume.LeftFresh != 0 || resB.Volume.LeftRework != 3 {
t.Fatalf("nothing is left UNDELIVERED here; 3 delivered units await a re-make: %+v", resB.Volume)
}
if fresh := rec.count() - callsBefore; fresh != 1 {
t.Fatalf("the consented run re-pays one unit = one edit call; it made %d", fresh)
}
}
// TestTheVolumeCeilingHoldsOnADraftOnlyPipeline pins the ceiling on the OTHER pipeline shape. It matters
// because the output unit is not the same object in the two: an editor pipeline groups draft chunks into
// coarse edit units, while a draft-only pipeline ships one unit per draft chunk (outputUnits). A ceiling
// that only held for the shape the fixtures happen to use would silently sell a different amount of book
// on a deployment configured the other way.
func TestTheVolumeCeilingHoldsOnADraftOnlyPipeline(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
var eps []chunktest.Chapter
var spine []string
for i := 1; i <= 4; i++ {
id := fmt.Sprintf("c%d", i)
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml", Body: fmt.Sprintf("<p>静かな図書館の朝%d。</p>", i)})
spine = append(spine, id)
}
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
epub: eps, spine: spine, regenerate: 1, waveWorkers: 4, draftOnly: true,
})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
r.MaxUnits = 2
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if len(res.Chunks) != 2 {
t.Fatalf("a draft-only run granted 2 units must ship 2, got %d", len(res.Chunks))
}
if res.Volume == nil || res.Volume.Delivered != 2 || res.Volume.LeftFresh != 2 {
t.Fatalf("the stop misreports itself on a draft-only pipeline: %+v", res.Volume)
}
// One stage per unit here, so two units cost exactly two calls — and nothing past the ceiling ran.
if rec.count() != 2 {
t.Fatalf("%d provider call(s); a draft-only unit is one call, so 2 granted units cost 2", rec.count())
}
started := chaptersWithRows(t, r.Store, "test-book")
if started[3] || started[4] {
t.Fatalf("chapters past the ceiling must not have started: %v", started)
}
}
// TestAnUnreproducibleHashIsNeverFree pins the DIRECTIONAL invariant volume.go calls load-bearing:
// "every uncertainty resolves to «this unit will cost», never to «this unit is free»". The asymmetry is
// the whole safety of the ceiling — judging a paying unit free lets the run spend PAST what was bought,
// while the opposite merely stops a run one unit early.
//
// The dangerous branch is the one where the wire bytes cannot be REPRODUCED at all (an absent hash),
// which is a different state from "reproduced and different". renderedContentHashes omits a position
// whose inputs it cannot rebuild — a member's stored draft text gone, a chunk boundary moved by a
// re-split — and an absent entry must read as "cannot conclude", i.e. paying. A condition written
// `ok && h != cs.ContentHash` passes the whole battery while inverting exactly this case.
func TestAnUnreproducibleHashIsNeverFree(t *testing.T) {
r := &Runner{}
draftNames := map[string]bool{"draft": true}
editNames := map[string]bool{"edit": true}
const snap = "SNAP"
row := store.ChunkStatus{Chapter: 1, ChunkIdx: 0, Stage: "draft",
Disposition: string(DispOK), ContentHash: "h1", SnapshotID: snap}
rows := []store.ChunkStatus{row}
present := map[chunkKey]map[string]string{{1, 0}: {"draft": "h1"}}
// Baseline: reproduced AND equal, on the current snapshot — genuinely free.
if !r.rowsResumeFree(rows, draftNames, editNames, snap, snap, present) {
t.Fatalf("a row whose bytes reproduce identically on the current snapshot resumes for $0")
}
// Reproduced and DIFFERENT — pays.
differs := map[chunkKey]map[string]string{{1, 0}: {"draft": "OTHER"}}
if r.rowsResumeFree(rows, draftNames, editNames, snap, snap, differs) {
t.Fatalf("the wire bytes moved; the unit will be called for and must not be judged free")
}
// NOT REPRODUCED AT ALL — the branch this test exists for. The stage key is missing.
noStage := map[chunkKey]map[string]string{{1, 0}: {}}
if r.rowsResumeFree(rows, draftNames, editNames, snap, snap, noStage) {
t.Fatalf("the bytes could not be reproduced, so nothing is known — judging the unit FREE lets the run spend past the ceiling")
}
// NOT REPRODUCED AT ALL — the position itself is absent.
if r.rowsResumeFree(rows, draftNames, editNames, snap, snap, map[chunkKey]map[string]string{}) {
t.Fatalf("an absent position must read as «cannot conclude», i.e. paying")
}
// A `skipped` row never reached a provider and never will: it is re-derived from the flag above it.
skipped := []store.ChunkStatus{{Chapter: 1, ChunkIdx: 0, Stage: "edit",
Disposition: string(DispSkipped), ContentHash: "whatever", SnapshotID: "STALE"}}
if !r.rowsResumeFree(skipped, draftNames, editNames, snap, snap, map[chunkKey]map[string]string{}) {
t.Fatalf("a skipped stage costs nothing even with no hash and a stale snapshot")
}
// A stage this pipeline no longer runs cannot cost anything either.
retired := []store.ChunkStatus{{Chapter: 1, ChunkIdx: 0, Stage: "gone",
Disposition: string(DispOK), ContentHash: "x", SnapshotID: "STALE"}}
if !r.rowsResumeFree(retired, draftNames, editNames, snap, snap, map[chunkKey]map[string]string{}) {
t.Fatalf("a retired stage is never run, so it is never a cost")
}
}
// TestTheCeilingAdmitsEveryMemberOfAUnit closes the coverage hole every other fixture in this package
// leaves open: they all build one-sentence chapters, so every editUnit has exactly ONE member and the
// per-member loop in planVolume's admit() is never exercised. On a real book a chapter is longer than one
// draft chunk, and that is the shape volume.go's own comment describes — "an editor pipeline groups draft
// chunks into coarse edit units".
//
// What the hole hides is not a cosmetic slip. Admission is asked TWICE with two different keys: the draft
// wave asks per member chunk (scope.allows) and the edit wave asks per unit (scope.allowsUnit). If admit()
// marked only the unit's leader, the non-leader members would never be drafted while the unit still read
// as admitted — so the edit wave would run over a unit whose drafts are missing, and the buyer would
// receive a truncated chapter they had paid for in full, silently.
func TestTheCeilingAdmitsEveryMemberOfAUnit(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// A fine DRAFT cut under a coarse EDIT ceiling is what produces multi-member units: each chapter
// splits into several draft chunks that regroup into one edit unit.
const seg = "\nsegmentation:\n draft_budget_out: 24\n edit_ceiling_out: 8000\n fertility: { cjk: 1.1978, other: 0.3852 }\n"
body := "<p>静かな図書館の朝。鈴木は本を読んだ。外では雨が降っていた。彼は窓を見た。時間は静かに過ぎた。</p>"
var eps []chunktest.Chapter
var spine []string
for i := 1; i <= 3; i++ {
id := fmt.Sprintf("c%d", i)
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml", Body: body})
spine = append(spine, id)
}
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
epub: eps, spine: spine, regenerate: 1, waveWorkers: 4, gatesYAML: seg,
})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
r.MaxUnits = 1
res, err := r.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if res.Volume == nil || res.Volume.Delivered != 1 {
t.Fatalf("one unit was granted and delivered: %+v", res.Volume)
}
// The fixture must actually produce a MULTI-member unit, or this test proves nothing about the loop.
rows, err := r.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
drafted := map[int]map[int]bool{}
for _, cs := range rows {
if cs.Stage != "draft" {
continue
}
if drafted[cs.Chapter] == nil {
drafted[cs.Chapter] = map[int]bool{}
}
drafted[cs.Chapter][cs.ChunkIdx] = true
}
// How many chunks the CUT produced for chapter 1 — read from the manifest, which is persisted before
// the waves and is therefore unaffected by the ceiling. Counting drafted rows instead would let a
// defect that skips members disguise itself as a thin fixture.
manifestChunks, _, err := r.readModelChunks()
if err != nil {
t.Fatal(err)
}
var ch1 []int
for _, c := range manifestChunks {
if c.Chapter == 1 {
ch1 = append(ch1, c.ChunkIdx)
}
}
if len(ch1) < 2 {
t.Fatalf("fixture is degenerate: the cut gave chapter 1 %d draft chunk(s), so the multi-member path is untested", len(ch1))
}
// EVERY member of the admitted unit was drafted — not just its leader.
for _, idx := range ch1 {
if !drafted[1][idx] {
t.Fatalf("chapter 1 has %d draft chunks in the manifest but member chunk %d was never drafted: the unit was admitted for the edit wave while part of its draft was skipped, so the buyer gets a truncated chapter they paid for in full", len(ch1), idx)
}
}
// And the unit's edit really ran over the whole thing: one shipped unit, no dropped members.
if len(res.Chunks) != 1 {
t.Fatalf("one admitted unit ships one output unit, got %d", len(res.Chunks))
}
if res.Chunks[0].DroppedMembers != 0 {
t.Fatalf("the edit lost %d member(s) of a unit that was paid for in full: %+v", res.Chunks[0].DroppedMembers, res.Chunks[0])
}
// Nothing outside the granted unit began.
if len(drafted[2]) != 0 || len(drafted[3]) != 0 {
t.Fatalf("chapters past the ceiling started anyway: ch2=%d ch3=%d chunks", len(drafted[2]), len(drafted[3]))
}
}
// TestAFlaggedMemberWithNoEditRowIsNotFree reproduces an OVERSPEND this pack shipped and then fixed, and
// it is the most dangerous class the ceiling can have: a unit wrongly judged free is admitted WITHOUT
// consuming a grant and then charged for anyway, so the run pays for more units than were bought — and
// because nothing was deferred, it does not even report a volume stop to say so.
//
// The state that produces it is ordinary, not exotic: a unit whose draft flagged for one member and whose
// EDIT row does not exist yet. Every book left by a bank-signing stop, a Ctrl-C, or any abort inside the
// edit wave is in it. The first version of unitsServedFree asked resolveChunkState whether the unit was
// terminal, and that resolver answers ChunkFlagged as soon as ANY row is flagged — before it ever checks
// that the expected positions are present.
func TestAFlaggedMemberWithNoEditRowIsNotFree(t *testing.T) {
var mu sync.Mutex
editWorks := false
rec := &reqRec{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
rec.record(string(body))
s := string(body)
if isEditBody(s) {
mu.Lock()
ok := editWorks
mu.Unlock()
if !ok {
// Run 1's editor never completes, so no edit chunk_status row is ever written — exactly
// the store state an interrupted edit wave leaves.
w.WriteHeader(http.StatusInternalServerError)
return
}
writeFakeCompletion(w, "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop")
return
}
if strings.Contains(s, "壊れた時計") {
writeFakeCompletion(w, "", "stop") // an empty draft flags the member
return
}
writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop")
}))
defer srv.Close()
const seg = "\nsegmentation:\n draft_budget_out: 24\n edit_ceiling_out: 8000\n fertility: { cjk: 1.1978, other: 0.3852 }\n"
body := "<p>静かな図書館の朝。鈴木は本を読んだ。壊れた時計があった。外では雨が降っていた。彼は窓を見た。</p>"
clean := "<p>静かな図書館の朝。鈴木は本を読んだ。外では雨が降っていた。彼は窓を見た。時間は過ぎた。</p>"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
epub: []chunktest.Chapter{{ID: "c1", Href: "c1.xhtml", Body: body}, {ID: "c2", Href: "c2.xhtml", Body: clean}},
spine: []string{"c1", "c2"}, regenerate: 0, waveWorkers: 1, gatesYAML: seg,
})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// Run 1: drafts land (one member flagged empty), the editor dies, so NO edit row is written anywhere.
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err == nil {
t.Fatalf("precondition: the editor was supposed to fail this run")
}
rows, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
r1.Close()
flagged, edits := 0, 0
for _, cs := range rows {
if cs.Stage == "edit" {
edits++
}
if cs.Disposition == string(DispFlagged) {
flagged++
}
}
if flagged == 0 || edits != 0 {
t.Fatalf("precondition: want a flagged draft member and NO edit row, got flagged=%d edit rows=%d", flagged, edits)
}
// Run 2, granted ONE unit. The unit above is not free — its edit has never run — so it must consume
// the grant, and the second unit must be deferred rather than also paid for.
mu.Lock()
editWorks = true
mu.Unlock()
callsBefore := rec.count()
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.MaxUnits = 1
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if res.Volume == nil {
t.Fatalf("one of the two units had to be deferred, so the run must report a volume stop; it reported none — the other unit was judged free and paid for anyway")
}
// The unit was never fully recorded, so it has never been DELIVERED — paying for it is delivery, not
// rework, and the split must say so.
if res.Volume.Delivered != 1 || res.Volume.Reworked != 0 || res.Volume.Free != 0 || res.Volume.LeftFresh != 1 {
t.Fatalf("a unit with no edit row is neither free nor rework — it has never been delivered: %+v", *res.Volume)
}
if len(res.Chunks) != 1 {
t.Fatalf("OVERSPEND: %d output unit(s) were paid for under --max-units 1", len(res.Chunks))
}
// One editor call for the one granted unit, and nothing for the deferred one.
if fresh := rec.count() - callsBefore; fresh != 1 {
t.Fatalf("the granted unit needed one edit call; the run made %d", fresh)
}
}
// TestTheCeilingRefusesAPipelineWhoseUnitsAreTwoThings pins the refusal for the one pipeline shape in
// which "output unit" stops naming a single object.
//
// config validates stage ROLES but imposes no ORDER, so [draft(translator), edit(editor),
// polish(translator)] loads. finalStageWave() then answers waveDraft — the last role is a translator —
// so outputUnits returns one singleton per DRAFT CHUNK, while the edit wave still groups those chunks
// through buildEditUnits. A ceiling granted in singletons would admit one member's chunk and then let the
// edit wave run the whole multi-member unit anyway, feeding the editor a unit whose source is complete
// and whose draft is missing a member — a full-price call over half-made input, charged as the one unit
// that was bought. It would also make the platform's chapters→units conversion stop being exact, which is
// the entire reason for measuring in units. Refused loudly rather than silently miscounted.
func TestTheCeilingRefusesAPipelineWhoseUnitsAreTwoThings(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// gatesYAML is appended immediately after the stage list, so this continues it with a THIRD stage
// whose role is a translator — putting the shipping stage back in the draft wave.
const polish = " - { name: polish, role: translator, model: fake-model, prompt_override: prompts/translator.md, prompt_version: v-test, temperature: 0.3, reasoning: \"off\" }\n"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 1, source: suzukiSource, gatesYAML: polish,
})
r := newRunner(t, bookPath)
defer r.Close()
// Precondition: the fixture really is the incoherent shape, or the test proves nothing.
if r.finalStageWave() != waveDraft || len(r.waveStagesIndexed(waveEdit)) == 0 {
t.Fatalf("fixture drifted: want a translator-last pipeline that still has editor stages, got finalWave=%v editStages=%d",
r.finalStageWave(), len(r.waveStagesIndexed(waveEdit)))
}
// With no ceiling the shape is none of this pack's business and must be left exactly as it was.
r.MaxUnits = 0
if scope, err := r.planVolume(context.Background(), nil, nil); err != nil || scope != nil {
t.Fatalf("an unbounded run must be untouched by this refusal: scope=%v err=%v", scope, err)
}
// With one, the ceiling cannot mean one thing, so it refuses.
r.MaxUnits = 1
_, err := r.planVolume(context.Background(), nil, nil)
if err == nil {
t.Fatalf("a ceiling counted in draft chunks while the edit wave works in grouped units must refuse, not guess")
}
if !strings.Contains(err.Error(), "cannot bound this pipeline") {
t.Fatalf("the refusal must say which shape it is turning away, got: %v", err)
}
}
// TestAFullyDeliveredBookOffersNothingLeftToBuy pins the defect that made re-payment look like a product.
//
// The trajectory: a book translated to 100%, then a bank edit. Every unit is now "not free", so the old
// single counter admitted them, called them granted, and reported the rest as "remain in the book" — which
// the CLI presented as what is left to BUY. Purchase one said 29 remained, purchase two said 28, and not
// one new chapter existed at any point. exit 0 and `ready` both agreed it had gone well.
//
// The counters must now say the true thing: nothing was DELIVERED, N units were RE-MADE, and the number
// of never-delivered units is ZERO — so there is nothing to invite anyone to buy.
func TestAFullyDeliveredBookOffersNothingLeftToBuy(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 4)
dir := filepath.Dir(bookPath)
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()
// Every chapter contains 静か, so signing it moves every unit's injected bytes: the whole book becomes
// re-payable, and none of it becomes new.
doc := decisionsDocFor(t, dir, "test-book", membank.Decision{
Action: membank.ActionApprove, Src: "静か", Dst: "тихий"})
if _, err := ApplyBankDecisions(ctx, bookPath, doc, false); err != nil {
t.Fatal(err)
}
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
r2.MaxUnits = 1
r2.AcceptRebill = RebillConsent{Given: true} // the operator consented; that is not what is under test
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
v := res.Volume
if v == nil {
t.Fatalf("three units were held back, so the run must report a volume stop")
}
if v.Delivered != 0 {
t.Fatalf("this book was already complete: no purchase over it can DELIVER anything, yet %d was reported", v.Delivered)
}
if v.Reworked != 1 {
t.Fatalf("one already-delivered unit was re-made; got %d", v.Reworked)
}
if v.LeftFresh != 0 {
t.Fatalf("⚠ %d unit(s) reported as never-delivered on a fully translated book — this is the number the CLI turns into «buy more», and it would be selling chapters the reader already owns", v.LeftFresh)
}
if v.LeftRework != 3 {
t.Fatalf("three delivered units still await a re-make; got %d", v.LeftRework)
}
// And the sentence the stop carries has to say it in words, not only in fields. (The operator-facing
// invitation is the CLI's own line and is pinned in cmd/tmctl/render_test.go, where it lives.)
line := v.String()
if !strings.Contains(line, "0 NEW unit(s) delivered") {
t.Fatalf("the stop must state that nothing new was delivered: %s", line)
}
if !strings.Contains(line, "0 unit(s) NEVER delivered") {
t.Fatalf("the stop must state that nothing is left undelivered: %s", line)
}
if !strings.Contains(line, "unrefreshed, NOT unbought") {
t.Fatalf("the remainder must be named for what it is: %s", line)
}
}
// TestARunThatRePaysNothingIsNotAskedForConsent pins the early return the consent gate needs in order not
// to refuse legitimate work — a branch a mutation showed nothing was holding.
//
// The gate judges its THRESHOLD against the whole book, precisely so a caller cannot shrink it by
// splitting the run. That is right, and it has an edge: a bounded run whose granted units are all FRESH
// re-pays nothing at all, and demanding consent from it would be asking a caller to approve a spend that
// is not going to happen — the other half of Р6, and the failure this pack was already caught committing
// once in the opposite direction.
//
// The state is built rather than waited for: the book is translated whole, the first two units' rows are
// then reset (which is exactly what a redrive does to a unit), and a term that occurs only in the LATER
// chapters is signed. That leaves units 12 never-delivered and units 34 delivered-and-superseded, so a
// purchase of two units is granted only fresh work while the book's drift sits over the threshold.
func TestARunThatRePaysNothingIsNotAskedForConsent(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
var eps []chunktest.Chapter
var spine []string
for i := 1; i <= 4; i++ {
id := fmt.Sprintf("c%d", i)
body := fmt.Sprintf("<p>朝%d。鈴木は歩いた。</p>", i)
if i >= 3 {
// 静か occurs ONLY in the later chapters, so signing it supersedes those units and leaves the
// earlier ones alone.
body = fmt.Sprintf("<p>静かな朝%d。鈴木は歩いた。</p>", i)
}
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml", Body: body})
spine = append(spine, id)
}
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
epub: eps, spine: spine, regenerate: 1, waveWorkers: 1, rebillConsentUSD: 0.002,
})
dir := filepath.Dir(bookPath)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
// Un-deliver the first two units, the way a redrive does.
for ch := 1; ch <= 2; ch++ {
if err := r1.Store.ResetChunkStages("test-book", ch, 0, []string{"draft", "edit"}); err != nil {
t.Fatal(err)
}
}
r1.Close()
doc := decisionsDocFor(t, dir, "test-book", membank.Decision{
Action: membank.ActionApprove, Src: "静か", Dst: "тихий"})
if _, err := ApplyBankDecisions(ctx, bookPath, doc, false); err != nil {
t.Fatal(err)
}
// Precondition: the book's drift really is over the threshold, so an UNBOUNDED run would be refused.
rGate := newRunner(t, bookPath)
rGate.Resnapshot = true
_, gateErr := rGate.TranslateBook(ctx)
rGate.Close()
if gateErr == nil || !strings.Contains(gateErr.Error(), "RE-PAY") {
t.Fatalf("precondition: the book's drift must exceed the threshold, got %v", gateErr)
}
// The bounded run is granted the two FRESH units only, so it re-pays nothing and must not be stopped.
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
r2.MaxUnits = 2
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatalf("this run re-pays nothing — every unit it was granted is new work — so there is no concrete spend to consent to, and refusing it denies legitimate work: %v", err)
}
if res.Volume == nil || res.Volume.Delivered != 2 || res.Volume.Reworked != 0 {
t.Fatalf("the grant should have gone entirely to fresh delivery: %+v", res.Volume)
}
if res.Volume.LeftRework != 2 {
t.Fatalf("the two superseded units are still waiting to be re-made: %+v", res.Volume)
}
}
// TestTheWireHashMemoDiesWithItsBank pins the SAFETY half of the reproduction memo. The memo itself is
// only speed — a bounded run used to redo the full-book re-render up to three times (once in planVolume,
// once per projection in the consent gate) with nothing changed between them. But every hash in it is
// rendered against the materialized bank, so a memo that outlived its bank would hand a caller hashes for
// a bank the run no longer has, and the free estimate would quote a number the paid run does not charge —
// the exact class of silent wrongness this pack exists to remove. Its lifetime must therefore be the
// bank's, and materializeBanks is the one place a bank changes (a mid-run re-seed included).
func TestTheWireHashMemoDiesWithItsBank(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
chunks, withText, err := r.readModelChunks()
if err != nil {
t.Fatal(err)
}
_ = chunks
full, err := withText()
if err != nil {
t.Fatal(err)
}
sel := precomputeSticky(full, r.baseMemory, r.Pipeline.Context.GlossaryTokenBudget)
first := r.cachedRenderedContentHashes(full, sel)
if len(first) == 0 {
t.Fatalf("the fixture produced no positions, so the memo is untested")
}
// Same bank → the very SAME map, not merely an equal one. Identity is the assertion that separates a
// memo from a function that happens to be deterministic: an equal-value check would pass on code that
// re-renders the whole book every call, which is exactly the cost this exists to remove.
again := r.cachedRenderedContentHashes(full, sel)
if reflect.ValueOf(again).Pointer() != reflect.ValueOf(first).Pointer() {
t.Fatalf("the second call built a NEW map: the reproduction is being redone, not reused")
}
if !sameMap(again, first) {
t.Fatalf("the memo changed under the same bank")
}
// A new materialization must throw it away. Anything else serves hashes belonging to a bank that is
// no longer the run's.
if err := r.projectFoldedMemory(); err != nil {
t.Fatal(err)
}
if r.contentHashes != nil {
t.Fatalf("the memo survived a re-materialization of the bank: its hashes are now about a bank this run does not have")
}
}
// sameMap reports whether two hash maps hold the same positions and values.
func sameMap(a, b map[chunkKey]map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, av := range a {
bv, ok := b[k]
if !ok || len(av) != len(bv) {
return false
}
for stage, h := range av {
if bv[stage] != h {
return false
}
}
}
return true
}
// TestAVolumeCeilingOnAMiningBookWarnsAboutTheNextPurchase pins the one warning this pack owes an
// operator, on the one composition it makes worse.
//
// A bounded purchase drafts only part of the book, so the bank-mining stop consolidates over less than
// the whole and writes a SMALLER auto-bank than the next purchase will. The next purchase folds the
// larger one, which moves the edit-wave snapshot the previous purchase's edit jobs are pinned to — and
// the job guard treats that exactly like a config change, so the run stops loudly without --resnapshot.
// None of that is new machinery; what IS new is that a volume ceiling turns a rare interrupted-run corner
// into the ordinary shape of selling a book in parts. The engine cannot fix the guard (ratified Р6
// contour, another pack's subject), so the least it owes is to say so before it happens — and a warning
// nothing tests is a warning that can silently stop being emitted.
func TestAVolumeCeilingOnAMiningBookWarnsAboutTheNextPurchase(t *testing.T) {
rec := &reqRec{}
// A plain provider, not the mining-stop one: this test is about the PLANNING warning, which is
// emitted before either wave, so the stop behaviour is beside the point and its assertions would only
// add an unrelated way to fail.
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
var logBuf bytes.Buffer
r := newRunner(t, bookPath)
defer r.Close()
r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
r.MaxUnits = 1
// The warning is emitted while PLANNING, before any wave, so the run's own outcome is beside the point
// here — what matters is that the operator was told before the money moved.
if _, err := r.TranslateBook(ctx); err != nil {
t.Logf("run ended with %v (not what this test is about)", err)
}
if !strings.Contains(logBuf.String(), "book that MINES its bank") {
t.Fatalf("a volume ceiling on a mining-configured book must warn that the NEXT purchase moves the edit snapshot and will need --resnapshot; log was:\n%s", logBuf.String())
}
if !strings.Contains(logBuf.String(), "--resnapshot") {
t.Fatalf("the warning must name the remedy, not just the problem:\n%s", logBuf.String())
}
// And it must NOT fire on a mining book with no ceiling — otherwise every ordinary run of every
// mining book carries a warning about a situation it is not in.
var quiet bytes.Buffer
r2 := newRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{}))
defer r2.Close()
r2.Log = slog.New(slog.NewTextHandler(&quiet, &slog.HandlerOptions{Level: slog.LevelWarn}))
if _, err := r2.TranslateBook(ctx); err != nil {
t.Logf("unbounded run ended with %v", err)
}
if strings.Contains(quiet.String(), "book that MINES its bank") {
t.Fatalf("an unbounded run is not in this situation and must not be warned about it:\n%s", quiet.String())
}
}
// TestAConsentedSliceIsAlwaysToldWhatTheBookCarries pins the property that makes the capped-consent path
// consent rather than a hole — a distinction settled by ratification rather than by argument.
//
// A caller CAN re-pay a whole book a slice at a time with `--resnapshot --max-units 1 --accept-rebill=X`
// looped: every run stays under its own named cap and the book-wide threshold never refuses. That is not
// a bypass — it reaches no total a single bare `--accept-rebill` would not reach in one invocation, and
// each run names an amount and is charged exactly it. What makes it consent rather than a bypass is that
// the caller is TOLD, on every run, that the book carries drift beyond its threshold and is being bought
// down a slice at a time. Take that disclosure away and the same code becomes the hole it is not.
func TestAConsentedSliceIsAlwaysToldWhatTheBookCarries(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
var eps []chunktest.Chapter
var spine []string
for i := 1; i <= 4; i++ {
id := fmt.Sprintf("c%d", i)
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml", Body: fmt.Sprintf("<p>静かな図書館の朝%d。</p>", i)})
spine = append(spine, id)
}
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
epub: eps, spine: spine, regenerate: 1, waveWorkers: 4, rebillConsentUSD: 0.005,
})
dir := filepath.Dir(bookPath)
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()
doc := decisionsDocFor(t, dir, "test-book", membank.Decision{
Action: membank.ActionApprove, Src: "静か", Dst: "тихий"})
if _, err := ApplyBankDecisions(ctx, bookPath, doc, false); err != nil {
t.Fatal(err)
}
var logBuf bytes.Buffer
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
r2.Resnapshot = true
r2.MaxUnits = 1
r2.AcceptRebill = RebillConsent{Given: true, Capped: true, CapUSD: 0.002}
if _, err := r2.TranslateBook(ctx); err != nil {
t.Fatalf("a funded slice must be admitted: %v", err)
}
out := logBuf.String()
// This run's own slice — the amount actually charged.
if !strings.Contains(out, "rebill_usd=0.001820") {
t.Fatalf("the accepted run must name what IT is charged; log:\n%s", out)
}
// And the book's whole drift beside it, so the caller can see they are buying it down in slices.
for _, want := range []string{"book_rebill_units=4", "book_rebill_usd=0.007280", "threshold_usd=0.005000"} {
if !strings.Contains(out, want) {
t.Fatalf("a consented slice must be told what the BOOK carries (%q missing) — without it the caller cannot tell one purchase from buying the whole book a slice at a time; log:\n%s", want, out)
}
}
}
// TestAPurchaseBuysNewBookBeforeReMakingOldBook pins the ADMISSION ORDER, which nothing else holds.
//
// It is a rot path rather than a live defect: collapsing planVolume's two passes back into one book-order
// pass leaves the whole battery green, so any later session tidying that loop — or adding a fourth unit
// class and reordering it — silently restores a measured defect. Under book order a purchase of two units
// on a book with two delivered re-makes chapters 1-2, reports Delivered=0 / Reworked=2 / LeftFresh=3,
// exits 0, and the platform records `ready`: the buyer paid and the book did not advance by a chapter.
//
// The fixture puts the signed term ONLY in the early chapters, so exactly the delivered units supersede
// and the undelivered tail stays fresh — the ordinary shape after a bank edit lands mid-sale.
//
// ⚠ Note the second assertion class: under book order this same purchase is not merely misallocated, it
// is REFUSED — admitting rework makes the run's own re-payment non-zero and the book's drift is over the
// threshold. Delivery-first admits only fresh work, so the consent gate has nothing to ask about and the
// run goes through. That the purchase SUCCEEDS is therefore part of what is being pinned.
func TestAPurchaseBuysNewBookBeforeReMakingOldBook(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
var eps []chunktest.Chapter
var spine []string
for i := 1; i <= 5; i++ {
id := fmt.Sprintf("c%d", i)
body := fmt.Sprintf("<p>朝%d。鈴木は歩いた。</p>", i)
if i <= 2 {
// 静か occurs ONLY in the chapters the first purchase delivers, so signing it later supersedes
// exactly those and leaves chapters 3-5 untouched.
body = fmt.Sprintf("<p>静かな朝%d。鈴木は歩いた。</p>", i)
}
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml", Body: body})
spine = append(spine, id)
}
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
epub: eps, spine: spine, regenerate: 1, waveWorkers: 1, rebillConsentUSD: 0.002,
})
dir := filepath.Dir(bookPath)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
r1.MaxUnits = 2
res1, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
r1.Close()
if res1.Volume == nil || res1.Volume.Delivered != 2 {
t.Fatalf("the first purchase delivers chapters 1-2: %+v", res1.Volume)
}
doc := decisionsDocFor(t, dir, "test-book", membank.Decision{
Action: membank.ActionApprove, Src: "静か", Dst: "тихий"})
if _, err := ApplyBankDecisions(ctx, bookPath, doc, false); err != nil {
t.Fatal(err)
}
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
r2.MaxUnits = 2
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatalf("a purchase that spends its grant entirely on NEW book re-pays nothing, so the consent gate has nothing to ask about and the run must go through; under book order this same purchase is refused: %v", err)
}
v := res2.Volume
if v == nil {
t.Fatalf("units were held back, so the run must report a volume stop")
}
if v.Delivered != 2 || v.Reworked != 0 {
t.Fatalf("the grant must buy NEW book while the book still has any — got Delivered=%d Reworked=%d; a single book-order pass would report 0 and 2", v.Delivered, v.Reworked)
}
if v.LeftFresh != 1 || v.LeftRework != 2 {
t.Fatalf("one chapter never delivered and two awaiting a re-make: %+v", *v)
}
// And the chapters that actually ran are the NEW ones, not the old ones re-made.
started := chaptersWithRows(t, r2.Store, "test-book")
for _, ch := range []int{3, 4} {
if !started[ch] {
t.Fatalf("chapter %d was never delivered and had the grant available, yet did not run: %v", ch, started)
}
}
if started[5] {
t.Fatalf("chapter 5 is past the grant and must not have run")
}
}
// TestTheVolumeCeilingMovesNoRequestByte is review axis 3 (determinism), executed rather than asserted.
//
// A ceiling is an OPERATOR axis: it decides how much of the book this process does, never what the
// process sends. If setting it moved a wave snapshot or a rendered byte, every already-paid unit of the
// book would be superseded the moment anyone passed the flag — a ceiling that re-bought the book would be
// worse than no ceiling. The property is claimed in Runner.MaxUnits's doc comment; nothing held it.
//
// Two halves, because either alone is weak: the SNAPSHOT must not move (that is what re-pins jobs and
// re-bills waves), and the WIRE BYTES must be identical (that is what the resume fast-path compares).
func TestTheVolumeCeilingMovesNoRequestByte(t *testing.T) {
// (1) The snapshot is blind to the ceiling.
rec0 := &reqRec{}
srv0 := newJSONProvider(rec0, draftEdit)
defer srv0.Close()
r := newRunner(t, volumeBook(t, srv0.URL, 3))
defer r.Close()
if err := r.seedGlossary(context.Background()); err != nil {
t.Fatal(err)
}
r.MaxUnits = 0
draftOff, _, err := r.snapshotIDForWave(waveDraft)
if err != nil {
t.Fatal(err)
}
editOff, _, err := r.snapshotIDForWave(waveEdit)
if err != nil {
t.Fatal(err)
}
r.MaxUnits = 2
draftOn, _, err := r.snapshotIDForWave(waveDraft)
if err != nil {
t.Fatal(err)
}
editOn, _, err := r.snapshotIDForWave(waveEdit)
if err != nil {
t.Fatal(err)
}
if draftOff != draftOn || editOff != editOn {
t.Fatalf("setting a volume ceiling moved a wave snapshot (draft %.12s→%.12s, edit %.12s→%.12s): every already-paid unit of the book would be superseded by passing the flag",
draftOff, draftOn, editOff, editOn)
}
// (2) The bytes a granted unit sends are the bytes it would have sent unbounded — including when the
// ceiling serves units OUT of book order, which delivery-before-rework does by design.
recFull := &reqRec{}
srvFull := newJSONProvider(recFull, draftEdit)
defer srvFull.Close()
rFull := newRunner(t, volumeBook(t, srvFull.URL, 3))
if _, err := rFull.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
rFull.Close()
recCut := &reqRec{}
srvCut := newJSONProvider(recCut, draftEdit)
defer srvCut.Close()
rCut := newRunner(t, volumeBook(t, srvCut.URL, 3))
defer rCut.Close()
rCut.MaxUnits = 1
if _, err := rCut.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
full := map[string]bool{}
for _, b := range recFull.all() {
full[b] = true
}
cut := recCut.all()
if len(cut) == 0 {
t.Fatalf("the bounded run sent nothing, so nothing is compared")
}
for _, b := range cut {
if !full[b] {
t.Fatalf("a bounded run sent a request the unbounded run never sent — the ceiling changed what goes on the wire, and every unit it touches would be re-billed:\n%.400s", b)
}
}
}
// TestAMoneyHaltStillSaysWhatTheVolumeGrantWas covers the last of the round-2 cosmetics, and it is the
// same class as the mining warning: a diagnostic nothing tests is a diagnostic that silently stops being
// emitted.
//
// When the MONEY ceiling stops a run there is no result to carry the volume report, so without this line
// the operator is told the run halted on money and nothing about what it had been granted or how far that
// got — the first thing anyone asks. The durable progress is in the store either way; this is about the
// stderr account of the run being whole rather than half.
func TestAMoneyHaltStillSaysWhatTheVolumeGrantWas(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 4)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
var logBuf bytes.Buffer
r := newRunner(t, bookPath)
defer r.Close()
r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
r.MaxUnits = 2 // the ceiling binds: two units are held back
r.CeilingUSD = 0.0000001 // ...but money runs out first
_, err := r.TranslateBook(ctx)
var halt *CeilingHalt
if !errors.As(err, &halt) {
t.Fatalf("precondition: the money ceiling must be what stops this run, got %v", err)
}
out := logBuf.String()
if !strings.Contains(out, "the stop below is NOT the volume ceiling") {
t.Fatalf("a money halt inside a volume-bounded run must say which ceiling stopped it and what the grant was; log:\n%s", out)
}
if !strings.Contains(out, "max_units=2") {
t.Fatalf("the line must name the grant the run was given:\n%s", out)
}
// And it must NOT appear when no ceiling was in force — an ordinary money halt has no volume grant to
// report, and inventing one would be noise on every halted run in the fleet.
var quiet bytes.Buffer
r2 := newRunner(t, volumeBook(t, srv.URL, 4))
defer r2.Close()
r2.Log = slog.New(slog.NewTextHandler(&quiet, &slog.HandlerOptions{Level: slog.LevelWarn}))
r2.CeilingUSD = 0.0000001
if _, err := r2.TranslateBook(ctx); err == nil {
t.Fatalf("precondition: this run must also halt on money")
}
if strings.Contains(quiet.String(), "the stop below is NOT the volume ceiling") {
t.Fatalf("an unbounded run has no volume grant to report:\n%s", quiet.String())
}
}
// TestFreeUnitsAreReJudgedWhenTheBankMovesMidRun pins the fix for the acceptance hunter's blocking
// finding: on a mining book the ceiling did not hold, and the report called the overspend free.
//
// THE CHAIN. planVolume classifies before the draft wave. Between the two waves sits the bank-mining
// stop, whose auto-continue branch RE-SEEDS the bank (mining.go) — after which waverun recomputes the
// edit-wave snapshot. Units classified FREE are admitted OUTSIDE the grant, because free work costs
// nothing. So every one of them was judged against a snapshot the run itself then replaced, and any whose
// injected bytes the new bank changes gets a fresh PAID editor call no grant authorised — reported as
// "rode along at $0". Measured by the hunter: four calls under a grant of one, $0.007280 called free.
//
// The test drives rescopeEditWave directly rather than waiting for a fixture in which the auto-bank
// happens to change a delivered unit's bytes. That is deliberate: the defect is that nothing GUARANTEES
// the two snapshots agree, so the invariant — "a unit admitted without a slot is re-judged against the
// snapshot the wave really uses" — is what must hold for every book, including ones no fixture reaches.
func TestFreeUnitsAreReJudgedWhenTheBankMovesMidRun(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 4)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
chunks, withText, err := r.readModelChunks()
if err != nil {
t.Fatal(err)
}
_ = chunks
full, err := withText()
if err != nil {
t.Fatal(err)
}
sel := precomputeSticky(full, r.baseMemory, r.Pipeline.Context.GlossaryTokenBudget)
units := r.outputUnits(full)
if len(units) < 3 {
t.Fatalf("fixture needs several units, got %d", len(units))
}
// A scope in the exact shape planVolume leaves: every unit judged FREE (the book is fully delivered
// and nothing has moved yet) and admitted without consuming any grant.
newScope := func(max int) *volumeScope {
s := &volumeScope{
admitted: map[chunkKey]bool{}, leader: map[chunkKey]bool{},
class: map[chunkKey]unitClass{}, editBlocked: map[chunkKey]bool{},
stop: VolumeStop{MaxUnits: max},
editSnapshot: "SNAPSHOT-AT-PLAN-TIME",
}
for _, u := range units {
key := chunkKey{u.Chapter, u.FirstChunkIdx}
s.admitted[key] = true
s.class[key] = unitFree
s.stop.Free++
for _, m := range u.Members {
s.leader[chunkKey{m.Chapter, m.ChunkIdx}] = true
}
}
return s
}
// (1) The snapshot did NOT move: nothing may be re-judged, and the ordinary path pays nothing for
// this machinery existing.
same := newScope(1)
same.editSnapshot = "X"
if err := r.rescopeEditWave(ctx, same, units, full, sel, "X"); err != nil {
t.Fatal(err)
}
if same.stop.Free != len(units) || same.stop.Paid() != 0 || len(same.editBlocked) != 0 {
t.Fatalf("an unmoved snapshot must change nothing: %+v", same.stop)
}
// (2) The snapshot MOVED. Every unit's edit row is now on a superseded snapshot that is NOT a
// re-pinnable bank-only move (the id is unrelated), so none of them is free any more. With a grant of
// one, exactly one may take a slot and the rest must be HELD BACK — not run for free.
moved := newScope(1)
if err := r.rescopeEditWave(ctx, moved, units, full, sel, "A-DIFFERENT-SNAPSHOT"); err != nil {
t.Fatal(err)
}
if moved.stop.Free != 0 {
t.Fatalf("units whose bytes the new bank changed are not free; %d still counted as riding along at $0 — that is the overspend reported as free", moved.stop.Free)
}
if moved.stop.Paid() != 1 {
t.Fatalf("the grant is one unit, so exactly one may be paid for; got %d — the ceiling did not hold", moved.stop.Paid())
}
if moved.stop.LeftRework != len(units)-1 {
t.Fatalf("the rest must be held back as already-delivered-not-yet-re-made, got LeftRework=%d of %d units", moved.stop.LeftRework, len(units))
}
// And the held-back ones must actually be refused by the wave's own admission check.
held := 0
for _, u := range units {
if !moved.allowsUnit(u) {
held++
}
}
if held != len(units)-1 {
t.Fatalf("%d unit(s) were blocked in the report but %d are actually refused by allowsUnit — the accounting and the gate disagree", moved.stop.LeftRework, held)
}
}
// TestTheEditWaveRefusesAStalePlan pins the WIRING of the re-plan, which the behavioural test above
// cannot: that test calls rescopeEditWave directly, so deleting its call site in the wave driver left the
// whole battery green while the ceiling stopped holding. The invariant is now the production code's own —
// the wave refuses to run a scope planned against a snapshot it is not using — so the re-plan cannot be
// removed without the run saying so.
func TestTheEditWaveRefusesAStalePlan(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 3)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// An ordinary bounded run must pass the guard — it is an invariant, not a tax.
r := newRunner(t, bookPath)
defer r.Close()
r.MaxUnits = 2
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatalf("a normal bounded run must satisfy the invariant: %v", err)
}
// And a scope carrying a stale plan is refused rather than run.
stale := &volumeScope{
admitted: map[chunkKey]bool{}, leader: map[chunkKey]bool{},
class: map[chunkKey]unitClass{}, editBlocked: map[chunkKey]bool{},
stop: VolumeStop{MaxUnits: 1}, editSnapshot: "PLANNED-AGAINST-SOMETHING-ELSE",
}
if stale.editSnapshot == "" {
t.Fatalf("fixture: the stale scope must carry a plan snapshot")
}
// rescopeEditWave is what repairs it; after it runs the guard's condition is satisfied.
if err := r.rescopeEditWave(ctx, stale, nil, nil, nil, "THE-REAL-ONE"); err != nil {
t.Fatal(err)
}
if stale.editSnapshot != "THE-REAL-ONE" {
t.Fatalf("the re-plan must adopt the snapshot it re-judged against, else the guard can never pass; got %q", stale.editSnapshot)
}
}
// TestAFlaggedUnitIsNotReportedAsDelivered pins the acceptance hunter's finding 2. A unit can be paid for
// and come back FLAGGED with nothing shippable; the plan-time counter called it delivered anyway, so a
// purchase of two units one of which flagged printed "2 NEW unit(s) delivered" over a single readable
// unit. Admission has to be decided before the work — that is what bounds the money — but a decision to
// PAY for a unit is not a fact about a chapter existing, and the report must not conflate them.
func TestAFlaggedUnitIsNotReportedAsDelivered(t *testing.T) {
var mu sync.Mutex
flaggedOne := false
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if !isEditBody(body) && strings.Contains(body, "朝1") {
mu.Lock()
flaggedOne = true
mu.Unlock()
return "", "stop" // an empty draft flags the unit and ships nothing
}
return draftEdit(body)
})
defer srv.Close()
var eps []chunktest.Chapter
var spine []string
for i := 1; i <= 5; i++ {
id := fmt.Sprintf("c%d", i)
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml", Body: fmt.Sprintf("<p>朝%d。鈴木は歩いた。</p>", i)})
spine = append(spine, id)
}
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
epub: eps, spine: spine, regenerate: 0, waveWorkers: 1,
})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
r.MaxUnits = 2
res, err := r.TranslateBook(ctx)
if err != nil {
var flags *CompletedWithFlags
if !errors.As(err, &flags) {
t.Fatal(err)
}
}
if !flaggedOne {
t.Fatalf("fixture never produced the flag, so it proves nothing")
}
shipped := 0
for _, oc := range res.Chunks {
if oc.FinalText != "" {
shipped++
}
}
v := res.Volume
if v == nil {
t.Fatalf("three units were held back, so a volume stop must be reported")
}
if v.Delivered != shipped {
t.Fatalf("the stop claims %d NEW unit(s) delivered but only %d shipped readable text — a paid-and-flagged unit is not a delivery: %+v", v.Delivered, shipped, *v)
}
if v.Flagged != 1 {
t.Fatalf("one unit was paid for and flagged; the report must say so rather than fold it into delivery: %+v", *v)
}
if !strings.Contains(v.String(), "PAID FOR BUT FLAGGED") {
t.Fatalf("the operator line must name money spent for no text: %s", v.String())
}
}