textmachine/backend/internal/pipeline/banksettled_test.go

595 lines
31 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"
"errors"
"log/slog"
"os"
"reflect"
"strings"
"testing"
"textmachine/backend/internal/store"
"textmachine/backend/internal/terminology"
"textmachine/backend/internal/text"
)
// askedAsCandidate reports whether a surface was sent to the paid role as a CANDIDATE of the block, which
// is the only question about money. The candidate list is spelled as markdown headings, and every
// candidate carries the source sentences it occurs in — so a surface can be all over a request without
// anybody having been asked about it.
func askedAsCandidate(rec *reqRec, src string) bool {
for _, body := range rec.all() {
if isTerminologyBody(body) && strings.Contains(body, "### "+src) {
return true
}
}
return false
}
// terminologyBodies is what the role was actually sent, for a failure that has to be readable.
func terminologyBodies(rec *reqRec) string {
var out []string
for _, body := range rec.all() {
if isTerminologyBody(body) {
out = append(out, body)
}
}
return strings.Join(out, "\n---\n")
}
// banksettled_test.go pins the filter that stops the paid terminology role being asked about surfaces the
// bank has already settled. The two cases below are the same run with ONE byte of the draft's proposal
// changed, so what they measure is the agreement and not two different fixtures.
// TestABankSettledCandidateIsNotPaidFor is case (i): the seed holds the surface, the draft proposed the
// rendering the seed holds, and the role is therefore never asked about it.
func TestABankSettledCandidateIsNotPaidFor(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "青茅山\tгора Цинмао", "stop"
}
// The draft proposes for 方源 exactly what the seed already holds.
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
seed := "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n"
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
defer r.Close()
var runLog bytes.Buffer
r.Log = slog.New(slog.NewTextHandler(&runLog, &slog.HandlerOptions{Level: slog.LevelInfo}))
_ = runToSignatureStop(t, r)
tres := r.lastTerminology
// The number is stated WITH its denominator: "1 skipped" says nothing without "of how many".
if tres.BankSettled != 1 {
t.Fatalf("the settled candidate must be skipped before payment: skipped=%d of candidates=%d", tres.BankSettled, tres.Candidates)
}
if tres.Candidates < 2 {
t.Fatalf("premise broken: with fewer than two candidates a skip is indistinguishable from an empty run: %d", tres.Candidates)
}
// What the money question actually asks: did 方源 reach a paid request as a CANDIDATE. Asked on the
// block heading and not on the bare surface — every candidate's source contexts quote the sentences it
// occurs in, so 方源 appears in the request whatever the filter does, and a substring assertion here
// would have reported the filter broken while it was working.
if askedAsCandidate(rec, "方源") {
t.Errorf("the settled surface was sent to the paid role anyway:\n%s", terminologyBodies(rec))
}
// CONTROL: the OTHER candidate did reach it, so the absence above is the filter and not a role that
// was never called.
if !askedAsCandidate(rec, "青茅山") {
t.Fatalf("control: the unsettled candidate must still be paid for, or nothing was measured")
}
// ⚠ THE KEYS OF THE SAVING LINE ARE PINNED HERE and not by the operator-message catalogue, because that
// catalogue guards message TEXTS and says so — the structured arguments are outside it by declaration.
// This is the one line a later shift will grep to learn what the filter bought, and a number without
// its denominator is unreadable: «12 skipped» says nothing without «of 300».
// ⚠ Asserted on the ONE line that carries the saving, not on the whole buffer: three Contains over a
// shared buffer are satisfiable by three DIFFERENT lines, so the denominator could go to zero and the
// test stay green. Found by review after it was written that way.
var saving string
for _, line := range strings.Split(runLog.String(), "\n") {
if strings.Contains(line, "already settled") {
saving = line
}
}
if saving == "" {
t.Fatalf("the saving must be logged at all:\n%s", runLog.String())
}
for _, want := range []string{"skipped=1", "of_candidates=3", "still_paid=2"} {
if !strings.Contains(saving, want) {
t.Errorf("the saving line must carry %q — a number without its denominator is unreadable:\n%s", want, saving)
}
}
// The sheet still lists the settled surface — the skip is about what was BOUGHT, not about what the
// owner is shown. Its dst column is empty, because no role answered for it and the sheet must not
// print a rendering nobody produced this run.
var settled *BankStopRow
for i, row := range r.lastBankStopRows {
if row.Src == "方源" {
settled = &r.lastBankStopRows[i]
}
}
if settled == nil {
t.Fatalf("the skipped candidate must still be on the sheet: %+v", r.lastBankStopRows)
}
if settled.Dst != "" {
t.Errorf("no role answered for this candidate, so the sheet must not carry a rendering as if one had: %q", settled.Dst)
}
// ⛔ AND THE SHEET MUST SAY WHY. An empty dst is the same glyph as «the role declined», «no reply line
// covered it» and «the budget did not reach it» — three facts that mean UNDECIDED beside one that means
// NOTHING TO DECIDE, and the review ranking files all four together. The report of this pack first
// claimed the owner could read the difference off the row; he could not, and this is that claim made
// true instead of withdrawn.
if !settled.SettledByBank {
t.Errorf("a row the role was never asked about must be marked as such: %+v", *settled)
}
if !strings.Contains(renderBankStopTable(r.lastBankStopRows), "NOT ASKED(the bank already renders this surface") {
t.Errorf("the rendered sheet must carry the mark, not only the struct:\n%s", renderBankStopTable(r.lastBankStopRows))
}
// CONTROL: the candidate that WAS paid for is not marked, so the mark is the filter answering.
for _, row := range r.lastBankStopRows {
if row.Src == "青茅山" && row.SettledByBank {
t.Errorf("a candidate the role WAS asked about must not be marked as unasked: %+v", row)
}
}
if len(settled.Variants) == 0 {
t.Errorf("the drafts' own proposal must still be on the sheet — that is what the owner reads instead: %+v", *settled)
}
}
// TestADisagreeingBankedCandidateIsStillPaidFor is case (ii), and it is the half that keeps the filter
// from paying for itself with the owner's information: the seed holds the surface, the draft proposed
// something ELSE, and that disagreement is the whole reason the role is asked about banked surfaces.
// Measured on the corpus, this is the majority of the population — 366 of the 439 candidates the owner's
// own seed holds — so a filter without this half would buy its saving by silencing them.
func TestADisagreeingBankedCandidateIsStillPaidFor(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tИсточник Фана\n青茅山\tгора Цинмао", "stop"
}
// One byte of difference from the case above: the draft calls 方源 something the seed does not.
return "Странник пришёл." + "\n" + bankSeparator + "\n方源\tСтранник\tname\n青茅山\tгора Цинмао\tplace", "stop"
})
defer srv.Close()
seed := "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n"
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
defer r.Close()
_ = runToSignatureStop(t, r)
if n := r.lastTerminology.BankSettled; n != 0 {
t.Fatalf("a draft that disagrees with the bank must be PAID for, not skipped: skipped=%d", n)
}
if !askedAsCandidate(rec, "方源") {
t.Fatalf("the disagreeing surface must reach the paid role as a candidate:\n%s", terminologyBodies(rec))
}
// And the finding the payment bought has to be on the sheet, which is what the payment is FOR.
var row *BankStopRow
for i, cur := range r.lastBankStopRows {
if cur.Src == "方源" {
row = &r.lastBankStopRows[i]
}
}
if row == nil {
t.Fatalf("premise broken: 方源 is not on the sheet: %+v", r.lastBankStopRows)
}
if len(row.BankHolds) == 0 {
t.Errorf("the payment bought the mark that the book already calls this term something else, and it must reach the sheet: %+v", *row)
}
}
// TestAResumeFindsItsCheckpointsAfterTheFilter pins the resume cure, and it is the assertion that caught
// the first cure being wrong. Batches are packed greedily, so dropping a candidate changes the composition
// of a surviving batch, and a bank-role batch is addressed by the hash of its request: a second run whose
// composition differs from the first finds no checkpoint and buys the whole pass again. The first cure
// written here made the filter stand down for a book that had already paid — which produced exactly the
// re-purchase it was meant to prevent, because run one had paid under the FILTERED composition.
//
// What is asserted is money, not a flag: the resumed run reaches the provider for the bank role zero
// times and its pass costs $0.
func TestAResumeFindsItsCheckpointsAfterTheFilter(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "青茅山\tгора Цинмао", "stop"
}
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
seed := "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n"
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed})
// ⚠ BOTH runs AUTO-CONTINUE (no --verify-bank), and that is load-bearing rather than convenience: a
// run that STOPS at the bank boundary does not write the auto-bank (18-bank-ontology.md — the view is
// not changed on a stop boundary), so the bank the second run reads would be byte-identical to the
// first's whatever the filter looked at. The first version of this test stopped, and a planting that
// made the filter read the WHOLE bank — auto-bank rows this very run rewrites, and therefore a bank
// that MOVES between runs — survived it. Auto-continuing is what makes the second run see a bank that
// the first one changed.
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(context.Background()); err != nil {
t.Fatalf("the first run: %v", err)
}
if r1.lastTerminology.BankSettled != 1 {
t.Fatalf("premise broken: the first run must filter, or the resume proves nothing: %+v", r1.lastTerminology)
}
if r1.lastTerminology.CostUSD == 0 {
t.Fatalf("premise broken: the first run must actually BUY the pass, or a $0 resume is meaningless")
}
terminologyCallsAfterFirst := countTerminologyCalls(rec)
r1.Close()
// PREMISE for the whole test: the first run really did leave an auto-bank behind, so the second run's
// bank is not the same object. Without this the assertions below hold for a reason unrelated to them.
if _, err := os.Stat(r1.autoBankPath()); err != nil {
t.Fatalf("premise broken: the first run wrote no auto-bank, so the bank cannot move between runs: %v", err)
}
r2 := newRunner(t, bookPath)
defer r2.Close()
var log bytes.Buffer
r2.Log = slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelWarn}))
if _, err := r2.TranslateBook(context.Background()); err != nil {
t.Fatalf("the resumed run: %v", err)
}
// ⛔ RUN 2 IS THE ONE-TIME RE-PACK, AND IT IS THE PACK'S ORDERED PRICE — NOT A REGRESSION.
//
// Until the settled basis existed, run 2 was $0: the filter is unconditional, so the composition was a
// pure function of the candidates and the bank, identical on every run, and every checkpoint replayed.
// The basis adds a THIRD input, and that input goes from empty to full at run 1's output boundary. So
// run 2 sees a composition run 1 never had, finds no checkpoint for it, and buys the pass ONCE. From
// run 3 on the basis is stable and every run replays for $0 — which is the half this test now pins, and
// the half the old two-run shape could not express at all.
//
// ⚠ THE CURE THAT LOOKS OBVIOUS IS THE BUG THIS FILE ALREADY DOCUMENTS: «stand down for a book that has
// already paid» makes the rule decide differently on the second run, and a rule that decides differently
// on the second run is not a resume rule (see dropBankSettled). The one-time cost is bounded by
// gates.terminology.budget_usd and ANNOUNCED — the reconsolidation warning below is that announcement,
// and run 2 is exactly the case its sentence describes.
if !strings.Contains(log.String(), logKeyReconsolidated+"=true") {
t.Errorf("run 2 re-bought the pass and said nothing: the one-time cost must announce itself, or an operator meets it as an unexplained charge\n%s", log.String())
}
if r2.lastTerminology.BankSettled != 1 {
t.Errorf("the filter must decide the same way on every run, or the composition moves: skipped=%d", r2.lastTerminology.BankSettled)
}
if r2.lastTerminology.BasisServed == 0 {
t.Errorf("premise broken: run 2 must be served by the basis run 1 wrote, or the re-pack below has another cause entirely")
}
callsAfterSecond := countTerminologyCalls(rec)
// The re-pack is a PURCHASE, and it is asserted rather than left to the cost field: «run 2 paid» and
// «run 2 called the provider» are different facts, and the second is what makes run 3's zero below mean
// «replayed» instead of «never ran».
if callsAfterSecond <= terminologyCallsAfterFirst {
t.Errorf("run 2 announced a re-purchase but reached the provider %d → %d times: either the announcement is false or the pass never ran",
terminologyCallsAfterFirst, callsAfterSecond)
}
r2.Close()
// --- run 3: the basis is stable now, so the composition is, so every checkpoint replays ---
r3 := newRunner(t, bookPath)
defer r3.Close()
var log3 bytes.Buffer
r3.Log = slog.New(slog.NewTextHandler(&log3, &slog.HandlerOptions{Level: slog.LevelWarn}))
if _, err := r3.TranslateBook(context.Background()); err != nil {
t.Fatalf("the third run: %v", err)
}
if r3.lastTerminology.CostUSD != 0 {
t.Errorf("run 3 must replay from its checkpoints for $0, got $%f — the basis is not stable between runs and EVERY purchase would re-buy the pass", r3.lastTerminology.CostUSD)
}
// Counted on the BANK ROLE's own calls: the run still walks the edit wave, so a count of all provider
// calls would move for a reason that has nothing to do with this contour.
if n := countTerminologyCalls(rec); n != callsAfterSecond {
t.Errorf("run 3 must reach the provider zero times for the bank role: role calls went %d → %d",
callsAfterSecond, n)
}
if strings.Contains(log3.String(), logKeyReconsolidated+"=true") {
t.Errorf("the one-time-cost warning fired on a run that re-bought nothing — it would then cry money on every ordinary resume, which is the defect this message was rewritten to remove:\n%s", log3.String())
}
}
// countTerminologyCalls is how many provider calls carried the terminology role's block.
func countTerminologyCalls(rec *reqRec) int {
n := 0
for _, body := range rec.all() {
if isTerminologyBody(body) {
n++
}
}
return n
}
// TestTheFilterChangesNothingOnTheCANDIDATESITKEEPS is the output-equivalence half of the order: the run
// must buy less and produce the same artifact for everything it still buys.
//
// The comparison is against a run of the SAME corpus and the SAME replies whose seed names an unrelated
// surface, so nothing is settled and the role is paid for every candidate. What the two runs must agree on,
// byte for byte, is the sheet row of the candidate both of them paid for; what they may differ in is the
// skipped surface itself, which is exactly "equal to the original minus the skipped rows".
func TestTheFilterChangesNothingOnTheCANDIDATESITKEEPS(t *testing.T) {
reply := func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop"
}
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
}
run := func(seed string) (*Runner, []BankStopRow) {
rec := &reqRec{}
srv := newJSONProvider(rec, reply)
t.Cleanup(srv.Close)
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
t.Cleanup(func() { _ = r.Close() })
_ = runToSignatureStop(t, r)
return r, r.lastBankStopRows
}
// A: the seed settles 方源. B: the seed names a surface this book does not contain, so nothing is
// settled and every candidate is paid for.
withFilter, rowsA := run("terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n")
_, rowsB := run("terms:\n - { src: 張三, dst: Чжан Сань, status: approved }\n")
if withFilter.lastTerminology.BankSettled != 1 {
t.Fatalf("premise broken: run A must have filtered exactly one candidate: %+v", withFilter.lastTerminology)
}
find := func(rows []BankStopRow, src string) *BankStopRow {
for i := range rows {
if rows[i].Src == src {
return &rows[i]
}
}
return nil
}
kept, kept2 := find(rowsA, "青茅山"), find(rowsB, "青茅山")
if kept == nil || kept2 == nil {
t.Fatalf("premise broken: the paid-for candidate must be on both sheets: A=%+v B=%+v", rowsA, rowsB)
}
if !reflect.DeepEqual(*kept, *kept2) {
t.Errorf("the filter changed the sheet row of a candidate it did not skip:\n with filter: %+v\n without: %+v", *kept, *kept2)
}
// CONTROL for that equality: the SKIPPED surface is where the two runs are allowed to differ, and they
// must — otherwise the DeepEqual above is comparing two runs that took the same path.
skipped, paidFor := find(rowsA, "方源"), find(rowsB, "方源")
if skipped == nil || paidFor == nil {
t.Fatalf("premise broken: the skipped surface must be on both sheets: A=%+v B=%+v", rowsA, rowsB)
}
if skipped.Dst != "" {
t.Errorf("the skipped candidate was never asked about, so its sheet row carries no rendering: %q", skipped.Dst)
}
if paidFor.Dst == "" {
t.Fatalf("control: without the filter the same candidate IS answered for, or the two runs did not differ")
}
}
// TestTheClassifiersTypeStillReachesTheSheetThroughTheFilter is the regression the filter's own first
// implementation carried, and it is here because the tests written for that filter could not have seen it.
//
// The classifier and the scorer MUTATE candidates in place, and the caller renders the sheet and the delta
// from the slice it passed in. Filtering the caller's slice copied the survivors into a new array, so those
// mutations stopped reaching the caller: the sheet would have carried the heuristic type and the unscored
// ranking, with every counter reading clean. Every other fixture in this package runs with the classifier
// OFF, where neither mutation happens — so this test turns it ON, which is the only state in which the
// defect is observable at all.
func TestTheClassifiersTypeStillReachesTheSheetThroughTheFilter(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isClassifierBody(body) {
// The heuristic drafted 青茅山 as a place; the classifier says otherwise, and THAT is the byte
// that has to survive the filter and reach the sheet.
return "青茅山\tterm\tnone", "stop"
}
if isTerminologyBody(body) {
return "青茅山\tгора Цинмао", "stop"
}
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
seed := "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n"
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true, glossarySeed: seed}))
defer r.Close()
_ = runToSignatureStop(t, r)
if r.lastTerminology.BankSettled != 1 {
t.Fatalf("premise broken: the filter must have skipped a candidate, or nothing is being crossed: %+v", r.lastTerminology)
}
if r.lastTerminology.Reclassified != 1 {
t.Fatalf("premise broken: the classifier must have CHANGED a type, or there is no mutation to lose: %+v", r.lastTerminology)
}
var row *BankStopRow
for i, cur := range r.lastBankStopRows {
if cur.Src == "青茅山" {
row = &r.lastBankStopRows[i]
}
}
if row == nil {
t.Fatalf("premise broken: 青茅山 is not on the sheet: %+v", r.lastBankStopRows)
}
if row.Type != "term" {
t.Errorf("the classifier's type must reach the sheet through the filter, got %q — the filter copied the candidates and the mutation was lost", row.Type)
}
// ⛔ AND THE WIRE, which neither of the two assertions above reaches. The classifier is bought FOR the
// type field, and that field travels to the ROLE for the whole batch — so the money is wasted the
// moment the request carries the heuristic's guess while the run prints `reclassified=N`. Asserted on
// the request bytes: this is the third form of one defect (the subset held as its own array), and the
// first two were both found after their own tests went green.
var asked string
for _, body := range rec.all() {
if isTerminologyBody(body) && strings.Contains(body, "### 青茅山") {
asked = body
}
}
if asked == "" {
t.Fatalf("premise broken: the surface must reach the paid role at all:\n%s", terminologyBodies(rec))
}
if !strings.Contains(asked, "type: term") || strings.Contains(asked, "type: place") {
t.Errorf("the classifier's corrected type must reach the ROLE's request, not only the sheet — the run paid for that field:\n%s", asked)
}
// THE SECOND HALF OF THE SAME GUARANTEE, and it needs its own assertion: applyTypes and ScoreVariants
// are two in-place mutations, and pinning one leaves the other free. Re-scoring is what makes the
// heuristic's `conform` signal go away here — transliteration conformance speaks only about name/place
// (scoreOpts), so once the classifier calls this surface a `term` the signal is no longer true of it.
// Applied to the filtered copy instead, the sheet keeps a ranking factor the classification retired,
// and the operator reads a reason that no longer holds. Measured both ways: correct code gives no
// signals, the planting gives [conform].
for _, sig := range row.Signals {
if sig == "conform" {
t.Errorf("the re-scoring must reach the sheet too: %q is ranked by a conformance that only speaks about name/place, and the classifier made it a term — signals=%v", row.Src, row.Signals)
}
}
}
// TestTheOneTimeCostIsAnnouncedExactlyWhenItHappens pins the migration warning in BOTH directions, and it
// exists because that warning has been wrong twice, each time in a way its own test could not see.
//
// - first it was emitted before the pass, on «this book has paid» — true of every ordinary resume of a
// filtered book, so a live resume that re-bought nothing was told it had paid again;
// - then the probe moved after the pass, where the pass's OWN checkpoints make «has paid» trivially true
// — so a fresh book's FIRST run, having paid once, was told it had paid twice.
//
// Both halves are therefore asserted: the warning must be SILENT on a fresh first run and must FIRE on the
// one case it describes. A test that only asserts silence is vacuous here — the firing branch is
// unreachable in it — and that is exactly how the second version shipped.
func TestTheOneTimeCostIsAnnouncedExactlyWhenItHappens(t *testing.T) {
reply := func(body string) (string, string) {
if isTerminologyBody(body) {
return "青茅山\tгора Цинмао", "stop"
}
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
}
t.Run("silent on a fresh book that paid once", func(t *testing.T) {
srv := newJSONProvider(&reqRec{}, reply)
defer srv.Close()
seed := "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n"
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
defer r.Close()
var log bytes.Buffer
r.Log = slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelWarn}))
_ = runToSignatureStop(t, r)
if r.lastTerminology.BankSettled != 1 || r.lastTerminology.CostUSD == 0 {
t.Fatalf("premise broken: the run must both FILTER and PAY, or the branch is not reached: %+v", r.lastTerminology)
}
if strings.Contains(log.String(), logKeyReconsolidated+"=true") {
t.Errorf("a book paying for the FIRST time was told it had paid before — the probe is reading this run's own checkpoints:\n%s", log.String())
}
})
t.Run("fires when an earlier composition really was paid for", func(t *testing.T) {
srv := newJSONProvider(&reqRec{}, reply)
defer srv.Close()
// Run one has NO seed surface for 方源, so nothing is filtered and the pass is paid for over the
// FULL candidate set.
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r1 := newVerifyRunner(t, bookPath)
_ = runToSignatureStop(t, r1)
if r1.lastTerminology.BankSettled != 0 || r1.lastTerminology.CostUSD == 0 {
t.Fatalf("premise broken: run one must filter NOTHING and pay: %+v", r1.lastTerminology)
}
minedDelta := r1.Book.MinedDelta
r1.Close()
// The owner signs 方源 with the rendering the drafts proposed. Its surface is now a seed surface,
// so run two filters it — and the composition it pays under is not the one run one paid under.
writeFile(t, minedDelta, "terms:\n - { src: 方源, dst: Фан Юань, status: approved }\n")
r2 := newVerifyRunner(t, bookPath)
defer r2.Close()
var log bytes.Buffer
r2.Log = slog.New(slog.NewTextHandler(&log, &slog.HandlerOptions{Level: slog.LevelWarn}))
if _, err := r2.TranslateBook(context.Background()); err != nil {
var stop *WaveSignatureStop
if !errors.As(err, &stop) {
t.Fatalf("run two: %v", err)
}
}
if r2.lastTerminology.BankSettled != 1 {
t.Fatalf("premise broken: run two must filter the newly-signed surface: %+v", r2.lastTerminology)
}
if r2.lastTerminology.CostUSD == 0 {
t.Fatalf("premise broken: the changed composition must make run two PAY again, or there is nothing to announce")
}
if !strings.Contains(log.String(), logKeyReconsolidated+"=true") {
t.Errorf("the one-time re-consolidation happened and was not announced:\n%s", log.String())
}
})
}
// TestTheSettledTestAnswersOnEachOfItsConditions pins the three conditions bankSettles rests on, one at a
// time. Two of them had no witness at all: every fixture that reached the filter carried a banknote-origin
// candidate whose surface matched a seed row's own src, so «banknote-only» and «aliases count» were both
// true of the fixture by accident and could have been dropped without a test noticing.
func TestTheSettledTestAnswersOnEachOfItsConditions(t *testing.T) {
seed := []store.GlossaryEntry{
{Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed",
Aliases: []store.GlossaryAlias{{Alias: "小方"}}},
{Src: "青茅山", Dst: "", Status: "approved", Source: "seed"}, // no rendering: nothing to agree with
}
settled := bankSettledSurfaces(seed)
// A row with no rendering is not in the index at all — there is nothing for a draft to agree with.
if _, ok := settled[text.NormalizeSourceKey("青茅山")]; ok {
t.Errorf("a seed row with no dst cannot settle anything: %+v", settled)
}
banknote := func(key, draft string) terminology.Candidate {
return terminology.Candidate{Key: key, Src: key, Origin: terminology.OriginBanknote,
Variants: []terminology.Variant{{Dst: draft}}}
}
// BASE: the shape the filter is for.
if !bankSettles(settled, banknote("方源", "Фан Юань")) {
t.Fatalf("premise broken: a banknote candidate on a seeded surface whose draft agrees must be settled")
}
// (1) ORIGIN. A MINER-origin candidate is not dropped by reverseSectionTerms, so it DOES reach the
// delta — filtering it would lose a term from the artifact, not just a paid question.
mined := banknote("方源", "Фан Юань")
mined.Origin = terminology.OriginMined
if bankSettles(settled, mined) {
t.Errorf("only the population the EMISSION drops may be filtered; a mined candidate reaches the delta")
}
// (2) ALIASES. The emission's own guard reads a seed row's aliases, so this one must too, or a
// proposal named by an alias is paid for and then thrown away.
if !bankSettles(settled, banknote("小方", "Фан Юань")) {
t.Errorf("a surface the seed holds as an ALIAS is settled just as its src is")
}
// (3) AGREEMENT, per variant and not per candidate: one disagreeing draft among several is enough.
two := banknote("方源", "Фан Юань")
two.Variants = append(two.Variants, terminology.Variant{Dst: "Источник Фана"})
if bankSettles(settled, two) {
t.Errorf("one disagreeing draft is the finding; the candidate must be paid for")
}
// (4) NO EVIDENCE is not evidence of agreement. Unreachable from a banknote candidate by construction
// — they always carry a proposal — and asserted so the rule cannot be quietly inverted.
none := banknote("方源", "")
none.Variants = nil
if bankSettles(settled, none) {
t.Errorf("a candidate with no observed rendering has not agreed with anything")
}
}
// TestTheFilterFoldsBothSidesTheWayProductionDoes pins the two normalizations inside the money filter,
// each of which decides whether a paid call happens and neither of which had a witness. Breaking either
// left the whole battery green: the source fold silently stops the filter firing for every traditional or
// katakana spelling — and BankSettled then reads 0, indistinguishable from «nothing to skip» — while the
// target fold is the money decision itself.
func TestTheFilterFoldsBothSidesTheWayProductionDoes(t *testing.T) {
// A seed row spelled TRADITIONALLY, and a candidate keyed the way production keys it.
seed := []store.GlossaryEntry{{Src: "長空", Dst: "Пустота Небес", Status: "approved", Source: "seed"}}
settled := bankSettledSurfaces(seed)
if _, ok := settled["长空"]; !ok {
t.Fatalf("the seed surface must be indexed by its NORMALIZED key, or the filter never fires for the traditional population: %+v", settled)
}
cand := func(draft string) terminology.Candidate {
return terminology.Candidate{Key: "长空", Src: "長空", Origin: terminology.OriginBanknote,
Variants: []terminology.Variant{{Dst: draft}}}
}
if !bankSettles(settled, cand("Пустота Небес")) {
t.Fatalf("premise broken: an agreeing draft on a seeded surface must settle")
}
// THE TARGET FOLD. A draft differing only by case and ё is the SAME rendering under the fold the vote
// is counted with, so it agrees — and paying for it would be paying to be told what the bank says.
if !bankSettles(settled, cand("пустота небес")) {
t.Errorf("the agreement must be judged under the same target-form fold the vote uses, not byte-wise")
}
// CONTROL: a genuinely different rendering still disagrees, so the fold above is a fold and not a
// comparison that has stopped comparing.
if bankSettles(settled, cand("Небесная пустота")) {
t.Errorf("a different rendering is the finding and must be paid for")
}
}