133 lines
5 KiB
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))
|
|
}
|