383 lines
17 KiB
Go
383 lines
17 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"log/slog"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/config"
|
||
"textmachine/backend/internal/miner"
|
||
"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()
|
||
if !strings.Contains(out, "budget would be exceeded") {
|
||
t.Fatalf("the fixture must actually hit the budget:\n%s", 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
|
||
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)
|
||
}
|
||
}
|