textmachine/backend/internal/pipeline/minerseed_contract_test.go

129 lines
6 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 (
"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)
}
}
}