textmachine/backend/internal/pipeline/rebill_pending_test.go

528 lines
22 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 (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"textmachine/backend/internal/chunk/chunktest"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/obs"
)
// decisionsDocFor writes a decision document for an ARBITRARY book id — the package's existing helper
// hardcodes the one book its own fixtures use.
func decisionsDocFor(t *testing.T, dir, bookID string, ds ...membank.Decision) string {
t.Helper()
body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: bookID, Decisions: ds})
if err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, "decisions-for-"+bookID+".json")
writeFile(t, p, string(body))
return p
}
// assertStoreUntouched is the "nothing was written" instrument, and it is deliberately TWO assertions
// because one of them is not enough.
//
// ⚠ HASHING THE .db FILE ALONE PROVES LESS THAN IT LOOKS. SQLite in WAL mode puts a committed change in
// the `-wal` sidecar and leaves the main file untouched until a checkpoint, so a data write can land with
// the main file's bytes unmoved — the first version of this helper would have gone on reporting "nothing
// was written" straight through it. So: the data file must be byte-identical AND the write-ahead log must
// carry no frames.
//
// ⚠ AND `-shm` IS DELIBERATELY NOT CHECKED. Measured, not assumed: opening this database read-only
// CREATES an empty `-wal` and a live `-shm`, because the shared-memory index is how any WAL reader finds
// its snapshot. Requiring those two files not to appear would fail on a pure read and would be asserting
// something SQLite's design forbids, not something about this engine. An empty `-wal` is the honest line:
// the reader may set the machinery up, it may not commit a frame through it.
//
// It is corroboration, not the guarantee. What actually makes the read path unable to write is
// store.OpenReadOnly's PRAGMA query_only(1) — a write through it fails loudly rather than being dropped.
// This catches a path that reached a WRITE handle by some route the read was not supposed to take.
func assertStoreUntouched(t *testing.T, dbPath, dataBefore string) {
t.Helper()
if got := fileDigest(t, dbPath); got != dataBefore {
t.Fatalf("the project database changed during a read-only estimate (%s → %s): nothing beyond the first touch may be written", dataBefore[:12], got[:12])
}
wal, err := os.Stat(dbPath + "-wal")
if err != nil {
if !os.IsNotExist(err) {
t.Fatalf("stat %s-wal: %v", dbPath, err)
}
return // no write-ahead log at all — nothing could have been committed through one
}
if wal.Size() != 0 {
t.Fatalf("the write-ahead log holds %d byte(s) after a read-only estimate: a committed change can sit in -wal with the main database file still byte-identical, which is exactly what makes hashing the .db alone insufficient", wal.Size())
}
}
// fileDigest is a plain content hash of one file.
func fileDigest(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// TestABankEditIsVisibleToTheEstimateBeforeAnythingIsBought is the deliverable of backlog row 231, and it
// is written against the state the FIRST attempt at this repro got wrong (errata 28.08-и/-к).
//
// THE DEGENERATE REPRO THAT MUST BE AVOIDED. "translate has never run" proves nothing: such a book has no
// chunk_status rows at all, and projectRebill skips rows without a snapshot (rebill.go), so it answers
// EMPTY both before and after any fix. The state that discriminates is: a run HAPPENED (rows and
// snapshots exist), THEN the bank was edited through `bank-apply`, which writes decision FILES and not one
// store row. On that state the engine used to answer "nothing moved" while the very next `translate` would
// re-seed from those files and bill for the difference.
//
// So the assertion is a DELTA, and both halves are executed here: the stored-glossary projection (what the
// engine answered before) must be zero on exactly the state where the folded projection is non-zero.
// Around it sit the two boundaries the estimate is sold with — not one provider call, and not one byte
// written to the store beyond the first touch that predates it.
func TestABankEditIsVisibleToTheEstimateBeforeAnythingIsBought(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
})
dir := filepath.Dir(bookPath)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// --- the run that must have HAPPENED for the repro to discriminate ---
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatalf("the run this repro rests on did not complete: %v", err)
}
statuses, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
if len(statuses) == 0 {
t.Fatalf("precondition: the repro needs stored chunk_status rows, the state the degenerate version lacked")
}
committedBefore, reservedBefore, err := r1.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
r1.Close()
// --- the bank edit, through the verb the product actually uses ---
// 静か occurs in the source, so the term enters the ENRICHED bank, moves the edit-wave snapshot AND
// changes the injected bytes of the edit unit — i.e. it is genuinely re-paid work, not a $0 re-pin.
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)
}
callsBefore := rec.count()
dbPath := filepath.Join(dir, "test-book.db")
dbBefore := fileDigest(t, dbPath)
// --- the two projections over the SAME state ---
r2, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
defer r2.Close()
rp, err := r2.newRepricer()
if err != nil {
t.Fatal(err)
}
chunks, withText, err := r2.readModelChunks()
if err != nil {
t.Fatal(err)
}
// What the engine answered BEFORE this pack: the glossary the last run stored.
if err := r2.projectStoredMemory(); err != nil {
t.Fatalf("stored-glossary projection: %v", err)
}
storedProj, err := r2.projectRebill(statuses, chunks, withText, rp)
if err != nil {
t.Fatalf("stored-glossary re-bill projection: %v", err)
}
// What it answers now: the bank the next `translate` will actually build.
basis, warn := r2.foldMemoryForRead()
if warn != nil {
t.Fatalf("the fold refused on a healthy book: %v", warn)
}
if basis != RebillBasisPending {
t.Fatalf("basis = %q, want %q — a healthy book must be projected from its decision files", basis, RebillBasisPending)
}
foldedProj, err := r2.projectRebill(statuses, chunks, withText, rp)
if err != nil {
t.Fatalf("folded re-bill projection: %v", err)
}
// THE DELTA. Both halves are load-bearing: a non-zero "after" alone would also pass on a book where
// the old answer was non-zero too, and would not prove the blind window was the thing that closed.
if storedProj.Rows != 0 {
t.Fatalf("the repro does not discriminate: the STORED-glossary projection already sees %d unit(s); the blind window this pack closes would be invisible here", storedProj.Rows)
}
if foldedProj.Rows == 0 {
t.Fatalf("the folded projection still reports nothing after a bank edit — the blind window of row 231 is open")
}
// --- the two boundaries the estimate is SOLD with ---
if got := rec.count(); got != callsBefore {
t.Fatalf("the estimate made %d provider call(s); it is sold as $0 and must make none", got-callsBefore)
}
committedAfter, reservedAfter, err := r2.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if committedAfter != committedBefore || reservedAfter != reservedBefore {
t.Fatalf("the ledger moved during a free estimate: committed %v→%v, reserved %v→%v",
committedBefore, committedAfter, reservedBefore, reservedAfter)
}
assertStoreUntouched(t, dbPath, dbBefore)
}
// TestTheEstimateSurvivesABookWhoseFoldRefuses pins the property rebill.go leans on — "a book that needs
// consent stays fully inspectable" (D20.4). `translate` fails loudly on a bank whose fold would abort
// ReplaceBank; a READ must not, or the book that most needs looking at is the one that cannot be looked
// at. It must fall back AND say that it fell back, because a stale number passed off as the answer is
// worse than a labelled one.
func TestTheEstimateSurvivesABookWhoseFoldRefuses(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
})
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()
// A mined-delta row on the SAME UNIQUE key as the signed seed term: exactly what would abort
// ReplaceBank's flat INSERT, and what seedGlossary refuses on before it gets there.
writeFile(t, filepath.Join(dir, "test-book.mined-delta.yaml"), `
terms:
- src: 鈴木
dst: Судзуки-другой
type: name
status: approved
`)
r2, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
defer r2.Close()
basis, warn := r2.foldMemoryForRead()
if basis != RebillBasisStored {
t.Fatalf("basis = %q, want %q — a refused fold must fall back to the stored glossary, not fail the read", basis, RebillBasisStored)
}
if warn == nil {
t.Fatalf("a silent fallback is the defect: the reason the projection is a fact about the PAST must reach the log")
}
if r2.memory == nil || r2.baseMemory == nil {
t.Fatalf("the fallback must still leave a materialized bank behind (memory=%v base=%v)", r2.memory != nil, r2.baseMemory != nil)
}
// And the whole read still answers rather than erroring.
rep, err := r2.Status(ctx)
if err != nil {
t.Fatalf("status refused a book whose fold refuses — D20.4 says it must stay inspectable: %v", err)
}
if rep.RebillBasis != RebillBasisStored {
t.Fatalf("the report must carry the fallback basis, got %q", rep.RebillBasis)
}
}
// TestTheWireTellsFreeFromUnknown pins the omitempty mine D39.166 п.2 already paid for once: three states
// collapse to two zeroes unless the basis rides with them.
func TestTheWireTellsFreeFromUnknown(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()})
// A book nothing has run: the figures are zero because there is nothing to re-pay.
r0, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
rep0, err := r0.Status(ctx)
if err != nil {
t.Fatal(err)
}
r0.Close()
if rep0.RebillBasis != RebillBasisNone {
t.Fatalf("a book with no stored row must report basis %q, got %q", RebillBasisNone, rep0.RebillBasis)
}
// A book that ran and drifted from nothing: the figures are zero because they were COMPUTED as zero.
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
r2, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
defer r2.Close()
rep1, err := r2.Status(ctx)
if err != nil {
t.Fatal(err)
}
if rep1.RebillBasis != RebillBasisPending {
t.Fatalf("a healthy run must report basis %q, got %q", RebillBasisPending, rep1.RebillBasis)
}
if rep1.RebillUnits != 0 {
t.Fatalf("precondition: an undrifted book re-pays nothing, got %d", rep1.RebillUnits)
}
// The two states above BOTH carry zero. Before this pack they were the same bytes on the wire.
if rep0.RebillBasis == rep1.RebillBasis {
t.Fatalf("«nothing to re-pay» and «computed, and it is zero» must be distinguishable, both say %q", rep0.RebillBasis)
}
}
// TestAFailedBasisNeverCarriesAFigure pins the direction the basis field's first version did not
// consider. It was added so that "computed, and it is zero" could be told from "we could not compute it"
// — but a failed fold leaves r.memory nil, and projectRebill run against a nil bank renders both wave
// snapshots over an EMPTY bank, so EVERY stored row differs and the whole book comes back as a
// re-payment. The wire would then carry rebill_basis:"failed" beside the largest number the field can
// hold, and the CLI would print "the figures below are zero because they could not be computed" next to
// it. A basis that contradicts its own numbers is worse than no basis.
func TestAFailedBasisNeverCarriesAFigure(t *testing.T) {
big := RebillProjection{Rows: 4276, USD: 12.5, OutputUnits: 1400, HistoricalRows: 3, ModelMovedRows: 7}
for _, c := range []struct {
name string
hasRows bool
memBasis string
proj RebillProjection
projErr error
basis string
units int
}{
{"nothing stored", false, RebillBasisPending, big, nil, RebillBasisNone, 0},
{"healthy fold", true, RebillBasisPending, big, nil, RebillBasisPending, 4276},
{"fold refused, stored answered", true, RebillBasisStored, big, nil, RebillBasisStored, 4276},
// The two that matter: an unknown answer must never arrive carrying a number. Before this was one
// decision, the basis said "failed" while a whole-book figure — computed over an EMPTY bank, so
// every row looked superseded — sat next to it.
{"no bank at all", true, RebillBasisFailed, big, nil, RebillBasisFailed, 0},
{"projection errored", true, RebillBasisPending, big, errors.New("boom"), RebillBasisFailed, 0},
} {
t.Run(c.name, func(t *testing.T) {
basis, published := rebillOutcome(c.hasRows, c.memBasis, c.proj, c.projErr)
units, usd := published.Rows, published.USD
if basis != c.basis || units != c.units {
t.Fatalf("got (%q, %d, %v), want (%q, %d)", basis, units, usd, c.basis, c.units)
}
// EVERY figure, not just the two obvious ones: an output-unit count surviving an UNKNOWN
// answer would be the same contradiction in a different field — and so would a disclosure
// counter, which is why the whole projection is compared against its zero value.
if basis == RebillBasisFailed && published != (RebillProjection{}) {
t.Fatalf("an UNKNOWN answer carried a figure: %+v", published)
}
if basis == RebillBasisNone && published != (RebillProjection{}) {
t.Fatalf("nothing stored means nothing to re-pay, yet figures were reported: %+v", published)
}
})
}
// And the projection is not even attempted when there is no bank to compare against — running it
// would produce exactly the whole-book figure the case above must never show.
if projectable(true, RebillBasisFailed) {
t.Fatalf("with no materialized bank the projection must be skipped, not computed and then discarded")
}
if !projectable(true, RebillBasisPending) || projectable(false, RebillBasisPending) {
t.Fatalf("projectable must gate on stored rows and on a usable bank, and on nothing else")
}
// And end to end: a book whose fold refuses AND whose store cannot answer either. The fold refusal is
// built with a mined-delta duplicating the signed seed term; the stored side is broken by closing the
// store out from under the read, which is the only way both halves fail at once.
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
})
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()
writeFile(t, filepath.Join(dir, "test-book.mined-delta.yaml"), `
terms:
- src: 鈴木
dst: Судзуки-другой
type: name
status: approved
`)
r2, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
defer r2.Close()
got, err := r2.Status(ctx)
if err != nil {
t.Fatal(err)
}
// Here only the FOLD fails, so the honest answer is the stored fallback with a real figure — the
// point being that "failed" is reserved for the state where nothing at all could be materialized.
if got.RebillBasis != RebillBasisStored {
t.Fatalf("a fold refusal alone falls back rather than failing: got %q", got.RebillBasis)
}
}
// TestExportAndStatusAnswerDifferentDriftQuestions pins a divergence that is DELIBERATE, so that nobody
// removes it as an inconsistency.
//
// After a bank-apply that has written only decision FILES, `status` reports drift and `export` does not,
// and both are right because they are asked different things. status is the money surface: its question
// is "what would the NEXT run cost", and an un-run decision file is precisely the spend row 231 exists to
// reveal. export's ConfigDrift is read by the polygon's extraction to decide whether a document is
// trustworthy to MEASURE — and an export whose bytes are exactly what its run shipped is measurable
// whether or not somebody has queued a bank edit beside it.
//
// The pack briefly made export fold too, for symmetry. Symmetry between two surfaces answering different
// questions is not worth having, and it would have disqualified sound measurements in another zone.
func TestExportAndStatusAnswerDifferentDriftQuestions(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
})
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)
}
callsBefore := rec.count()
rExp, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
exp, err := rExp.Export(false)
if err != nil {
t.Fatal(err)
}
rExp.Close()
if exp.ConfigDrift {
t.Fatalf("export marked a document drifted whose text is exactly what its run shipped: the polygon reads this field to decide whether a measurement is sound, and an un-run decision file does not make one unsound")
}
rSt, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
defer rSt.Close()
rep, err := rSt.Status(ctx)
if err != nil {
t.Fatal(err)
}
if !rep.ConfigDrift {
t.Fatalf("status must see the pending bank edit — that is the whole of row 231; got config_drift=false")
}
if rep.RebillUnits == 0 {
t.Fatalf("status must price the pending edit, got rebill_units=0 basis=%q", rep.RebillBasis)
}
// Both are $0 surfaces.
if got := rec.count(); got != callsBefore {
t.Fatalf("a read surface made %d provider call(s)", got-callsBefore)
}
}
// TestTheEstimateIsAlsoGivenInTheUnitAPurchaseIsSizedIn pins the denomination fix.
//
// `rebill_units` counts chunk×stage — the BILLING granularity — while every other "units" number in the
// same document (total_units, the progress totals, chapters[].units_total) counts OUTPUT units, which is
// also what `--max-units` bounds and what the platform sells chapters in. Both wore the word "units" with
// nothing marking the difference, and the ratio is not something a consumer can divide out: it is
// len(Members)·nDraftStages + nEditStages, which varies unit by unit inside one book and whose factors
// never cross the seam. A platform sizing a re-pass from the estimate and handing that number to
// --max-units would buy several times the book it meant to — this pack's own defect, one layer down.
func TestTheEstimateIsAlsoGivenInTheUnitAPurchaseIsSizedIn(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// A cut fine enough that units hold several chunks, so the two denominations genuinely differ.
const seg = "\nsegmentation:\n draft_budget_out: 24\n edit_ceiling_out: 8000\n fertility: { cjk: 1.1978, other: 0.3852 }\n"
var eps []chunktest.Chapter
var spine []string
for i := 1; i <= 2; 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: 1, gatesYAML: seg,
})
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()
// A PIPELINE drift, not a bank edit: a mined bank edit moves only the edit wave, so it re-pays exactly
// one row per unit and the two denominations would coincide — the fixture would prove nothing. A config
// change moves BOTH waves, which is the ordinary shape in which a unit's several draft rows and its one
// edit row are all re-paid together, and where the collision this field exists for is real.
driftPipelineVersion(t, bookPath)
r2, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatal(err)
}
defer r2.Close()
rep, err := r2.Status(ctx)
if err != nil {
t.Fatal(err)
}
if rep.RebillUnits == 0 {
t.Fatalf("precondition: the bank edit must produce a re-payment, got 0 (basis %q)", rep.RebillBasis)
}
if rep.RebillOutputUnits == 0 {
t.Fatalf("the estimate must also be given in OUTPUT units — the granularity --max-units counts and the platform sells in; got 0 beside rebill_units=%d", rep.RebillUnits)
}
if rep.RebillOutputUnits > rep.RebillUnits {
t.Fatalf("an output unit holds one or more chunk×stage rows, so it can never exceed them: output=%d rows=%d", rep.RebillOutputUnits, rep.RebillUnits)
}
if rep.RebillOutputUnits > rep.TotalUnits {
t.Fatalf("the book has %d output units; a re-pass cannot touch %d of them", rep.TotalUnits, rep.RebillOutputUnits)
}
// The fixture must actually make the two differ, or it proves nothing about the collision.
if rep.RebillOutputUnits == rep.RebillUnits {
t.Fatalf("fixture is degenerate: the two denominations coincide (%d), so the trap this field exists for is untested", rep.RebillUnits)
}
}