233 lines
7.6 KiB
Go
233 lines
7.6 KiB
Go
package membank
|
||
|
||
// labelharness_test.go: the K6 (glossary post-check) half of the durable labels re-measurement — the
|
||
// companion to internal/checks/labelharness_test.go. It lives here because dstFormPresent / entry are
|
||
// unexported. Env-gated exactly the same way (TM_CHECKER_LABELS=1, corpus OUT of git).
|
||
//
|
||
// For every (unit, glossary term) row of the package-6 pool it asks the REAL dstFormPresent whether an
|
||
// accepted dst form appears in the unit's final, and joins that to the human label (defect = a real miss,
|
||
// ok = the form is present). predicted_defect = NOT dstFormPresent. The package-6 baseline (metrics.json
|
||
// k6) predates the bank-quality stemmer (D39.75), so the harness measures BOTH the pre-stemmer path (inert
|
||
// stemmer == the ratified baseline) and the current path (stemmer on) — the delta is the stemmer / #10 work.
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/lang"
|
||
"textmachine/backend/internal/text"
|
||
)
|
||
|
||
func envOrMem(key, def string) string {
|
||
if v := os.Getenv(key); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
var memLabelsDir = envOrMem("TM_CHECKER_LABELS_DIR", "/home/ubuntu/books/gu-zhenren/labels")
|
||
|
||
type k6Pool struct {
|
||
ID string `json:"id"`
|
||
Run string `json:"run"`
|
||
Chapter int `json:"chapter"`
|
||
Chunk int `json:"chunk_idx"`
|
||
Src string `json:"src"`
|
||
Dst string `json:"dst"`
|
||
Forms []string `json:"forms"`
|
||
Status string `json:"status"`
|
||
Type string `json:"type"`
|
||
}
|
||
|
||
type k6Label struct {
|
||
ID string `json:"id"`
|
||
Label string `json:"label"`
|
||
}
|
||
|
||
type memUnit struct {
|
||
Run string `json:"run"`
|
||
Chapter int `json:"chapter"`
|
||
Chunk int `json:"chunk_idx"`
|
||
Final string `json:"final"`
|
||
}
|
||
|
||
// stripManifestHeadingMem mirrors the checks harness: the post-check also sees final BEFORE ApplyHeading.
|
||
func stripManifestHeadingMem(final string) string {
|
||
lines := strings.Split(final, "\n")
|
||
if len(lines) == 0 {
|
||
return final
|
||
}
|
||
rest, ok := strings.CutPrefix(strings.TrimSpace(lines[0]), "Глава ")
|
||
if !ok {
|
||
return final
|
||
}
|
||
for _, r := range rest {
|
||
if r < '0' || r > '9' {
|
||
return final
|
||
}
|
||
}
|
||
i := 1
|
||
for i < len(lines) && strings.TrimSpace(lines[i]) == "" {
|
||
i++
|
||
}
|
||
return strings.Join(lines[i:], "\n")
|
||
}
|
||
|
||
// k6metric is the confusion count with the same defect>disputable>ok convention (here labels are per-row).
|
||
type k6metric struct{ TP, FP, FN, TN, Disp int }
|
||
|
||
func (m *k6metric) add(pred, defect bool) {
|
||
switch {
|
||
case pred && defect:
|
||
m.TP++
|
||
case pred && !defect:
|
||
m.FP++
|
||
case !pred && defect:
|
||
m.FN++
|
||
default:
|
||
m.TN++
|
||
}
|
||
}
|
||
|
||
// measureK6 runs dstFormPresent over every pool row against its unit's final and joins to the labels.
|
||
// When demoteSingleHan is set it models the #10 fix: a single-Han-rune key miss is demoted to
|
||
// observability (not a predicted CONFIRMED defect), exactly as production Postcheck now routes it.
|
||
func measureK6(t *testing.T, stemmer lang.TargetStemmer, demoteSingleHan bool) (k6metric, []string) {
|
||
t.Helper()
|
||
// corpus finals keyed by unit id.
|
||
finals := map[string]string{}
|
||
cb, err := os.ReadFile(filepath.Join(memLabelsDir, "raw", "corpus.jsonl"))
|
||
if err != nil {
|
||
t.Fatalf("read corpus: %v", err)
|
||
}
|
||
for _, line := range strings.Split(strings.TrimSpace(string(cb)), "\n") {
|
||
if strings.TrimSpace(line) == "" {
|
||
continue
|
||
}
|
||
var u memUnit
|
||
if err := json.Unmarshal([]byte(line), &u); err != nil {
|
||
t.Fatalf("parse corpus: %v", err)
|
||
}
|
||
finals[fmt.Sprintf("%s:%d:%d", u.Run, u.Chapter, u.Chunk)] = u.Final
|
||
}
|
||
// labels.
|
||
labels := map[string]string{}
|
||
lb, err := os.ReadFile(filepath.Join(memLabelsDir, "labels", "k6.jsonl"))
|
||
if err != nil {
|
||
t.Fatalf("read k6 labels: %v", err)
|
||
}
|
||
for _, line := range strings.Split(strings.TrimSpace(string(lb)), "\n") {
|
||
if strings.TrimSpace(line) == "" {
|
||
continue
|
||
}
|
||
var l k6Label
|
||
if err := json.Unmarshal([]byte(line), &l); err != nil {
|
||
t.Fatalf("parse k6 label: %v", err)
|
||
}
|
||
labels[l.ID] = l.Label
|
||
}
|
||
// pool → dstFormPresent per row, joined to labels.
|
||
pb, err := os.ReadFile(filepath.Join(memLabelsDir, "pools", "k6_glossary.jsonl"))
|
||
if err != nil {
|
||
t.Fatalf("read k6 pool: %v", err)
|
||
}
|
||
var m k6metric
|
||
var flaggedOK []string // fp ids (flagged as miss but label=ok) — the #10 / inflection-gap surface
|
||
for _, line := range strings.Split(strings.TrimSpace(string(pb)), "\n") {
|
||
if strings.TrimSpace(line) == "" {
|
||
continue
|
||
}
|
||
var p k6Pool
|
||
if err := json.Unmarshal([]byte(line), &p); err != nil {
|
||
t.Fatalf("parse k6 pool: %v", err)
|
||
}
|
||
lab, ok := labels[p.ID]
|
||
if !ok {
|
||
continue // pool wider than the labelled sample
|
||
}
|
||
final := stripManifestHeadingMem(finals[fmt.Sprintf("%s:%d:%d", p.Run, p.Chapter, p.Chunk)])
|
||
nout := text.NormalizeTargetForm(final)
|
||
noutRunes := []rune(nout)
|
||
outWords := lang.TokenizeWords(nout)
|
||
var forms []string
|
||
for _, f := range p.Forms {
|
||
if nf := text.NormalizeTargetForm(f); nf != "" {
|
||
forms = append(forms, nf)
|
||
}
|
||
}
|
||
e := &entry{src: p.Src, dst: p.Dst, declForms: forms}
|
||
present := dstFormPresent(e, noutRunes, outWords, stemmer)
|
||
// The firing key for a pool row is its src (the term whose key matched). Model the #10 demote.
|
||
predDefect := !present && !(demoteSingleHan && singleHanKeyFired(p.Src))
|
||
switch lab {
|
||
case "disputable":
|
||
m.Disp++
|
||
case "defect":
|
||
m.add(predDefect, true)
|
||
default:
|
||
if predDefect {
|
||
flaggedOK = append(flaggedOK, p.ID)
|
||
}
|
||
m.add(predDefect, false)
|
||
}
|
||
}
|
||
return m, flaggedOK
|
||
}
|
||
|
||
func TestK6LabelsBaseline(t *testing.T) {
|
||
if _, err := os.Stat(filepath.Join(memLabelsDir, "raw", "corpus.jsonl")); err != nil {
|
||
if os.Getenv("TM_CHECKER_LABELS") == "1" {
|
||
t.Fatalf("TM_CHECKER_LABELS=1 but corpus is missing: %v", err)
|
||
}
|
||
t.Skipf("labelled corpus absent (%v) — set TM_CHECKER_LABELS=1 to force", err)
|
||
}
|
||
|
||
// Pre-stemmer, pre-#10 path = the ratified package-6 baseline (metrics.json k6).
|
||
inert := lang.TargetStemmer{}
|
||
base, baseFP := measureK6(t, inert, false)
|
||
t.Logf("K6 BASELINE (pre-stemmer, pre-#10 = metrics.json): tp=%d fp=%d fn=%d tn=%d disp=%d", base.TP, base.FP, base.FN, base.TN, base.Disp)
|
||
t.Logf("K6 baseline fp ids: %v", baseFP)
|
||
if (base != k6metric{TP: 1, FP: 14, FN: 0, TN: 243, Disp: 2}) {
|
||
t.Errorf("k6 baseline drift: got %+v want tp1 fp14 fn0 tn243 disp2", base)
|
||
}
|
||
|
||
// Stemmer on (D39.75), no #10 — the intermediate landed state. Delta vs baseline = inflection-gap fps
|
||
// the stemmer now accepts (凡人→смертных, 甲等→разряд А).
|
||
stem := lang.NewTargetStemmer(lang.TargetChecksFor("ru"))
|
||
mid, midFP := measureK6(t, stem, false)
|
||
t.Logf("K6 stemmer-on, pre-#10: fp=%d (down from 14 — %v) precision=%.4f", mid.FP, diff(baseFP, midFP), prec(mid))
|
||
|
||
// Stemmer on + #10 demote — THIS pack's after-state. The 6 single-Han-rune (转) misses demote to
|
||
// observability, leaving the 元石/蛊虫 inflection residual (a SEED-completeness gap, not a code one).
|
||
after, afterFP := measureK6(t, stem, true)
|
||
t.Logf("K6 AFTER (stemmer + #10): tp=%d fp=%d fn=%d tn=%d disp=%d precision=%.4f", after.TP, after.FP, after.FN, after.TN, after.Disp, prec(after))
|
||
t.Logf("K6 after fp ids (residual = 元石/蛊虫 inflection-gap, seed concern): %v", afterFP)
|
||
if (after != k6metric{TP: 1, FP: 6, FN: 0, TN: 251, Disp: 2}) {
|
||
t.Errorf("k6 after-#10 drift: got %+v want tp1 fp6 fn0 tn251 disp2", after)
|
||
}
|
||
}
|
||
|
||
func prec(m k6metric) float64 {
|
||
if m.TP+m.FP == 0 {
|
||
return 0
|
||
}
|
||
return float64(m.TP) / float64(m.TP+m.FP)
|
||
}
|
||
|
||
// diff returns ids in a not in b (the fps removed by a change).
|
||
func diff(a, b []string) []string {
|
||
in := map[string]bool{}
|
||
for _, s := range b {
|
||
in[s] = true
|
||
}
|
||
var out []string
|
||
for _, s := range a {
|
||
if !in[s] {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|