package membank // mempostcheck_test.go: mutation tests for the #10 fix (D39.39) — a single-Han-rune firing key demotes a // dst-absent post-check miss from a hard CONFIRMED consistency-failure to the Unverified observability // channel, because its Aho-Corasick substring firing (转 inside 转身) is not reliable evidence the entity // occurred. Multi-rune Han keys keep a word-like span and stay CONFIRMED. import ( "testing" "textmachine/backend/internal/store" ) func TestSingleHanKeyMissDemoted(t *testing.T) { // 转 (a rank head) seeded with allow_short, approved. It fires as a SUBSTRING of 转身 «turned around», // where the rank rendering «ранг» is legitimately absent — the classic #10 false CONFIRMED miss. e := gl("转", "ранг", "", "approved") e.AllowShort = true b := bankFrom([]store.GlossaryEntry{e}) sel := b.Select("他转身离开,头也不回。", 1, nil, 0) if len(sel.Injected) != 1 { t.Fatalf("单 Han key with allow_short should fire (substring), got %d injected", len(sel.Injected)) } res := b.Postcheck(sel.Injected, "Он повернулся и ушёл, не оглядываясь.") // no «ранг» in the output if res.ConfirmedCount() != 0 { t.Errorf("#10: a single-Han-rune key miss must NOT be a CONFIRMED miss, got %d confirmed", res.ConfirmedCount()) } if len(res.Unverified) != 1 { t.Errorf("#10: the single-Han-rune miss must survive as Unverified observability, got %d", len(res.Unverified)) } if len(res.Unverified) == 1 && !(res.Unverified[0].Demoted && res.Unverified[0].Disp == "confirmed") { t.Errorf("#10: the demoted miss must be flagged Demoted while keeping disp:confirmed (the injection trust), got %+v", res.Unverified[0]) } } func TestMultiHanKeyMissStaysConfirmed(t *testing.T) { // Control: a TWO-rune Han key keeps a word-like span, so a genuine drop is still a CONFIRMED miss — // the #10 demote must be narrow (single rune only), not a blanket Han exemption. e := gl("元石", "первокамень", "", "approved") b := bankFrom([]store.GlossaryEntry{e}) sel := b.Select("他手里握着元石。", 1, nil, 0) if len(sel.Injected) != 1 { t.Fatalf("2-rune Han key should fire, got %d", len(sel.Injected)) } res := b.Postcheck(sel.Injected, "Он держал в руке камень.") // «первокамень» absent → a real miss if res.ConfirmedCount() != 1 { t.Errorf("a multi-rune Han key miss must stay CONFIRMED, got %d", res.ConfirmedCount()) } } func TestSingleNonHanKeyMissStaysConfirmed(t *testing.T) { // The #10 demote is Han-ONLY by RATIFIED SCOPE, not because kana is segmented: a lone katakana ロ firing as // a substring of ゼロ is equally weak evidence — kana is NOT boundary-checked either (memory.go:536). The // demote is deliberately scoped to Han until the B6 tokenizer (backlog row 81), so a single NON-Han key must // stay a CONFIRMED miss (and carry no Demoted flag). This kills the mutant that demotes on len(r)==1 ALONE // (dropping the unicode.Han guard in singleHanKeyFired), which the rest of the suite otherwise survives. e := gl("ロ", "Зеро", "", "approved") e.AllowShort = true b := bankFrom([]store.GlossaryEntry{e}) sel := b.Select("彼はゼロを見た。", 1, nil, 0) if len(sel.Injected) != 1 { t.Fatalf("single non-Han (kana) key with allow_short should fire, got %d injected", len(sel.Injected)) } res := b.Postcheck(sel.Injected, "Он посмотрел вверх.") // «Зеро» absent → a real miss if res.ConfirmedCount() != 1 { t.Errorf("a single NON-Han (kana) key miss must stay CONFIRMED (#10 demote is Han-only), got %d", res.ConfirmedCount()) } if len(res.Unverified) != 0 { t.Errorf("a non-Han single-rune miss must not be demoted to Unverified, got %d", len(res.Unverified)) } } func TestAmbiguousMissNotDemoted(t *testing.T) { // The negative twin of the #10 demote: an AMBIGUOUS (auto/draft) injection that misses lands in Unverified // as the model's ENTITLED refusal, not as a demotion. It must carry Demoted=false — Demoted is a // single-Han-KEY-evidence axis (a SIGNED miss on weak evidence), orthogonal to the injection trust. This // kills the mutant `m.Demoted = true` in Postcheck's default branch, which the rest of the suite otherwise // survives (every other Unverified case is a demoted CONFIRMED miss, so none pins Demoted=false on ambiguous). e := gl("元石", "первокамень", "", "auto") // auto → Ambiguous injection; 2-rune Han key → no #10 demote b := bankFrom([]store.GlossaryEntry{e}) sel := b.Select("他手里握着元石。", 1, nil, 0) if len(sel.Injected) != 1 { t.Fatalf("2-rune auto key should fire, got %d", len(sel.Injected)) } res := b.Postcheck(sel.Injected, "Он держал в руке камень.") // «первокамень» absent → a miss if res.ConfirmedCount() != 0 { t.Errorf("an ambiguous miss must not be CONFIRMED, got %d", res.ConfirmedCount()) } if len(res.Unverified) != 1 { t.Fatalf("the ambiguous miss must survive as Unverified, got %d", len(res.Unverified)) } if res.Unverified[0].Demoted { t.Errorf("an ambiguous miss must carry Demoted=false (Demoted is a single-Han-key axis, not the injection trust), got %+v", res.Unverified[0]) } if res.Unverified[0].Disp != "ambiguous" { t.Errorf("the ambiguous miss must keep disp:ambiguous, got %q", res.Unverified[0].Disp) } }