textmachine/backend/internal/pipeline/memory_trustgate_test.go

126 lines
6.1 KiB
Go

package pipeline
import (
"strings"
"testing"
"textmachine/backend/internal/store"
)
// memory_trustgate_test.go pins the DISPOSITION-GATED longest-match suppressor (D39 слой 4,
// L3-suppressor-disposition-blind): a longer but LOWER-trust key (draft/ambiguous 四代族长) must NOT
// suppress a nested HIGHER-trust key (approved 族长) — the term-drift code root. The whole-entity
// longest-match is preserved when the longer key is ≥ trust. Each refused suppression is recorded
// loudly (trustGated). Han keys are used so normalizeSourceKey is (near) identity — the stored
// event keys equal the raw src.
// trustGateKeys collects the (suppressor→protected) pairs of a selection's trust-gate events.
func trustGateKeys(sel memorySelection) map[string]string {
m := map[string]string{}
for _, e := range sel.trustGated {
m[e.Suppressor] = e.Protected
}
return m
}
// TestSuppressorTrustGateFires is the FIX: a longer draft key nested over an approved shorter key no
// longer eats it. BEFORE the gate the approved 族长 was dropped from Select (never injected, invisible
// to post-check) and the draft 四代族长 injected only AMBIGUOUS (editor-excluded) → the reader got
// NOTHING. AFTER: the approved 族长 survives as CONFIRMED (editor gets it) and the collision is logged.
func TestSuppressorTrustGateFires(t *testing.T) {
zuzhang := gl("族长", "глава клана", "title", "approved")
zuzhang.Decl = declJSON(false, "глава клана", "главы клана", "главе клана")
sidai := gl("四代族长", "четвёртый глава клана", "title", "draft") // longer, LOWER trust
b := bankFrom([]store.GlossaryEntry{zuzhang, sidai})
sel := b.Select("四代族长 вошёл в зал.", 1, nil, 0)
m := injMap(sel)
if m["族长"] != memConfirmed {
t.Fatalf("approved 族长 must survive as CONFIRMED (the fix), got %q; injected=%v", m["族长"], m)
}
if m["四代族长"] != memAmbiguous {
t.Errorf("draft 四代族长 must still inject AMBIGUOUS alongside, got %q", m["四代族长"])
}
// Editor constraint block: the approved term is now present (was empty before the fix).
if blk := renderEditorConstraintBlock(sel.injected); !strings.Contains(blk, "глава клана") {
t.Errorf("editor constraint block must carry the approved «глава клана», got %q", blk)
}
// Loud record of the refused suppression.
if got := trustGateKeys(sel); got["四代族长"] != "族长" {
t.Errorf("expected a trust-gate event 四代族长⊃族长, got %v", got)
}
if len(sel.trustGated) != 1 {
t.Errorf("expected exactly 1 trust-gate event, got %d", len(sel.trustGated))
}
}
// TestSuppressorApprovedOverApprovedStillSuppresses is the regression-safety guard: an APPROVED
// longer key legitimately eats a nested APPROVED shorter one (whole-entity longest-match, unchanged).
// This also spot-verifies the D38.5 promote: once 四代族长 is promoted draft→approved the whole entity
// wins — but the FIX above means the promote is no longer LOAD-BEARING for correctness (a draft
// 四代族长 no longer silently drops 族长).
func TestSuppressorApprovedOverApprovedStillSuppresses(t *testing.T) {
zuzhang := gl("族长", "глава клана", "title", "approved")
zuzhang.Decl = declJSON(false, "глава клана")
sidai := gl("四代族长", "четвёртый глава клана", "title", "approved") // longer, EQUAL trust
b := bankFrom([]store.GlossaryEntry{zuzhang, sidai})
sel := b.Select("四代族长 вошёл в зал.", 1, nil, 0)
m := injMap(sel)
if _, present := m["族长"]; present {
t.Errorf("approved 族长 nested in an EQUAL-trust approved 四代族长 must be suppressed (whole-entity), got injected=%v", m)
}
if m["四代族长"] != memConfirmed {
t.Errorf("the whole entity 四代族长 must inject CONFIRMED, got %q", m["四代族长"])
}
if len(sel.trustGated) != 0 {
t.Errorf("an EQUAL-trust suppression is legitimate — no trust-gate event, got %d", len(sel.trustGated))
}
}
// TestSuppressorDraftOverDraftStillSuppresses pins the edge the task calls out: two DRAFT keys
// (equal trust) behave as before — the longer suppresses the nested, no trust-gate event.
func TestSuppressorDraftOverDraftStillSuppresses(t *testing.T) {
zuzhang := gl("族长", "глава клана", "title", "draft")
sidai := gl("四代族长", "четвёртый глава клана", "title", "draft")
b := bankFrom([]store.GlossaryEntry{zuzhang, sidai})
sel := b.Select("四代族长 вошёл в зал.", 1, nil, 0)
m := injMap(sel)
if _, present := m["族长"]; present {
t.Errorf("draft 族长 nested in an equal-trust draft 四代族长 must be suppressed, got %v", m)
}
if m["四代族长"] != memAmbiguous {
t.Errorf("the longer draft must inject AMBIGUOUS, got %q", m["四代族长"])
}
if len(sel.trustGated) != 0 {
t.Errorf("equal-trust suppression records no event, got %d", len(sel.trustGated))
}
}
// TestSuppressorSpoilerBlockedLongDoesNotSuppress preserves self-review #6: a spoiler-BLOCKED longer
// key is not a valid suppressor at all, so the nested valid approved key survives — and this is NOT a
// trust-gate event (the refusal path records only a spoiler-VALID lower-trust suppressor).
func TestSuppressorSpoilerBlockedLongDoesNotSuppress(t *testing.T) {
zuzhang := gl("族长", "глава клана", "title", "approved")
zuzhang.Decl = declJSON(false, "глава клана")
sidai := gl("四代族长", "четвёртый глава клана", "title", "approved")
sidai.SinceCh = 200 // spoiler-blocked before ch200
b := bankFrom([]store.GlossaryEntry{zuzhang, sidai})
sel := b.Select("四代族长 вошёл в зал.", 1, nil, 0) // ch1 → 四代族长 blocked
m := injMap(sel)
if m["族长"] != memConfirmed {
t.Errorf("approved 族长 must survive a spoiler-blocked longer key, got %q", m["族长"])
}
if len(sel.rejected) != 1 || sel.rejected[0].entry.src != "四代族长" {
t.Errorf("the spoiler-blocked 四代族长 must be rejected+logged, got %v", sel.rejected)
}
if len(sel.trustGated) != 0 {
t.Errorf("a spoiler-block is not a trust-gate event, got %d", len(sel.trustGated))
}
}