textmachine/backend/internal/pipeline/bankbasis_test.go

343 lines
20 KiB
Go

package pipeline
import (
"strings"
"testing"
"textmachine/backend/internal/store"
"textmachine/backend/internal/terminology"
)
// bankbasis_test.go: the pins of the settled basis's two pure halves. Every one of them guards a MONEY
// property — a fingerprint that moves too easily re-buys a bank out of a lifetime ceiling, one that moves
// too rarely serves an answer decided on evidence the book no longer holds.
// basisCand is the fixture candidate: enough fields that a pin can move ONE of them and see what happens.
func basisCand() terminology.Candidate {
return terminology.Candidate{
Key: "元石", Src: "元石", Type: "name", Freq: 4, SinceCh: 2,
Aliases: []string{"元石头"}, Related: []string{"元海"}, Evidence: []string{"surname:元"},
Origin: terminology.OriginMined,
Variants: []terminology.Variant{
{Dst: "юаньши", Chunks: 2}, {Dst: "камень юань", Chunks: 1},
},
KWIC: []string{"…元石…"},
}
}
const basisSource = "他拿出元石,看了看。元石在手中发光。这是元海的东西。"
// TestBasisFingerprintIgnoresWhatTheRoleMerelyGUESSED is the pin of what must NOT be in the hash. Draft
// variants, frequency and the first chapter of appearance all reach the role's prompt, and all three move
// between purchases for reasons that are not new knowledge about the term — model noise, a cap, a re-cut.
// Hashing any of them turns ordinary variation into a re-purchase of the whole bank.
func TestBasisFingerprintIgnoresWhatTheRoleMerelyGUESSED(t *testing.T) {
base := candidateBasisFingerprint(basisCand(), basisSource, nil, 8)
for _, tc := range []struct {
name string
mut func(c *terminology.Candidate)
}{
{"the drafts proposed something else", func(c *terminology.Candidate) {
c.Variants = []terminology.Variant{{Dst: "энергетический камень", Chunks: 9}}
}},
{"the drafts proposed nothing at all", func(c *terminology.Candidate) { c.Variants = nil }},
{"the detector counted more occurrences", func(c *terminology.Candidate) { c.Freq = 400 }},
{"the term now first appears in another chapter", func(c *terminology.Candidate) { c.SinceCh = 77 }},
{"the KWIC subset shown to the model changed", func(c *terminology.Candidate) {
c.KWIC = []string{"совсем", "другие", "окна"}
}},
} {
t.Run(tc.name, func(t *testing.T) {
c := basisCand()
tc.mut(&c)
got := candidateBasisFingerprint(c, basisSource, nil, 8)
if got.Whole != base.Whole {
t.Fatalf("the fingerprint MOVED on %q, so this book and every other one would re-buy its bank out of a lifetime ceiling for a change that is not new knowledge about the term\n before %s\n after %s",
tc.name, base.Whole[:16], got.Whole[:16])
}
})
}
// The control that makes those five zeros readable: the fingerprint is NOT constant. Without this the
// whole test passes on a function that returns the same string for everything.
moved := candidateBasisFingerprint(basisCand(), strings.Replace(basisSource, "他拿出元石", "他慢慢拿出元石", 1), nil, 8)
if moved.Whole == base.Whole {
t.Fatal("control: the fingerprint did not move when the SOURCE around the key changed — it is constant, and the five assertions above proved nothing")
}
}
// TestBasisFingerprintReadsEveryOccurrenceNotTheFirstFew is the pin the whole shape exists for: the
// fingerprint must not depend on how many contexts the model is shown, or on which of them are chosen.
// Otherwise the stratified selection that is already on the backlog, or any tuning of kwic_per_term, zeroes
// the settled set of every book on earth at once.
func TestBasisFingerprintReadsEveryOccurrenceNotTheFirstFew(t *testing.T) {
src := strings.Repeat("前面的话。元石在这里。", 6)
full := terminology.EvidenceWindows(src, "元石", 4)
if len(full) != 6 {
t.Fatalf("premise broken: the fixture must hold 6 occurrences for «uncapped» to mean anything, got %d", len(full))
}
// KWIC over the same text, capped — the subset the role actually sees.
capped := terminology.AttachKWIC([]terminology.Candidate{{Key: "元石"}},
[]terminology.Chunk{{NSource: src}}, 3, 4)
if len(capped[0].KWIC) != 3 {
t.Fatalf("premise broken: KWIC must be CAPPED here, or the two views are the same view; got %d", len(capped[0].KWIC))
}
// The last occurrence is beyond the cap: an edit there is invisible to KWIC and must be visible here.
last := strings.LastIndex(src, "元石")
edited := src[:last] + "新" + src[last:]
if equalStrings(full, terminology.EvidenceWindows(edited, "元石", 4)) {
t.Fatal("an edit at the LAST occurrence — past the KWIC cap — left the evidence windows unchanged; the fingerprint would keep serving an answer whose evidence moved")
}
after := terminology.AttachKWIC([]terminology.Candidate{{Key: "元石"}},
[]terminology.Chunk{{NSource: edited}}, 3, 4)
if !equalStrings(capped[0].KWIC, after[0].KWIC) {
t.Fatal("control: the KWIC subset ALSO changed, so this fixture does not separate «what the model is shown» from «what the book says» and the assertion above measures nothing")
}
}
// TestBasisFingerprintNamesTheSignedRowsItDependsOn pins both halves of the canon component: a signature
// that relates to the candidate MOVES the fingerprint (so the term is re-asked, paid for, and judged
// against the new law), and the Canon hash moves with it — which is what lets the re-purchase state its own
// cause instead of being an unexplained charge.
func TestBasisFingerprintNamesTheSignedRowsItDependsOn(t *testing.T) {
bare := candidateBasisFingerprint(basisCand(), basisSource, nil, 8)
related := []terminology.Neighbour{{Src: "元", Dst: "юань"}}
withKin := candidateBasisFingerprint(basisCand(), basisSource, related, 8)
if withKin.Whole == bare.Whole {
t.Fatal("signing a RELATED row left the fingerprint unmoved: the book's own law could change under a term and the engine would keep serving the rendering decided before it")
}
if withKin.Canon == bare.Canon {
t.Fatal("the Canon half did not move, so a re-purchase caused by a signature would be reported as «the source moved» — a message lying about its own cause")
}
// The other half: a signature with NOTHING to do with this candidate must not move it. Otherwise every
// act of signing re-buys the whole book, which is the opposite of what a signature is for.
unrelated := []terminology.Neighbour{{Src: "春天", Dst: "весна"}}
if got := candidateBasisFingerprint(basisCand(), basisSource, unrelated, 8); got.Whole != bare.Whole {
t.Fatal("signing an UNRELATED row moved the fingerprint: every signature would re-buy every term of the book")
}
}
// TestBasisUnitsGiveEveryCandidateAUnitOfItsOwn pins the zero-value trap that produced a false measurement
// before it was found: MergeUnits returns a PARTIAL map and the engine's convention reads a missing key as
// «singleton» (terminology.Batch checks `id != 0`). Indexed raw, every unit-less candidate shares unit 0 —
// one unsettled row among them re-buys them all.
func TestBasisUnitsGiveEveryCandidateAUnitOfItsOwn(t *testing.T) {
// Three candidates with no series and no family between them: nothing should join them.
cands := []terminology.Candidate{{Key: "春天"}, {Key: "石头"}, {Key: "海"}}
units := bankBasisUnits(cands, terminology.SeriesParams{}, terminology.FamilyParams{})
seen := map[int]string{}
for _, c := range cands {
id, ok := units[c.Key]
if !ok {
t.Fatalf("%q got no unit id at all, so a caller indexing the map reads 0 for it", c.Key)
}
if id == 0 {
t.Fatalf("%q got unit id 0, which the engine's own convention means «no unit» — every such candidate would share one unit and block each other", c.Key)
}
if other, clash := seen[id]; clash {
t.Fatalf("%q and %q share unit id %d although neither is in a series or a family: one being re-asked would re-buy the other", other, c.Key, id)
}
seen[id] = c.Key
}
}
// TestBasisServesOnlyADecidedRow pins the predicate's conditions one at a time, and each row of the table
// is a way the engine could pay for, or wrongly skip, a term.
//
// ⛔ THE `auto`-WITH-A-RENDERING ROW IS THE ONE TO READ. The miner really does produce it — the raw
// first-chunk banknote guess lands as a dst while the status stays `auto` — a pair four engine comments
// declare impossible and backlog row 487 has open. This pack does NOT fix that; it pins the premise it
// depends on, so the day 487 is fixed the settled set does not move by a step nobody priced.
func TestBasisServesOnlyADecidedRow(t *testing.T) {
c := basisCand()
fp := candidateBasisFingerprint(c, basisSource, nil, 8)
rec := bankBasisRecord{Key: c.Key, FP: fp, Answer: bankBasisAnswer{Dst: "юаньши", Type: "name"}}
draft := store.GlossaryEntry{Src: c.Key, Dst: "юаньши", Status: "draft", Source: "mined"}
for _, tc := range []struct {
name string
rec *bankBasisRecord
row *store.GlossaryEntry
fp bankBasisFP
want basisReason
}{
{"decided, evidence unchanged", &rec, &draft, fp, basisServed},
{"never decided", nil, &draft, fp, basisNew},
{"the source around it moved", &rec, &draft, bankBasisFP{Whole: "other", Canon: fp.Canon}, basisMoved},
{"a relative was signed", &rec, &draft, bankBasisFP{Whole: "other", Canon: "other"}, basisKin},
{"the bank no longer holds the row", &rec, nil, fp, basisAbsent},
{"status auto WITH a rendering (backlog row 487)", &rec,
&store.GlossaryEntry{Src: c.Key, Dst: "юаньши", Status: "auto", Source: "mined"}, fp, basisUnsettled},
{"status draft with NO rendering", &rec,
&store.GlossaryEntry{Src: c.Key, Dst: " ", Status: "draft", Source: "mined"}, fp, basisUnsettled},
{"signed by the owner — its own class", &rec,
&store.GlossaryEntry{Src: c.Key, Dst: "Юаньши", Status: "approved"}, fp, basisSigned},
} {
t.Run(tc.name, func(t *testing.T) {
basis := map[string]bankBasisRecord{}
if tc.rec != nil {
basis[c.Key] = *tc.rec
}
rows := map[string]store.GlossaryEntry{}
if tc.row != nil {
rows[c.Key] = *tc.row
}
got := basisVerdictFor(c, tc.fp, basis, rows, nil, "s")
if got.Reason != tc.want {
t.Fatalf("verdict = %q, want %q", got.Reason, tc.want)
}
if tc.want == basisServed && got.Answer.Dst != "юаньши" {
t.Fatalf("a served candidate must carry the stored answer, got %+v — a basis that serves an EMPTY rendering is the silent regression this mechanism exists to avoid", got.Answer)
}
if tc.want != basisServed && got.Answer.Dst != "" {
t.Fatalf("a re-asked candidate must carry NO answer, got %q: it would be stamped as consolidated without anyone consolidating it", got.Answer.Dst)
}
})
}
}
// TestBasisServesAUnitOnlyWhole pins the family rule in both directions: one unsettled member holds its
// unit, and a member of ANOTHER unit does not.
func TestBasisServesAUnitOnlyWhole(t *testing.T) {
kin := []terminology.Candidate{{Key: "一转"}, {Key: "三转"}, {Key: "春天"}}
fps := map[string]bankBasisFP{}
basis := map[string]bankBasisRecord{}
rows := map[string]store.GlossaryEntry{}
for _, c := range kin {
fps[c.Key] = bankBasisFP{Whole: "fp-" + c.Key}
rows[c.Key] = store.GlossaryEntry{Src: c.Key, Dst: "передача", Status: "draft", Source: "mined"}
basis[c.Key] = bankBasisRecord{Key: c.Key, FP: fps[c.Key], Answer: bankBasisAnswer{Dst: "передача"}}
}
units := map[string]int{"一转": 7, "三转": 7, "春天": 9}
if v := bankBasisSettle(kin, fps, basis, rows, nil, units, "s"); countServed(v) != 3 {
t.Fatalf("premise broken: with everything settled all three must be served, got %d — the rows below would then pass for the wrong reason", countServed(v))
}
// Now one member of unit 7 loses its record.
delete(basis, "一转")
v := bankBasisSettle(kin, fps, basis, rows, nil, units, "s")
if v["三转"].Reason != basisUnit {
t.Fatalf("its unit-mate was NOT held: verdict %q — a family would be decided in halves, which is the chimera co-batching exists to prevent", v["三转"].Reason)
}
if v["一转"].Reason != basisNew {
t.Fatalf("the member that actually lost its record must report its OWN reason, got %q", v["一转"].Reason)
}
if v["春天"].Reason != basisServed {
t.Fatalf("a candidate in ANOTHER unit was held too (%q): the unit rule is not keyed on the unit at all", v["春天"].Reason)
}
}
// TestBasisWidthIsPairDataWithAnEngineDefault answers the canon's standing review question — «would a pair
// that is not in the repository work without a Go edit» — by execution rather than by assertion in a
// report. It also pins that the width REACHES the hash: a pair datum nothing reads is a key that looks
// like a decision and is none.
func TestBasisWidthIsPairDataWithAnEngineDefault(t *testing.T) {
r := &Runner{}
if got := r.basisWidth(); got != terminologyDefaultBasisWidth {
t.Fatalf("a book with no pack must fall through to the engine default, got %d", got)
}
narrow := candidateBasisFingerprint(basisCand(), basisSource, nil, 2)
wide := candidateBasisFingerprint(basisCand(), basisSource, nil, 30)
if narrow.Whole == wide.Whole {
t.Fatal("the evidence width does not reach the fingerprint: the pair datum would be a key that reads as a decision and decides nothing")
}
// ⛔ AND THE WIDTH IS NAMED IN THE HASH ITSELF, not merely reflected through the windows it produced.
// A planted mutation that dropped the width from the hash survived the two lines above, because for an
// ATTESTED surface the windows differ by width anyway. They do not for a surface the source does not
// contain: its window list is empty at every width, so the two fingerprints collide and the book keeps
// serving a decision taken under a rule that is no longer the rule. The population is real — a
// draft-invented term is exactly a candidate with no occurrence — and the cure costs one hashed field.
absent := basisCand()
absent.Key, absent.Src = "这个词不在原文里", "这个词不在原文里"
if got := terminology.EvidenceWindows(basisSource, absent.Key, 30); len(got) != 0 {
t.Fatalf("premise broken: the fixture key must NOT occur in the source, got %d window(s)", len(got))
}
if candidateBasisFingerprint(absent, basisSource, nil, 2).Whole ==
candidateBasisFingerprint(absent, basisSource, nil, 30).Whole {
t.Fatal("two different evidence widths produced ONE fingerprint for an unattested surface: the rule that decided it is not part of what is stored, so changing the rule leaves those rows silently settled under the old one")
}
}
// TestASignedRowDoesNotHoldItsFamilyOpen pins the owner-signature rule in both of its halves, and it exists
// because the two halves are easy to confuse into one.
//
// ⚠ ITS PREMISE, NAMED IN THE TEST RATHER THAN ASSUMED BY IT: backlog row 447 is OPEN, so a signed surface
// IS still sent to the paid role — the two-condition filter pays for banked surfaces on purpose, to keep
// the dispute marks on the sheet the owner signs by. This pin does not assert that it is bought (that is
// the filter's subject, not the basis's); it asserts that being bought and holding a family open have been
// separated. If row 447 closes, the first half stops being true and the second half must still hold —
// which is why they are two assertions and not one.
func TestASignedRowDoesNotHoldItsFamilyOpen(t *testing.T) {
fam := []terminology.Candidate{{Key: "古月山寨"}, {Key: "古月"}, {Key: "古月族长"}}
units := map[string]int{"古月山寨": 3, "古月": 3, "古月族长": 3}
fps := map[string]bankBasisFP{}
basis := map[string]bankBasisRecord{}
rows := map[string]store.GlossaryEntry{}
for _, c := range fam {
fps[c.Key] = bankBasisFP{Whole: "fp-" + c.Key}
rows[c.Key] = store.GlossaryEntry{Src: c.Key, Dst: "решение движка", Status: "draft", Source: "mined"}
basis[c.Key] = bankBasisRecord{Key: c.Key, FP: fps[c.Key],
Answer: bankBasisAnswer{Dst: "решение движка"}}
}
// The owner signs ONE member, and the engine's memory of it is dropped: signing is not a decision the
// basis recorded, so this is the state a real book reaches — exactly what both bought runs held.
rows["古月山寨"] = store.GlossaryEntry{Src: "古月山寨", Dst: "горная крепость Гуюэ", Status: "approved"}
delete(basis, "古月山寨")
v := bankBasisSettle(fam, fps, basis, rows, nil, units, "s")
if v["古月山寨"].Reason != basisSigned {
t.Fatalf("the signed row is in class %q, want %q — the owner's signature must be its own class, not «unsettled»", v["古月山寨"].Reason, basisSigned)
}
// HALF ONE: the owner's word is never handed back as if the engine had decided it.
if got := v["古月山寨"].Answer; got.Dst != "" {
t.Fatalf("the basis handed back %q for a SIGNED row: the owner's canon would be republished as an engine decision, and the two words the bank ontology keeps apart would have merged", got.Dst)
}
// HALF TWO: the rest of the family is served WHOLE, which is the whole point of the exemption.
for _, k := range []string{"古月", "古月族长"} {
if v[k].Reason != basisServed {
t.Fatalf("%q was re-asked (%q) although the only member missing from the basis is one the OWNER SIGNED — his signature would re-buy the family it belongs to", k, v[k].Reason)
}
if v[k].Answer.Dst != "решение движка" {
t.Fatalf("%q was served with %q instead of its stored answer", k, v[k].Answer.Dst)
}
}
// The control that keeps the exemption from being a hole: an UNSIGNED member missing from the basis
// still holds the family. Without this row the test passes on a predicate with no unit rule at all.
delete(basis, "古月")
rows["古月"] = store.GlossaryEntry{Src: "古月", Dst: "решение движка", Status: "draft", Source: "mined"}
v2 := bankBasisSettle(fam, fps, basis, rows, nil, units, "s")
if v2["古月族长"].Reason != basisUnit {
t.Fatalf("control: an UNSIGNED member missing from the basis left its family served (%q) — the exemption has swallowed the unit rule", v2["古月族长"].Reason)
}
}
// TestBasisRowsToStoreRecordsOnlyDecidedRows pins the pipeline half of the same guarantee the store keeps
// at its write: a term the role declined, never answered, or has no fingerprint for is NOT recorded.
//
// ⚠ TWO GUARDS FOR ONE PROPERTY IS DELIBERATE, and each is pinned separately rather than one standing for
// both. The store's refusal is the one that cannot be forgotten by a future caller; this one keeps the
// decision where the reason lives — «undecided» and «decided» are a pipeline distinction, and a row
// dropped here never has to be explained to a layer that does not know what a role is.
func TestBasisRowsToStoreRecordsOnlyDecidedRows(t *testing.T) {
cands := []terminology.Candidate{{Key: "元石"}, {Key: "青茅山"}, {Key: "方源"}, {Key: "无指纹"}}
consolidated := map[string]string{
"元石": "юаньши",
"青茅山": "", // the role declined it
"无指纹": "передача", // answered, but no fingerprint was computed for it
// 方源 is absent entirely: no reply line covered it
}
fps := map[string]bankBasisFP{
"元石": {Whole: "fp1", Canon: "c1"}, "青茅山": {Whole: "fp2"}, "方源": {Whole: "fp3"},
}
rows := basisRowsToStore(cands, consolidated, map[string]string{"元石": "name"}, map[string]string{"元石": "male"}, fps, nil, nil, basisShape(40, "p"))
if len(rows) != 1 {
t.Fatalf("recorded %d row(s), want exactly the one that was DECIDED: %+v", len(rows), rows)
}
got := rows[0]
if got.SrcKey != "元石" || got.Dst != "юаньши" || got.Type != "name" || got.Gender != "male" {
t.Fatalf("the decided row lost part of its answer: %+v", got)
}
// ⚠ The SHAPE, not the bare version: since D1 the stored string carries the effective evidence width,
// so that a width change reports its own cause instead of «the source moved».
if got.FPVersion != basisShape(40, "p") || got.FPWhole != "fp1" || got.FPCanon != "c1" {
t.Fatalf("the row carries no comparable fingerprint, so a later purchase could not tell whether the evidence moved: %+v", got)
}
}