1516 lines
67 KiB
Go
1516 lines
67 KiB
Go
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 1–2 never-delivered and units 3–4 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())
|
||
}
|
||
}
|