package miner import ( "encoding/json" "os" "strings" "testing" "gopkg.in/yaml.v3" "textmachine/backend/internal/seed" "textmachine/backend/internal/standdata" "textmachine/backend/internal/text" ) // 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 three inputs are of TWO different kinds and both defaults are derived from the repository MARKER // (standdata), never hardcoded to one machine and never taken from $HOME: // - the jieba contrast artifact sits at an in-repo PATH (eval/exp16/data) but is NOT in git — it falls // under the polygon's blanket raw-data rule (eval/.gitignore:4), so no clone ever receives it; it is // regenerated from jieba 0.42.1 (SHA in docs/experiments/16-bank-mining.md:183); // - records.json and the seed are book derivatives and live in the stand corpus, which since 24.08 is // /books under its OWN git repository (D39.157 п.2) — the old $HOME/books address is dead. // // All three stay overridable: TM_MINER_PARITY_{CONTRAST,RECORDS,SEED}. The test still SKIPS when a path // is absent unless TM_MINER_PARITY=1 forces it (then a missing path fails loud). var ( minerParityContrast = standdata.EnvOr("TM_MINER_PARITY_CONTRAST", standdata.RepoFile("eval", "exp16", "data", "jieba_dict_general_zh.txt")) minerParityRecords = standdata.EnvOr("TM_MINER_PARITY_RECORDS", standdata.StandFile("gu-zhenren", "rerun", "records.json")) minerParitySeed = standdata.EnvOr("TM_MINER_PARITY_SEED", standdata.StandFile("gu-zhenren", "guzhenren-seed-v2.yaml")) ) 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([]Chunk, 0, len(recs)) for _, r := range recs { chunks = append(chunks, Chunk{Chapter: r.Chapter, ChunkIdx: r.ChunkIdx, NSource: text.NormalizeSourceKey(r.Source)}) } // GT surfaces (the seed's terms + aliases). The recall metric needs only the SURFACES, so the test // reads the seed SCHEMA directly (internal/seed) rather than the memory bank's loader: the miner must // not depend on the bank, not even in a test binary. Surfaces are trimmed exactly as that loader trims // them, so a stray space in the seed cannot silently drop a GT entity from the denominator. seedRaw, err := os.ReadFile(minerParitySeed) if err != nil { t.Fatal(err) } var sf seed.File if err := yaml.Unmarshal(seedRaw, &sf); err != nil { t.Fatal(err) } gt := sf.Terms mr := mineDetect(chunks, contrast, FrozenConfig(), 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[text.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[text.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 := []string{strings.TrimSpace(e.Src)} for _, a := range e.Aliases { surfaces = append(surfaces, strings.TrimSpace(a.Alias)) } for _, s := range surfaces { if nk := text.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[text.NormalizeSourceKey("方源")], rank[text.NormalizeSourceKey("蛊")], rank[text.NormalizeSourceKey("蛊师")], rank[text.NormalizeSourceKey("古月")], recall, hits, len(gt)) }