326 lines
16 KiB
Go
326 lines
16 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/seed"
|
||
|
||
"gopkg.in/yaml.v3"
|
||
)
|
||
|
||
// bankbasis_answers_test.go: the pin of the settled basis's ONE silent regression — the one §4.2 of the
|
||
// pack calls the quietest thing that can go wrong here.
|
||
//
|
||
// ⛔ WHAT IT GUARDS. A basis that merely REMOVED settled candidates from the paid set would look like a
|
||
// saving and be a loss. attachConsolidatedDst stamps nothing for a candidate the role did not answer, so
|
||
// the emission falls back to the raw first-chunk banknote guess and the row drops from `draft` to `auto`
|
||
// (miner.DeltaFile: `st.Dst = props[0].Dst` under `props[0].Via == ""`, and `st.Status` is only raised to
|
||
// «draft» by a CONSOLIDATED dst). Where the drafts proposed nothing on the surface itself, the rendering
|
||
// disappears entirely.
|
||
//
|
||
// ⚠ AND THE PAIR IS COMPARED, NOT THE RENDERING ALONE. A zeroed dst is visible — an empty cell on the
|
||
// sheet. A row that quietly swapped its consolidated rendering for the first chunk's guess and its status
|
||
// for `auto` looks NORMAL and travels on as an ordinary undecided row. Comparing `dst` alone would go
|
||
// green on exactly the worse half, so every assertion below is over (dst, status) together.
|
||
func TestTheBasisCarriesTheBoughtAnswerRatherThanDroppingIt(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isClassifierBody(body) {
|
||
// ⚠ THE CLASSIFIER IS ON, AND THE FIXTURE ANSWERS IT, because the answer has TWO halves bought
|
||
// on the same money and reaching the row through different doors. With the classifier off,
|
||
// `type` and `gender` are empty on every row, so a basis that dropped the classifier's half
|
||
// entirely would leave this pin green — it would be comparing "" with "". Measured: with the
|
||
// phase off, a planting that discards the type and the gender survived the pin unchanged.
|
||
return "方源\tname\tmale\n青茅山\tterm\tnone", "stop"
|
||
}
|
||
if isTerminologyBody(body) {
|
||
return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop"
|
||
}
|
||
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true})
|
||
|
||
// Run 1: the roles are paid, and what they decided is what the book now holds.
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if r1.lastTerminology == nil || r1.lastTerminology.Consolidated == 0 {
|
||
t.Fatalf("premise broken: run 1 must consolidate something, or there is no bought answer to lose: %+v", r1.lastTerminology)
|
||
}
|
||
bought := autoBankPairs(t, r1.autoBankPath())
|
||
decided := 0
|
||
for _, p := range bought {
|
||
if p.status == "draft" && p.dst != "" {
|
||
decided++
|
||
}
|
||
}
|
||
if decided == 0 {
|
||
t.Fatalf("premise broken: run 1 emitted no `draft` row with a rendering, so the loss this test is about cannot happen in this fixture: %+v", bought)
|
||
}
|
||
// The classifier's half must actually be PRESENT, or the type/gender comparisons below are "" against
|
||
// "" — the exact way this pin was measured to pass over a planting that threw both away.
|
||
withType, withGender := 0, 0
|
||
for _, p := range bought {
|
||
if p.typ != "" {
|
||
withType++
|
||
}
|
||
if p.gender != "" {
|
||
withGender++
|
||
}
|
||
}
|
||
if withType == 0 || withGender == 0 {
|
||
t.Fatalf("premise broken: the classifier's half of the answer is absent from run 1 (rows with a type: %d, with a gender: %d), so this pin cannot see it being lost: %+v", withType, withGender, bought)
|
||
}
|
||
r1.Close()
|
||
|
||
// Run 2 meets the basis run 1 wrote and pays its one-time re-pack; run 3 is the steady state where the
|
||
// basis actually answers. Both are walked, because the property must hold in the steady state and not
|
||
// merely survive one hop.
|
||
var last map[string]bankPair
|
||
for i := 2; i <= 3; i++ {
|
||
r := newRunner(t, bookPath)
|
||
if _, err := r.TranslateBook(context.Background()); err != nil {
|
||
t.Fatalf("run %d: %v", i, err)
|
||
}
|
||
if r.lastTerminology == nil || r.lastTerminology.BasisServed == 0 {
|
||
t.Fatalf("premise broken: run %d must be served by the basis, or this test never exercises it: %+v", i, r.lastTerminology)
|
||
}
|
||
last = autoBankPairs(t, r.autoBankPath())
|
||
r.Close()
|
||
}
|
||
|
||
// THE ASSERTION: every row the book had bought still says exactly what it said.
|
||
for src, was := range bought {
|
||
now, present := last[src]
|
||
if !present {
|
||
t.Errorf("%q vanished from the auto-bank once the basis answered it: the book lost a rendering it had PAID for", src)
|
||
continue
|
||
}
|
||
if now != was {
|
||
t.Errorf("%q changed under the basis: was dst=%q status=%q type=%q gender=%q, now dst=%q status=%q type=%q gender=%q.\n"+
|
||
" A `draft` row falling to `auto` is the silent half of this regression: the row still LOOKS ordinary, "+
|
||
"it simply carries the first chunk's guess instead of the rendering the roles were paid for.",
|
||
src, was.dst, was.status, was.typ, was.gender, now.dst, now.status, now.typ, now.gender)
|
||
}
|
||
}
|
||
// The control that keeps those comparisons from being vacuous: the second reading is not empty, and it
|
||
// is not a copy of a file nobody rewrote.
|
||
if len(last) == 0 {
|
||
t.Fatal("control: the steady-state auto-bank holds NO rows, so «everything matches» is a statement about an empty map")
|
||
}
|
||
}
|
||
|
||
// bankPair is one auto-bank row reduced to the two fields that decide whether an answer survived.
|
||
// ⚠ TYPE AND GENDER RIDE HERE TOO. They are the CLASSIFIER's half of the answer, bought on the same
|
||
// money, and they reach the row by a different door (attachClassifiedType / attachClassifiedGender). A
|
||
// pin over the rendering alone would go green on a basis that carried the terminologist's word and
|
||
// dropped the classifier's — and a row that loses its gender loses a wire directive the engine already
|
||
// promised.
|
||
type bankPair struct{ dst, status, typ, gender string }
|
||
|
||
// autoBankPairs reads the engine's own auto-bank document — the artifact the emission actually produces —
|
||
// rather than a projection of it, so the pin cannot pass on a view that agrees with itself while the
|
||
// document beneath disagrees.
|
||
func autoBankPairs(t *testing.T, path string) map[string]bankPair {
|
||
t.Helper()
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatalf("the run must leave an auto-bank behind, or the emission never happened: %v", err)
|
||
}
|
||
var f seed.File
|
||
if err := yaml.Unmarshal(raw, &f); err != nil {
|
||
t.Fatalf("parse the auto-bank %s: %v", path, err)
|
||
}
|
||
out := map[string]bankPair{}
|
||
for _, term := range f.Terms {
|
||
out[strings.TrimSpace(term.Src)] = bankPair{dst: term.Dst, status: term.Status, typ: term.Type, gender: term.Gender}
|
||
}
|
||
if len(out) == 0 {
|
||
t.Fatalf("the auto-bank at %s holds no terms (%d bytes): every comparison against it would be vacuous", path, len(raw))
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestTheBasisIsNotWrittenBeforeTheRunReachesABoundary is the pin §4.3 of the pack asks for: a planting
|
||
// that records the basis straight after the paid pass — before the run forks into «stop» or «continue» —
|
||
// has to turn a FREE resume into a paid one.
|
||
//
|
||
// ⛔ THE MECHANISM, because the failure is not obvious. A run that dies after the bank roles have been paid
|
||
// leaves their checkpoints behind, and a resume rebuilds the SAME batch composition and replays them for
|
||
// $0. Write the basis before the fork and the crashed run has already recorded its decisions: the resume
|
||
// then serves them, builds a NARROWER composition, finds no checkpoint addressed by that request hash, and
|
||
// buys the whole pass again — money out of a lifetime ceiling, for work already done, with nothing red.
|
||
//
|
||
// The crash is produced by the BOOK CEILING rather than by a killed process: the draft wave and the bank
|
||
// roles fit under it, the edit wave's reservation does not, so TranslateBook returns a refusal at exactly
|
||
// the position this pin needs — after the paid pass, before either output boundary.
|
||
func TestTheBasisIsNotWrittenBeforeTheRunReachesABoundary(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isTerminologyBody(body) {
|
||
return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop"
|
||
}
|
||
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
|
||
tightenBookCeiling(t, bookPath, 0.004)
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
_, err := r1.TranslateBook(context.Background())
|
||
if err == nil {
|
||
t.Fatal("premise broken: the tightened ceiling must STOP this run before it reaches an output boundary, or nothing here is about a crash")
|
||
}
|
||
if r1.lastTerminology == nil || r1.lastTerminology.CostUSD == 0 {
|
||
t.Fatalf("premise broken: the bank roles must have been PAID before the run died, or there are no checkpoints for the resume to replay: %+v", r1.lastTerminology)
|
||
}
|
||
paidCalls := countTerminologyCalls(rec)
|
||
// THE STATE THIS PIN IS ABOUT: the run died, so no boundary ran, so nothing was recorded.
|
||
stored, serr := r1.Store.BankBasisForBook(r1.Book.BookID)
|
||
if serr != nil {
|
||
t.Fatal(serr)
|
||
}
|
||
if len(stored) != 0 {
|
||
t.Fatalf("a run that never reached an output boundary recorded %d basis row(s): the next resume will serve them, narrow the batch composition, miss every checkpoint and re-buy the pass it already paid for", len(stored))
|
||
}
|
||
r1.Close()
|
||
|
||
// The consequence, asserted rather than inferred: the resume replays for $0.
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
if _, err := r2.TranslateBook(context.Background()); err == nil {
|
||
t.Fatal("premise broken: the resume must meet the same ceiling, or it is not the run this pin is about")
|
||
}
|
||
if r2.lastTerminology == nil {
|
||
t.Fatal("premise broken: the resumed run must reach the bank pass again")
|
||
}
|
||
if r2.lastTerminology.CostUSD != 0 {
|
||
t.Errorf("the resume re-bought the bank pass ($%f) although the crashed run had paid for it: the basis is being recorded before the run reaches a boundary", r2.lastTerminology.CostUSD)
|
||
}
|
||
if n := countTerminologyCalls(rec); n != paidCalls {
|
||
t.Errorf("the resume reached the provider for the bank role: calls went %d → %d", paidCalls, n)
|
||
}
|
||
}
|
||
|
||
// tightenBookCeiling rewrites the fixture's book ceiling in place. It edits the generated book.yaml rather
|
||
// than adding an option to the fixture builder, because the ceiling is the only thing this one pin needs
|
||
// and a knob nothing else sets is a knob that goes stale unread.
|
||
func tightenBookCeiling(t *testing.T, bookPath string, usd float64) {
|
||
t.Helper()
|
||
raw, err := os.ReadFile(bookPath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out, replaced := []string{}, false
|
||
for _, ln := range strings.Split(string(raw), "\n") {
|
||
if strings.Contains(ln, "book_usd") {
|
||
out = append(out, fmt.Sprintf("ceilings: { book_usd: %g, day_usd: 10.0 }", usd))
|
||
replaced = true
|
||
continue
|
||
}
|
||
out = append(out, ln)
|
||
}
|
||
if !replaced {
|
||
t.Fatalf("the fixture's book.yaml declares no book_usd, so this helper silently changed nothing:\n%s", raw)
|
||
}
|
||
if err := os.WriteFile(bookPath, []byte(strings.Join(out, "\n")), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// TestABookWithTheGateOffTouchesNoBasisAtAll pins the promise every new mechanism owes the books already in
|
||
// the field: a project that does not buy bank roles must behave exactly as it did before this one existed.
|
||
//
|
||
// ⛔ WHY IT IS A PIN AND NOT AN OBSERVATION. The basis lives on the paid path, and the paid path is gated
|
||
// (`gates.terminology.enabled`). A gate whose OFF branch grew a side effect is the quiet way a mechanism
|
||
// reaches books nobody enabled it for — here it would mean rows in a table for a contour that never ran,
|
||
// and a later purchase serving answers nobody bought. The engine's own refusals are written against that
|
||
// shape everywhere else; this is the same refusal, asserted.
|
||
func TestABookWithTheGateOffTouchesNoBasisAtAll(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
// terminology OFF: the mining stop still runs, the delta is still emitted, nothing is bought.
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{})
|
||
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
if _, err := r.TranslateBook(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// The premise: the run really did reach the bank-mining stop and emit a delta. Without it the zeros
|
||
// below would be a statement about a run that never got near the contour.
|
||
if r.lastMinedCount == 0 {
|
||
t.Fatalf("premise broken: the run mined nothing, so «the gate-off path touched no basis» is about a path that was never walked")
|
||
}
|
||
if r.lastTerminology != nil {
|
||
t.Fatalf("premise broken: the terminology pass ran with the gate OFF: %+v", r.lastTerminology)
|
||
}
|
||
stored, err := r.Store.BankBasisForBook(r.Book.BookID)
|
||
if err != nil {
|
||
// The TABLE must exist — the migration is additive and runs for every project — so a read error
|
||
// here is a real failure, not an absent feature.
|
||
t.Fatalf("the basis table must exist on every project, gate or no gate: %v", err)
|
||
}
|
||
if len(stored) != 0 {
|
||
t.Fatalf("a run with the bank gate OFF recorded %d basis row(s): the mechanism reached a book nobody enabled it for, and a later purchase would serve answers nobody bought", len(stored))
|
||
}
|
||
}
|
||
|
||
// TestTheSignatureStopWritesTheBasisToo pins the boundary nothing was guarding, and acceptance proved the
|
||
// hole with a control rather than by reading: removing the write at `run-finished` turned the package RED,
|
||
// removing it at the SIGNATURE STOP left it entirely `ok`.
|
||
//
|
||
// ⛔ WHY THE UNGUARDED ONE IS THE EXPENSIVE ONE. The two boundaries are not interchangeable and the stop is
|
||
// the one that cannot be reached from the other: the signature stop returns as an ERROR VALUE
|
||
// (`WaveSignatureStop`), so a run that stops for the owner's signature never arrives at `run-finished` at
|
||
// all. Lose the write there and the resume after the owner signs re-buys the ENTIRE bank pass — out of a
|
||
// lifetime per-book ceiling — while every fixture that only ever auto-continues stays green.
|
||
func TestTheSignatureStopWritesTheBasisToo(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, miningStopProvider(t, rec, ""))
|
||
defer srv.Close()
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
|
||
|
||
r := newVerifyRunner(t, bookPath)
|
||
defer r.Close()
|
||
stop := runToSignatureStop(t, r)
|
||
if stop.Terms == 0 {
|
||
t.Fatal("premise broken: the stop fired with zero terms")
|
||
}
|
||
// The premise that makes the assertion about the STOP and not about a finished run: this run ended as
|
||
// an error value, so `run-finished` never ran.
|
||
if r.lastTerminology == nil || len(r.lastTerminology.BasisRows) == 0 {
|
||
t.Fatalf("premise broken: the stopping run must have decided something to record: %+v", r.lastTerminology)
|
||
}
|
||
stored, err := r.Store.BankBasisForBook(r.Book.BookID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(stored) == 0 {
|
||
t.Fatalf("the signature stop recorded NOTHING although it decided %d row(s): the resume after the owner signs would re-buy the whole bank pass out of a lifetime ceiling, and no fixture that auto-continues would ever notice",
|
||
len(r.lastTerminology.BasisRows))
|
||
}
|
||
if len(stored) != len(r.lastTerminology.BasisRows) {
|
||
t.Errorf("the stop recorded %d row(s) of %d decided — a partial write is the same loss, only harder to see",
|
||
len(stored), len(r.lastTerminology.BasisRows))
|
||
}
|
||
// AND THE ORDER, which is the other half of this boundary's contract: the basis goes in BEFORE the flag
|
||
// memory, because a crash between them must cost the cheaper loss. Asserted on the durable state: the
|
||
// memory is present too, so «basis written» is not true merely because the stop died early.
|
||
presented, err := r.Store.StopPresentedSurfaces(r.Book.BookID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(presented) == 0 {
|
||
t.Fatal("control: the flag memory is empty, so this stop did not complete its boundary and the assertion above is about a half-written state")
|
||
}
|
||
}
|