textmachine/backend/internal/pipeline/miner_parity_test.go

133 lines
5 KiB
Go

package pipeline
import (
"encoding/json"
"os"
"testing"
)
// miner_parity_test.go: the WS3 (д) Go↔Python full-book parity check. It re-runs the default-B miner on
// the PINNED exp16 inputs (jieba contrast + the 25-chapter records.json + the seed GT) and asserts the
// Palladius-INVARIANT guarantees the Go port must reproduce (research/20 §D, ws3_miner_verify.py):
//
// - candidate SET count = 13618 (membership, integer-determined — Palladius-invariant);
// - recall@proposed (overall) = 0.965 (set membership — Palladius-invariant);
// - catastrophe screen: 方源/蛊/蛊师 at ranks 0/1/2 (invariant), 古月 ∈ top-50 (rank 22 in default B —
// the +56 Palladius bonus is dropped; the SCREEN still passes, exact rank is not a product value).
//
// It is DATA-GATED: the jieba artifact + book records are OUT of git (CLAUDE.md), so the test SKIPS when
// they are absent (CI / a fresh checkout) and runs on the stand (or when TM_MINER_PARITY=1 forces it,
// failing loud if the data is missing). $0, deterministic — Python is the reference; a divergence means
// the Go port is wrong (fix Go), never the reference.
// The stand-data paths default to the stand layout but are overridable via env so the test is not
// pinned to one machine's absolute paths (test hygiene): TM_MINER_PARITY_{CONTRAST,RECORDS,SEED}.
// The data is out of git (CLAUDE.md), so the test still SKIPS when a path is absent unless
// TM_MINER_PARITY=1 forces it (then a missing path fails loud).
var (
minerParityContrast = envOr("TM_MINER_PARITY_CONTRAST", "/home/ubuntu/projects/textmachine/eval/exp16/data/jieba_dict_general_zh.txt")
minerParityRecords = envOr("TM_MINER_PARITY_RECORDS", "/home/ubuntu/books/gu-zhenren/rerun/records.json")
minerParitySeed = envOr("TM_MINER_PARITY_SEED", "/home/ubuntu/books/gu-zhenren/guzhenren-seed-v2.yaml")
)
// envOr returns the environment override for key, or def when it is unset/empty.
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func TestMinerFullBookParity(t *testing.T) {
force := os.Getenv("TM_MINER_PARITY") == "1"
for _, p := range []string{minerParityContrast, minerParityRecords, minerParitySeed} {
if _, err := os.Stat(p); err != nil {
if force {
t.Fatalf("TM_MINER_PARITY=1 but required data is missing: %s", p)
}
t.Skipf("stand data absent (%s) — skipping full-book parity (set TM_MINER_PARITY=1 to force)", p)
}
}
// Contrast (jieba dict).
cf, err := os.Open(minerParityContrast)
if err != nil {
t.Fatal(err)
}
defer cf.Close()
contrast, err := LoadContrast(cf)
if err != nil {
t.Fatal(err)
}
// Chunks (records.json → normalized miner chunks).
raw, err := os.ReadFile(minerParityRecords)
if err != nil {
t.Fatal(err)
}
var recs []struct {
Chapter int `json:"chapter"`
ChunkIdx int `json:"chunk_idx"`
Source string `json:"source"`
}
if err := json.Unmarshal(raw, &recs); err != nil {
t.Fatal(err)
}
chunks := make([]MinerChunk, 0, len(recs))
for _, r := range recs {
chunks = append(chunks, MinerChunk{Chapter: r.Chapter, ChunkIdx: r.ChunkIdx, NSource: normalizeSourceKey(r.Source)})
}
// GT (the seed, parsed by the REAL loader — a divergence would fail seed-lint too).
gt, err := loadGlossarySeed(minerParitySeed)
if err != nil {
t.Fatal(err)
}
mr := mineDetect(chunks, contrast, frozenMinerConfig(), testLangPack(t))
// 1) candidate SET count.
n := len(mr.ranked)
if n != 13618 {
t.Errorf("candidate SET count = %d, want 13618 (Palladius-invariant)", n)
}
// 2) catastrophe screen.
rank := map[string]int{}
for i, c := range mr.ranked {
rank[c.Src] = i
}
catExpect := map[string]int{"方源": 0, "蛊": 1, "蛊师": 2}
for src, want := range catExpect {
if got, ok := rank[normalizeSourceKey(src)]; !ok || got != want {
t.Errorf("catastrophe rank[%s] = %d (present=%v), want %d (Palladius-invariant)", src, got, ok, want)
}
}
if got, ok := rank[normalizeSourceKey("古月")]; !ok || got >= 50 {
t.Errorf("catastrophe rank[古月] = %d (present=%v), want ∈top-50 (default B ≈22)", got, ok)
} else {
t.Logf("古月 rank = %d (default B — Palladius sub-channel dropped; reference A3 = 21)", got)
}
// 3) recall@proposed (overall): fraction of GT entities with any normalized surface in the SET.
candSet := make(map[string]bool, n)
for _, c := range mr.ranked {
candSet[c.Src] = true
}
hits := 0
for _, e := range gt {
surfaces := append([]string{e.Src}, aliasStrings(e.Aliases)...)
for _, s := range surfaces {
if nk := normalizeSourceKey(s); nk != "" && candSet[nk] {
hits++
break
}
}
}
recall := float64(hits) / float64(len(gt))
if recall < 0.960 || recall > 0.970 {
t.Errorf("recall@proposed = %.4f (%d/%d), want ≈0.965", recall, hits, len(gt))
}
t.Logf("PARITY: n=%d catastrophe{方源:%d 蛊:%d 蛊师:%d 古月:%d} recall@proposed=%.4f (%d/%d GT)",
n, rank[normalizeSourceKey("方源")], rank[normalizeSourceKey("蛊")], rank[normalizeSourceKey("蛊师")],
rank[normalizeSourceKey("古月")], recall, hits, len(gt))
}