textmachine/backend/internal/miner/miner_parity_test.go

213 lines
8.4 KiB
Go

package miner
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"gopkg.in/yaml.v3"
"textmachine/backend/internal/seed"
"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 their defaults are derived, never hardcoded to one
// machine, so a clone at any path under any username finds whatever is actually present:
// - 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). The path is resolved
// against the repo root because the old absolute default was additionally unfindable in every clone
// not at /home/ubuntu/projects/textmachine — a portability bug ON TOP of the data gap, not instead of it;
// - records.json and the seed are book derivatives and live OUT of git in the stand (~/books, CLAUDE.md),
// so they are resolved from $HOME.
//
// 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 = envOr("TM_MINER_PARITY_CONTRAST", repoFile("eval", "exp16", "data", "jieba_dict_general_zh.txt"))
minerParityRecords = envOr("TM_MINER_PARITY_RECORDS", standFile("gu-zhenren", "rerun", "records.json"))
minerParitySeed = envOr("TM_MINER_PARITY_SEED", standFile("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
}
// repoRoot finds the repository root by MARKER (backend/go.mod) rather than by counting "..", so the
// derivation cannot quietly go wrong. It starts from this file's own directory, and falls back to the
// working directory when that path is not absolute — which is what -trimpath does: it rewrites
// runtime.Caller to a module-relative path, and a naive "../../.." then yields a RELATIVE root that can
// only ever miss, turning the parity test into a permanent silent skip on every machine including the
// stand. `go test` always runs in the package directory, so the fallback is sound.
func repoRoot() string {
start := ""
if _, self, _, ok := runtime.Caller(0); ok && filepath.IsAbs(self) {
start = filepath.Dir(self)
}
if start == "" {
wd, err := os.Getwd()
if err != nil {
return ""
}
start = wd
}
for dir := start; ; {
if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
// repoFile resolves parts against the repository root so an in-repo path is found wherever the clone
// lives. When the root cannot be established the returned path is deliberately un-plausible: a missing
// marker must read as "my derivation is broken", never as "the data is simply absent".
func repoFile(parts ...string) string {
root := repoRoot()
if root == "" {
return filepath.Join("<repo-root-not-found>", filepath.Join(parts...))
}
return filepath.Join(append([]string{root}, parts...)...)
}
// standFile resolves parts against the out-of-git stand root ($HOME/books). Deriving the root from $HOME
// instead of a literal /home/ubuntu keeps the default correct for any user. An unresolvable HOME yields a
// deliberately un-plausible path for the same reason as repoFile: "I could not find your home directory"
// must not be reported as "the stand data is absent".
func standFile(parts ...string) string {
home, err := os.UserHomeDir()
if err != nil {
return filepath.Join("<home-not-found>", "books", filepath.Join(parts...))
}
return filepath.Join(append([]string{home, "books"}, parts...)...)
}
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))
}