package checks // labelharness_test.go: the DURABLE re-measurement of the deterministic checkers against the // polygon-6 labelled corpus (/books/gu-zhenren/labels/, package-6 baseline metrics.json, D39.39). // The previous harness lived in a scratch module and was lost ("персист, не scratch"); this is its // in-git replacement. It is env-gated exactly like the miner-parity test: the labelled corpus carries // book text and is OUT of git (CLAUDE.md), so the test SKIPS when the corpus is absent (CI / fresh // checkout) and runs on the stand, or when TM_CHECKER_LABELS=1 forces it (then a missing path fails loud). // // It calls the REAL internal/checks functions over the real (source, draft, final) of every unit and // reports precision/recall per defect class, joining the checker verdict to the human labels by the exact // convention package-6 ratified (reverse-engineered from the stored verdicts and locked against // metrics.json): unit-level classes (K1/K5a/K5b/K5c) aggregate occurrence labels to their unit with the // priority defect>disputable>ok and fire on the sub-counter; K2 is per-token over the labelled field; K3 // is per-word-type; K4b is per chevron line, measured as the per-line PREDICATE chevronSpeechShape && // isSpokenChevronLine (spoken=defect, ambiguous=disputable) — NOT the unit em-dash-MIXING count; the two // coincide on this corpus (0 divergence) but the predicate is what tech-debt anchor #3 concerns; k4_inverse // is per em-dash line (thought=defect) via the attribution inner-marker rule (the D39.39 build — no longer // "always silent"). // // All 12 metrics.json keys are reproduced (11 here + K6 in the membank harness). k4a (n=348, all-TN — every // dialogue line across the k4.jsonl buckets carries no straight-quote/hyphen MARKER defect) is measured // MARKER-ONLY (measureK4a), deliberately NOT via the folded DialogueDash counter: the k4_inverse build folds // inverseMarker into that counter, so a raw-count k4a would misread the 3 inverse fires as marker defects. The // marker-only measure — a leading « " » / « - » / « – » defect, never the correct em-dash — keeps k4a locked // at 0/0/0/348 regardless of the fold (verified against metrics.json). // // $0, pure, deterministic. The K6 glossary post-check has its own harness in internal/membank // (labelharness_test.go there) because dstFormPresent is unexported. import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "testing" "textmachine/backend/internal/lang" "textmachine/backend/internal/standdata" ) var ( // The labelled corpus lives in the stand, which since 24.08 is /books under its own git // repository (D39.157 п.2) — resolved by the repository MARKER, never from $HOME, whose value says // nothing about where this clone is. TM_CHECKER_LABELS_DIR stays senior to the default. labelsDir = standdata.EnvOr("TM_CHECKER_LABELS_DIR", standdata.StandFile("gu-zhenren", "labels")) langpackRoot = standdata.EnvOr("TM_CHECKER_LANGPACK_ROOT", "../../configs/langpacks") ) // --- corpus / label record shapes ------------------------------------------------- type corpusUnit struct { Run string `json:"run"` Chapter int `json:"chapter"` ChunkIdx int `json:"chunk_idx"` Source string `json:"source"` Draft string `json:"draft"` Final string `json:"final"` } func (u corpusUnit) id() string { return fmt.Sprintf("%s:%d:%d", u.Run, u.Chapter, u.ChunkIdx) } // unitOf strips a label id down to run:chapter:chunk_idx. func unitOf(labelID string) string { p := strings.Split(labelID, ":") if len(p) < 3 { return labelID } return strings.Join(p[:3], ":") } type labelRow struct { ID string `json:"id"` Label string `json:"label"` LabelCC string `json:"label_code_convention"` Token string `json:"token"` Field string `json:"field"` Word string `json:"word"` Bucket string `json:"bucket"` Speech string `json:"speech"` SrcField string `json:"src_field"` } type metric struct{ TP, FP, FN, TN, Disp int } func (m metric) n() int { return m.TP + m.FP + m.FN + m.TN } func (m *metric) add(pred, defect bool) { switch { case pred && defect: m.TP++ case pred && !defect: m.FP++ case !pred && defect: m.FN++ default: m.TN++ } } func (m metric) precision() string { if m.TP+m.FP == 0 { return "n/a" } return fmt.Sprintf("%.4f", float64(m.TP)/float64(m.TP+m.FP)) } func (m metric) recall() string { if m.TP+m.FN == 0 { return "n/a" } return fmt.Sprintf("%.4f", float64(m.TP)/float64(m.TP+m.FN)) } func loadJSONL(t *testing.T, path string) []labelRow { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } var out []labelRow for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { if strings.TrimSpace(line) == "" { continue } var r labelRow if err := json.Unmarshal([]byte(line), &r); err != nil { t.Fatalf("parse %s: %v (%q)", path, err, line) } out = append(out, r) } return out } // buildCheckers compiles the real zh-ru checker spec exactly as the runner does. func buildCheckers(t *testing.T) *Checkers { t.Helper() pack, err := lang.Load(langpackRoot, "zh", "ru") if err != nil { t.Fatalf("load langpack: %v", err) } c := CompileCheckersFor(pack, lang.TargetChecksFor("ru")) c.SetSourceScripts(lang.LangScripts("zh")) return c } func loadCorpus(t *testing.T) map[string]corpusUnit { t.Helper() b, err := os.ReadFile(filepath.Join(labelsDir, "raw", "corpus.jsonl")) if err != nil { t.Fatalf("read corpus: %v", err) } units := map[string]corpusUnit{} for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { if strings.TrimSpace(line) == "" { continue } var u corpusUnit if err := json.Unmarshal([]byte(line), &u); err != nil { t.Fatalf("parse corpus: %v", err) } units[u.id()] = u } return units } // unitFlag maps every unit to whether a per-unit checker sub-counter fired, given a picker. func unitFlags(units map[string]corpusUnit, c *Checkers, pick func(CheapGateResult) int, regression bool) map[string]bool { out := make(map[string]bool, len(units)) cfg := CheapGateConfig{Checkers: c, RegressionEnabled: regression} for id, u := range units { // Production runs the cheap gates on finalText BEFORE chunk.ApplyHeading prepends the // deterministic «Глава N» manifest heading (waverun.go:547 / export.go:215 run AFTER // RunCheapGates on waverun.go:501). The corpus baked that heading in, so strip it back off — // otherwise the chapter number reads as an "appeared" number to the regression guard (the // README's documented trap). Old-stack acceptance headings ("Раздел N: title") are baked BODY // text, not the bare manifest heading, so they are left in place — exactly the package-6 baseline. r := RunCheapGates(u.Source, u.Draft, stripManifestHeading(u.Final), cfg) out[id] = pick(r) > 0 } return out } // stripManifestHeading removes a leading bare «Глава N» line (+ the following blank line) — the // deterministic manifest heading current code applies after the gates. A titled heading // («Глава N: …» / «Раздел N: …») is left untouched (old-stack baked body text). func stripManifestHeading(final string) string { lines := strings.Split(final, "\n") if len(lines) == 0 { return final } l0 := strings.TrimSpace(lines[0]) rest, ok := strings.CutPrefix(l0, "Глава ") if !ok { return final } for _, r := range rest { if r < '0' || r > '9' { return final // has a title after the number → not the bare manifest heading } } i := 1 for i < len(lines) && strings.TrimSpace(lines[i]) == "" { i++ } return strings.Join(lines[i:], "\n") } // unitLevelMetric aggregates occurrence labels to their unit (priority defect>disputable>ok) and joins to // the unit checker flag. labelPick selects the label field (label / label_code_convention). func unitLevelMetric(labels []labelRow, flags map[string]bool, labelPick func(labelRow) string) metric { type acc struct{ hasDefect, hasDisp bool } byUnit := map[string]*acc{} for _, l := range labels { u := unitOf(l.ID) a := byUnit[u] if a == nil { a = &acc{} byUnit[u] = a } switch labelPick(l) { case "defect": a.hasDefect = true case "disputable": a.hasDisp = true } } var m metric for u, a := range byUnit { switch { case a.hasDefect: m.add(flags[u], true) case a.hasDisp: m.Disp++ default: m.add(flags[u], false) } } return m } func TestCheckerLabelsBaseline(t *testing.T) { if _, err := os.Stat(filepath.Join(labelsDir, "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) } c := buildCheckers(t) units := loadCorpus(t) lbl := func(name string) []labelRow { return loadJSONL(t, filepath.Join(labelsDir, "labels", name)) } pickLabel := func(l labelRow) string { return l.Label } results := map[string]metric{} // Unit-level src↔target classes. results["K1_unit"] = unitLevelMetric(lbl("k1.jsonl"), unitFlags(units, c, func(r CheapGateResult) int { return r.DC1TimeUnits }, false), pickLabel) results["k5a_unit"] = unitLevelMetric(lbl("k5a.jsonl"), unitFlags(units, c, func(r CheapGateResult) int { return r.NumberMagnitude }, false), pickLabel) results["k5b_unit"] = unitLevelMetric(lbl("k5b.jsonl"), unitFlags(units, c, func(r CheapGateResult) int { return r.DC2Magnitude }, false), pickLabel) percentFlags := unitFlags(units, c, func(r CheapGateResult) int { return r.PercentScale }, false) results["k5c_unit"] = unitLevelMetric(lbl("k5c.jsonl"), percentFlags, pickLabel) results["k5c_unit_codeconv"] = unitLevelMetric(lbl("k5c.jsonl"), percentFlags, func(l labelRow) string { if l.LabelCC != "" { return l.LabelCC } return l.Label }) // K2 — per token over the labelled field. results["k2"] = measureK2(lbl("k2.jsonl"), units, c) // K3 — per word-type. results["k3"] = measureK3(lbl("k3.jsonl"), c) // K4b / k4_inverse — per line. results["k4b"], results["k4_inverse"] = measureK4(lbl("k4.jsonl"), units, c) // K4a — the marker-defect class over every k4.jsonl line (all-TN), measured marker-only so the inverseMarker // fold cannot leak into it. results["k4a"] = measureK4a(lbl("k4.jsonl"), units) // K5d — number drift (regression guard ON). results["k5d"] = measureK5d(lbl("k5d.jsonl"), units, c) printMetrics(t, results) writeMetricsJSON(t, results) assertBaseline(t, results) } // measureK2 runs lintLatinResidue on the labelled field's text and flags a token if its surface is a hit. func measureK2(labels []labelRow, units map[string]corpusUnit, c *Checkers) metric { var m metric for _, l := range labels { u, ok := units[unitOf(l.ID)] if !ok { continue } text := u.Final if l.Field == "draft" { text = u.Draft } _, det := lintLatinResidue(text, nil) hit := false for _, d := range det { // detail line: "Latin word left untranslated in the target output: a, b, c" if idx := strings.Index(d, ": "); idx >= 0 { for _, s := range strings.Split(d[idx+2:], ", ") { if s == l.Token { hit = true } } } } joinAdd(&m, l.Label, hit) } return m } // measureK3 runs the broken-word lint on each labelled word-type. func measureK3(labels []labelRow, c *Checkers) metric { var m metric for _, l := range labels { n, _ := c.lintBrokenWord(l.Word) joinAdd(&m, l.Label, n > 0) } return m } // measureK4 reproduces the per-line chevron-speech verdict (K4b) and the inverse dash-thought recall // (k4_inverse). The chevron flag is the per-line predicate chevronSpeechShape && isSpokenChevronLine — // the "detector" measurement, in isolation from the unit-level em-dash mixing count (tech-debt anchor #3 // is exactly about this per-line shape). k4_inverse has no rule (a thought typeset with a dash is // structurally invisible) so it is never flagged — a pure recall gap. func measureK4(labels []labelRow, units map[string]corpusUnit, c *Checkers) (metric, metric) { var k4b, k4inv metric for _, l := range labels { switch l.Bucket { case "chevron": line := strings.TrimLeft(lineForLabel(l, units), " \t ") flagged := chevronSpeechShape([]rune(line)) && c.isSpokenChevronLine(line) joinK4(&k4b, l.Speech, "spoken", flagged) case "em-dash": // k4_inverse (D39.39): a dash line whose ATTRIBUTION carries an inner-speech marker is a thought // typeset as spoken — scoped to the attribution (matches production speechAttribution), not the // whole line, so a spoken reply that merely mentions «про себя» is not flagged. line := strings.TrimLeft(lineForLabel(l, units), " \t ") rs := []rune(line) isDash := len(rs) > 0 && (rs[0] == '—' || rs[0] == '–' || rs[0] == '-') attr := speechAttribution(line) joinK4(&k4inv, l.Speech, "thought", isDash && attr != "" && c.lineHasInnerMarker(attr)) } } return k4b, k4inv } // measureK4a reproduces the metrics.json k4a class: the dialogue-dash MARKER defect (a line opened with a // straight quote or a hyphen/en-dash where the em-dash belongs) over EVERY k4.jsonl line (all buckets, n=348). // It reuses the production leading-marker predicates (quoteShape/dialogueShape) but is MARKER-ONLY: the correct // em-dash «—» is never a defect, so the k4_inverse build (inverseMarker folded into the DialogueDash counter) // cannot flip an em-dash thought line into this class. The corpus carries no such defect → 0/0/0/348. func measureK4a(labels []labelRow, units map[string]corpusUnit) metric { var m metric for _, l := range labels { rs := []rune(strings.TrimLeft(lineForLabel(l, units), " \t ")) defect := false if len(rs) > 0 { switch rs[0] { case '"': defect = quoteShape(rs[1:]) case '-', '–': defect = dialogueShape(rs[1:]) } } m.add(defect, false) // k4a labels every corpus dialogue line as non-defect for the marker class } return m } // lineForLabel extracts the labelled line from the unit by its :field:line_no id tail. func lineForLabel(l labelRow, units map[string]corpusUnit) string { p := strings.Split(l.ID, ":") if len(p) < 6 { return "" } field := p[4] var lineNo int fmt.Sscanf(p[5], "%d", &lineNo) u, ok := units[unitOf(l.ID)] if !ok { return "" } text := u.Final if field == "draft" { text = u.Draft } lines := strings.Split(text, "\n") if lineNo >= 0 && lineNo < len(lines) { return lines[lineNo] } return "" } // measureK5d runs the regression guard (draft→final number drift) per unit and joins to position labels. func measureK5d(labels []labelRow, units map[string]corpusUnit, c *Checkers) metric { flags := unitFlags(units, c, func(r CheapGateResult) int { return r.NumberDrift }, true) var m metric for _, l := range labels { joinAdd(&m, l.Label, flags[unitOf(l.ID)]) } return m } func joinAdd(m *metric, label string, pred bool) { switch label { case "disputable": m.Disp++ case "defect": m.add(pred, true) default: m.add(pred, false) } } // joinK4 uses the K4 label vocabulary: ambiguous=disputable; the defectSpeech value (spoken for K4b, thought // for the inverse) is the defect; every other resolvable speech is ok. func joinK4(m *metric, speech, defectSpeech string, pred bool) { switch speech { case "ambiguous": m.Disp++ default: m.add(pred, speech == defectSpeech) } } func printMetrics(t *testing.T, results map[string]metric) { keys := make([]string, 0, len(results)) for k := range results { keys = append(keys, k) } sort.Strings(keys) t.Logf("%-20s %4s %4s %4s %4s %4s %5s | %-8s %-8s", "class", "n", "tp", "fp", "fn", "tn", "disp", "prec", "recall") for _, k := range keys { m := results[k] t.Logf("%-20s %4d %4d %4d %4d %4d %5d | %-8s %-8s", k, m.n(), m.TP, m.FP, m.FN, m.TN, m.Disp, m.precision(), m.recall()) } } func writeMetricsJSON(t *testing.T, results map[string]metric) { out := os.Getenv("TM_CHECKER_LABELS_OUT") if out == "" { return } m := map[string]any{} for k, v := range results { m[k] = map[string]int{"n": v.n(), "tp": v.TP, "fp": v.FP, "fn": v.FN, "tn": v.TN, "disputable": v.Disp} } b, _ := json.MarshalIndent(m, "", " ") if err := os.WriteFile(out, b, 0o644); err != nil { t.Logf("write metrics out: %v", err) } } // assertBaseline pins each class to its expected value on the CURRENT code. Classes untouched by the // checker package hold their ratified package-6 metrics.json values; the two classes this package moved // (k2, k4b) pin the new measured after-values with the delta recorded below. k6 lives in the membank harness. func assertBaseline(t *testing.T, r map[string]metric) { want := map[string]metric{ "K1_unit": {TP: 6, FP: 0, FN: 0, TN: 9}, // unchanged (metrics.json) "k3": {TP: 1, FP: 0, FN: 9, TN: 2167}, // unchanged (one «-йть» pattern; combmark did not move it) "k5a_unit": {TP: 0, FP: 0, FN: 1, TN: 26, Disp: 3}, // #5 CLOSED: the 1 miss is DC2-covered (k5b) "k5b_unit": {TP: 1, FP: 0, FN: 0, TN: 7, Disp: 3}, // unchanged "k5c_unit": {TP: 1, FP: 5, FN: 1, TN: 8, Disp: 1}, // #1: PercentScale reclassified SOFT (Р2); detection unchanged "k5c_unit_codeconv": {TP: 7, FP: 0, FN: 1, TN: 8}, // unchanged (#8 两 masked by suppressor) // #6 caps-aware Latin: recall 0.75→0.85 (BANK×2, Cultivation×2 now caught). MEASURED precision 0.971: // the 1 fp is «Qidian» (a brand). 1.0 is achievable only IF the book config allowlists it — no committed // gu-zhenren config currently does, so the honest measured number is 0.971, not 1.0. "k2": {TP: 34, FP: 1, FN: 6, TN: 51}, // #3 attribution join extended to «X!»/«X?»/«X…»: recall 0.109→0.217, precision 0.833→0.909, +5 tp, // ZERO new fp. Residual 36 misses = 24 unattributed replies (no dash join this shape can see) + 12 // attributed with a verb OUTSIDE speech_verb (isSpokenChevronLine silent) — Р4 ceiling ~0.22, inline // in chevronSpeechShape. "k4b": {TP: 10, FP: 1, FN: 36, TN: 99, Disp: 4}, // k4_inverse (D39.39 build, inner_marker-veto-on-dash): dash line + inner-speech marker → thought // typeset as spoken. recall 0→0.75 (+3 tp), precision 1.0, 0 fp. The 1 residual miss uses a marker // not in the target list (recall ceiling — extend the data, not the rule). "k4_inverse": {TP: 3, FP: 0, FN: 1, TN: 71, Disp: 5}, "k5d": {TP: 0, FP: 4, FN: 0, TN: 1}, // unchanged (manifest-heading strip) // k4a: the dialogue-dash MARKER class over all 348 k4.jsonl lines, all-TN. Measured marker-only, so the // inverseMarker fold into DialogueDash cannot leak the 3 inverse fires into it (metrics.json: 0/0/0/348). "k4a": {TP: 0, FP: 0, FN: 0, TN: 348}, } for k, w := range want { got := r[k] if got != w { t.Errorf("expected drift %s: got %+v want %+v", k, got, w) } } }