package pipeline import ( "strings" "unicode" ) // mempostcheck.go: the post-check (registry E1/E2 / research/13 Q5) — the main, almost // free detector of silent degradation. For every injected record whose SRC actually // fired in THIS chunk, it asks: did an accepted DST form appear in the output? A miss // catches BOTH failure modes — "the model ignored the glossary" (soft-following loses // 17–36% of terms, 2310.05824) AND "we injected the wrong dst and the model obeyed it" // (the owner's fear, 2510.00829). It is a post-CHECK, never a blind post-REPLACE (E2): // forcing a dictionary form into an oblique Russian case breaks agreement (46% of // constrained-model errors in en-cs are agreement, 2106.12398), so v1 only FLAGS. // // Decl-awareness is load-bearing (research/14 §2, quantified): a naive \b-regexp on the // base form gives 18–36% FALSE flags on a hard chunk — inflected forms rendered // CORRECTLY ("Вэйчжуане", "Бородатого Вана", "Дэна", "сюцая") that the boundary regexp // misses. So the check matches against the STORED decl forms (filled at term-commit), // not the bare lemma. BUT stored-decl reliability is itself a function of decl // COMPLETENESS — an OOV translit name whose forms were under-filled re-introduces the // same false flags. That is why v1 keeps the post-check a FLAGGER (observable, in the // retrieval-state), NOT a hard disposition gate, until the false-flag rate is MEASURED // on real inflected Russian (E1 — memory_e1_test.go). A hard gate flips on only after // the owner validates precision (mirrors the coverage gate's opt-in, D12 Q4). // // Sticky records are DELIBERATELY excluded: a sticky entry's src is NOT in this chunk // (that is why scene-inertia carries it), so its dst is legitimately absent from the // output — post-checking it would false-flag every pronominal chunk. Only records whose // key exactly fired here are expected in the output. // postcheckMiss is one flagged term: an injected record whose src fired but whose dst // (any accepted form) is absent from the output. type postcheckMiss struct { Src string `json:"src"` Dst string `json:"dst"` Disp string `json:"disp"` // confirmed | ambiguous (A2: ambiguous injections force a post-check) } // countConfirmedMisses counts misses of CONFIRMED (approved) records only. AMBIGUOUS // misses (an auto/draft candidate the model was ENTITLED to reject) must never flip a // disposition or count as a consistency failure: doing so would invert the contract — // punishing a model that correctly rejected an unverified suggestion while passing one // that obeyed a wrong CONFIRMED injection (external-review major #1). The AMBIGUOUS misses // still travel in the detail (A2 forced-post-check observability), just not as the count. func countConfirmedMisses(misses []postcheckMiss) int { n := 0 for _, m := range misses { if m.Disp == string(memConfirmed) { n++ } } return n } // postcheck runs the decl-aware check over the injected records against the model // output. Pure and deterministic. Returns the misses (empty = all fired terms present). func (b *MemoryBank) postcheck(injected []pickedEntry, output string) []postcheckMiss { nout := normalizeTargetForm(output) var misses []postcheckMiss noutRunes := []rune(nout) for _, p := range injected { if p.via == "sticky" { // sticky context is not expected in the output continue } if strings.TrimSpace(p.entry.dst) == "" { // a ruby candidate with no dst yet — nothing to check continue } if !dstFormPresent(p.entry, noutRunes) { misses = append(misses, postcheckMiss{Src: p.entry.src, Dst: p.entry.dst, Disp: string(p.disp)}) } } return misses } // dstFormPresent reports whether any accepted dst form of the entry appears in the // normalized output as a WHOLE WORD (bounded by non-letters or string edges), not a bare // substring. The whole-word rule closes self-review #3: a DROPPED short translit name // («Ван») must not be masked by an unrelated common word that merely contains its letters // («караВАН», «диВАН», «ИВАН» — a different person). Decl-aware (the research/14 // requirement): the stored decl forms carry the inflections, so precision and recall are // both satisfied when decl is complete; the base-dst fallback (empty decl) is the naive // case the E1 measurement quantifies. noutRunes is the normalized output pre-decomposed. func dstFormPresent(e *memoryEntry, noutRunes []rune) bool { forms := e.declForms if len(forms) == 0 { forms = []string{normalizeTargetForm(e.dst)} } for _, f := range forms { if f != "" && containsWholeWord(noutRunes, []rune(f)) { return true } } return false } // containsWholeWord reports whether form occurs in text with a letter-boundary on both // ends (the char before the match is not a letter or is the start; likewise after). Runs // on runes so Cyrillic boundaries are correct. O(len(text)·len(form)) — fine, the // post-check runs once per chunk over a handful of short forms. func containsWholeWord(text, form []rune) bool { n := len(form) if n == 0 || n > len(text) { return false } for i := 0; i+n <= len(text); i++ { if !runesEqual(text[i:i+n], form) { continue } if (i == 0 || !unicode.IsLetter(text[i-1])) && (i+n == len(text) || !unicode.IsLetter(text[i+n])) { return true } } return false } func runesEqual(a, b []rune) bool { for i := range a { if a[i] != b[i] { return false } } return true }