package pipeline import ( "os" "path/filepath" "strings" "testing" "textmachine/backend/internal/membank" "textmachine/backend/internal/miner" "textmachine/backend/internal/store" "textmachine/backend/internal/text" ) // minerseed_contract_test.go: the CROSS-SUBSYSTEM contract the split has to keep provable — what the // miner EMITS must be loadable by the bank's REAL loader, with zero fail-louds. It is the pre-condition // of the bank-mining reseed: the stop writes a delta the owner signs, the next run loads it through // loadGlossarySeed, and a delta that does not lint aborts a paid run instead of a $0 test. // // It lives in the composite root because that is the only package that sees BOTH sides (the subsystems // deliberately cannot import each other). Pre-split this was seedlint_test.go's // TestSeedLintEmittedMinedDelta; the seed-lint fixtures moved with the loader into membank and this one // belongs here, with them. // minerContrast is a tiny general-zh reference for the emitted-delta fixture (mirrors the miner's own // smallContrast: common chars carry high freq, the domain formant is absent). func minerContrast(t *testing.T) *miner.Contrast { t.Helper() const data = "的 100000 uj\n是 80000 v\n人 50000 n\n出 9000 v\n现 9000 v\n" + "月 4000 n\n光 3800 n\n希 900 n\n望 3000 v\n影 800 n\n在 20000 p\n此 1200 r\n" + "古 400 nr\n方 900 nr\n源 300 n\n族 500 n\n长 6000 a\n青 700 n\n茅 20 n\n山 5000 n\n" + "四 8000 m\n代 3000 n\n甲 300 n\n等 12000 u\n正 4000 a\n" c, err := miner.LoadContrast(strings.NewReader(data)) if err != nil { t.Fatalf("LoadContrast: %v", err) } return c } // writeMinedDelta writes an emitted delta to a temp seed file and returns its path. func writeMinedDelta(t *testing.T, body string) string { t.Helper() p := filepath.Join(t.TempDir(), "mined-delta.yaml") if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } return p } func TestSeedLintEmittedMinedDelta(t *testing.T) { // The emitted mined delta must be loadable by the REAL loader (0 fail-louds) — the pre-condition for // the bank-mining reseed. // The fixture must actually PROPOSE terms — the pre-split version of this test used a corpus the // detector types nothing in, so it linted an EMPTY delta and proved nothing. This corpus is the one // the miner's own reject fixture relies on (方源 + 花家 both proposed). src := text.NormalizeSourceKey(strings.Repeat("方源来到青茅山。方源很强。花家很大。花家的人。", 6)) chunks := []miner.Chunk{{Chapter: 1, ChunkIdx: 0, NSource: src}} seed := []store.GlossaryEntry{{Src: "青茅山", Dst: "гора Цинмао", Type: "place", Status: "approved", Source: "seed"}} mined := miner.MineBank(chunks, minerContrast(t), seed, nil, miner.FrozenConfig(), testLangPack(t)) if len(mined) == 0 { t.Fatal("test premise broken: the fixture must propose at least one term (else the contract is vacuous)") } yamlStr, err := miner.DeltaYAML(mined, nil) if err != nil { t.Fatal(err) } p := writeMinedDelta(t, yamlStr) if err := membank.SeedLint(p); err != nil { t.Fatalf("the emitted mined delta must lint clean (loadable), got:\n%v\n--- delta ---\n%s", err, yamlStr) } // …and the loader must actually materialize the emitted terms (a delta that lints but loads to // nothing would pass the lint above while the reseed silently gains no terms). entries, err := membank.LoadGlossarySeed(p) if err != nil { t.Fatalf("the emitted delta must load: %v", err) } if len(entries) != len(mined) { t.Errorf("loader returned %d entries for %d mined terms — the emission/loader schemas diverged", len(entries), len(mined)) } } // TestSeedLintEmittedMinedDeltaWithProposedDst extends the same contract to the WHAT-carrying form // (D39.36 fix): a delta whose terms arrived with a translator-proposed dst must ALSO load through the // real loader. The dst travels as evidence on a status:auto term, and a sign map that the loader // refuses would be worse than the bare one it replaced. func TestSeedLintEmittedMinedDeltaWithProposedDst(t *testing.T) { src := text.NormalizeSourceKey(strings.Repeat("方源来到青茅山。方源很强。花家很大。花家的人。", 6)) chunks := []miner.Chunk{{Chapter: 1, ChunkIdx: 0, NSource: src}} seedRows := []store.GlossaryEntry{{Src: "青茅山", Dst: "гора Цинмао", Type: "place", Status: "approved", Source: "seed"}} mined := miner.MineBank(chunks, minerContrast(t), seedRows, nil, miner.FrozenConfig(), testLangPack(t)) if len(mined) == 0 { t.Fatal("test premise broken: the fixture must propose at least one term") } proposals := map[string][]miner.DstProposal{} for _, m := range mined { proposals[text.NormalizeSourceKey(m.Src)] = []miner.DstProposal{ {Dst: "Предложенный вариант", Type: "name", Chunks: 2}, {Dst: "Другой вариант", Type: "name", Chunks: 1}, } } yamlStr, err := miner.DeltaYAML(mined, proposals) if err != nil { t.Fatal(err) } if !strings.Contains(yamlStr, "Предложенный вариант") { t.Fatalf("the winning proposal must reach the sign map:\n%s", yamlStr) } if !strings.Contains(yamlStr, "NOT approved") { t.Fatalf("the sign map must say the dst is a PROPOSAL, not a canon:\n%s", yamlStr) } p := writeMinedDelta(t, yamlStr) if err := membank.SeedLint(p); err != nil { t.Fatalf("a dst-carrying mined delta must lint clean, got:\n%v\n--- delta ---\n%s", err, yamlStr) } entries, err := membank.LoadGlossarySeed(p) if err != nil { t.Fatalf("the emitted delta must load: %v", err) } if len(entries) != len(mined) { t.Errorf("loader returned %d entries for %d mined terms", len(entries), len(mined)) } // The proposal must NOT arrive pre-trusted: status stays auto, so the trust gate keeps it out of // the injection until the owner signs. This is the whole safety boundary of the fix. for _, e := range entries { if e.Status != "auto" { t.Errorf("term %q loaded with status %q — a PROPOSED dst must never arrive approved", e.Src, e.Status) } } }