textmachine/backend/internal/pipeline/memseed_test.go

395 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
import (
"os"
"path/filepath"
"testing"
"textmachine/backend/internal/store"
)
func writeSeed(t *testing.T, content string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "glossary.yaml")
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func TestLoadGlossarySeed(t *testing.T) {
seed := writeSeed(t, `
terms:
- src: 阿Q
dst: А-кью
type: name
decl: { invariant: true, forms: ["А-кью"] }
aliases:
- { alias: 阿桂, type: прозвище }
- src: 王胡
dst: Бородатый Ван
status: draft
decl: { forms: ["Бородатый Ван", "Бородатого Вана"] }
`)
entries, err := loadGlossarySeed(seed)
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 {
t.Fatalf("want 2 entries, got %d", len(entries))
}
// Empty status defaults to approved (a manual seed is curated).
if entries[0].Status != "approved" {
t.Errorf("default status = %q, want approved", entries[0].Status)
}
if entries[0].Source != "seed" {
t.Errorf("source = %q, want seed", entries[0].Source)
}
if len(entries[0].Aliases) != 1 || entries[0].Aliases[0].Alias != "阿桂" {
t.Errorf("alias not parsed: %+v", entries[0].Aliases)
}
if entries[0].Decl == "" {
t.Error("decl JSON not marshaled")
}
if entries[1].Status != "draft" {
t.Errorf("explicit status not honored: %q", entries[1].Status)
}
}
func TestLoadGlossarySeedRejectsBad(t *testing.T) {
// Missing src → fail loud (a silently-dropped term is the A-class hole).
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - dst: без ключа\n")); err == nil {
t.Error("expected error for a term without src")
}
// Unknown status → fail loud.
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - src: 甲\n status: bogus\n")); err == nil {
t.Error("expected error for an unknown status")
}
}
// TestSeedAliasDedupAndDuplicateTerm covers self-review #5: a duplicate alias within a
// term must be deduped (not crash the run on UNIQUE), and a duplicate (src,sense,window)
// term must fail loud (not crash mid-run).
func TestSeedAliasDedupAndDuplicateTerm(t *testing.T) {
// Duplicate alias surface (annotated with two types) → deduped, no error.
entries, err := loadGlossarySeed(writeSeed(t, `
terms:
- src: 四叔
dst: Четвёртый дядюшка
aliases:
- { alias: 鲁四老爷, type: имя }
- { alias: 鲁四老爷, type: титул }
`))
if err != nil {
t.Fatalf("duplicate alias should be deduped, not error: %v", err)
}
if len(entries) != 1 || len(entries[0].Aliases) != 1 {
t.Fatalf("alias not deduped: %+v", entries)
}
// Duplicate (src, sense, window) → fail loud.
if _, err := loadGlossarySeed(writeSeed(t, `
terms:
- { src: 道, dst: путь }
- { src: 道, dst: дао }
`)); err == nil {
t.Error("duplicate (src,sense,window) term must fail loud")
}
// Same src, different SENSE on a SINGLE-CHAR Han src (道): the src is A3-banned
// (significantLen 1 < min-key 2), so it never enters the automaton → its polysemy is
// inert and cannot livelock → still allowed (D16.1 fail-loud is scoped to a matchable src).
if _, err := loadGlossarySeed(writeSeed(t, `
terms:
- { src: 道, dst: путь, sense: way }
- { src: 道, dst: дао, sense: dao }
`)); err != nil {
t.Errorf("polysemy on a single-key-banned src is inert and must stay allowed: %v", err)
}
}
// TestSeedPolysemyOverlapFailsLoud covers D16.1: two approved rows with the same MATCHABLE
// src, DIFFERENT senses, DIFFERENT dst and OVERLAPPING spoiler windows both inject as
// CONFIRMED (the matcher keys only on src) and the post-check is guaranteed to miss one —
// with the gate ON a deterministic chunk livelock. Fail loud. Reverting the different-sense
// branch in loadGlossarySeed passes the contradiction through and fails here.
func TestSeedPolysemyOverlapFailsLoud(t *testing.T) {
// Matchable src (神通, 2 Han ≥ min-key), different senses, default (overlapping) windows,
// different dst → livelock → fail loud.
if _, err := loadGlossarySeed(writeSeed(t, `
terms:
- { src: 神通, dst: сила, sense: power }
- { src: 神通, dst: чудо, sense: miracle }
`)); err == nil {
t.Error("D16.1: polysemy on a matchable src with overlapping windows must fail loud")
}
// Same src+senses but NON-overlapping windows is a legitimate spoiler-boundary rendering
// change (only one sense can fire per chapter) → allowed.
if _, err := loadGlossarySeed(writeSeed(t, `
terms:
- { src: 神通, dst: сила, sense: power, until_ch: 5 }
- { src: 神通, dst: чудо, sense: miracle, since_ch: 6 }
`)); err != nil {
t.Errorf("polysemy with non-overlapping windows should be allowed: %v", err)
}
// Same MATCHABLE src+sense with the SAME dst and overlapping windows → NOT a contradiction
// (both render identically), allowed.
if _, err := loadGlossarySeed(writeSeed(t, `
terms:
- { src: 神通, dst: сила, sense: power, until_ch: 5 }
- { src: 神通, dst: сила, sense: might, since_ch: 3 }
`)); err != nil {
t.Errorf("same-dst overlap is not a contradiction: %v", err)
}
}
// TestSeedApprovedEmptyDstFailsLoud covers external-review minor #5.
func TestSeedApprovedEmptyDstFailsLoud(t *testing.T) {
for _, status := range []string{"approved", "draft"} {
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - { src: 甲, status: "+status+" }\n")); err == nil {
t.Errorf("a %s term with an empty dst must fail loud (silently inert otherwise)", status)
}
}
// Only status=auto (a raw candidate — e.g. a ruby reading) may have an empty dst.
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - { src: 甲, status: auto }\n")); err != nil {
t.Errorf("an auto candidate may have an empty dst: %v", err)
}
}
// TestSeedOverlappingSpoilerWindowsFailLoud covers external-review minor #4.
func TestSeedOverlappingSpoilerWindowsFailLoud(t *testing.T) {
// Same src+sense, overlapping windows [0,5] and [3,10], DIFFERENT dst → contradiction.
if _, err := loadGlossarySeed(writeSeed(t, `
terms:
- { src: 影卫, dst: страж, until_ch: 5 }
- { src: 影卫, dst: тень, since_ch: 3, until_ch: 10 }
`)); err == nil {
t.Error("overlapping spoiler windows with different dst must fail loud")
}
// Non-overlapping windows (a rendering that changes after a spoiler boundary) → allowed.
if _, err := loadGlossarySeed(writeSeed(t, `
terms:
- { src: 影卫, dst: страж, until_ch: 5 }
- { src: 影卫, dst: предатель, since_ch: 6 }
`)); err != nil {
t.Errorf("non-overlapping windows should be allowed: %v", err)
}
}
func TestClassifyRubyReading(t *testing.T) {
cases := []struct {
base, reading, want string
}{
{"鈴木", "すずき", rubyClassName}, // all-Han base + all-kana reading → name candidate
{"強敵", "とも", rubyClassName}, // SAME shape (a double-reading!) — deliberately NOT distinguishable offline → still a CANDIDATE, never auto-locked
{"強敵", "きょうてき", rubyClassName}, // real reading, same class
{"泣蟲", "泣き虫", rubyClassGloss}, // reading carries kanji → a semantic gloss/double-reading → footnote, not a lock
{"ゲーム", "gēmu", rubyClassAmbiguous}, // base is kana → not a plain furigana-name
}
for _, c := range cases {
if got := classifyRubyReading(c.base, c.reading); got != c.want {
t.Errorf("classifyRubyReading(%q,%q) = %q; want %q", c.base, c.reading, got, c.want)
}
}
}
func TestRubyToCandidates(t *testing.T) {
readings := []store.RubyReading{
{BookID: "b", Base: "鈴木", Reading: "すずき", FirstChapter: 2, Occurrences: 9},
{BookID: "b", Base: "鈴木", Reading: "スズキ", FirstChapter: 5, Occurrences: 3}, // variant → deduped by base
{BookID: "b", Base: "田中", Reading: "たなか", FirstChapter: 1, Occurrences: 4},
{BookID: "b", Base: "図書館", Reading: "としょかん", FirstChapter: 1, Occurrences: 20},
}
// 図書館 is owned by the manual seed → its ruby candidate is skipped.
manual := map[string]bool{"図書館": true}
cands := rubyToCandidates(readings, manual)
if len(cands) != 2 {
t.Fatalf("want 2 candidates (鈴木, 田中; 図書館 skipped), got %d: %+v", len(cands), cands)
}
byBase := map[string]store.GlossaryEntry{}
for _, c := range cands {
byBase[c.Src] = c
if c.Status != "auto" || c.Source != "ruby" || c.Dst != "" {
t.Errorf("ruby candidate must be auto/ruby with no dst: %+v", c)
}
}
// 鈴木 deduped to the DOMINANT reading (すずき, 9 occ > スズキ, 3) with the earliest chapter.
suzuki := byBase["鈴木"]
if suzuki.RubyReading != "すずき" {
t.Errorf("dominant reading = %q, want すずき", suzuki.RubyReading)
}
if suzuki.SinceCh != 2 {
t.Errorf("since_ch = %d, want the earliest (2)", suzuki.SinceCh)
}
if suzuki.Confidence != 9 {
t.Errorf("confidence = %d, want 9 (occurrences of the dominant reading)", suzuki.Confidence)
}
if suzuki.RubyClass != rubyClassName {
t.Errorf("ruby_class = %q, want %q", suzuki.RubyClass, rubyClassName)
}
if _, skipped := byBase["図書館"]; skipped {
t.Error("図書館 should have been skipped (owned by the manual seed)")
}
}
// TestApprovedSharedKeyCollisions covers the Task-6 re-audit finding: a firing key shared by
// two DIFFERENT approved terms with different dst + overlapping windows is the alias
// generalization of the D16.1 polysemy livelock. Reverting approvedSharedKeyCollisions (or its
// seedGlossary wiring) admits the contradictory injection. Legitimate cases (same dst,
// non-overlapping windows) and the same-src case (D16.1's job) must NOT be flagged here.
func TestApprovedSharedKeyCollisions(t *testing.T) {
al := func(a string) []store.GlossaryAlias {
return []store.GlossaryAlias{{Alias: a, AliasType: "прозвище"}}
}
// Shared alias 老赵 across two different approved terms, different dst, default (overlapping)
// windows → collision (livelock).
e1 := store.GlossaryEntry{Src: "赵甲", Dst: "Чжао Цзя", Status: "approved", Aliases: al("老赵")}
e2 := store.GlossaryEntry{Src: "赵乙", Dst: "Чжао И", Status: "approved", Aliases: al("老赵")}
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1, e2}); len(cols) == 0 {
t.Error("shared alias 老赵 across different terms must be flagged (livelock)")
}
// Same dst → a legitimate merge (both render identically), no contradiction.
e2same := e2
e2same.Dst = "Чжао Цзя"
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1, e2same}); len(cols) != 0 {
t.Errorf("same-dst shared surface is a merge, not a collision: %v", cols)
}
// Non-overlapping windows → a spoiler-boundary handoff (only one fires per chapter) → allowed.
e1w, e2w := e1, e2
e1w.UntilCh, e2w.SinceCh = 5, 6
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{e1w, e2w}); len(cols) != 0 {
t.Errorf("non-overlapping windows are allowed: %v", cols)
}
// Self-review #1: a SUB-FLOOR src (道, single-key-banned) that shares an ELIGIBLE alias
// (大道) between different-dst rows slips loadGlossarySeed's src-scoped D16.1 check, so this
// guard must catch it via the shared alias key (livelock).
s1 := store.GlossaryEntry{Src: "道", Dst: "путь", Sense: "way", Status: "approved", Aliases: al("大道")}
s2 := store.GlossaryEntry{Src: "道", Dst: "дао", Sense: "dao", Status: "approved", Aliases: al("大道")}
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{s1, s2}); len(cols) == 0 {
t.Error("#1: sub-floor src sharing an eligible alias with a different dst must be flagged")
}
// The same sub-floor src with DISTINCT aliases is fine (道 does not fire, no shared key).
d1 := store.GlossaryEntry{Src: "道", Dst: "путь", Sense: "way", Status: "approved", Aliases: al("正道")}
d2 := store.GlossaryEntry{Src: "道", Dst: "дао", Sense: "dao", Status: "approved", Aliases: al("大道")}
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{d1, d2}); len(cols) != 0 {
t.Errorf("sub-floor src with distinct aliases has no shared firing key: %v", cols)
}
// A same-src pair with an ELIGIBLE shared src key IS a real collision in isolation (in the
// live flow loadGlossarySeed's D16.1 check fails loud first, so no double report).
p1 := store.GlossaryEntry{Src: "神通", Dst: "сила", Sense: "a", Status: "approved"}
p2 := store.GlossaryEntry{Src: "神通", Dst: "чудо", Sense: "b", Status: "approved"}
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{p1, p2}); len(cols) == 0 {
t.Error("an eligible shared src key is a real collision here too")
}
// An auto term sharing the key is not CONFIRMED, so no authoritative contradiction → allowed.
a1 := store.GlossaryEntry{Src: "钱甲", Dst: "Цянь А", Status: "approved", Aliases: al("老钱")}
a2 := store.GlossaryEntry{Src: "钱乙", Dst: "Цянь Б", Status: "auto", Aliases: al("老钱")}
if cols := approvedSharedKeyCollisions([]store.GlossaryEntry{a1, a2}); len(cols) != 0 {
t.Errorf("an auto term sharing a key is not a CONFIRMED contradiction: %v", cols)
}
}
// TestRubyReadingBecomesManualAlias covers D16.4: a manual term's name-shape ruby reading
// (all-Han base + all-kana reading) becomes a matchable ALIAS so the kana spelling of a
// seeded name is found. A kanji-bearing gloss/double-reading is NOT attached (footnote, not
// a surface), and a reading whose base is not a manual term is left to the auto-candidate
// path. Reverting attachRubyAliasesToManual leaves the manual entry alias-less and fails here.
func TestRubyReadingBecomesManualAlias(t *testing.T) {
entries := []store.GlossaryEntry{
{BookID: "b", Src: "鈴木", Dst: "Судзуки", Status: "approved", Source: "seed"},
{BookID: "b", Src: "泣蟲", Dst: "плакса", Status: "approved", Source: "seed"},
}
readings := []store.RubyReading{
{BookID: "b", Base: "鈴木", Reading: "すずき", FirstChapter: 1, Occurrences: 5}, // name-shape → alias
{BookID: "b", Base: "泣蟲", Reading: "泣き虫", FirstChapter: 1, Occurrences: 2}, // gloss (carries kanji) → NOT an alias
{BookID: "b", Base: "田中", Reading: "たなか", FirstChapter: 1, Occurrences: 3}, // base not manual → skipped (auto path)
}
attachRubyAliasesToManual(entries, readings)
if !hasAliasSurface(entries[0].Aliases, "すずき") {
t.Errorf("鈴木 did not gain its kana reading as an alias: %+v", entries[0].Aliases)
}
if hasAliasSurface(entries[1].Aliases, "泣き虫") {
t.Errorf("a kanji-bearing gloss reading must NOT become an alias: %+v", entries[1].Aliases)
}
// Idempotent: a second attach must not duplicate the alias.
attachRubyAliasesToManual(entries, readings)
n := 0
for _, a := range entries[0].Aliases {
if a.Alias == "すずき" {
n++
}
}
if n != 1 {
t.Errorf("ruby-alias attach not idempotent: すずき appears %d times", n)
}
// End-to-end: the kana spelling now matches through the materialized bank.
b := materializeMemory(entries, false)
if _, ok := injMap(b.Select("きのう、すずきに会った。", 1, nil, 0))["鈴木"]; !ok {
t.Error("kana spelling すずき did not match the manual 鈴木 via the reading alias")
}
}
// TestRubyHomophoneSkippedNotFailLoud covers the self-review interaction: two homophone seeded
// names (鈴木/鈴樹 both read すずき, different dst) must NOT both auto-get the kana alias — that
// would make approvedSharedKeyCollisions fail the whole book loud on an alias the operator
// cannot edit out. The colliding reading is skipped+reported (graceful), and the resulting
// entry set passes the shared-key collision check.
func TestRubyHomophoneSkippedNotFailLoud(t *testing.T) {
entries := []store.GlossaryEntry{
{BookID: "b", Src: "鈴木", Dst: "Судзуки", Status: "approved", Source: "seed"},
{BookID: "b", Src: "鈴樹", Dst: "Судзуки-другой", Status: "approved", Source: "seed"},
}
readings := []store.RubyReading{
{BookID: "b", Base: "鈴木", Reading: "すずき", FirstChapter: 1, Occurrences: 8},
{BookID: "b", Base: "鈴樹", Reading: "すずき", FirstChapter: 3, Occurrences: 4}, // homophone → collision
}
skipped := attachRubyAliasesToManual(entries, readings)
if len(skipped) != 1 {
t.Fatalf("want exactly one skipped homophone reading, got %d: %v", len(skipped), skipped)
}
// Exactly ONE term got the kana alias (the first, deterministically); the other was skipped.
got := 0
for _, e := range entries {
if hasAliasSurface(e.Aliases, "すずき") {
got++
}
}
if got != 1 {
t.Fatalf("want the kana alias on exactly one term, got %d", got)
}
// The resulting set must NOT trip the shared-key collision fail-loud (the whole point).
if cols := approvedSharedKeyCollisions(entries); len(cols) != 0 {
t.Errorf("homophone skip must avoid the fail-loud, got collisions: %v", cols)
}
}
// TestSeedSurroundingWhitespaceTrimmed covers the workflow-confirmed seed major: surrounding
// whitespace on a keying surface passed the emptiness check but was stored raw, so the term
// keyed WITH the space and could never match (silently inert), and a trailing-space sense
// disguised a same-sense contradiction. Reverting any of the three trims in loadGlossarySeed
// fails a branch here.
func TestSeedSurroundingWhitespaceTrimmed(t *testing.T) {
// (1) A leading-space src on an approved term must be trimmed, not stored raw — else the
// key " 強敵" can never match "強敵" and the approved term renders nothing for the whole book.
entries, err := loadGlossarySeed(writeSeed(t, "terms:\n - { src: \" 強敵\", dst: Соперник }\n"))
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 || entries[0].Src != "強敵" {
t.Fatalf("src surrounding whitespace not trimmed: %+v", entries)
}
// (2) A trailing-space sense must be trimmed so a same-sense contradiction (same src, same
// sense, same default window, DIFFERENT dst) is caught by the duplicate-key guard — not
// stored as two distinct-looking rows that both inject a contradictory rendering.
if _, err := loadGlossarySeed(writeSeed(t, "terms:\n - { src: 先生, dst: учитель, sense: teacher }\n - { src: 先生, dst: доктор, sense: \"teacher \" }\n")); err == nil {
t.Error("trailing-space sense must be trimmed so the duplicate (src,sense,window) fails loud")
}
// (3) An alias with surrounding whitespace must be trimmed (an alias is itself a match key).
al, err := loadGlossarySeed(writeSeed(t, "terms:\n - src: 甲\n dst: A\n aliases:\n - { alias: \" 乙 \", type: x }\n"))
if err != nil {
t.Fatal(err)
}
if len(al) != 1 || len(al[0].Aliases) != 1 || al[0].Aliases[0].Alias != "乙" {
t.Fatalf("alias surrounding whitespace not trimmed: %+v", al)
}
}