textmachine/backend/internal/miner/miner_parity_test.go

149 lines
6 KiB
Go

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