576 lines
26 KiB
Go
576 lines
26 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
|
||
"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) *MemoryBank { return materializeMemory(entries, false) }
|
||
|
||
// injMap maps injected src → disposition for assertions.
|
||
func injMap(sel memorySelection) 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"] != memConfirmed || m["鲁镇"] != memConfirmed || m["四叔"] != memConfirmed {
|
||
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]injectionDisposition{sishuID: memConfirmed}, budget)
|
||
var stickySishu *pickedEntry
|
||
for i := range sel.injected {
|
||
if sel.injected[i].entry.src == "四叔" {
|
||
stickySishu = &sel.injected[i]
|
||
}
|
||
}
|
||
if stickySishu == nil || stickySishu.via != "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"] != memAmbiguous {
|
||
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 len(good) != 0 {
|
||
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(misses []postcheckMiss, src string) bool {
|
||
for _, m := range misses {
|
||
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 memorySelection) 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(&memoryEntry{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) == "" {
|
||
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["鈴木"] != memConfirmed {
|
||
t.Errorf("Han key must be CONFIRMED: %v", m)
|
||
}
|
||
if m["すずき"] != memAmbiguous {
|
||
t.Errorf("short phonetic key must be AMBIGUOUS (A2 collision): %v", m)
|
||
}
|
||
if m["ながいなまえ"] != memConfirmed {
|
||
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)["リン"] != memConfirmed {
|
||
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)["すずき"] != memAmbiguous {
|
||
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 _, disp := range sel1.activeIDs {
|
||
if disp != memAmbiguous {
|
||
t.Fatalf("activeIDs must carry the AMBIGUOUS disposition, got %q", disp)
|
||
}
|
||
}
|
||
|
||
// 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.via != "sticky" {
|
||
t.Fatalf("sticky did not carry すずき: %v", injMap(sel2))
|
||
}
|
||
if carried.disp != memAmbiguous {
|
||
t.Errorf("D16.2: sticky carry upgraded disposition to %q, want AMBIGUOUS", carried.disp)
|
||
}
|
||
}
|
||
|
||
// 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).
|
||
if got := unionSticky([]map[string]injectionDisposition{{id: memConfirmed}, {id: memAmbiguous}})[id]; got != memAmbiguous {
|
||
t.Errorf("newest firing must win: got %q, want ambiguous", got)
|
||
}
|
||
// Reversed → a re-established CONFIRMED (via a clean key in the newer chunk) wins.
|
||
if got := unionSticky([]map[string]injectionDisposition{{id: memAmbiguous}, {id: memConfirmed}})[id]; got != memConfirmed {
|
||
t.Errorf("a re-established CONFIRMED must win: got %q, want confirmed", got)
|
||
}
|
||
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"] != memConfirmed {
|
||
t.Error("latin key must fire as a whole word")
|
||
}
|
||
if injMap(b.Select("rose.", 1, nil, 0))["rose"] != memConfirmed {
|
||
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"] != memConfirmed {
|
||
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))["ながいなまえ"] != memConfirmed {
|
||
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["甲乙"] != memConfirmed || m["乙丙"] != memConfirmed {
|
||
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 CONFIRMED src→dst MAPPING (binding each canon to its source term, so a
|
||
// homonymic dst stays distinguishable), NOT bare target forms. It stays CONFIRMED-only (an
|
||
// AMBIGUOUS candidate must not force a rewrite); dedup is by (src,dst), which only widens the list.
|
||
func TestRenderEditorConstraintBlock(t *testing.T) {
|
||
suzuki := &memoryEntry{src: "鈴木", dst: "Судзуки", status: "approved"}
|
||
cand := &memoryEntry{src: "小D", dst: "Малыш Дэ", status: "auto"} // AMBIGUOUS
|
||
tanaka := &memoryEntry{src: "田中", dst: "Танака", status: "approved"}
|
||
tanakaDup := &memoryEntry{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 := &memoryEntry{src: "済", dst: "Танака", status: "approved"}
|
||
sel := []pickedEntry{
|
||
{entry: suzuki, via: "鈴木", disp: memConfirmed},
|
||
{entry: cand, via: "小d", disp: memAmbiguous},
|
||
{entry: tanaka, via: "田中", disp: memConfirmed},
|
||
{entry: tanakaDup, via: "田中", disp: memConfirmed},
|
||
{entry: homonym, via: "済", disp: memConfirmed},
|
||
}
|
||
block := renderEditorConstraintBlock(sel)
|
||
|
||
// 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)
|
||
}
|
||
// AMBIGUOUS excluded — the editor must not be forced toward an unverified rendering.
|
||
if strings.Contains(block, "Малыш Дэ") || strings.Contains(block, "小D") {
|
||
t.Errorf("AMBIGUOUS candidate leaked into the editor constraints: %q", 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 all-AMBIGUOUS (or empty) selection yields no block at all.
|
||
if got := renderEditorConstraintBlock([]pickedEntry{{entry: cand, disp: memAmbiguous}}); got != "" {
|
||
t.Errorf("an all-AMBIGUOUS selection must yield no editor block, got %q", got)
|
||
}
|
||
}
|