package membank import ( "encoding/json" "strings" "testing" "textmachine/backend/internal/lang" "textmachine/backend/internal/store" ) // memory_test.go ports the executable spec eval/memory_hotpath.py (T1–T10) to Go // unit tests, per the onboarding contract — but NOT the accept-REGEXPS verbatim // (research/14: a \b-regexp is the 18–36% false-flag source). The selection tests // (T1–T8) are the same cases; the post-check tests (T9/T10) use the decl-aware // matcher (memory_e1_test.go measures its false-flag rate on real Russian). func gl(src, dst, sense, status string) store.GlossaryEntry { return store.GlossaryEntry{BookID: "b", Src: src, Dst: dst, Sense: sense, Status: status, Source: "seed"} } func declJSON(invariant bool, forms ...string) string { b, _ := json.Marshal(declInfo{Invariant: invariant, Forms: forms}) return string(b) } func alias(a string) store.GlossaryAlias { return store.GlossaryAlias{Alias: a, AliasType: "прозвище"} } // specGlossary mirrors eval/memory_hotpath.py's G (with decl forms replacing the // toy accept-regexps). func specGlossary() []store.GlossaryEntry { ahq := gl("阿Q", "А-кью", "name", "approved") ahq.Decl = declJSON(true, "А-кью", "А кью") sishu := gl("四叔", "Четвёртый дядюшка", "char", "approved") sishu.Aliases = []store.GlossaryAlias{alias("鲁四老爷")} sishu.Decl = declJSON(false, "дядюшка", "дядюшки", "дядюшке", "дядюшку", "дядюшкой") luzhen := gl("鲁镇", "Лучжэнь", "", "approved") luzhen.Decl = declJSON(false, "Лучжэнь", "Лучжэня", "Лучжэне") songzao := gl("送灶", "проводы бога очага", "festival", "approved") songzao.Decl = declJSON(true, "бога очага") xiaoyan := gl("萧炎", "Сяо Янь", "name", "approved") xiaoyan.Aliases = []store.GlossaryAlias{alias("炎哥")} xiaoyan.Decl = declJSON(true, "Сяо Янь") xiuwei := gl("修为", "уровень совершенствования", "cultivation", "approved") xiuwei.Decl = declJSON(false, "совершенствования", "совершенствование") shadow := gl("影卫", "Тёмный страж", "spoiler", "approved") shadow.SinceCh = 200 shadow.Decl = declJSON(false, "Тёмный страж", "Тёмного стража") newname := gl("小D", "Малыш Дэ", "", "auto") newname.Decl = declJSON(false, "Малыш Дэ", "Малыша Дэ") return []store.GlossaryEntry{ahq, sishu, luzhen, songzao, xiaoyan, xiuwei, shadow, newname} } func bankFrom(entries []store.GlossaryEntry) *Bank { return Materialize(entries, false) } // injMap maps injected src → disposition for assertions. func injMap(sel Selection) map[string]InjectionDisposition { m := map[string]InjectionDisposition{} for _, p := range sel.Injected { m[p.entry.src] = p.Disp } return m } func TestHotPathSpecT1toT10(t *testing.T) { b := bankFrom(specGlossary()) const budget = 0 // T1: exact + alias select; alias 鲁四老爷 pulls 四叔. sel := b.Select("阿Q走进鲁镇,遇见鲁四老爷。", 1, nil, budget) m := injMap(sel) if m["阿Q"] != Confirmed || m["鲁镇"] != Confirmed || m["四叔"] != Confirmed { t.Errorf("T1 exact+alias: %v", m) } // T2: 送到灶 (literal) — 送灶 must NOT inject (bigram absent, 灶 single-key banned). sel = b.Select("厨房里,老妈子把饭菜送到灶间去热一热。", 1, nil, budget) if _, ok := injMap(sel)["送灶"]; ok { t.Error("T2 wrong-sense 送灶 injected (A3 homograph)") } // T3: 炎势 (flame) — 萧炎 must NOT inject. sel = b.Select("山火蔓延,炎势冲天,村民连夜逃走。", 1, nil, budget) if _, ok := injMap(sel)["萧炎"]; ok { t.Error("T3 wrong-sense 炎 injected") } // T4: 修好 (repair) — 修为 must NOT inject. sel = b.Select("工匠把石桥修好了。", 1, nil, budget) if _, ok := injMap(sel)["修为"]; ok { t.Error("T4 wrong-sense 修 injected") } // T5: trad→simp normalization — 魯鎮 matches key 鲁镇 (A4). sel = b.Select("魯鎮的冬天很冷。", 1, nil, budget) if _, ok := injMap(sel)["鲁镇"]; !ok { t.Error("T5 trad→simp: 魯鎮 did not match 鲁镇") } // T6: pronominal chunk — sticky carries 四叔 from the previous chunk, INHERITING the // prior chunk's CONFIRMED disposition (D16.2: sticky carries the disposition forward). sishuID := "四叔\x1fchar\x1f0\x1f0" sel = b.Select("他慢慢站起来,叹了口气。", 2, map[string]InjectionTrust{sishuID: {Disp: Confirmed, KeyTrusted: true}}, budget) var stickySishu *PickedEntry for i := range sel.Injected { if sel.Injected[i].entry.src == "四叔" { stickySishu = &sel.Injected[i] } } if stickySishu == nil || !stickySishu.Sticky { t.Errorf("T6 sticky scene-inertia failed: %+v", injMap(sel)) } // T7: spoiler — 影卫 (since_ch=200) in chapter 5 → hard reject. sel = b.Select("影卫出现了。", 5, nil, budget) if _, ok := injMap(sel)["影卫"]; ok { t.Error("T7 spoiler 影卫 injected in ch5") } if len(sel.Rejected) != 1 || sel.Rejected[0].entry.src != "影卫" { t.Errorf("T7 spoiler not rejected+logged: %+v", sel.Rejected) } // T7b: same entry passes after the reveal chapter. sel = b.Select("影卫出现了。", 250, nil, budget) if _, ok := injMap(sel)["影卫"]; !ok { t.Error("T7b spoiler entry did not pass at ch250") } // T8: auto term 小D → ambiguous disposition. sel = b.Select("小D也来了。", 1, nil, budget) if injMap(sel)["小D"] != Ambiguous { t.Errorf("T8 auto-term not ambiguous: %v", injMap(sel)) } // T9: post-check catches a missing dst (leak) and passes a present one. sel = b.Select("阿Q走进鲁镇。", 1, nil, budget) bad := b.Postcheck(sel.Injected, "Некто вошёл в Лучжэнь.") // А-кью dropped good := b.Postcheck(sel.Injected, "А-кью вошёл в Лучжэнь.") if !hasMiss(bad, "阿Q") { t.Errorf("T9 post-check missed the leak: %+v", bad) } if !good.Empty() { t.Errorf("T9 post-check false-flagged a correct output: %+v", good) } // T10: post-check catches poisoning — injected dst absent, output used a foreign name. sel = b.Select("阿Q走进鲁镇。", 1, nil, budget) poison := b.Postcheck(sel.Injected, "Линь Чун вошёл в деревню Вэйчжуан.") if !hasMiss(poison, "阿Q") { t.Errorf("T10 post-check missed the poisoning: %+v", poison) } } func hasMiss(res PostcheckResult, src string) bool { for _, m := range res.All() { if m.Src == src { return true } } return false } // TestTrapCorpus ports the retrieval_bench wrong-sense traps: exact multi-char match // must fire 0/N on homograph chunks that contain the KEY CHARACTER but not the term. func TestTrapCorpus(t *testing.T) { entries := []store.GlossaryEntry{ gl("送灶", "проводы бога очага", "festival", "approved"), gl("修为", "уровень совершенствования", "cultivation", "approved"), gl("萧炎", "Сяо Янь", "name", "approved"), gl("斗气", "боевая ци", "term", "approved"), gl("钱老爷", "господин Цянь", "name", "approved"), // multi-char; the bare 钱 homograph must not fire } b := bankFrom(entries) traps := []struct{ name, text string }{ {"送到灶 literal", "厨房里冷了,老妈子把剩下的饭菜送到灶间去重新热一热。"}, {"修好 repair", "工匠们花了三天,终于把那条被山洪冲垮的石桥修好了。"}, {"炎势 flame", "山火蔓延,炎势冲天,浓烟滚滚,村民连夜收拾细软。"}, {"雾气 mist", "清晨的山谷里雾气很重,空气格外清新,露水打湿了草叶。"}, {"钱 money homograph", "他摸出几个铜钱,买了一碗热汤。"}, // 钱 present but 钱老爷 absent {"Chekhov negative", "Дмитрий Гуров сидел в ялтинском павильоне и лениво пил чай."}, } for _, tr := range traps { t.Run(tr.name, func(t *testing.T) { sel := b.Select(tr.text, 1, nil, 0) if len(sel.Injected) != 0 { t.Errorf("trap fired %d injection(s), want 0: %v", len(sel.Injected), injMap(sel)) } }) } } // TestLongestMatchContainment: a shorter key fully inside a longer key's span is // suppressed (longest-match / whole-entity replacement, A3), but the shorter entity // still fires when it stands alone. func TestLongestMatchContainment(t *testing.T) { entries := []store.GlossaryEntry{ gl("阿Q", "А-кью", "name", "approved"), gl("阿Q正传", "«Подлинная история А-кью»", "title", "approved"), } b := bankFrom(entries) // Inside the title: only the title fires. sel := b.Select("我读了《阿Q正传》。", 1, nil, 0) m := injMap(sel) if _, ok := m["阿Q正传"]; !ok { t.Errorf("title did not fire: %v", m) } if _, ok := m["阿Q"]; ok { t.Errorf("short key 阿Q fired inside the longer title span: %v", m) } // Standalone: the character name fires. sel = b.Select("阿Q走进村子。", 1, nil, 0) if _, ok := injMap(sel)["阿Q"]; !ok { t.Error("阿Q did not fire standalone") } } func TestSingleKeyBanAndAllowShort(t *testing.T) { banned := gl("炎", "пламя-как-имя", "", "approved") // single Han → banned by default b := bankFrom([]store.GlossaryEntry{banned}) if sel := b.Select("炎势冲天。", 1, nil, 0); len(sel.Injected) != 0 { t.Error("single-char key fired despite the A3 ban") } // allow_short escape hatch lets it fire. banned.AllowShort = true b = bankFrom([]store.GlossaryEntry{banned}) if sel := b.Select("炎势冲天。", 1, nil, 0); len(sel.Injected) != 1 { t.Error("allow_short did not re-enable the single-char key") } } func TestBudgetEviction(t *testing.T) { entries := []store.GlossaryEntry{ gl("甲甲", "Альфа", "", "approved"), gl("乙乙", "Бета", "", "auto"), // ambiguous → lower priority gl("丙丙", "Гамма", "", "approved"), } b := bankFrom(entries) chunk := "甲甲、乙乙、丙丙都在。" // Unbounded: all three fire, in priority order (the two approved before the auto). all := b.Select(chunk, 1, nil, 0) if len(all.Injected) != 3 { t.Fatalf("unbounded: want 3 injected, got %d", len(all.Injected)) } // A token budget that fits exactly the top TWO priority records (computed with the // same cost helper Select uses, so the test never drifts from the implementation). budget := glossaryLineTokens(all.Injected[0].entry) + glossaryLineTokens(all.Injected[1].entry) sel := b.Select(chunk, 1, nil, budget) if len(sel.Injected) != 2 || len(sel.Evicted) != 1 { t.Fatalf("budget: injected=%d evicted=%d (budget=%d)", len(sel.Injected), len(sel.Evicted), budget) } // The auto (ambiguous) term is the one evicted (confirmed>ambiguous priority, F2). if sel.Evicted[0].entry.src != "乙乙" { t.Errorf("eviction priority wrong: evicted %s (want the auto term 乙乙)", sel.Evicted[0].entry.src) } } // TestSelectDeterminism runs Select many times over a multi-hit chunk and asserts the // injected order is byte-identical — guards against a map-iteration leak (the // determinism invariant: the injected bytes enter request_hash). func TestSelectDeterminism(t *testing.T) { b := bankFrom(specGlossary()) chunk := "阿Q走进鲁镇,遇见鲁四老爷,谈起修为。" first := selKey(b.Select(chunk, 1, nil, 0)) for i := 0; i < 200; i++ { if got := selKey(b.Select(chunk, 1, nil, 0)); got != first { t.Fatalf("non-deterministic Select: run %d gave %q, first was %q", i, got, first) } } } func selKey(sel Selection) string { s := "" for _, p := range sel.Injected { s += p.entry.src + ":" + string(p.Disp) + "|" } return s } // TestEmptyDstNotMatchable covers self-review #1: a ruby candidate (dst="") must not be // matchable — it renders nothing, so admitting it would waste budget, evict a real line, // and inflate the exact-hit count. func TestEmptyDstNotMatchable(t *testing.T) { phantom := gl("甲甲", "", "", "auto") // ruby-style candidate, no dst real := gl("乙乙", "Настоящий", "", "auto") b := bankFrom([]store.GlossaryEntry{phantom, real}) // Tiny budget that would fit exactly one line: the phantom must not steal it. budget := glossaryLineTokens(&entry{src: "乙乙", dst: "Настоящий"}) sel := b.Select("甲甲和乙乙都在场。", 1, nil, budget) if len(sel.Injected) != 1 || sel.Injected[0].entry.src != "乙乙" { t.Fatalf("empty-dst phantom polluted selection: injected=%v", injMap(sel)) } if RenderGlossaryBlock(sel.Injected, ruTX()) == "" { t.Error("a renderable line was evicted by an empty-dst phantom") } // Unbounded: the phantom never appears as an exact hit. sel = b.Select("甲甲和乙乙都在场。", 1, nil, 0) if _, ok := injMap(sel)["甲甲"]; ok { t.Error("empty-dst phantom counted as an exact hit") } } // TestSpoilerBeforeSuppression covers self-review #6: a spoiler-blocked LONGER key must // not suppress a valid SHORTER name nested inside it. func TestSpoilerBeforeSuppression(t *testing.T) { short := gl("林动", "Линь Дун", "", "approved") long := gl("林动的父亲", "отец Линь Дуна", "", "approved") long.UntilCh = 3 // a spoiler: valid only through chapter 3 b := bankFrom([]store.GlossaryEntry{short, long}) // Chapter 10: the longer key is spoiler-blocked; the shorter valid name must survive. sel := b.Select("林动的父亲缓缓转身。", 10, nil, 0) if _, ok := injMap(sel)["林动"]; !ok { t.Errorf("valid shorter name dropped by a spoiler-blocked longer key: %v", injMap(sel)) } if _, ok := injMap(sel)["林动的父亲"]; ok { t.Error("spoiler-blocked longer key was injected") } if len(sel.Rejected) != 1 || sel.Rejected[0].entry.src != "林动的父亲" { t.Errorf("spoiler-blocked longer key not logged as Rejected: %+v", sel.Rejected) } // Chapter 2 (longer key in window): longest-match still prefers the longer entity. sel = b.Select("林动的父亲缓缓转身。", 2, nil, 0) if _, ok := injMap(sel)["林动"]; ok { t.Error("longest-match failed when the longer key is spoiler-valid") } if _, ok := injMap(sel)["林动的父亲"]; !ok { t.Error("spoiler-valid longer key was not injected at ch2") } } // TestBudgetFloorFree covers self-review #7: the per-line budget cost is floor-free, so a // block of short name lines is not ~1.85×-overcounted and needlessly evicted. func TestBudgetFloorFree(t *testing.T) { var entries []store.GlossaryEntry srcs := []string{"甲甲", "乙乙", "丙丙", "丁丁", "戊戊", "己己", "庚庚", "辛辛"} dsts := []string{"Ко", "Оцу", "Хэй", "Тэй", "Бо", "Ки", "Ко2", "Син"} for i := range srcs { entries = append(entries, gl(srcs[i], dsts[i], "", "approved")) } b := bankFrom(entries) chunk := strings.Join(srcs, "、") + "都在场。" all := b.Select(chunk, 1, nil, 0) if len(all.Injected) != 8 { t.Fatalf("want 8 injected, got %d", len(all.Injected)) } // A budget equal to the EXACT sum of the (floor-free) line costs fits all 8. budget := 0 for _, p := range all.Injected { budget += glossaryLineTokens(p.entry) } sel := b.Select(chunk, 1, nil, budget) if len(sel.Injected) != 8 || len(sel.Evicted) != 0 { t.Errorf("floor-free budget=%d should fit all 8: injected=%d evicted=%d", budget, len(sel.Injected), len(sel.Evicted)) } } // TestPerLanguageMinKeyAndCollisionDisposition covers external-review major #2/#3: a // purely-phonetic key needs ≥3 chars (a 2-char kana/latin key collides inside ordinary // words), and a short (≤3) phonetic key injects as AMBIGUOUS even when approved. func TestPerLanguageMinKeyAndCollisionDisposition(t *testing.T) { entries := []store.GlossaryEntry{ gl("鈴木", "Судзуки", "", "approved"), // 2 Han → fires, CONFIRMED (ideographic anchor) gl("すずき", "Судзуки-х", "s2", "approved"), // 3 kana → fires but AMBIGUOUS (collision-prone) gl("ながいなまえ", "Длинное имя", "", "approved"), // 6 kana → fires, CONFIRMED (long enough) gl("リン", "Рин", "", "approved"), // 2 kana → BANNED (phonetic min 3) gl("ai", "ИИ", "", "approved"), // 2 latin → BANNED (phonetic min 3) } b := bankFrom(entries) sel := b.Select("鈴木とすずきとながいなまえが会った。", 1, nil, 0) m := injMap(sel) if m["鈴木"] != Confirmed { t.Errorf("Han key must be CONFIRMED: %v", m) } if m["すずき"] != Ambiguous { t.Errorf("short phonetic key must be AMBIGUOUS (A2 collision): %v", m) } if m["ながいなまえ"] != Confirmed { t.Errorf("long phonetic key must be CONFIRMED: %v", m) } // The collision cases external-review named: a short phonetic key must NOT fire inside // an ordinary word (リン in リンゴ/apple, ai in raid). if s := b.Select("リンゴを食べた。", 1, nil, 0); len(s.Injected) != 0 { t.Errorf("2-kana key fired inside リンゴ (collision): %v", injMap(s)) } if s := b.Select("raidアレイを組んだ。", 1, nil, 0); len(s.Injected) != 0 { t.Errorf("2-latin key fired inside raid (collision): %v", injMap(s)) } // allow_short is the author's explicit override: the short key fires AND as CONFIRMED. rin := gl("リン", "Рин", "", "approved") rin.AllowShort = true b2 := bankFrom([]store.GlossaryEntry{rin}) if s := b2.Select("リンさんが来た。", 1, nil, 0); injMap(s)["リン"] != Confirmed { t.Errorf("allow_short must let リン fire as CONFIRMED: %v", injMap(s)) } } // TestStickyInheritsDisposition covers D16.2: a collision-prone AMBIGUOUS exact match must // carry into the next chunk's sticky AS AMBIGUOUS — never silently upgraded to CONFIRMED. A // status-based recompute would upgrade it (past the post-check, into the editor constraints). // Reverting the inherit change (recomputing via dispositionFor) makes the carry CONFIRMED here. func TestStickyInheritsDisposition(t *testing.T) { // An approved name whose only key is collision-prone (3 kana): an exact match fires // AMBIGUOUS (the A2 collision downgrade), not CONFIRMED, despite the approved status. suzuki := gl("すずき", "Судзуки", "", "approved") b := bankFrom([]store.GlossaryEntry{suzuki}) sel1 := b.Select("すずきが来た。", 1, nil, 0) if injMap(sel1)["すずき"] != Ambiguous { t.Fatalf("collision-prone approved key must fire AMBIGUOUS: %v", injMap(sel1)) } if len(sel1.ActiveIDs) != 1 { t.Fatalf("want exactly one active id, got %d", len(sel1.ActiveIDs)) } for _, fired := range sel1.ActiveIDs { if fired.Disp != Ambiguous { t.Fatalf("activeIDs must carry the AMBIGUOUS disposition, got %q", fired.Disp) } // The MATCH half travels beside it and says WHY: this key is collision-prone, and the draft wire // withholds the gender directive from it however the row is signed. if fired.KeyTrusted { t.Fatal("a collision-prone key must carry KeyTrusted=false into the sticky window") } } // Pronominal chunk (no key): sticky carries すずき, and its disposition MUST stay AMBIGUOUS. sel2 := b.Select("彼は立ち上がった。", 1, sel1.ActiveIDs, 0) var carried *PickedEntry for i := range sel2.Injected { if sel2.Injected[i].entry.src == "すずき" { carried = &sel2.Injected[i] } } if carried == nil || !carried.Sticky { t.Fatalf("sticky did not carry すずき: %v", injMap(sel2)) } if carried.Disp != Ambiguous { t.Errorf("D16.2: sticky carry upgraded disposition to %q, want AMBIGUOUS", carried.Disp) } if carried.KeyTrusted { t.Error("D16.2: the carry has no firing key of its own, so a doubtful match must stay doubtful") } } // TestUnionStickyNewestWins covers self-review finding #6: UnionSticky merges the sticky // window so the MOST RECENT firing's disposition wins — an id that fired CONFIRMED via a clean // key then AMBIGUOUS via a collision alias must carry AMBIGUOUS (no silent upgrade); a revert to // keep-first would reintroduce the D16.2 upgrade. This exercises the merge that // TestStickyInheritsDisposition (single-chunk activeIDs) bypasses. func TestUnionStickyNewestWins(t *testing.T) { id := "x\x1f\x1f0\x1f0" // Oldest→newest CONFIRMED then AMBIGUOUS → newest (AMBIGUOUS) wins (the anti-upgrade case). older := map[string]InjectionTrust{id: {Disp: Confirmed, KeyTrusted: true}} newerDoubtful := map[string]InjectionTrust{id: {Disp: Ambiguous}} if got := UnionSticky([]map[string]InjectionTrust{older, newerDoubtful})[id]; got.Disp != Ambiguous { t.Errorf("newest firing must win: got %q, want ambiguous", got.Disp) } // Both halves travel together, so the newest firing decides the MATCH question too — otherwise a // stale trusted key would keep licensing the gender directive after a doubtful one re-fired. if got := UnionSticky([]map[string]InjectionTrust{older, newerDoubtful})[id]; got.KeyTrusted { t.Error("newest firing must win on the match half too: a doubtful re-firing kept KeyTrusted") } // Reversed → a re-established CONFIRMED (via a clean key in the newer chunk) wins. if got := UnionSticky([]map[string]InjectionTrust{newerDoubtful, older})[id]; got.Disp != Confirmed { t.Errorf("a re-established CONFIRMED must win: got %q, want confirmed", got.Disp) } if UnionSticky(nil) != nil { t.Error("empty window → nil") } } // TestSourcePhoneticWordBoundary covers D16.3: a spaced-phonetic (Latin/Cyrillic) source key // fires only as a WHOLE WORD, never inside a longer same-script word ("rose" in "roseanne"). // A cross-script neighbour is a valid boundary. Reverting suppressUnboundedPhonetic fires // "rose" inside "roseanne" and fails here. func TestSourcePhoneticWordBoundary(t *testing.T) { rose := gl("rose", "Роза", "", "approved") // 4 Latin → CONFIRMED, but boundary-checked b := bankFrom([]store.GlossaryEntry{rose}) // Inside a longer Latin word → suppressed (both a suffix and a prefix collision). if s := b.Select("roseanne walked in.", 1, nil, 0); len(s.Injected) != 0 { t.Errorf("latin key fired inside 'roseanne': %v", injMap(s)) } if s := b.Select("the roses bloomed.", 1, nil, 0); len(s.Injected) != 0 { t.Errorf("latin key fired inside 'roses': %v", injMap(s)) } // Standalone / at string edges → fires CONFIRMED. if injMap(b.Select("a rose bloomed.", 1, nil, 0))["rose"] != Confirmed { t.Error("latin key must fire as a whole word") } if injMap(b.Select("rose.", 1, nil, 0))["rose"] != Confirmed { t.Error("latin key at string edges must fire") } // Cross-script neighbour (a Han char in unspaced source) is a valid boundary → fires. if injMap(b.Select("我叫rose。", 1, nil, 0))["rose"] != Confirmed { t.Error("latin key abutting a cross-script (Han) neighbour must fire") } } // TestKanaNotSourceBoundaryChecked documents the D16.3 kana carve-out: kana has no word // segmentation, so a kana key is NOT boundary-checked — it fires between kana neighbours (a // name+particle case すずきは), matching the long-key-CONFIRMED contract. The ≥4-kana compound // precision is deferred to the kana-precision measurement + B6 tokenizer (flagged). func TestKanaNotSourceBoundaryChecked(t *testing.T) { // A long kana key must still fire between kana neighbours (と…が), unaffected by the // source boundary check (this mirrors the existing long-phonetic-CONFIRMED expectation). name := gl("ながいなまえ", "Длинное имя", "", "approved") b := bankFrom([]store.GlossaryEntry{name}) if injMap(b.Select("私とながいなまえが会った。", 1, nil, 0))["ながいなまえ"] != Confirmed { t.Error("kana key must NOT be source-boundary-suppressed (name+particle recall)") } } // TestOverlappingNonNestedKeysBothFire documents the eval-review low finding: two keys // that OVERLAP without nesting both fire (both surfaces are genuinely present in the // text). Accepted behavior (precision-side), asserted so a future change is deliberate. func TestOverlappingNonNestedKeysBothFire(t *testing.T) { entries := []store.GlossaryEntry{ gl("甲乙", "Пара-А", "", "approved"), gl("乙丙", "Пара-Б", "", "approved"), } b := bankFrom(entries) sel := b.Select("甲乙丙都在。", 1, nil, 0) // 甲乙 at [0,2), 乙丙 at [1,3) — overlap, neither contains the other m := injMap(sel) if m["甲乙"] != Confirmed || m["乙丙"] != Confirmed { t.Errorf("overlapping non-nested keys should both fire: %v", m) } } func TestInjectivityCollision(t *testing.T) { entries := []store.GlossaryEntry{ {BookID: "b", Src: "甲", Dst: "Мастер", Status: "approved"}, {BookID: "b", Src: "乙", Dst: "Мастер", Status: "approved"}, // same dst → collision (B2) {BookID: "b", Src: "丙", Dst: "Ученик", Status: "approved"}, } cols := InjectivityCollisions(entries) if len(cols) != 1 { t.Fatalf("want 1 dst collision, got %d: %v", len(cols), cols) } } // TestRenderEditorConstraintBlock covers WS2 §2в (D30.1 open-question resolved): the BILINGUAL // editor's injection is a src→dst MAPPING (binding each canon to its source term, so a homonymic dst // stays distinguishable), NOT bare target forms; dedup is by (src,dst), which only widens the list. // // ⚠ THE SECTION SPLIT THIS TEST USED TO PIN IS GONE (D39.104 п.2, backlog row 134, ordered by this // pack's brief). It asserted that an unsigned row reaches the editor only under a second header whose // text grants the editor leave to translate otherwise. That leave is exactly what the doctrine // withdrew — it is the channel through which two chunks of one book render one term two ways — so the // rows now sit in the ONE law block and the assertions below pin that instead. The guarantee did not // move somewhere else: it was retired, and what replaced it is «the same law for every row». func TestRenderEditorConstraintBlock(t *testing.T) { suzuki := &entry{src: "鈴木", dst: "Судзуки", status: "approved"} cand := &entry{src: "小D", dst: "Малыш Дэ", status: "auto"} // AMBIGUOUS tanaka := &entry{src: "田中", dst: "Танака", status: "approved"} tanakaDup := &entry{src: "田中", dst: "Танака", status: "approved"} // same (src,dst) → deduped // A distinct source term that renders to the SAME Russian surface — the homonym case the // src→dst binding exists for: both lines survive (widening), the editor can tell them apart. homonym := &entry{src: "済", dst: "Танака", status: "approved"} sel := []PickedEntry{ {entry: suzuki, Via: "鈴木", Disp: Confirmed}, {entry: cand, Via: "小d", Disp: Ambiguous}, {entry: tanaka, Via: "田中", Disp: Confirmed}, {entry: tanakaDup, Via: "田中", Disp: Confirmed}, {entry: homonym, Via: "済", Disp: Confirmed}, } block := RenderEditorConstraintBlock(sel, ruTX()) // src→dst mapping present under the editor's own header. if !strings.Contains(block, "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ") { t.Errorf("editor header missing: %q", block) } if !strings.Contains(block, "鈴木 → «Судзуки»") || !strings.Contains(block, "田中 → «Танака»") { t.Errorf("src→dst mapping missing: %q", block) } // ONE BLOCK: the unsigned candidate is in it, under the SAME header, and nothing on its line marks it // apart. All three are asserted, because losing any one of them is a different regression — a second // header, a dropped row, or a surviving marker. if !strings.Contains(block, "小D → «Малыш Дэ»") { t.Errorf("the unsigned candidate must reach the editor: %q", block) } if strings.Contains(block, ruTX().EditorUnverifiedHeader) { t.Errorf("the second section is retired; the editor gets ONE law block: %q", block) } if strings.Contains(block, ruTX().UnverifiedMarker) { t.Errorf("the ⟨проверить⟩ marker left the wire (D39.104 п.2): %q", block) } if n := strings.Count(block, "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ"); n != 1 { t.Errorf("one law block means one header, got %d: %q", n, block) } // Deduped by (src,dst): 田中→Танака appears once despite the duplicate entry. if n := strings.Count(block, "田中 → «Танака»"); n != 1 { t.Errorf("(src,dst) not deduped: 田中→Танака appears %d times: %q", n, block) } // Homonym widening: a DIFFERENT source with the same dst is NOT collapsed — both lines survive. if !strings.Contains(block, "済 → «Танака»") { t.Errorf("homonymic dst must stay distinguishable by source term: %q", block) } // An EMPTY selection still yields no block at all. if got := RenderEditorConstraintBlock(nil, ruTX()); got != "" { t.Errorf("an empty selection must yield no editor block, got %q", got) } // An all-UNSIGNED selection prints the LAW header, which is the doctrine's most exposed consequence // and is asserted here rather than left implied: an auto-mined book, which has no signed row at all, // gets the same binding block a signed one gets. The old shape refused that header on exactly this // input, so a revert reds here first. got := RenderEditorConstraintBlock([]PickedEntry{{entry: cand, Disp: Ambiguous}}, ruTX()) if !strings.Contains(got, ruTX().EditorHeader) { t.Errorf("an all-unsigned selection is still the book's law on the wire: %q", got) } if !strings.Contains(got, "Малыш Дэ") { t.Errorf("an all-unsigned selection must still deliver its rows: %q", got) } // A book whose rows are all SIGNED renders exactly what it rendered before the sections merged: one // header, no blank-line seam, nothing added. onlyCanon := RenderEditorConstraintBlock([]PickedEntry{{entry: suzuki, Via: "鈴木", Disp: Confirmed}}, ruTX()) if want := ruTX().EditorHeader + "\n- 鈴木 → «Судзуки»"; onlyCanon != want { t.Errorf("an all-signed block must be unchanged:\n got %q\nwant %q", onlyCanon, want) } } // TestAStickyCarryKeepsTheTRUSTOfTheKeyThatFiredIt is the half of the D16.2 carry that the existing pin // could not see. TestStickyInheritsDisposition uses a row whose SRC IS the collision-prone key, so // recomputing the trust from the row gives the same answer as inheriting it and the two are // indistinguishable. The state that separates them is a row with a long Han src and a SHORT PHONETIC ALIAS: // the alias is what fires, the alias is what is doubtful, and the src is not. // // Found by planting: recomputing the trust on the carry (instead of inheriting it) survived the whole tree, // and under it a doubtful match is re-trusted the moment it is carried — which since row 134 hands the // model a gender directive for an entity that may not be on the page. func TestAStickyCarryKeepsTheTrustOfTheKeyThatFiredIt(t *testing.T) { e := gl("鈴木", "Судзуки", "", "approved") e.Gender = "female" e.Aliases = []store.GlossaryAlias{{Alias: "すずき", AliasType: "прозвище"}} b := bankFrom([]store.GlossaryEntry{e}) // Chunk 1 fires through the ALIAS, which is three kana — collision-prone, so the match is doubtful even // though the row is signed and its own src is Han. first := b.Select("すずきが来た。", 1, nil, 0) if len(first.Injected) != 1 || first.Injected[0].KeyTrusted { t.Fatalf("premise: the alias must fire and be distrusted, got %+v", injMap(first)) } // Chunk 2 names nobody: the record is CARRIED. The carry has no firing key of its own, so the only // honest answer is the one the firing key gave. second := b.Select("彼は立ち上がった。", 1, first.ActiveIDs, 0) var carried *PickedEntry for i := range second.Injected { if second.Injected[i].Sticky { carried = &second.Injected[i] } } if carried == nil { t.Fatalf("premise: the record must be carried into the pronominal chunk, got %+v", injMap(second)) } if carried.KeyTrusted { t.Fatal("the carry was re-trusted: a doubtful match must stay doubtful when it is carried, or the wire gains a gender directive for an entity that may not be on the page") } // ⚠ AND THE DISPOSITION HALF, which this fixture is the only one able to separate. The other carry test // uses a row whose SRC is itself the collision-prone key, so recomputing the disposition from the src // reproduces the same answer and a planting that does exactly that survives. Here the src is Han (clean) // and only the ALIAS was doubtful, so a recompute would upgrade this carry to Confirmed — which skips // the forced post-check and ranks the row ahead in the budget. That is the D16.2 upgrade wearing the // other half's coat: the wire stays quiet (the trust half is intact), so nothing else reds. if carried.Disp != Ambiguous { t.Fatalf("the carry's DISPOSITION was recomputed instead of inherited: got %q, want ambiguous — the "+ "carry has no firing key of its own, and the row's own surface is not the key that fired", carried.Disp) } // And the wire agrees: no directive on either block. tx := lang.InjectionTextsFor("ru") for _, block := range []string{ RenderGlossaryBlock(second.Injected, tx), RenderEditorConstraintBlock(second.Injected, tx), } { if strings.Contains(block, tx.GenderFemale) { t.Errorf("a carried doubtful match must carry no gender to any wire: %s", block) } } } // TestAStickyCarryCarriesBOTHHalvesApart is the other direction of the same split, and the one row 210 // depends on: an UNSIGNED row on a CLEAN key is Ambiguous and TRUSTED, and re-folding the two halves into // one (deriving the trust from the disposition) silently withholds its gender the moment it is carried. func TestAStickyCarryCarriesBothHalvesApart(t *testing.T) { e := gl("方源", "Фан Юань", "", "draft") // unsigned, clean Han key e.Gender = "male" b := bankFrom([]store.GlossaryEntry{e}) first := b.Select("方源看着蛊。", 1, nil, 0) if len(first.Injected) != 1 || first.Injected[0].Disp != Ambiguous || !first.Injected[0].KeyTrusted { t.Fatalf("premise: an unsigned row on a clean key is Ambiguous AND trusted, got %+v", first.Injected) } second := b.Select("他慢慢站起来。", 1, first.ActiveIDs, 0) var carried *PickedEntry for i := range second.Injected { if second.Injected[i].Sticky { carried = &second.Injected[i] } } if carried == nil { t.Fatalf("premise: the record must be carried, got %+v", injMap(second)) } if carried.Disp != Ambiguous { t.Errorf("the carry must not upgrade the disposition, got %q", carried.Disp) } if !carried.KeyTrusted { t.Fatal("the trust half was derived from the disposition: an unsigned row on a clean key loses its gender the moment it is carried, which is row 210's door in its sticky form") } tx := lang.InjectionTextsFor("ru") if draft := RenderGlossaryBlock(second.Injected, tx); !strings.Contains(draft, tx.GenderMale) { t.Errorf("the carried row must still carry its gender to the translator: %q", draft) } } // TestTheStickyWindowIsTwoChunksDeep pins the scene-inertia depth, which nothing pinned: at one, a term // named in chunk N is gone by N+2 and the injection silently narrows. func TestTheStickyWindowIsTwoChunksDeep(t *testing.T) { if StickyDepth != 2 { t.Fatalf("StickyDepth = %d; the window is a ratified code rule (A5/Р2) and changing it narrows every book's injection — move it deliberately, with a note, or not at all", StickyDepth) } }