648 lines
38 KiB
Go
648 lines
38 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/config"
|
||
"textmachine/backend/internal/lang"
|
||
"textmachine/backend/internal/store"
|
||
"textmachine/backend/internal/terminology"
|
||
)
|
||
|
||
// bankbasis_review_test.go: the pins an ADVERSARIAL pass over this pack's own finished work asked for.
|
||
// Every one of them guards a decision the pack had DECLARED and nothing had been asserting — which is the
|
||
// state where a declaration is prose, not a guarantee.
|
||
|
||
// TestTheBankedTypeReachesTheSheetWhenTheBasisAnswers pins the reader the pack's first reader-table missed.
|
||
//
|
||
// ⛔ THE SHAPE OF THE MISS. The sheet, the stop table and the sidecar's `kind` all read Candidate.Type, and
|
||
// applyTypes — the only thing that writes it — runs BEFORE the basis answers reach the maps it reads. So the
|
||
// banked type travelled to the auto-bank and not to the sheet: the row the owner signs by said `place` while
|
||
// the bank row for the same term said `term`, in the steady state, for as long as the basis kept answering.
|
||
func TestTheBankedTypeReachesTheSheetWhenTheBasisAnswers(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. THAT is the byte that
|
||
// has to survive being answered from memory.
|
||
return "青茅山\tterm\tnone", "stop"
|
||
}
|
||
if isTerminologyBody(body) {
|
||
return "青茅山\tгора Цинмао", "stop"
|
||
}
|
||
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(context.Background()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if r1.lastTerminology.Reclassified != 1 {
|
||
t.Fatalf("premise broken: the classifier must CHANGE a type on run 1, or there is nothing for the basis to carry: %+v", r1.lastTerminology)
|
||
}
|
||
want := sheetTypeOf(t, sheetRowsOf(t, r1), "青茅山")
|
||
if want != "term" {
|
||
t.Fatalf("premise broken: run 1's sheet must already carry the classifier's type, got %q", want)
|
||
}
|
||
reclassifiedOnRun1 := r1.lastTerminology.Reclassified
|
||
r1.Close()
|
||
|
||
// Run 2 pays the one-time re-pack; run 3 is the steady state where the basis answers.
|
||
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.BasisServed == 0 {
|
||
t.Fatalf("premise broken: run %d must be served by the basis: %+v", i, r.lastTerminology)
|
||
}
|
||
if got := sheetTypeOf(t, sheetRowsOf(t, r), "青茅山"); got != want {
|
||
t.Errorf("run %d: the SHEET says type=%q while the answer the basis carries says %q — the owner signs by a row that disagrees with the bank row for the same term",
|
||
i, got, want)
|
||
}
|
||
// The other half of the same decision: the counter must NOT have absorbed the stamp. `reclassified`
|
||
// means «how many types the CLASSIFIER changed», and the classifier saw nothing this run.
|
||
if r.lastTerminology.Reclassified != 0 {
|
||
t.Errorf("run %d: reclassified=%d, want 0 — the basis's stamp inflated a counter that answers a different question (run 1 legitimately had %d)",
|
||
i, r.lastTerminology.Reclassified, reclassifiedOnRun1)
|
||
}
|
||
r.Close()
|
||
}
|
||
}
|
||
|
||
// sheetRowsOf reads the sheet the owner actually gets — the stop-table SIDECAR beside the database, which
|
||
// every run writes — rather than r.lastBankStopRows, which is set only on the stopping branch and is empty
|
||
// on an auto-continuing run. Reading the shipped artifact also puts its parser under the same assertion.
|
||
func sheetRowsOf(t *testing.T, r *Runner) []BankStopRow {
|
||
t.Helper()
|
||
raw, err := os.ReadFile(r.bankStopTablePath())
|
||
if err != nil {
|
||
t.Fatalf("every run writes the stop table sidecar; this one did not: %v", err)
|
||
}
|
||
rows, err := ParseBankStopTable(raw)
|
||
if err != nil {
|
||
t.Fatalf("parse the stop table the run just wrote: %v", err)
|
||
}
|
||
if len(rows) == 0 {
|
||
t.Fatalf("the stop table holds no rows (%d bytes): every assertion against it would be vacuous", len(raw))
|
||
}
|
||
return rows
|
||
}
|
||
|
||
func sheetTypeOf(t *testing.T, rows []BankStopRow, src string) string {
|
||
t.Helper()
|
||
for _, r := range rows {
|
||
if r.Src == src {
|
||
return r.Type
|
||
}
|
||
}
|
||
t.Fatalf("%q is not on the sheet at all (%d row(s)) — the fixture, not the subject", src, len(rows))
|
||
return ""
|
||
}
|
||
|
||
// TestTheOwnersSignedRowIsNeverRecordedInTheBasis pins the refusal at the WRITE. A signed surface still goes
|
||
// to the paid role on purpose (backlog row 447), so its consolidation does reach the recorder — and recording
|
||
// it puts the ENGINE's word into the memory of decisions under a surface whose law is the OWNER's. The day
|
||
// that signature is lifted, the predicate finds a record, sees an unsigned draft row, and serves the
|
||
// engine's rendering as «this book decided it earlier», over the owner's own.
|
||
func TestTheOwnersSignedRowIsNeverRecordedInTheBasis(t *testing.T) {
|
||
cands := []terminology.Candidate{{Key: "方源"}, {Key: "青茅山"}, {Key: "元石"}}
|
||
out := map[string]string{"方源": "Фан Юань", "青茅山": "гора Цинмао", "元石": "юаньши"}
|
||
fps := map[string]bankBasisFP{"方源": {Whole: "a"}, "青茅山": {Whole: "b"}, "元石": {Whole: "c"}}
|
||
rows := map[string]store.GlossaryEntry{
|
||
"方源": {Src: "方源", Dst: "Фан Юань", Status: "approved"}, // the owner's word
|
||
"青茅山": {Src: "青茅山", Dst: "гора Цинмао", Status: "draft", Source: "mined"},
|
||
}
|
||
ambiguous := map[string]bool{"元石": true} // two senses or two windows: nothing can say which is the answer
|
||
|
||
got := basisRowsToStore(cands, out, nil, nil, fps, rows, ambiguous, basisShape(40, "p"))
|
||
keys := map[string]bool{}
|
||
for _, r := range got {
|
||
keys[r.SrcKey] = true
|
||
}
|
||
if keys["方源"] {
|
||
t.Error("a surface the OWNER signed was recorded in the basis: lift the signature and the engine's word is served over his, which merges the two words the bank ontology keeps apart")
|
||
}
|
||
if keys["元石"] {
|
||
t.Error("an AMBIGUOUS surface was recorded: the carrier's key is (book, surface), so two senses of one surface cannot be remembered apart and whichever was written last answers for both")
|
||
}
|
||
if !keys["青茅山"] {
|
||
t.Fatalf("control: the ordinary engine-decided row was NOT recorded either (%d row(s)) — this test would then pass on a recorder that stores nothing", len(got))
|
||
}
|
||
if len(got) != 1 {
|
||
t.Fatalf("recorded %d row(s), want exactly the one: %+v", len(got), got)
|
||
}
|
||
}
|
||
|
||
// TestAnAmbiguousSurfaceIsReAskedRatherThanGuessed pins the read side of the same class. The bank's
|
||
// uniqueness key is the FOUR-tuple (src, sense, since_ch, until_ch); a candidate carries none of sense or
|
||
// window. A map written as `out[key] = row` keeps whichever row the store returned last — it orders by that
|
||
// tuple, so the LATEST window wins — and answers for the other.
|
||
func TestAnAmbiguousSurfaceIsReAskedRatherThanGuessed(t *testing.T) {
|
||
bank := []store.GlossaryEntry{
|
||
// One surface, two LEGITIMATE rows: the owner signed chapters 1–10, the engine drafted 11 onward.
|
||
{Src: "古月", Dst: "Гуюэ", Status: "approved", SinceCh: 1, UntilCh: 10},
|
||
{Src: "古月", Dst: "клан Гу Юэ", Status: "draft", SinceCh: 11, Source: "mined"},
|
||
{Src: "元石", Dst: "юаньши", Status: "draft", Source: "mined"},
|
||
}
|
||
rows, ambiguous := bankBasisRows(bank, func(s string) string { return s })
|
||
if !ambiguous["古月"] {
|
||
t.Fatal("a surface the bank answers with TWO rows was indexed as if it had one: the predicate would serve whichever row sorted last, and a signature on the other window would be invisible to it")
|
||
}
|
||
if _, indexed := rows["古月"]; indexed {
|
||
t.Fatal("the ambiguous surface is ALSO in the unambiguous index: a caller reading that map gets the collapsed answer anyway")
|
||
}
|
||
if _, ok := rows["元石"]; !ok || len(rows) != 1 {
|
||
t.Fatalf("control: the unambiguous surface must still be indexed, got %d row(s) %+v — otherwise the refusal above is «the index is empty», not «this one is ambiguous»", len(rows), rows)
|
||
}
|
||
// And the predicate says WHY, in its own class: «ambiguous» and «the source moved» call for different
|
||
// reactions, and a re-purchase that cannot name its cause is one nobody can act on.
|
||
fp := bankBasisFP{Whole: "x"}
|
||
basis := map[string]bankBasisRecord{"古月": {Key: "古月", FP: fp, Answer: bankBasisAnswer{Dst: "клан Гу Юэ"}}}
|
||
v := basisVerdictFor(terminology.Candidate{Key: "古月"}, fp, basis, rows, ambiguous, "s")
|
||
if v.Reason != basisAmbiguous {
|
||
t.Fatalf("verdict = %q, want %q", v.Reason, basisAmbiguous)
|
||
}
|
||
if v.Answer.Dst != "" {
|
||
t.Fatalf("an ambiguous surface was handed an answer (%q) — the guess this class exists to refuse", v.Answer.Dst)
|
||
}
|
||
}
|
||
|
||
// TestBasisWidthIsReadFromThePairWhenThePairStatesIt pins the branch the canon's standing review question
|
||
// turns on — «would a pair that is not in the repository work without a Go edit» — and it exists because the
|
||
// pack's first pin for it exercised only the DEFAULT: it built a Runner with no pack at all, so a planting
|
||
// that read kwic_width instead of basis_width survived every test in the tree.
|
||
func TestBasisWidthIsReadFromThePairWhenThePairStatesIt(t *testing.T) {
|
||
r := &Runner{pack: &lang.Pack{Terminology: &lang.TerminologySizing{KWICWidth: 11, BasisWidth: 7}}}
|
||
if got := r.basisWidth(); got != 7 {
|
||
t.Fatalf("basisWidth() = %d, want the pair's basis_width (7). Reading %d would mean the KWIC width — a prompt-shape knob — decides which stored decisions survive, so tuning the prompt re-buys every book's bank",
|
||
got, r.pack.Terminology.KWICWidth)
|
||
}
|
||
// The other half: a pair that states nothing falls through to the engine default, so a pair with no
|
||
// terminology.txt keeps working.
|
||
bare := &Runner{pack: &lang.Pack{Terminology: &lang.TerminologySizing{KWICWidth: 11}}}
|
||
if got := bare.basisWidth(); got != terminologyDefaultBasisWidth {
|
||
t.Fatalf("a pair stating no basis_width must fall through to the engine default, got %d", got)
|
||
}
|
||
}
|
||
|
||
// TestASettledRowIsNotAlsoMarkedNeverAsked pins the exclusivity of the two savings. They are different facts
|
||
// with different sentences on the sheet — «the bank already renders this surface and every draft agreed»
|
||
// versus «this book decided it earlier» — and one candidate carrying both prints two mutually exclusive
|
||
// sentences, is counted twice in the read-out, and, worst of the three, is lifted out of the contested queue
|
||
// by SettledByBank, which is exactly the exemption the comment on that queue refuses for a basis row.
|
||
func TestASettledRowIsNotAlsoMarkedNeverAsked(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||
if isTerminologyBody(body) {
|
||
// BOTH surfaces are answered, and that is what puts an ENGINE row — and therefore a basis
|
||
// record — on 方源, while 青茅山 becomes the SEED surface the bank filter takes. One run, two
|
||
// savings, which is the state this pin needs and the only state in which «no row carries
|
||
// both marks» is a measurement rather than a tautology.
|
||
return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop"
|
||
}
|
||
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
|
||
})
|
||
defer srv.Close()
|
||
// ⚠ THE FIXTURE HAS TO BUILD A STATE NEITHER SAVING REACHES ALONE, and it does it the way a real book
|
||
// does. Run 1: the seed DISAGREES with the drafts (Цинмаошань against гора Цинмао), so the bank filter
|
||
// leaves 青茅山 to the paid role — which is what puts a BASIS record on a surface the seed also holds.
|
||
// Then the owner edits the seed to the rendering the drafts actually use, and from run 2 the bank
|
||
// filter takes the same surface as well. One candidate, both savings, which is the overlap this pin is
|
||
// about.
|
||
seed := "terms:\n - { src: 青茅山, dst: Цинмаошань, status: draft }\n"
|
||
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed})
|
||
|
||
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 BUY 青茅山, or no basis record exists for it: %+v", r1.lastTerminology)
|
||
}
|
||
seedPath := r1.Book.GlossarySeed
|
||
r1.Close()
|
||
writeFile(t, seedPath, "terms:\n - { src: 青茅山, dst: гора Цинмао, status: draft }\n")
|
||
|
||
var last *Runner
|
||
for i := 2; i <= 3; i++ {
|
||
r := newRunner(t, bookPath)
|
||
// Editing the seed legitimately moves the edit-wave snapshot, and the engine refuses to re-pay for
|
||
// it without consent — correct, loud behaviour that has nothing to do with this pin. Consent, as
|
||
// the owner would.
|
||
r.Resnapshot = true
|
||
r.AcceptRebill = RebillConsent{Given: true}
|
||
if _, err := r.TranslateBook(context.Background()); err != nil {
|
||
t.Fatalf("run %d: %v", i, err)
|
||
}
|
||
if i == 3 {
|
||
last = r
|
||
break
|
||
}
|
||
r.Close()
|
||
}
|
||
defer last.Close()
|
||
|
||
rows := sheetRowsOf(t, last)
|
||
both := 0
|
||
for _, row := range rows {
|
||
if row.SettledByBank && row.SettledByBasis {
|
||
both++
|
||
t.Errorf("%q carries BOTH marks: the sheet prints «the bank already renders this surface and every draft agreed» AND «this book decided it earlier» about one row, the read-out counts it twice, and SettledByBank lifts it out of the contested queue — the exemption stopcontest.go explicitly refuses for a basis row",
|
||
row.Src)
|
||
}
|
||
}
|
||
tres := last.lastTerminology
|
||
if tres.BankSettled+tres.BasisServed > len(rows) {
|
||
t.Errorf("the two savings sum to %d over %d candidates: they overlap, so the read-out's `never_asked` and `settled_earlier` double-count",
|
||
tres.BankSettled+tres.BasisServed, len(rows))
|
||
}
|
||
_ = both
|
||
|
||
// ⛔ THE PREMISE IS THAT BOTH SAVINGS ARE LIVE IN THIS RUN — not that both claimed one surface, which
|
||
// is now impossible for a reason deeper than this test, and the reason is worth stating because an
|
||
// earlier revision of this pin DID assert it and had to be rewritten.
|
||
//
|
||
// The bank filter takes only SEED surfaces (dropBankSettled → unsignedEngineSurfaces), and the basis
|
||
// records only ENGINE rows (basisRowsToStore → membank.IsEngineUnsigned). Those two populations are
|
||
// DISJOINT BY CONSTRUCTION, so no surface can carry a record and be taken by the filter. Before the
|
||
// «whose word is this» fix they were not disjoint: the recorder asked `Status == "approved"` instead,
|
||
// so an owner's hand-written unsigned seed row read as the engine's, got a basis record, and became
|
||
// exactly the contended surface this test used to build. ⇒ the fixture that built it is now
|
||
// unbuildable, and asserting an unbuildable state would be asserting nothing.
|
||
if tres.BankSettled == 0 || tres.BasisServed == 0 {
|
||
t.Fatalf("premise broken: this run must produce BOTH savings for the exclusivity to be about anything (bank_settled=%d basis_served=%d)", tres.BankSettled, tres.BasisServed)
|
||
}
|
||
// And the disjointness itself, asserted rather than trusted: nothing the bank filter took this run has
|
||
// a row in the memory of decisions.
|
||
stored, err := last.Store.BankBasisForBook(last.Book.BookID)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(stored) == 0 {
|
||
t.Fatalf("premise broken: the basis holds nothing at all, so «no overlap» is a statement about an empty memory")
|
||
}
|
||
// ⛔ AND THE CLASSES MUST ACCOUNT FOR THE OFFERED POPULATION AND NOTHING MORE. This is what the scoping
|
||
// still guards now that «whose row is this» is asked properly: the two savings can no longer collide on
|
||
// one surface — the bank filter takes SEED surfaces, the recorder keeps ENGINE rows, and those are
|
||
// disjoint — so the visible damage of asking the predicate about candidates nobody offered it is no
|
||
// longer a double MARK but a double COUNT: every bank-taken candidate lands in a re-ask class, and the
|
||
// run reports terms as «bought again» that were never a question for this mechanism.
|
||
offered := len(rows) - tres.BankSettled
|
||
counted := tres.BasisServed
|
||
for _, n := range tres.BasisReAsked {
|
||
counted += n
|
||
}
|
||
if counted != offered {
|
||
t.Errorf("the basis reported on %d candidate(s) while only %d were offered to it (%d of %d taken by the bank filter): its classes are counting a population it was never asked about, so every re-ask number the run prints is inflated by the OTHER saving",
|
||
counted, offered, tres.BankSettled, len(rows))
|
||
}
|
||
for _, k := range tres.BankSettledKeys {
|
||
if _, held := stored[k]; held {
|
||
t.Errorf("%q was taken by the BANK filter and also holds a basis record (%d stored: %v): the two populations are meant to be disjoint by construction — the filter takes seed surfaces, the recorder keeps engine rows — and this surface is in both",
|
||
k, len(stored), basisKeysOf(stored))
|
||
}
|
||
}
|
||
}
|
||
|
||
func basisKeysOf(m map[string]store.BankBasisRow) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestTheThreeDeclaredDecisionsAboutABasisRowAreGuarded pins three choices this pack DECLARED in comments
|
||
// and nothing was asserting — the state in which a declaration is prose rather than a guarantee. All three
|
||
// were found by planting: each mutation survived the whole of ./internal/pipeline/ and ./cmd/tmctl/.
|
||
func TestTheThreeDeclaredDecisionsAboutABasisRowAreGuarded(t *testing.T) {
|
||
basisRow := BankStopRow{
|
||
Src: "元石", Dst: "юаньши", Type: "name", Origin: "mined", Freq: 9, Conf: -1,
|
||
SettledByBasis: true,
|
||
// The drafts of THIS run proposed something else entirely — which is the whole point: the rendering
|
||
// came from an earlier purchase, so «no draft proposed it» is true and meaningless.
|
||
Variants: []BankStopVariant{{Dst: "камень юань", Chunks: 3}},
|
||
}
|
||
|
||
// (a) INVENTED must not fire. It means «the engine produced a rendering no draft proposed» and is a
|
||
// finding about THIS run's drafts; on a row answered from memory it is a false accusation printed
|
||
// against the book's own established canon, on the sheet the owner signs by.
|
||
rows := bankStopRows([]terminology.Candidate{{
|
||
Key: "元石", Src: "元石", Type: "name", Origin: terminology.OriginMined, Freq: 9,
|
||
Variants: []terminology.Variant{{Dst: "камень юань", Chunks: 3}},
|
||
}}, map[string]string{"元石": "юаньши"}, &terminologyResult{BasisServedKeys: []string{"元石"}})
|
||
if len(rows) != 1 {
|
||
t.Fatalf("premise broken: projected %d row(s), want 1", len(rows))
|
||
}
|
||
if !rows[0].SettledByBasis {
|
||
t.Fatal("premise broken: the row must be marked as answered by the basis, or (a) tests nothing")
|
||
}
|
||
if rows[0].Invented {
|
||
t.Error("(a) a row answered from the basis was flagged INVENTED: the drafts of THIS run were never offered the term, so the flag accuses the book's own canon of being made up")
|
||
}
|
||
// The control: the flag is NOT simply dead — an ordinary row with an unproposed rendering still raises it.
|
||
ctl := bankStopRows([]terminology.Candidate{{
|
||
Key: "元石", Src: "元石", Origin: terminology.OriginMined,
|
||
Variants: []terminology.Variant{{Dst: "камень юань", Chunks: 3}},
|
||
}}, map[string]string{"元石": "юаньши"}, &terminologyResult{})
|
||
if !ctl[0].Invented {
|
||
t.Fatal("control: INVENTED did not fire on an ordinary row with an unproposed rendering, so the assertion above holds for the wrong reason")
|
||
}
|
||
|
||
// (b) The contested queue must NOT exempt it. SettledByBank means «the bank renders the surface and
|
||
// every draft agreed» — genuinely nothing to doubt. A basis row carries a real rendering chosen by a
|
||
// paid role, and neither half of that sentence is true of it, so it is judged on the same signals as
|
||
// any other answered row.
|
||
opts := StopContestOpts{CountInvented: true, CountUnresolved: true, UseConf: true, MaxConf: 50, CountConflicts: true}
|
||
doubted := basisRow
|
||
doubted.Contradicts = []string{`this run also renders 元 as "юань"`}
|
||
if got := doubted.Contest(opts); !got.Contested {
|
||
t.Error("(b) a basis row with a live contradiction was NOT contested: the basis was exempted by its resemblance to SettledByBank, and the queue goes silent over most of a settled book")
|
||
}
|
||
// The control: a BANK-settled row still IS exempt, so (b) is about the basis and not about the queue.
|
||
bankSettled := basisRow
|
||
bankSettled.SettledByBasis, bankSettled.SettledByBank = false, true
|
||
bankSettled.Contradicts = []string{`this run also renders 元 as "юань"`}
|
||
if got := bankSettled.Contest(opts); got.Contested {
|
||
t.Fatal("control: a BANK-settled row became contested, so the exemption this pack kept is gone and (b) proves nothing about the basis")
|
||
}
|
||
|
||
// (c) The stop table must round-trip BOTH marks. They are trailing clauses of one line, so a reader that
|
||
// cuts at the first label it finds swallows the other — and the renderer prints NOT ASKED first, so the
|
||
// order is not theoretical.
|
||
both := BankStopRow{Src: "青茅山", Dst: "гора Цинмао", Type: "place", Origin: "mined", Conf: -1,
|
||
SettledByBank: true, SettledByBasis: true}
|
||
parsed, err := ParseBankStopTable([]byte(renderBankStopTable([]BankStopRow{both})))
|
||
if err != nil {
|
||
t.Fatalf("(c) the table this engine just wrote did not parse: %v", err)
|
||
}
|
||
if len(parsed) != 1 {
|
||
t.Fatalf("(c) parsed %d row(s), want 1", len(parsed))
|
||
}
|
||
if !parsed[0].SettledByBank || !parsed[0].SettledByBasis {
|
||
t.Errorf("(c) a row carrying BOTH labels round-tripped as bank=%v basis=%v: one label was swallowed by the other's cut, and a reader of the shipped table loses a state the sheet shows",
|
||
parsed[0].SettledByBank, parsed[0].SettledByBasis)
|
||
}
|
||
}
|
||
|
||
// TestAProducerChangeReportsItsOwnCause pins BOTH halves of the producer rule, and the second half is the
|
||
// one the pack's first attempt missed.
|
||
//
|
||
// ⛔ HALF ONE — it must move something. Before the settled basis, improving the terminologist's prompt or
|
||
// moving it to another model missed every checkpoint (RequestHash folds model, effort and snapshot) and the
|
||
// book re-bought its bank from the new producer. Leave the producer out entirely and a settled row is never
|
||
// asked again no matter who is asking now: a better prompt reaches the unsettled TAIL of every book and
|
||
// nothing else, permanently. Improving a bank role's prompt is a direct lever on translationese.
|
||
//
|
||
// ⛔ HALF TWO — it must move the RIGHT thing, and the pack's first attempt hashed the producer in with the
|
||
// EVIDENCE. The re-purchase then arrived as `evidence-moved`, «the source around this surface changed» —
|
||
// told to the operator on every row, about a book nobody had touched. A producer is part of the RULE that
|
||
// decided a row, exactly as the evidence width is, and the rule is compared before the evidence so each
|
||
// part can name itself. The first version of this pin asserted only that the hash MOVED and was green and
|
||
// empty on precisely this.
|
||
func TestAProducerChangeReportsItsOwnCause(t *testing.T) {
|
||
c := terminology.Candidate{Key: "元石"}
|
||
fp := candidateBasisFingerprint(c, basisSource, nil, 40)
|
||
rows := map[string]store.GlossaryEntry{"元石": {Src: "元石", Dst: "юаньши", Status: "draft", Source: "mined"}}
|
||
rec := func(shape string) map[string]bankBasisRecord {
|
||
return map[string]bankBasisRecord{"元石": {Key: "元石", Shape: shape, FP: fp, Answer: bankBasisAnswer{Dst: "юаньши"}}}
|
||
}
|
||
was := basisShape(40, "producer-A")
|
||
|
||
// The premise: under the SAME rule the row is served, or every assertion below holds trivially.
|
||
if v := basisVerdictFor(c, fp, rec(was), rows, nil, was); v.Reason != basisServed {
|
||
t.Fatalf("premise broken: an unchanged rule must serve, got %q", v.Reason)
|
||
}
|
||
for _, tc := range []struct {
|
||
name string
|
||
now string
|
||
want basisReason
|
||
}{
|
||
{"the role's prompt or model changed", basisShape(40, "producer-B"), basisProducerMoved},
|
||
{"the pair's evidence width changed", basisShape(80, "producer-A"), basisWidthMoved},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
v := basisVerdictFor(c, fp, rec(was), rows, nil, tc.now)
|
||
if v.Reason != tc.want {
|
||
t.Fatalf("verdict = %q, want %q. The evidence did not move — the RULE did, and reporting it as «the source around this term changed» sends the owner looking at the book for a change somebody made to a prompt or to a knob",
|
||
v.Reason, tc.want)
|
||
}
|
||
if v.Answer.Dst != "" {
|
||
t.Fatalf("a row decided under another rule was served anyway (%q)", v.Answer.Dst)
|
||
}
|
||
})
|
||
}
|
||
// AND the cause must not be «never decided»: a record whose rule moved is kept, not dropped, because a
|
||
// dropped record reads as `new` — false of every one of these rows, and the wrong cause printed per row
|
||
// while the summary line states the right one.
|
||
if v := basisVerdictFor(c, fp, rec(basisShape(40, "producer-B")), rows, nil, was); v.Reason == basisNew {
|
||
t.Fatal("a row whose producer changed was reported as never decided")
|
||
}
|
||
}
|
||
|
||
// TestTheProducerIdentityIsResolvedInOnePlace pins that the identity is one function, and that it is built
|
||
// from what the role's system turn ACTUALLY says on this book rather than from the template's own hash.
|
||
//
|
||
// ⛔ THE RENDERED TEXT IS THE POINT, not tidiness. The pair's terminologist prompt reads the BRIEF —
|
||
// `{{title}}`, `{{audience}}`, `{{honorifics}}`, `{{transcription}}`, `{{venuti}}`. Change `honorifics: keep`
|
||
// to `adapt`, or the transcription convention, and both waves of the book are re-bought through BriefHash,
|
||
// while an identity keyed on the template's SHA would go on serving renderings decided under the OLD law
|
||
// into waves paid for under the new one. Neither the evidence nor the model would have moved; the law the
|
||
// role was told to apply would have. ⚠ And it is pair-agnostic BY SHAPE rather than by a list of brief
|
||
// fields in Go: whatever a pair's own prompt chooses to read enters because it enters the rendered text.
|
||
func TestTheProducerIdentityIsResolvedInOnePlace(t *testing.T) {
|
||
newRun := func(book *config.Book, model string) *Runner {
|
||
r := &Runner{Pipeline: &config.Pipeline{}, Book: book}
|
||
r.Pipeline.Gates.Terminology.Model = model
|
||
r.Pipeline.Gates.Terminology.Reasoning = "low"
|
||
r.terminologyTemplate = &PromptTemplate{
|
||
System: "Книга: «{{title}}». Хонорифики: {{honorifics}}.", SHA256: "fixed-template-sha"}
|
||
return r
|
||
}
|
||
keep := &config.Book{BookID: "b", Title: "蛊真人", Honorifics: "keep"}
|
||
adapt := &config.Book{BookID: "b", Title: "蛊真人", Honorifics: "adapt"}
|
||
|
||
base := newRun(keep, "deepseek-v4-flash").bankRoleProducer()
|
||
if base == "" {
|
||
t.Fatal("a configured role must produce a comparable identity")
|
||
}
|
||
if same := newRun(keep, "deepseek-v4-flash").bankRoleProducer(); same != base {
|
||
t.Fatal("the same configuration produced two identities: every purchase would re-buy everything")
|
||
}
|
||
// THE BRIEF: the template's own SHA is identical in both runs, so a producer keyed on it could not tell
|
||
// these apart — and the law the role applies is not the same.
|
||
if brief := newRun(adapt, "deepseek-v4-flash").bankRoleProducer(); brief == base {
|
||
t.Fatal("changing the book's `honorifics` left the producer identity unmoved although the role's system turn renders it: decisions taken under the old law would keep being served into waves bought under the new one")
|
||
}
|
||
// THE MODEL.
|
||
if other := newRun(keep, "deepseek-v4-pro").bankRoleProducer(); other == base {
|
||
t.Fatal("changing the bank role's model left the producer identity unmoved")
|
||
}
|
||
// THE CLASSIFIER, which authors the type and gender halves of every stored answer.
|
||
withClassifier := newRun(keep, "deepseek-v4-flash")
|
||
withClassifier.classifierTemplate = &PromptTemplate{System: "classify", SHA256: "c"}
|
||
if withClassifier.bankRoleProducer() == base {
|
||
t.Fatal("turning the CLASSIFIER on left the producer identity unmoved, although it authors the type and gender halves of every stored answer")
|
||
}
|
||
// A TEMPLATE THAT CANNOT RENDER must not hash like an absent role: «could not be described» and «off»
|
||
// are different states, and the first must re-buy rather than serve.
|
||
broken := newRun(keep, "deepseek-v4-flash")
|
||
broken.terminologyTemplate = &PromptTemplate{System: "{{no_such_field}}", SHA256: "fixed-template-sha"}
|
||
off := newRun(keep, "deepseek-v4-flash")
|
||
off.terminologyTemplate = nil
|
||
if broken.bankRoleProducer() == off.bankRoleProducer() {
|
||
t.Fatal("an unrenderable prompt hashed the same as an absent one")
|
||
}
|
||
}
|
||
|
||
// TestAnOwnersUNSIGNEDRowIsNotTheEnginesMemoryEither pins the other half of «whose word is this», and it is
|
||
// the half the pack's first fix missed: it asked `Status == "approved"` — a SECOND spelling of a question
|
||
// membank.IsEngineUnsigned already answers — so a term the owner wrote BY HAND and left unsigned
|
||
// (`source: seed`, `status: draft`, a shape the loader admits and even requires a rendering for) read as
|
||
// «not the owner's», and the engine recorded its own consolidation under it.
|
||
func TestAnOwnersUNSIGNEDRowIsNotTheEnginesMemoryEither(t *testing.T) {
|
||
cands := []terminology.Candidate{{Key: "青茅山"}, {Key: "元石"}}
|
||
out := map[string]string{"青茅山": "гора Цинмао", "元石": "юаньши"}
|
||
fps := map[string]bankBasisFP{"青茅山": {Whole: "a"}, "元石": {Whole: "b"}}
|
||
rows := map[string]store.GlossaryEntry{
|
||
// The owner's own hand, unsigned: Source "seed" is what makes it his, NOT the status.
|
||
"青茅山": {Src: "青茅山", Dst: "Цинмаошань", Status: "draft", Source: "seed"},
|
||
// The engine's own unsigned row: same STATUS, different author.
|
||
"元石": {Src: "元石", Dst: "юаньши", Status: "draft", Source: "mined"},
|
||
}
|
||
// THE WRITE side: the owner's row must not enter the memory of decisions.
|
||
got := basisRowsToStore(cands, out, nil, nil, fps, rows, nil, basisShape(40, "p"))
|
||
if len(got) != 1 || got[0].SrcKey != "元石" {
|
||
t.Fatalf("recorded %+v, want only the ENGINE's row: a seed term the owner left unsigned is his word, and storing the engine's answer under it means serving that answer back as «this book decided it earlier»", got)
|
||
}
|
||
// THE READ side: and it is not served, with a class of its own — «you signed it» and «you wrote it and
|
||
// left it unsigned» are different sentences to the owner.
|
||
basis := map[string]bankBasisRecord{"青茅山": {Key: "青茅山", FP: fps["青茅山"], Answer: bankBasisAnswer{Dst: "гора Цинмао"}}}
|
||
v := basisVerdictFor(cands[0], fps["青茅山"], basis, rows, nil, "s")
|
||
if v.Reason != basisOwnerRow {
|
||
t.Fatalf("verdict = %q, want %q", v.Reason, basisOwnerRow)
|
||
}
|
||
if v.Answer.Dst != "" {
|
||
t.Fatalf("the engine's rendering was handed back for the owner's own row (%q)", v.Answer.Dst)
|
||
}
|
||
// The control: the ENGINE's row on the same status IS served, so the refusal above is about authorship
|
||
// and not about `draft`.
|
||
basis["元石"] = bankBasisRecord{Key: "元石", FP: fps["元石"], Answer: bankBasisAnswer{Dst: "юаньши"}}
|
||
if e := basisVerdictFor(cands[1], fps["元石"], basis, rows, nil, "s"); e.Reason != basisServed {
|
||
t.Fatalf("control: the engine's own draft row was refused too (%q) — the predicate is reading the STATUS, not the author", e.Reason)
|
||
}
|
||
}
|
||
|
||
// TestAWidthChangeReportsItsOwnCauseAndNotTheSources pins D1: the evidence width lives in the stored SHAPE,
|
||
// so turning it is reported as «the rule that decided this is no longer the rule» — with both widths — and
|
||
// never as «the source around this term changed».
|
||
//
|
||
// ⛔ WHY THE CAUSE MATTERS MORE THAN THE COUNT. Both readings re-buy the same rows; they ask the owner for
|
||
// opposite reactions. «The source moved» is nothing to do — the book changed, the engine noticed. «The width
|
||
// changed» is a knob somebody turned, and the owner is the only one who can say whether that was meant. A
|
||
// re-purchase reported under the wrong cause spends money and teaches the wrong lesson.
|
||
func TestAWidthChangeReportsItsOwnCauseAndNotTheSources(t *testing.T) {
|
||
if basisShape(40, "p") == basisShape(80, "p") {
|
||
t.Fatal("the shape does not carry the width: a change of basis_width would be indistinguishable from a change of the source, and every re-purchase it causes would be reported under the wrong cause")
|
||
}
|
||
if !strings.Contains(basisShape(40, "p"), basisFingerprintVersion) {
|
||
t.Fatalf("the shape must still carry the version it extends, got %q", basisShape(40, "p"))
|
||
}
|
||
// And the two halves stay independent: the same width under a different version, and the same version
|
||
// under a different width, must both be distinguishable — otherwise one of them silently masks the other.
|
||
if basisShape(40, "p") == basisShape(41, "p") {
|
||
t.Fatal("two adjacent widths collapse to one shape")
|
||
}
|
||
// The row is STORED under the shape, not under the bare version — checked on the value the store gets,
|
||
// because that is the string a later purchase compares against.
|
||
rows := basisRowsToStore(
|
||
[]terminology.Candidate{{Key: "元石"}},
|
||
map[string]string{"元石": "юаньши"},
|
||
nil, nil,
|
||
map[string]bankBasisFP{"元石": {Whole: "h"}},
|
||
map[string]store.GlossaryEntry{"元石": {Src: "元石", Dst: "юаньши", Status: "draft", Source: "mined"}},
|
||
nil, basisShape(40, "p"))
|
||
if len(rows) != 1 {
|
||
t.Fatalf("premise broken: recorded %d row(s), want 1", len(rows))
|
||
}
|
||
if rows[0].FPVersion != basisShape(40, "p") {
|
||
t.Fatalf("stored shape = %q, want %q — a row written under one width and read under another must report a SHAPE change, and it can only do that if the width is in the string it stores",
|
||
rows[0].FPVersion, basisShape(40, "p"))
|
||
}
|
||
if rows[0].FPVersion == basisFingerprintVersion {
|
||
t.Fatal("the row stored the bare version: the width is gone from the comparison, and a width change would be reported as «the source moved»")
|
||
}
|
||
}
|
||
|
||
// TestABankRowThatDisagreesWithTheMemoryIsReAsked pins D3: the predicate's sentence is «this book decided
|
||
// this AND the bank still holds that decision», and until this condition existed only the first half was
|
||
// checked — the stored answer was served and then travelled into the delta, overwriting whatever the bank
|
||
// held, with the sheet reporting the row as settled earlier.
|
||
//
|
||
// ⚠ ITS POPULATION IS NAMED, NOT CLAIMED: on the run that writes them the two agree by construction (the
|
||
// auto-bank is emitted from the same consolidation the record was built from), so this cannot fire on
|
||
// ordinary material and is NOT measured on the bought runs. The reachable way to diverge is a hand edit of
|
||
// `<project_db>.auto-bank.yaml`, which the bank ontology lists among the SOURCES.
|
||
func TestABankRowThatDisagreesWithTheMemoryIsReAsked(t *testing.T) {
|
||
c := terminology.Candidate{Key: "元石"}
|
||
fp := bankBasisFP{Whole: "h"}
|
||
basis := map[string]bankBasisRecord{"元石": {Key: "元石", FP: fp, Answer: bankBasisAnswer{Dst: "юаньши"}}}
|
||
|
||
agreeing := map[string]store.GlossaryEntry{"元石": {Src: "元石", Dst: "юаньши", Status: "draft", Source: "mined"}}
|
||
if v := basisVerdictFor(c, fp, basis, agreeing, nil, "s"); v.Reason != basisServed {
|
||
t.Fatalf("premise broken: an AGREEING row must still be served, got %q — otherwise the assertion below holds for the wrong reason", v.Reason)
|
||
}
|
||
diverged := map[string]store.GlossaryEntry{"元石": {Src: "元石", Dst: "камень юань", Status: "draft", Source: "mined"}}
|
||
v := basisVerdictFor(c, fp, basis, diverged, nil, "s")
|
||
if v.Reason != basisDiverged {
|
||
t.Fatalf("verdict = %q, want %q: the memory would have been served over a bank row that says something else, and the sheet would have called it «decided earlier»", v.Reason, basisDiverged)
|
||
}
|
||
if v.Answer.Dst != "" {
|
||
t.Fatalf("a diverged surface was handed an answer (%q) — the overwrite this condition exists to refuse", v.Answer.Dst)
|
||
}
|
||
// ⚠ AND THE COMPARISON IS UNDER THE TARGET-FORM FOLD, the same one the vote is counted with: a case or
|
||
// ё difference is ONE decision, and re-asking over it would spend money on a spelling.
|
||
folded := map[string]store.GlossaryEntry{"元石": {Src: "元石", Dst: "Юаньши", Status: "draft", Source: "mined"}}
|
||
if f := basisVerdictFor(c, fp, basis, folded, nil, "s"); f.Reason != basisServed {
|
||
t.Fatalf("a row differing only by case was treated as a disagreement (%q): the engine would buy a term again over a spelling", f.Reason)
|
||
}
|
||
}
|
||
|
||
// TestARecordWhoseRuleMovedIsKeptAndNamed pins the loading rule directly, because inside bankBasisPass it
|
||
// needs a store, a book and a runner — and a money rule that can only be exercised end to end is one nobody
|
||
// plants a mutation into.
|
||
func TestARecordWhoseRuleMovedIsKeptAndNamed(t *testing.T) {
|
||
now := basisShape(40, "producer-A")
|
||
stored := map[string]store.BankBasisRow{
|
||
"元石": {SrcKey: "元石", FPVersion: now, FPWhole: "a", Dst: "юаньши"},
|
||
"青茅山": {SrcKey: "青茅山", FPVersion: basisShape(40, "producer-B"), FPWhole: "b", Dst: "гора Цинмао"},
|
||
"古月": {SrcKey: "古月", FPVersion: basisShape(80, "producer-A"), FPWhole: "c", Dst: "Гуюэ"},
|
||
}
|
||
basis, changed, old := bankBasisRecordsFrom(stored, now)
|
||
if len(basis) != 3 {
|
||
t.Fatalf("kept %d record(s) of 3: a record whose rule moved was DROPPED, and a dropped record reads as «never decided» — the wrong cause on every one of those rows", len(basis))
|
||
}
|
||
if changed != 2 {
|
||
t.Fatalf("counted %d moved shape(s), want 2", changed)
|
||
}
|
||
if len(old) != 2 {
|
||
t.Fatalf("the operator line needs the shapes it is comparing against, got %v", old)
|
||
}
|
||
// Each kept record carries ITS OWN shape, which is what lets the verdict name the part that moved.
|
||
if basis["青茅山"].Shape == now || basis["古月"].Shape == now {
|
||
t.Fatal("a moved record was stamped with the CURRENT shape: the verdict can no longer tell which part of the rule changed, and every such row would report «the source moved»")
|
||
}
|
||
if basis["元石"].Shape != now {
|
||
t.Fatalf("the unmoved record lost its shape (%q): it would be re-asked for a rule change that did not happen", basis["元石"].Shape)
|
||
}
|
||
// The control that keeps the counts readable: with nothing moved, nothing is reported.
|
||
if _, c, o := bankBasisRecordsFrom(map[string]store.BankBasisRow{"元石": stored["元石"]}, now); c != 0 || len(o) != 0 {
|
||
t.Fatalf("control: an unchanged shape reported %d change(s) %v — the counter fires on its own", c, o)
|
||
}
|
||
}
|