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)) }