textmachine/backend/internal/pipeline/mempostcheck.go

109 lines
4.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// 1736% 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 1836% 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)
}
// 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
}