587 lines
29 KiB
Go
587 lines
29 KiB
Go
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{"元海": "море истинной ци"}, terminologyResult{})
|
||
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)
|
||
}
|
||
}
|