textmachine/backend/internal/pipeline/volumeslot_test.go

987 lines
43 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"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"textmachine/backend/internal/chunk/chunktest"
"textmachine/backend/internal/obs"
)
// volumeslot_test.go: unified backlog row 232, second half — a unit interrupted between the waves used to
// take a slot of the volume grant from EVERY run that advanced it: four bought units became two
// chapters. A unit takes a slot once, in the run that starts it; the run that finishes it does so outside
// its own grant (volume.go, unitCarried), and says so.
// interruptibleEditor is a provider whose EDITOR fails until released — the store state a run cut off
// between the draft wave and the edit wave leaves behind: draft rows on file, no edit row anywhere.
func interruptibleEditor(rec *reqRec) (*httptest.Server, func()) {
var mu sync.Mutex
editWorks := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
rec.record(string(body))
if isEditBody(string(body)) {
mu.Lock()
ok := editWorks
mu.Unlock()
if !ok {
w.WriteHeader(http.StatusInternalServerError)
return
}
writeFakeCompletion(w, "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop")
return
}
writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop")
}))
return srv, func() { mu.Lock(); editWorks = true; mu.Unlock() }
}
// TestAUnitInterruptedBetweenWavesTakesNoSecondSlot is row 232's measurement turned around: two
// purchases of two units on a five-unit book, the first cut off between its waves. It used to leave the
// buyer with two chapters for four bought units; now the first purchase's two units are finished OUTSIDE
// the second grant and the second grant buys two NEW units — four bought, four delivered.
//
// Mutation this catches: classify a started-but-unshipped unit as unitFresh again (delete the
// unitStarted branch in classifyUnits) — the second run reports Delivered=2 with chapters 34 never
// started, and the money assertion below goes red on the exact number the row names.
func TestAUnitInterruptedBetweenWavesTakesNoSecondSlot(t *testing.T) {
const chapters = 5
rec := &reqRec{}
srv, releaseEditor := interruptibleEditor(rec)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, chapters)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// Purchase 1: two units granted, both drafted, the editor dies → cut off between the waves.
r1 := newRunner(t, bookPath)
r1.MaxUnits = 2
if _, err := r1.TranslateBook(ctx); err == nil {
t.Fatal("precondition: the editor was supposed to fail this run")
}
rows, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
r1.Close()
drafted, edits := map[int]bool{}, 0
for _, cs := range rows {
if cs.Stage == "edit" {
edits++
}
drafted[cs.Chapter] = true
}
if edits != 0 || !drafted[1] || !drafted[2] || len(drafted) != 2 {
t.Fatalf("precondition: chapters 12 drafted and NO edit row anywhere, got drafted=%v edit rows=%d", drafted, edits)
}
// Purchase 2: two more units. The two cut-off units are finished outside this grant; the grant buys
// chapters 3 and 4; chapter 5 is held back, so the run reports the stop.
releaseEditor()
callsBefore := rec.count()
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.MaxUnits = 2
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
v := res.Volume
if v == nil {
t.Fatal("chapter 5 was held back, so the run must report a volume stop")
}
if v.Delivered != 4 || v.Carried != 2 || v.Reworked != 0 || v.Free != 0 || v.Flagged != 0 {
t.Fatalf("four bought units must be four delivered — two carried from the first grant, two new: %+v", *v)
}
if v.LeftFresh != 1 || v.LeftRework != 0 {
t.Fatalf("one unit never delivered remains, nothing awaits a re-make: %+v", *v)
}
if len(res.Chunks) != 4 {
t.Fatalf("the run shipped %d unit(s), want the 4 the two purchases paid for", len(res.Chunks))
}
started := chaptersWithRows(t, r2.Store, "test-book")
for ch := 1; ch <= 4; ch++ {
if !started[ch] {
t.Fatalf("chapter %d was bought and must exist: %v", ch, started)
}
}
if started[5] {
t.Fatal("chapter 5 is past the second grant and must not have run")
}
// The money: two editor calls finish the carried units, two full chains buy the new ones.
if fresh := rec.count() - callsBefore; fresh != 2+2*2 {
t.Fatalf("the second purchase made %d provider call(s), want 6 (2 edits for the carried units + draft+edit for two new ones)", fresh)
}
// And the sentence says which of the new units this grant did not pay a slot for.
if line := v.String(); !strings.Contains(line, "4 NEW unit(s) delivered") || !strings.Contains(line, "2 of the new ones had been started by an earlier run") {
t.Fatalf("the stop line must name the carried units: %s", line)
}
}
// TestAUnitCutOffInsideTheDraftWaveIsCarriedNotRestarted is the same slot on a unit interrupted INSIDE
// a wave rather than between them: some members drafted, the rest not. The evidence of a start is any
// row at a position this run executes, so the half-drafted unit is finished outside the grant too.
func TestAUnitCutOffInsideTheDraftWaveIsCarriedNotRestarted(t *testing.T) {
var mu sync.Mutex
broken := true
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) {
writeFakeCompletion(w, "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop")
return
}
mu.Lock()
dead := broken && strings.Contains(s, "壊れた時計")
mu.Unlock()
if dead {
w.WriteHeader(http.StatusInternalServerError) // the wave dies on this member; the earlier one is on file
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"
split := "<p>静かな図書館の朝。鈴木は本を読んだ。壊れた時計があった。外では雨が降っていた。彼は窓を見た。</p>"
clean := "<p>朝%d。鈴木は歩いた。</p>"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
epub: []chunktest.Chapter{
{ID: "c1", Href: "c1.xhtml", Body: split},
{ID: "c2", Href: "c2.xhtml", Body: fmt.Sprintf(clean, 2)},
{ID: "c3", Href: "c3.xhtml", Body: fmt.Sprintf(clean, 3)},
},
spine: []string{"c1", "c2", "c3"}, regenerate: 0, waveWorkers: 1, gatesYAML: seg,
})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
r1.MaxUnits = 1
if _, err := r1.TranslateBook(ctx); err == nil {
t.Fatal("precondition: the draft wave was supposed to die on the second member")
}
rows, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
r1.Close()
members := 0
for _, cs := range rows {
if cs.Chapter != 1 || cs.Stage != "draft" {
t.Fatalf("precondition: only chapter 1 draft rows may exist, got %+v", cs)
}
members++
}
if members == 0 {
t.Fatal("precondition: at least one member of chapter 1 must have been drafted before the wave died")
}
mu.Lock()
broken = false
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)
}
v := res.Volume
if v == nil {
t.Fatal("chapter 3 was held back, so the run must report a volume stop")
}
if v.Delivered != 2 || v.Carried != 1 || v.LeftFresh != 1 || v.Free != 0 || v.Reworked != 0 {
t.Fatalf("the half-drafted unit is carried, the grant buys chapter 2, chapter 3 waits: %+v", *v)
}
started := chaptersWithRows(t, r2.Store, "test-book")
if !started[1] || !started[2] || started[3] {
t.Fatalf("chapters 12 must be complete and chapter 3 untouched: %v", started)
}
// The carried unit paid only for what it lacked: its remaining draft members and its edit. Nothing
// already on file was bought again.
unitCalls := len(chunksOfChapter(t, r2, 1)) - members + 1
if fresh := rec.count() - callsBefore; fresh != unitCalls+2 {
t.Fatalf("the second run made %d provider call(s), want %d (the carried unit's %d missing draft(s) + its edit, plus draft+edit for chapter 2)",
fresh, unitCalls+2, unitCalls-1)
}
}
// chunksOfChapter is the manifest's chunks of one chapter — how many DRAFT positions a unit there has.
func chunksOfChapter(t *testing.T, r *Runner, chapter int) []int {
t.Helper()
var idx []int
for _, ch := range chunksOf(t, r) {
if ch.Chapter == chapter {
idx = append(idx, ch.ChunkIdx)
}
}
return idx
}
// renameStages gives both fixture stages new names, so every stored row belongs to a stage the pipeline no
// longer runs while the book itself is unchanged.
func renameStages(t *testing.T, bookPath string) {
t.Helper()
p := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
s := strings.Replace(string(b), "name: draft,", "name: draftx,", 1)
s = strings.Replace(s, "name: edit,", "name: editx,", 1)
if s == string(b) {
t.Fatalf("fixture drifted: no stage names to rename in:\n%s", b)
}
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
// TestRowsOfARetiredStageAreNotAStart pins the evidence rule of unitCarried at its edge: a row for a
// stage the current pipeline does not run is neither a position this run would execute nor proof that a
// run under THIS pipeline began the unit. A book whose stages were all renamed has rows for none of its
// current positions; its units are started afresh and take a slot each, exactly as unitFullyRecorded
// already judges them incomplete by the same enumeration of positions.
//
// Mutation this catches: judge «started» by `len(rows) > 0` instead of by rows at current positions, and
// every unit of the renamed book rides outside the grant — Carried=3 / LeftFresh=0 under --max-units 1.
func TestRowsOfARetiredStageAreNotAStart(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()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
renameStages(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
r2.AcceptRebill = RebillConsent{Given: true}
r2.MaxUnits = 1
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
v := res.Volume
if v == nil {
t.Fatal("two units were held back, so the run must report a volume stop")
}
if v.Carried != 0 || v.Delivered != 1 || v.LeftFresh != 2 || v.Free != 0 {
t.Fatalf("rows of retired stages are not a start: one unit takes the slot, two wait — got %+v", *v)
}
if len(res.Chunks) != 1 {
t.Fatalf("OVERSPEND: %d unit(s) ran under --max-units 1", len(res.Chunks))
}
}
// TestTheRePlanCountsSlotsNotCarriedUnits pins the grant arithmetic at the edit-wave re-plan
// (rescopeEditWave): a free unit that the mid-run bank move makes paying takes a slot if one is FREE —
// and a slot is free when the units already paying were carried from an earlier grant. Compared against
// every paying unit instead (Paid), a run with carried units on the book would refuse the slot it still
// has and hold the unit back as «not re-made», delivering less than was bought.
//
// The scope is built by hand in the shape planVolume leaves — one unit carried, the rest free — because
// the defect is arithmetic, and the fixture that reaches it (a bounded run interrupted between waves on a
// book that mines its bank and then moves it mid-run) is the composition of three others.
func TestTheRePlanCountsSlotsNotCarriedUnits(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)
}
_, withText, err := r.readModelChunks()
if err != nil {
t.Fatal(err)
}
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) != 4 {
t.Fatalf("fixture needs four units, got %d", len(units))
}
// One unit carried from an earlier grant (paying, no slot), three judged free; a grant of ONE, none of
// it taken yet.
s := &volumeScope{
admitted: map[chunkKey]bool{}, leader: map[chunkKey]bool{},
class: map[chunkKey]unitClass{}, editBlocked: map[chunkKey]bool{},
stop: VolumeStop{MaxUnits: 1, Delivered: 1, Carried: 1, Free: 3},
editSnapshot: "SNAPSHOT-AT-PLAN-TIME",
}
for i, u := range units {
key := chunkKey{u.Chapter, u.FirstChunkIdx}
s.admitted[key] = true
s.class[key] = unitFree
if i == 0 {
s.class[key] = unitCarried
}
for _, m := range u.Members {
s.leader[chunkKey{m.Chapter, m.ChunkIdx}] = true
}
}
if err := r.rescopeEditWave(ctx, s, units, full, sel, "A-DIFFERENT-SNAPSHOT"); err != nil {
t.Fatal(err)
}
// The three free units are paying now. The grant's one slot is still free — the carried unit took
// none — so exactly one of them takes it and two are held back.
if s.stop.Reworked != 1 || s.stop.LeftRework != 2 || s.stop.Free != 0 {
t.Fatalf("one slot was free and must go to a re-judged unit: got Reworked=%d LeftRework=%d Free=%d (a re-plan that counts the carried unit as a slot refuses all three)",
s.stop.Reworked, s.stop.LeftRework, s.stop.Free)
}
if s.stop.Delivered != 1 || s.stop.Carried != 1 {
t.Fatalf("the carried unit is untouched by the re-plan: %+v", s.stop)
}
}
// TestACarriedUnitOnADraftOnlyPipeline is the prompt's review axis 3, executed rather than reasoned: on a
// draft-only pipeline the SHIPPING wave is the draft one (finalStageWave), and the slot rule has to work
// there too — a unit whose second draft stage never ran is started, never shipped, and finished outside the
// grant, exactly as on an editor pipeline. Nothing in the rule branches on the wave, and this is what says
// so out loud instead of inferring it.
//
// It is also the negative half of the axis: the delivered test next door proves a draft-only book's
// already-shipped units are NOT re-sold; this proves the axis has not gone the other way either, calling
// an unfinished unit delivered.
func TestACarriedUnitOnADraftOnlyPipeline(t *testing.T) {
var mu sync.Mutex
firstDone, released := false, false
rec := &reqRec{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
rec.record(string(body))
mu.Lock()
dead := firstDone && !released
firstDone = true
mu.Unlock()
if dead {
// Every call after the first fails until released: the first unit keeps its stage-1 row and
// never reaches stage 2 — the store state an interrupted draft wave leaves on this shape.
w.WriteHeader(http.StatusInternalServerError)
return
}
writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop")
}))
defer srv.Close()
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: fmt.Sprintf("<p>静かな図書館の朝%d。</p>", i)})
spine = append(spine, id)
}
bookPath := setupProjectOpts(t, srv.URL, projectOpts{epub: eps, spine: spine, draftOnly: true, waveWorkers: 1})
addSecondDraftStage(t, bookPath) // both stages exist from the first run, so no snapshot moves between them
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err == nil {
t.Fatal("precondition: the second draft stage was supposed to fail this run")
}
rows, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
announced, err := r1.Store.AnnouncedOnceKeys()
if err != nil {
t.Fatal(err)
}
r1.Close()
if len(rows) != 1 || rows[0].Stage != "draft" || rows[0].Chapter != 1 {
t.Fatalf("precondition: exactly one row — chapter 1's first draft stage — must be on file, got %+v", rows)
}
if len(announced) != 0 {
t.Fatalf("precondition: nothing shipped, so nothing may be announced; got %v", announced)
}
mu.Lock()
released = 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)
}
v := res.Volume
if v == nil {
t.Fatal("one unit was held back, so the run must report a volume stop")
}
// Chapter 1 was started and never shipped: carried, no slot. The slot buys chapter 2; chapter 3 waits.
if v.Delivered != 2 || v.Carried != 1 || v.Reworked != 0 || v.Free != 0 {
t.Fatalf("on a draft-only pipeline a started-but-unshipped unit is carried, and the grant buys a new one: %+v", *v)
}
if v.LeftFresh != 1 || v.LeftRework != 0 {
t.Fatalf("one unit never delivered remains, and nothing is awaiting a re-make: %+v", *v)
}
started := chaptersWithRows(t, r2.Store, "test-book")
if !started[1] || !started[2] || started[3] {
t.Fatalf("chapters 1-2 must be complete and chapter 3 untouched: %v", started)
}
// The carried unit paid only for the stage it lacked; the new unit paid for both of its stages.
if fresh := rec.count() - callsBefore; fresh != 1+2 {
t.Fatalf("the run made %d provider call(s), want 3 (the carried unit's missing stage + both stages of the new unit)", fresh)
}
}
// TestTheCarryIsBoundedByTheGrant is the ceiling's own survival: units an earlier run started ride outside
// the grant, and if that carry were unbounded the flag would stop bounding anything. The earlier run here
// carries NO ceiling at all — it drafts the whole book and dies before the edit wave, so nothing was ever
// charged a slot for those units — and the next purchase is granted one. It must finish ONE of them, not
// all five.
//
// Measured before the bound existed: `--max-units 1` paid for five units and made five provider calls, and
// because nothing was left over the run reported no volume stop at all. Found by a fresh-context review of
// this pack's own work.
func TestTheCarryIsBoundedByTheGrant(t *testing.T) {
const chapters = 5
rec := &reqRec{}
srv, releaseEditor := interruptibleEditor(rec)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, chapters)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// Run 1: NO grant. Every chapter is drafted, the editor dies — five started, never shipped units.
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err == nil {
t.Fatal("precondition: the editor was supposed to fail this run")
}
drafted := chaptersWithRows(t, r1.Store, "test-book")
r1.Close()
if len(drafted) != chapters {
t.Fatalf("precondition: the unbounded run must draft the whole book, got %v", drafted)
}
releaseEditor()
callsBefore := rec.count()
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.MaxUnits = 1
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
v := res.Volume
if v == nil {
t.Fatal("four units were held back, so the run must report a volume stop")
}
if v.Carried != 1 || v.Delivered != 1 {
t.Fatalf("a grant of one may finish ONE interrupted unit, not the whole book: %+v", *v)
}
if v.LeftFresh != chapters-1 {
t.Fatalf("the other four units are undelivered and must be reported as such: %+v", *v)
}
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, and not one more: the drafts are on file and the four held-back units never begin.
if fresh := rec.count() - callsBefore; fresh != 1 {
t.Fatalf("the run made %d provider call(s), want exactly 1 — the carry must not spend past the grant", fresh)
}
}
// TestARunThatWorkedOutsideItsGrantSaysSo is the disclosure half of the carry: a run whose whole remainder
// was carried holds nothing back, so a stop judged by the remainder alone would print nothing — in exactly
// the case where the run delivered most beyond its grant. The stop line is the only channel that explains
// why a purchase of two delivered two units it did not pay a slot for.
func TestARunThatWorkedOutsideItsGrantSaysSo(t *testing.T) {
rec := &reqRec{}
srv, releaseEditor := interruptibleEditor(rec)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 2)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err == nil {
t.Fatal("precondition: the editor was supposed to fail this run")
}
r1.Close()
releaseEditor()
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.MaxUnits = 2
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
v := res.Volume
if v == nil {
t.Fatal("the run finished two units outside its grant and must say so; nothing was held back, so a report judged by the remainder alone would be silent here")
}
if v.Carried != 2 || v.Delivered != 2 || v.Left() != 0 {
t.Fatalf("both units were carried and nothing remains: %+v", *v)
}
line := v.String()
if !strings.Contains(line, "2 of the new ones had been started by an earlier run") {
t.Fatalf("the line must name the units finished outside the grant: %s", line)
}
// ⚠ AND IT MUST NOT SAY THE RUN STOPPED SHORT. This run reached the end of the book; the report is
// attached because work happened outside the grant, not because the grant cut anything off. One
// opening for both cases printed «not at the end of the book» in the same sentence as «0 unit(s)
// NEVER delivered» — an operator told at once that the run was cut short and that nothing remains.
if strings.Contains(line, "not at the end of the book") {
t.Fatalf("the run reached the END of the book, and the line claims it stopped short of it: %s", line)
}
if !strings.Contains(line, "reached the END of the book") || !strings.Contains(line, "Nothing is left in the book") {
t.Fatalf("the end-of-book branch must say so in both halves of the sentence: %s", line)
}
}
// TestReconcileKeepsCarriedInsideDelivered pins the containment granted() depends on. Carried is documented
// as a PART of Delivered, and granted() = Delivered Carried + Reworked; a fall-through that moves
// Delivered past Carried makes the grant arithmetic negative and the stop line say «0 NEW unit(s)
// delivered … (1 of the new ones had been started by an earlier run)» — a sentence that cannot be true.
//
// The state is reachable: a book with one carried unit and one unit that resumes FREE and then flags, which
// is the case reconcile's own default branch is written for.
func TestReconcileKeepsCarriedInsideDelivered(t *testing.T) {
s := &volumeScope{
admitted: map[chunkKey]bool{{1, 0}: true, {2, 0}: true},
leader: map[chunkKey]bool{{1, 0}: true, {2, 0}: true},
class: map[chunkKey]unitClass{{1, 0}: unitCarried, {2, 0}: unitFree},
stop: VolumeStop{MaxUnits: 1, Delivered: 1, Carried: 1, Free: 1},
}
s.reconcile([]ChunkOutcome{{Chapter: 2, ChunkIdx: 0, Disposition: DispFlagged, FinalText: ""}})
if s.stop.Carried > s.stop.Delivered {
t.Fatalf("Carried is a part of Delivered and must stay one: %+v", s.stop)
}
if g := s.stop.granted(); g < 0 {
t.Fatalf("the grant arithmetic went negative: granted()=%d from %+v", g, s.stop)
}
if line := s.stop.String(); strings.Contains(line, "0 NEW unit(s) delivered") && strings.Contains(line, "had been started by an earlier run") {
t.Fatalf("the line attributes carried units to a delivered count of zero: %s", line)
}
}
// TestReconcileKeepsContainmentOnTheFreshBranch is the same invariant on the OTHER branch. reconcile's
// docstring claims «every branch here keeps it one», and the fresh branch is the one the test above never
// reaches: it asks whether a delivered unit that is NOT one of the carried ones is still there to move.
//
// ⚠ THE STATE IS CONSTRUCTED, NOT OBSERVED, and that is deliberate. Reaching `Delivered == Carried` with a
// unitFresh outcome takes a run whose every admitted paying unit was carried while a fresh unit still
// resolved — a shape no fixture produces today. The invariant is asserted anyway because the code claims
// it for EVERY branch, and a branch nobody holds to its claim is the one that quietly stops holding: swap
// the guard back to `Delivered > 0` and this is the only test in the tree that notices.
func TestReconcileKeepsContainmentOnTheFreshBranch(t *testing.T) {
s := &volumeScope{
admitted: map[chunkKey]bool{{1, 0}: true, {2, 0}: true},
leader: map[chunkKey]bool{{1, 0}: true, {2, 0}: true},
class: map[chunkKey]unitClass{{1, 0}: unitCarried, {2, 0}: unitFresh},
stop: VolumeStop{MaxUnits: 1, Delivered: 1, Carried: 1},
}
s.reconcile([]ChunkOutcome{{Chapter: 2, ChunkIdx: 0, Disposition: DispFlagged, FinalText: ""}})
if s.stop.Carried > s.stop.Delivered {
t.Fatalf("the fresh branch moved Delivered past Carried, so Carried is no longer a part of it: %+v", s.stop)
}
if g := s.stop.granted(); g < 0 {
t.Fatalf("the grant arithmetic went negative: granted()=%d from %+v", g, s.stop)
}
if line := s.stop.String(); strings.Contains(line, "0 NEW unit(s) delivered") && strings.Contains(line, "had been started by an earlier run") {
t.Fatalf("the line attributes carried units to a delivered count of zero: %s", line)
}
}
// TestTheRunLogSaysWhichKindOfVolumeStopItWas is the log half of the operator sentence. Both take the same
// fork and for the same reason: a run that reached the END of the book did not stop short of it, and one
// message that says «it will stop having done what was granted, not at the end of the book» about every
// bounded run is worthless exactly where the grant behaved unusually — when the report is attached because
// work happened OUTSIDE the grant rather than because the grant cut anything off.
//
// The log is a separate channel from the result (an operator reads stderr, not BookResult), so it needs its
// own pin: the operator line's test says nothing about it, and deleting the fork here leaves every test in
// the tree green.
func TestTheRunLogSaysWhichKindOfVolumeStopItWas(t *testing.T) {
for _, tc := range []struct {
name string
chapters int
maxUnits int
want, ban string
}{
{
// Two chapters, both started and abandoned, a grant of two: nothing is held back, and the run
// is reported only because it finished them outside its grant.
name: "the book ended", chapters: 2, maxUnits: 2,
want: "reached the end of the book under a VOLUME ceiling that held nothing back",
ban: "not at the end of the book",
},
{
// Three chapters, a grant of one: one is carried, one waits — the grant really did cut the run
// short, and the old sentence is the true one.
name: "the grant ran out", chapters: 3, maxUnits: 1,
want: "not at the end of the book",
ban: "reached the end of the book",
},
} {
t.Run(tc.name, func(t *testing.T) {
rec := &reqRec{}
srv, releaseEditor := interruptibleEditor(rec)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, tc.chapters)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err == nil {
t.Fatal("precondition: the editor was supposed to fail this run")
}
r1.Close()
releaseEditor()
var logBuf bytes.Buffer
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelInfo}))
r2.MaxUnits = tc.maxUnits
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if res.Volume == nil {
t.Fatal("the run must report a volume stop: work was held back or done outside the grant")
}
out := logBuf.String()
if !strings.Contains(out, tc.want) {
t.Fatalf("the run log must say which kind of stop this was; want %q in:\n%s", tc.want, out)
}
if strings.Contains(out, tc.ban) {
t.Fatalf("the run log states the OTHER kind of stop; %q must not appear in:\n%s", tc.ban, out)
}
})
}
}
// TestTheStopLineSaysTheGrantHeldWorkBackWhenItDid is the OTHER side of the two-opening fork, and it is the
// side nobody held. TestARunThatWorkedOutsideItsGrantSaysSo pins the new opening (the book ended, nothing
// was held back); the opening that was there BEFORE the fork existed — the grant stopped the run short —
// went unpinned, so a fork that reaches the end-of-book wording unconditionally, or on the wrong question,
// passes the whole tree. Under either the mixed state below prints «reached the END of the book … Still in
// the book: 1 unit(s) NEVER delivered»: an operator told the book is finished and that a unit of it was
// never delivered, in one sentence.
//
// ⚠ TWO ROWS AND NOT ONE, because the two ways of losing the fork are lost on different states. A fork that
// is always true fails on either row; a fork asked about the CARRY instead of the remainder only fails
// where both are non-zero, which is precisely the state the carry made reachable.
func TestTheStopLineSaysTheGrantHeldWorkBackWhenItDid(t *testing.T) {
for _, tc := range []struct {
name string
stop VolumeStop
}{
{
// The plain grant stop: work was held back, nothing was carried.
name: "held back, nothing carried",
stop: VolumeStop{MaxUnits: 2, Delivered: 2, LeftFresh: 3},
},
{
// MIXED: the run finished units an earlier run began AND the grant still cut it short. The
// carry is non-zero here, so a fork keyed on the carry answers the wrong question.
name: "held back and carried",
stop: VolumeStop{MaxUnits: 3, Delivered: 3, Carried: 3, LeftFresh: 1},
},
} {
t.Run(tc.name, func(t *testing.T) {
line := tc.stop.String()
if !strings.Contains(line, "not at the end of the book") {
t.Fatalf("the grant cut this run short and the line must say so: %s", line)
}
if strings.Contains(line, "reached the END of the book") {
t.Fatalf("units are still undelivered, and the line claims the book ended: %s", line)
}
if !strings.Contains(line, "Still in the book:") {
t.Fatalf("the remainder must be named: %s", line)
}
})
}
}
// TestOneCarriedUnitIsStillWorkOutsideTheGrant pins the BORDER of bound(), not its shape. bound() reports a
// run whose remainder is empty but whose carry is not, and the smallest such run — ONE interrupted unit
// finished at the end of the book — is the one a `> 1` border silently drops: the run delivers a unit it
// never paid a slot for and returns no report at all, which is the silent-limit anti-pattern D39.165 §1б
// names. TestARunThatWorkedOutsideItsGrantSaysSo carries two, so it survives that border and cannot see it.
func TestOneCarriedUnitIsStillWorkOutsideTheGrant(t *testing.T) {
rec := &reqRec{}
srv, releaseEditor := interruptibleEditor(rec)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 1)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// Run 1: the single chapter is drafted and the editor dies — one started, never shipped unit.
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err == nil {
t.Fatal("precondition: the editor was supposed to fail this run")
}
r1.Close()
releaseEditor()
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.MaxUnits = 1
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
v := res.Volume
if v == nil {
t.Fatal("the run finished ONE unit outside its grant and must still report it: a report that starts at two units leaves the smallest discrepancy unexplained")
}
if v.Carried != 1 || v.Delivered != 1 || v.Left() != 0 {
t.Fatalf("one carried unit, nothing left: %+v", *v)
}
if !strings.Contains(v.String(), "1 of the new ones had been started by an earlier run") {
t.Fatalf("the line must name the unit finished outside the grant: %s", v.String())
}
}
// TestTheStopSentenceCountsEveryUnitTheRunPaidFor pins the FIRST number an operator reads. The struct's
// arithmetic is checked elsewhere; the SENTENCE is what reaches a human, and it was pinned nowhere — planted,
// dropping the flagged units out of «%d paying output unit(s)» left the whole tree green while under-counting
// what the run was billed for.
func TestTheStopSentenceCountsEveryUnitTheRunPaidFor(t *testing.T) {
// ⚠ Reworked and Flagged are DIFFERENT numbers here on purpose. They used to both be 1, and that made
// the flagged clause unpinned by coincidence: planted, the clause printed the REWORK count as the flagged
// count and the whole tree stayed green, because the two fixtures that read this sentence each had them
// equal. A fixture where every count is distinct is what turns a substring check into an assertion.
v := VolumeStop{MaxUnits: 4, Delivered: 2, Reworked: 1, Flagged: 2, Free: 3, LeftFresh: 2}
line := v.String()
// 2 delivered + 1 re-made + 2 paid-and-flagged = 5 units billed.
if !strings.Contains(line, "5 paying output unit(s)") {
t.Errorf("the paying count must include the units that flagged — they cost money and delivered nothing: %s", line)
}
// And the free riders are named, because otherwise the operator sees a run that touched seven units
// under a grant of four and cannot tell why.
if !strings.Contains(line, "3 rode along at $0") {
t.Errorf("the $0 riders must be named: %s", line)
}
if !strings.Contains(line, "2 PAID FOR BUT FLAGGED") {
t.Errorf("money spent for no readable text must be named apart, with its OWN count: %s", line)
}
// A stop that held back only ALREADY-DELIVERED units says so as unrefreshed, never as unbought — the
// head must not claim the book ended while the tail lists a remainder.
rework := VolumeStop{MaxUnits: 1, Delivered: 1, LeftRework: 3}.String()
if strings.Contains(rework, "reached the END of the book") {
t.Errorf("units remain (unrefreshed), so the grant did hold work back: %s", rework)
}
if !strings.Contains(rework, "3 already delivered but not yet re-made") {
t.Errorf("the unrefreshed remainder must be named as such: %s", rework)
}
}
// TestReconcileMovesAFlaggedCarryOutOfCarried is the branch nobody fixtured, and it is also where the
// ordering waverun.go depends on becomes visible: after this, a run whose only work outside the grant was
// that unit has Carried==0, so a report decided from the RECONCILED counters would say nothing at all.
func TestReconcileMovesAFlaggedCarryOutOfCarried(t *testing.T) {
s := &volumeScope{
admitted: map[chunkKey]bool{{1, 0}: true},
leader: map[chunkKey]bool{{1, 0}: true},
class: map[chunkKey]unitClass{{1, 0}: unitCarried},
stop: VolumeStop{MaxUnits: 1, Delivered: 1, Carried: 1},
}
if !s.bound() {
t.Fatal("premise: before reconcile the run has something to say — it finished a unit outside its grant")
}
s.reconcile([]ChunkOutcome{{Chapter: 1, ChunkIdx: 0, Disposition: DispFlagged, FinalText: ""}})
if s.stop.Carried != 0 || s.stop.Delivered != 0 || s.stop.Flagged != 1 {
t.Fatalf("a carried unit that flagged is paid-and-flagged, not delivered: %+v", s.stop)
}
if s.bound() {
t.Fatal("this is the state the ordering protects: after reconcile there is nothing left to report — which is why waverun asks bound() BEFORE it")
}
}
// TestReconcileLeavesUnpaidAndReworkedUnitsAlone pins reconcile's two remaining branches, both un-fixtured
// and both money-facing: a FREE unit that flags was never paid for and must not be counted as paid, and a
// re-made unit that flags must LEAVE the rework count rather than being counted twice.
func TestReconcileLeavesUnpaidAndReworkedUnitsAlone(t *testing.T) {
free := &volumeScope{
admitted: map[chunkKey]bool{{1, 0}: true},
leader: map[chunkKey]bool{{1, 0}: true},
class: map[chunkKey]unitClass{{1, 0}: unitFree},
stop: VolumeStop{MaxUnits: 1, Free: 1},
}
free.reconcile([]ChunkOutcome{{Chapter: 1, ChunkIdx: 0, Disposition: DispFlagged, FinalText: ""}})
if free.stop.Flagged != 0 || free.stop.Paid() != 0 {
t.Errorf("a unit that rode along at $0 and flagged cost nothing; counting it as PAID FOR BUT FLAGGED invents a charge: %+v", free.stop)
}
rework := &volumeScope{
admitted: map[chunkKey]bool{{1, 0}: true},
leader: map[chunkKey]bool{{1, 0}: true},
class: map[chunkKey]unitClass{{1, 0}: unitRework},
stop: VolumeStop{MaxUnits: 1, Reworked: 1},
}
rework.reconcile([]ChunkOutcome{{Chapter: 1, ChunkIdx: 0, Disposition: DispFlagged, FinalText: ""}})
if rework.stop.Reworked != 0 || rework.stop.Flagged != 1 {
t.Errorf("a re-made unit that flagged is flagged, not still re-made — counting both double-counts the grant: %+v", rework.stop)
}
if rework.stop.Paid()+rework.stop.Flagged != 1 {
t.Errorf("the sentence's paying count would over-report by one: %+v", rework.stop)
}
}
// TestTheVolumeReportIsAskedBeforeTheCountersAreTruedUp pins the ORDER of bound() and reconcile() at the
// level where the order exists — the wave driver — because nothing did.
//
// ⚠ waverun.go's own comment calls this order load-bearing, says it was found by planting, and sends the
// reader to TestReconcileMovesAFlaggedCarryOutOfCarried. That test is a unit test of volumeScope: it proves
// reconcile moves a flagged carry out of Carried, and it cannot see WHERE the driver asks bound(). Planted —
// the bound() block moved to just after reconcile — the whole pipeline package stayed green while
// `res.Volume` came back nil, i.e. the run that finished a unit outside its grant and flagged it said
// NOTHING about it. That is the disclosure the report exists for, and «the comment explains it» is not a
// guarantee; this is the same «a claim about structure passed off as a guarantee» class the round found
// four times, here in a comment that is otherwise entirely correct.
//
// The fixture is the smallest run that can tell the two orders apart: ONE carried unit, nothing left, and
// the carry comes back FLAGGED — so bound() is true before reconcile (Carried==1) and false after it
// (Carried==0, Flagged==1). A carry that DELIVERS cannot see this, which is why the neighbouring
// TestOneCarriedUnitIsStillWorkOutsideTheGrant passes under the planting.
func TestTheVolumeReportIsAskedBeforeTheCountersAreTruedUp(t *testing.T) {
rec := &reqRec{}
var mu sync.Mutex
editorRefuses := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
rec.record(string(body))
if isEditBody(string(body)) {
mu.Lock()
refuse := editorRefuses
mu.Unlock()
if !refuse {
w.WriteHeader(http.StatusInternalServerError) // run 1: the unit is started and never ships
return
}
writeFakeCompletion(w, "I'm sorry, but I can't help with that request.", "refusal")
return
}
writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop")
}))
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 1)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err == nil {
t.Fatal("precondition: the editor was supposed to fail this run, leaving one started-but-unshipped unit")
}
r1.Close()
mu.Lock()
editorRefuses = true
mu.Unlock()
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.MaxUnits = 1
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
// Premise: the run's ONLY work was the carried unit, and it flagged. Without both halves the assertion
// below would pass under either order and prove nothing.
if res.Flagged != 1 {
t.Fatalf("premise broken: the carried unit must come back FLAGGED, got flagged=%d over %d chunk(s)",
res.Flagged, len(res.Chunks))
}
v := res.Volume
if v == nil {
t.Fatal("the run finished a unit outside its grant, paid for it, and it FLAGGED — and the run reported " +
"no volume at all. bound() is being asked AFTER reconcile has moved that carry out of Carried, so " +
"the one run that most needs the disclosure is the one that goes silent")
}
if !strings.Contains(v.String(), "PAID FOR BUT FLAGGED") {
t.Errorf("the report must name the money spent for no readable text: %s", v.String())
}
}
// TestTheVolumeReportIsAskedBeforeReconcileOnADraftOnlyPipeline is the draft-only twin of the test above,
// and it exists because the first version of that pin turned out to be carried by its neighbours.
//
// ⚠ The bound() decision sits BEFORE the fork into the draft-only and editor paths, so BOTH paths depend on
// its position — but only the editor path had a fixture that could tell the two orders apart. A planting
// that moved the decision after the editor path's reconcile reddened three draft-only tests for the wrong
// reason (they lost the report entirely, not out of order), and a planting that moved it after BOTH
// reconciles would have been caught by nothing on this shape. On a draft-only pipeline the SHIPPING wave is
// the draft one, so the same carried-and-flagged unit is reachable here and reports the same way.
func TestTheVolumeReportIsAskedBeforeReconcileOnADraftOnlyPipeline(t *testing.T) {
var mu sync.Mutex
calls, released := 0, false
rec := &reqRec{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
rec.record(string(body))
mu.Lock()
calls++
n, rel := calls, released
mu.Unlock()
switch {
case rel:
// Run 2: the second draft stage answers, and REFUSES — the carried unit is finished and flagged.
writeFakeCompletion(w, "I'm sorry, but I can't help with that request.", "refusal")
case n == 1:
writeFakeCompletion(w, "ЧЕРНОВИК ПЕРЕВОДА", "stop") // stage 1 of run 1 checkpoints
default:
w.WriteHeader(http.StatusInternalServerError) // stage 2 of run 1 dies: started, never shipped
}
}))
defer srv.Close()
eps := []chunktest.Chapter{{ID: "c1", Href: "c1.xhtml", Body: "<p>静かな図書館の朝1。</p>"}}
bookPath := setupProjectOpts(t, srv.URL, projectOpts{epub: eps, spine: []string{"c1"}, draftOnly: true, waveWorkers: 1})
addSecondDraftStage(t, bookPath)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err == nil {
t.Fatal("precondition: the second draft stage was supposed to fail this run")
}
r1.Close()
mu.Lock()
released = true
mu.Unlock()
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.MaxUnits = 1
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if res.Flagged != 1 {
t.Fatalf("premise broken: the carried unit must come back FLAGGED on the shipping (draft) wave, got "+
"flagged=%d over %d chunk(s)", res.Flagged, len(res.Chunks))
}
if res.Volume == nil {
t.Fatal("a draft-only run finished a unit outside its grant, paid for it, and it FLAGGED — and reported " +
"no volume at all. The bound() decision is being taken from counters reconcile has already trued up")
}
if !strings.Contains(res.Volume.String(), "PAID FOR BUT FLAGGED") {
t.Errorf("the report must name the money spent for no readable text: %s", res.Volume.String())
}
}