textmachine/backend/internal/pipeline/bankfixpack_test.go

587 lines
29 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"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/config"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/miner"
"textmachine/backend/internal/store"
"textmachine/backend/internal/terminology"
"textmachine/backend/internal/text"
)
// bankfixpack_test.go: the fix-pack's pipeline-level seams — the arbitration record on the stop table (§G3),
// the signature map's alias join (§G3), the auto-bank rewrite diff (row 130) and the eviction list.
// TestBankStopTableCarriesTheArbitrationRecord pins §A7's finding closed: «why does this term have THIS
// dst» has to be answerable from the artifacts. The row now carries the winning variant's ranking signals,
// whether the rendering is one the drafts ever proposed, how many CONVENTIONS they actually offered (as
// against raw forms), and the role's own confidence.
func TestBankStopTableCarriesTheArbitrationRecord(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
// A rendering NO draft proposed, with a confidence field — both the invented flag and the
// confidence column have to survive the parser.
return "方源\tГуюэ Фан Юань\t40", "stop"
}
// The drafts disagree on the CASE only: one convention written two ways.
return "Фан Юань пришёл." + "\n" + bankSeparator +
"\n方源\tФан Юань\tname\n方源\tфан юань\tname", "stop"
})
defer srv.Close()
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true}))
defer r.Close()
stop := runToSignatureStop(t, r)
var row *BankStopRow
for i := range stop.Rows {
if stop.Rows[i].Src == "方源" {
row = &stop.Rows[i]
}
}
if row == nil {
t.Fatalf("方源 missing from the table: %+v", stop.Rows)
}
if row.Conf != 40 {
t.Fatalf("the role's stated confidence must reach the review table, got %d", row.Conf)
}
if !row.Invented {
t.Fatalf("a rendering no draft proposed must say so: %+v", *row)
}
if row.Conventions != 1 {
t.Fatalf("two spellings of one rendering are ONE convention, got %d (%v)", row.Conventions, row.Variants)
}
if row.Spread < 2 {
t.Fatalf("the owner must still see that the drafts wrote it two ways, got spread=%d", row.Spread)
}
raw, err := os.ReadFile(stop.TablePath)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"conventions=", "confidence=40", "INVENTED"} {
if !strings.Contains(string(raw), want) {
t.Fatalf("the sidecar must carry %q:\n%s", want, raw)
}
}
}
// TestBankStopRowCarriesTheWinningVariantsSignals pins the OTHER half of §A7's answer: not only WHAT was
// chosen but WHY. The signals are the §C2-3 factors that fired for the variant the ranking put first, and
// they were already computed and thrown away before this pack — so the failure mode is a row that looks
// complete and explains nothing.
func TestBankStopRowCarriesTheWinningVariantsSignals(t *testing.T) {
c := terminology.Candidate{Key: "元海", Src: "元海", Type: "term", Variants: []terminology.Variant{
{Dst: "море истинной ци", Chunks: 5, Forms: 1},
{Dst: "первозданное море", Chunks: 1, Forms: 1},
}}
terminology.ScoreVariants(&c, terminology.ScoreOpts{})
if len(c.Variants[0].Signals) == 0 {
t.Fatalf("test premise broken: the winner must have fired at least one factor: %+v", c.Variants)
}
rows := bankStopRows([]terminology.Candidate{c}, map[string]string{"元海": "море истинной ци"}, nil)
if len(rows[0].Signals) == 0 {
t.Fatalf("the winning variant's audit trail must reach the row: %+v", rows[0])
}
if strings.Join(rows[0].Signals, ",") != strings.Join(c.Variants[0].Signals, ",") {
t.Fatalf("the row must carry the TOP variant's signals, got %v want %v", rows[0].Signals, c.Variants[0].Signals)
}
if !strings.Contains(renderBankStopTable(rows), "why: ") {
t.Fatalf("and the sidecar must print them:\n%s", renderBankStopTable(rows))
}
// A row with no consolidation carries no confidence rather than a zero one: 0 means «the role said it was
// not sure at all», which is the first row to review, not the same as silence.
if rows[0].Conf >= 0 {
t.Fatalf("an absent confidence must stay absent, got %d", rows[0].Conf)
}
}
// TestTerminologistBudgetCutIsNotReportedAsAnEmptyReply guards the seam between the two «this batch bought
// nothing» signals. A batch the budget never reached has an empty reply text for a completely different
// reason, and warning about an EMPTY COMPLETION there would cry wolf on every budget-bounded run — the
// budget already said what happened, one line earlier.
func TestTerminologistBudgetCutIsNotReportedAsAnEmptyReply(t *testing.T) {
var logBuf bytes.Buffer
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань\n花家\tклан Хуа\n青茅山\tгора Цинмао", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
// batch_runes tiny → several batches; a budget sized under one batch → the pass stops at the first.
probe := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, batchRunes: 400}))
_ = runToSignatureStop(t, probe)
if probe.lastTerminology.Batches < 2 {
t.Fatalf("the fixture must actually batch, got %d", probe.lastTerminology.Batches)
}
perBatch := probe.lastTerminology.EstimateUSD / float64(probe.lastTerminology.Batches)
probe.Close()
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{
terminology: true, batchRunes: 400, budgetUSD: perBatch * 1.5,
}))
defer r.Close()
r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
_ = runToSignatureStop(t, r)
out := logBuf.String()
// ⚠ THE PREMISE IS ASSERTED STRUCTURALLY, not by grepping the log. It used to look for the sentence
// «budget would be exceeded», which tied a fixture-sanity check to the wording of a message — and the
// money-and-honesty pack replaced that message (the pass is now CUT TO WHAT FITS before the first call
// instead of aborting part-way through it). A premise that depends on a sentence is the D39.171 trap in
// miniature: it goes red when the sentence improves and green when the fixture stops exercising the
// budget. `BatchesDropped` is the fact itself and cannot drift with prose. THE SUBJECT OF THIS TEST —
// the assertion below — is untouched.
if r.lastTerminology.BatchesDropped == 0 {
t.Fatalf("the fixture must actually hit the budget (batches planned=%d, dropped=%d):\n%s",
r.lastTerminology.Batches, r.lastTerminology.BatchesDropped, out)
}
if strings.Contains(out, "EMPTY completion") {
t.Fatalf("a batch the budget never reached is not a paid batch that came back empty:\n%s", out)
}
}
// TestSelfContradictionIsReportedAtTheStop is §G2 end to end: the run consolidates a part and then renders
// the compound without it, and both the log and the row say so. The canon check cannot see this — the book
// has no signed rows at all — which is precisely why 18 of the 149 contradictions on the live bank were
// invisible.
func TestSelfContradictionIsReportedAtTheStop(t *testing.T) {
var logBuf bytes.Buffer
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
// 花家 → «клан Хуа», and the compound built on it drops «Хуа» entirely.
return "花家\tклан Хуа\n花家的人\tлюди клана", "stop"
}
return "Фан Юань пришёл." + "\n" + bankSeparator +
"\n花家\tклан Хуа\tname\n花家的人\tлюди клана\tterm", "stop"
})
defer srv.Close()
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true}))
defer r.Close()
r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
_ = runToSignatureStop(t, r)
if r.lastTerminology.SelfConflicts == 0 {
t.Fatalf("the compound contradicts the part the same reply consolidated: %+v", r.lastTerminology)
}
if !strings.Contains(logBuf.String(), "contradict each other") {
t.Fatalf("a run breaking its own canon must say so:\n%s", logBuf.String())
}
var flagged bool
for _, row := range r.lastBankStopRows {
if row.Src == "花家的人" && len(row.Contradicts) > 0 {
flagged = true
}
}
if !flagged {
t.Fatalf("the contradicting row must be marked in the review table: %+v", r.lastBankStopRows)
}
}
// TestSignatureMapJoinsAliasProposals is the acceptance finding of §G3: a rendering that reached the
// terminologist through an ALIAS of a cluster showed up in the stop table (Merge routes it to the owner)
// and was ABSENT from the owner's row in the signature map, which joined on the alias's own key. The map
// and the table then described the same bank differently, for exactly the terms the miner clustered.
func TestSignatureMapJoinsAliasProposals(t *testing.T) {
mined := []terminology.Mined{{Key: "方源", Src: "方源", Type: "name", Aliases: []string{"方小子"}}}
observed := []terminology.Observed{
{Key: "方小子", Src: "方小子", Type: "name", Proposals: []terminology.Proposal{{Dst: "малыш Фан", Chunks: 3}}},
}
cands := terminology.Merge(mined, observed, text.NormalizeTargetForm)
props := proposalsFromCandidates(cands)
if got := props["方源"]; len(got) == 0 || got[0].Dst != "малыш Фан" {
t.Fatalf("an alias-routed proposal must land on the OWNER's key, got %+v", props)
}
if props["方源"][0].Via != "方小子" {
t.Fatalf("and it must say which surface actually proposed it, got %q", props["方源"][0].Via)
}
yaml, err := miner.DeltaYAML([]miner.Term{{Src: "方源", Type: "name", Freq: 9}}, props)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(yaml, "малыш Фан") {
t.Fatalf("the signature map must carry the rendering the table showed:\n%s", yaml)
}
if !strings.Contains(yaml, "proposed for 方小子") {
t.Fatalf("and it must not present an alias's guess as the term's own:\n%s", yaml)
}
// AND it must not become the term's dst. Merge routes an alias's rendering to the cluster owner so the
// ranking sees all the evidence; promoting it to the owner's rendering is the clustering DECISION, which
// belongs to a signature. Here NOTHING was proposed on 方源 itself, so the row carries no dst at all.
if strings.Contains(yaml, "dst: малыш Фан") {
t.Fatalf("an alias's rendering became the term's dst — it reaches the wire with no model and no gate:\n%s", yaml)
}
// With a DIRECT proposal present, that one is the dst and the alias stays evidence in the note.
observed = append(observed, terminology.Observed{
Key: "方源", Src: "方源", Type: "name", Proposals: []terminology.Proposal{{Dst: "Фан Юань", Chunks: 1}},
})
direct := proposalsFromCandidates(terminology.Merge(mined, observed, text.NormalizeTargetForm))
yaml2, err := miner.DeltaYAML([]miner.Term{{Src: "方源", Type: "name", Freq: 9}}, direct)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(yaml2, "dst: Фан Юань") {
t.Fatalf("a rendering proposed on the term's own surface must be its dst:\n%s", yaml2)
}
if !strings.Contains(yaml2, "малыш Фан") {
t.Fatalf("and the alias proposal must still be listed as evidence:\n%s", yaml2)
}
}
// TestAutoBankRewriteNamesDroppedTerms is the $0 minimum of backlog row 130. The auto-bank file is rewritten
// WHOLE from a top-N-capped delta, so a term that was in the bank for twenty chapters can vanish from it
// mid-run with nothing in the artifacts saying so. The pack does not change that behaviour — accumulating is
// the owner's STOP decision, because it moves memory_version — it makes the loss visible.
func TestAutoBankRewriteNamesDroppedTerms(t *testing.T) {
var logBuf bytes.Buffer
r := &Runner{
Log: slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn})),
Book: &config.Book{BookID: "test-book", ProjectDB: filepath.Join(t.TempDir(), "project.db")},
}
ctx := context.Background()
first := []miner.Term{{Src: "方源", Type: "name", Freq: 40}, {Src: "青茅山", Type: "place", Freq: 12}}
if err := r.writeAutoBank(ctx, first, nil, nil); err != nil {
t.Fatal(err)
}
if logBuf.Len() != 0 {
t.Fatalf("the first write has nothing to diff against:\n%s", logBuf.String())
}
// The next run's delta no longer holds 青茅山 — the rank cap cut it, and it silently leaves the bank.
second := []miner.Term{{Src: "方源", Type: "name", Freq: 40}, {Src: "花家", Type: "name", Freq: 9}}
if err := r.writeAutoBank(ctx, second, nil, nil); err != nil {
t.Fatal(err)
}
out := logBuf.String()
if !strings.Contains(out, "青茅山") {
t.Fatalf("a term dropped by the rewrite must be NAMED:\n%s", out)
}
if strings.Contains(out, "方源") {
t.Fatalf("a term that is still there is not a loss:\n%s", out)
}
// The boundary is intact: the file is the new delta, not a merge of both runs.
raw, err := os.ReadFile(r.autoBankPath())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "青茅山") {
t.Fatalf("the pack must REPORT the drop, not accumulate the file (that moves memory_version):\n%s", raw)
}
// And the owner's OWN two verbs are not a loss. Promoting a term into the mined-delta (it becomes a seed
// surface) or declining it in mined-rejects removes it from the next delta ON PURPOSE — warning about
// that fires a false alarm on the normal signature cycle and advises the owner to do what he just did.
logBuf.Reset()
third := []miner.Term{{Src: "方源", Type: "name", Freq: 40}}
handled := map[string]bool{text.NormalizeSourceKey("花家"): true}
if err := r.writeAutoBank(ctx, third, nil, handled); err != nil {
t.Fatal(err)
}
if strings.Contains(logBuf.String(), "花家") {
t.Fatalf("a term the owner promoted or declined is not a silent loss:\n%s", logBuf.String())
}
}
// TestAutoBankDiffSurvivesTheEngineOwnRows is the row-130 warning through the path production takes: two
// real auto-mode runs, a term that leaves the delta between them, and the warning that has to name it.
//
// It has to be end-to-end, because the defect lives in the CALL SITE, not in the helper. From the second
// auto-mode run the stored glossary also holds the engine's own unsigned rows — seedGlossary re-seeds the
// auto-bank at start (seeding.go:83-93) — so passing the raw seed makes every term the engine ever proposed
// read as «the owner decided about this». The diff then empties and the warning is structurally dead in
// production while every unit test stays green. Risk 2, the self-exclusion trap, arriving at the one place
// in this file that did not filter for it.
func TestAutoBankDiffSurvivesTheEngineOwnRows(t *testing.T) {
var logBuf bytes.Buffer
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань\n花家\tклан Хуа\n青茅山\tгора Цинмао", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
// Run 1: the auto mode mines 花家 among others, writes the auto-bank and seeds it into the store.
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
var had bool
for _, src := range r1.autoBankSurfaces() {
if src == "花家" {
had = true
}
}
if !had {
t.Fatalf("test premise broken: the first run must bank 花家, got %v", r1.autoBankSurfaces())
}
r1.Close()
// The book changes and 花家 stops occurring, so it leaves the delta — standing in for the rank cap doing
// the same thing mid-book, which is the case backlog row 130 is about.
writeFile(t, filepath.Join(filepath.Dir(bookPath), "source.txt"),
strings.Repeat("方源来到青茅山。方源很强。青茅山很高。", 6))
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // the source moved, and this fixture is about the bank diff, not the snapshot gate
// ⚠ CONSENT IS EXPLICIT HERE SINCE THE ROW-238 FIX (money-and-honesty pack, 31.08). Editing the source
// re-buys already-billed rows, and the re-payment consent gate now SEES that — before the fix its probe
// read a manifest the run had just rewritten, so it stayed silent and this scenario passed by leaning
// on a defect. Consent is orthogonal to what this test asserts; the guarantee that used to be implied
// here — «an edited source proceeds without consent» — was FALSE and now lives, inverted and explicit,
// in TestTheCONSENTGateSeesAnInPlaceSourceEdit (rebillsource_test.go). Scenario-only edit, sanctioned
// by the orchestrator 31.08 on the acceptance of this pack; no assertion of this test is touched.
r2.AcceptRebill = RebillConsent{Given: true}
r2.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
if _, err := r2.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
// The store now holds the engine's own unsigned rows — the state that used to swallow the warning.
seed, err := r2.Store.GlossaryForBook("test-book")
if err != nil {
t.Fatal(err)
}
engineRows := 0
for _, e := range seed {
if e.Source == "mined" && e.Status != "approved" {
engineRows++
}
}
if engineRows == 0 {
t.Fatal("test premise broken: the store must hold the engine's own unsigned rows by the second run")
}
out := logBuf.String()
if !strings.Contains(out, "NOT in the one this run just wrote") || !strings.Contains(out, "花家") {
t.Fatalf("the row-130 warning must NAME the term the rewrite dropped, engine rows in the seed and all:\n%s", out)
}
}
// TestEvictedBankRowsAreNamed: n_evicted counted the rows the injection budget dropped and never said which,
// so «the model had no canon for this term» was an unactionable integer. The names go to the log rather than
// to a new column — a per-row detail column is a schema migration, and this repository has already paid for
// a non-idempotent one (backlog row 49а).
func TestEvictedBankRowsAreNamed(t *testing.T) {
var logBuf bytes.Buffer
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// A budget of one token cannot fit even the first glossary line, so every matched row is evicted.
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
source: suzukiSource, glossarySeed: suzukiSeed, glossaryTokenBudget: 1,
})
r := newRunner(t, bookPath)
defer r.Close()
r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
if err != nil || rs == nil {
t.Fatalf("retrieval_state: %v %v", rs, err)
}
if rs.NEvicted == 0 {
t.Fatal("test premise broken: a one-token budget must evict the matched row")
}
out := logBuf.String()
if !strings.Contains(out, "鈴木") {
t.Fatalf("the evicted rows must be NAMED, not counted:\n%s", out)
}
}
// TestConsolidationAgainstTheBankIsReportedAtTheStop is the bank-side disagreement end to end: the seed
// already renders 方源 one way (UNSIGNED — the point of the case) and the run consolidates the same
// surface differently. Neither of the collapse stage's own checks sees this: both test containment and
// skip the equal-source pair, and CanonConflicts is approved-only.
func TestConsolidationAgainstTheBankIsReportedAtTheStop(t *testing.T) {
var logBuf bytes.Buffer
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань", "stop" // the seed calls the same surface «Странник»
}
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
// until_ch keeps the seed row off the proposal's UNIQUE key, so this is a disagreement and not a
// replacement.
seed := "terms:\n - { src: 方源, dst: Странник, status: draft, until_ch: 20 }\n"
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
defer r.Close()
r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
_ = runToSignatureStop(t, r)
if r.lastTerminology.BankConflicts == 0 {
t.Fatalf("a rendering that disagrees with an UNSIGNED row of the same surface must be counted: %+v", r.lastTerminology)
}
log := logBuf.String()
if !strings.Contains(log, "DISAGREES with a row the bank already holds") {
t.Fatalf("the run must say it out loud:\n%s", log)
}
for _, want := range []string{"方源", "Фан Юань", "Странник", "[ch 1..20]"} {
if !strings.Contains(log, want) {
t.Errorf("the warning must name %q, or an operator cannot tell which two rows collided:\n%s", want, log)
}
}
// The finding has to reach the signature sheet, not only the log: that sheet is where a term is
// decided. It must also stay DISTINCT from the §G2 self-contradiction marker — "the run disagreed with
// itself" and "the book already calls it something else" are different decisions.
var marked *BankStopRow
for i, row := range r.lastBankStopRows {
if row.Src == "方源" {
marked = &r.lastBankStopRows[i]
}
}
if marked == nil {
t.Fatalf("premise broken: 方源 is not in the stop table at all: %+v", r.lastBankStopRows)
}
if len(marked.BankHolds) == 0 {
t.Fatalf("the contradicting row must be marked on the signature sheet: %+v", *marked)
}
if len(marked.Contradicts) != 0 {
t.Errorf("this is not a self-contradiction and must not be filed as one: %+v", marked.Contradicts)
}
if !strings.Contains(marked.BankHolds[0], `unsigned draft "方源"→"Странник"`) || !strings.Contains(marked.BankHolds[0], "[ch 1..20]") {
t.Errorf("the mark must name the EXISTING row, its signature and its window: %q", marked.BankHolds[0])
}
table := renderBankStopTable(r.lastBankStopRows)
if !strings.Contains(table, "THE BANK ALREADY HOLDS: unsigned draft") {
t.Errorf("the rendered table must carry the mark:\n%s", table)
}
if strings.Contains(table, "CONTRADICTS this run's own") {
t.Errorf("the two markers must stay distinguishable on the sheet:\n%s", table)
}
// And nowhere else: the signature map's bytes become bank rows, so a diagnostic leaking into it would
// turn an observation into content.
sigMap := readSignatureMap(t, r)
for _, forbidden := range []string{"THE BANK ALREADY HOLDS", "Странник", "[ch 1..20]"} {
if strings.Contains(sigMap, forbidden) {
t.Errorf("the diagnostic must not enter the signature map (%q):\n%s", forbidden, sigMap)
}
}
// CONTROL for those three zeros. 方源 is deliberately not the control: it is a seed surface, so the
// emission skips it and it never reaches the map — which is why the stop TABLE (every candidate),
// not the map (signable rows only), is the right address for this mark.
if !strings.Contains(sigMap, "青茅山") || !strings.Contains(sigMap, "terms:") {
t.Fatalf("control: the signature map must hold this run's emittable terms, or the checks above assert nothing:\n%s", sigMap)
}
// CONTROL: with the seed AGREEING with what the role consolidates, nothing is reported.
var quiet bytes.Buffer
srv2 := newJSONProvider(&reqRec{}, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tСтранник", "stop"
}
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
})
defer srv2.Close()
r2 := newVerifyRunner(t, setupMiningStopProject(t, srv2.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
defer r2.Close()
r2.Log = slog.New(slog.NewTextHandler(&quiet, &slog.HandlerOptions{Level: slog.LevelWarn}))
_ = runToSignatureStop(t, r2)
if r2.lastTerminology.BankConflicts != 0 {
t.Errorf("control: agreeing with the bank is not a contradiction, got %d", r2.lastTerminology.BankConflicts)
}
if strings.Contains(renderBankStopTable(r2.lastBankStopRows), "THE BANK ALREADY HOLDS") {
t.Errorf("control: an agreeing run must leave the sheet unmarked:\n%s", renderBankStopTable(r2.lastBankStopRows))
}
}
// TestConsolidatedRowsCarryTheWindowAndTheAliases pins the two fields that decide what
// membank.ConsolidationKeyConflicts may say. Dropping either leaves every existing test green while
// changing the verdict: without SinceCh a re-mined engine row looks like a fresh contradiction instead of
// a replacement, and without the aliases an alias-only collision is invisible.
func TestConsolidatedRowsCarryTheWindowAndTheAliases(t *testing.T) {
// A banknote candidate whose raw surface differs from its normalized key, so the row's source is not
// ambiguous between the two: the row that lands carries the KEY, and the conflict check compares the
// store's raw UNIQUE key against it.
cands := []terminology.Candidate{
{Key: "赵甲", Src: "趙甲", SinceCh: 7, Aliases: []string{"老赵"}},
{Key: "神通", Src: "神通"}, // unanswered → no row
}
rows := consolidatedRows(cands, map[string]string{"赵甲": "Чжао Цзя"})
if len(rows) != 1 {
t.Fatalf("only an answered candidate becomes a row: %+v", rows)
}
if rows[0].Src != "赵甲" {
t.Errorf("the row must carry the source it will LAND with (the key), got %q", rows[0].Src)
}
if rows[0].SinceCh != 7 {
t.Errorf("the candidate's window must reach the check: since_ch=%d, want 7", rows[0].SinceCh)
}
if len(rows[0].Aliases) != 1 || rows[0].Aliases[0].Alias != "老赵" {
t.Errorf("the identity cluster must reach the check as alias keys: %+v", rows[0].Aliases)
}
// Both fields change the verdict of the real check, so neither is decoration.
bank := []store.GlossaryEntry{
{Src: "赵甲", Dst: "Чжао Цзя-старый", Status: "auto", Source: "mined", SinceCh: 7},
{Src: "赵乙", Dst: "Чжао И", Status: "draft", Aliases: []store.GlossaryAlias{{Alias: "老赵"}}},
}
got := membank.ConsolidationKeyConflicts(rows, bank)
if len(got) != 1 || got[0].BankSrc != "赵乙" {
t.Fatalf("with the window carried, the same-tuple row is a replacement and only the alias hit is a conflict: %+v", got)
}
windowless := []store.GlossaryEntry{{Src: rows[0].Src, Dst: rows[0].Dst, Aliases: rows[0].Aliases}}
if n := len(membank.ConsolidationKeyConflicts(windowless, bank)); n != 2 {
t.Errorf("control: without the window the replacement is reported as a contradiction too, want 2 got %d", n)
}
aliasless := []store.GlossaryEntry{{Src: rows[0].Src, Dst: rows[0].Dst, SinceCh: rows[0].SinceCh}}
if n := len(membank.ConsolidationKeyConflicts(aliasless, bank)); n != 0 {
t.Errorf("control: without the aliases the alias-only collision is invisible, want 0 got %d", n)
}
// The findings have to survive the trip to the sheet. They are keyed by the candidate KEY on both
// sides; keying either side by the raw surface loses every banknote candidate whose draft spelling
// differs from its key — which is the whole population the key choice above exists for.
// Straight through the production path: the findings go to the sheet ungrouped, so the key they are
// matched on is decided in one place. The candidate is chosen so that the four strings that could
// plausibly match it — candidate key, raw surface, firing key (an alias here) and the bank row's own
// source — are all different.
res := terminologyResult{BankHoldRows: got}
sheet := bankStopRows(cands, map[string]string{"赵甲": "Чжао Цзя"}, &res)
if len(sheet) == 0 || sheet[0].Src != "趙甲" {
t.Fatalf("premise broken: the sheet row must be the raw-surface candidate: %+v", sheet)
}
if len(sheet[0].BankHolds) != 1 {
t.Fatalf("the mark must reach the sheet for a candidate whose key differs from its surface: %+v", sheet[0])
}
}
// TestApprovedNeighboursAdmitsOnlySignedRows pins the anchor's own rule, which until now lived only in a
// comment. The neighbours are what the CANON block shows a paid role, so an unsigned row leaking in
// changes the bytes that reach the model: the run would be told the book has already decided a term it
// has only proposed. Both halves of the filter matter — a signed row with no rendering anchors nothing.
func TestApprovedNeighboursAdmitsOnlySignedRows(t *testing.T) {
rows := []store.GlossaryEntry{
{Src: "方源", Dst: "Фан Юань", Status: "approved"},
{Src: "花家", Dst: "клан Хуа", Status: "draft"},
{Src: "青茅山", Dst: "гора Цинмао", Status: "auto"},
{Src: "元始", Dst: "", Status: "approved"}, // signed but unrendered: nothing to agree with
}
got := approvedNeighbours(rows)
if len(got) != 1 || got[0].Src != "方源" || got[0].Dst != "Фан Юань" {
t.Fatalf("only a signed row WITH a rendering may anchor the role: %+v", got)
}
// CONTROL: the same four rows all signed and rendered give four, so the one above is the filter
// answering and not a fixture the function cannot read.
all := make([]store.GlossaryEntry, len(rows))
for i, e := range rows {
e.Status, e.Dst = "approved", "д"
all[i] = e
}
if n := len(approvedNeighbours(all)); n != len(rows) {
t.Fatalf("control: every signed rendered row must anchor, want %d got %d", len(rows), n)
}
}