textmachine/backend/internal/pipeline/mempostcheck.go

145 lines
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)
}
// 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 a boundary regexp would miss.
//
// The accepted set is the UNION of the base dst AND the stored decl forms (D24.3/D24.4):
// the base translation is itself an approved form, so it is ALWAYS checked — not only as a
// fallback when decl is empty. Checking it only on empty decl false-flagged 37/55
// acceptance-stage-A misses (nominative_gap): an approved dst present in the NOMINATIVE
// while `decl.forms` listed only oblique cases (方源→Фан Юань present in the output, decl
// carried only genitive/dative). Requiring the base form to be duplicated into decl is
// redundant input every future book would trip on.
//
// PRECISION/RECALL TRADEOFF, not a free win (adversarial review, pkg 5): enlarging the
// accepted set is monotonic — it can only turn misses into passes, never the reverse. On
// the measured stage-A data those removed misses are all FALSE positives on correctly
// rendered nominatives (precision up, the 37/55). The one way it can COST recall is narrow
// but real: an entry whose decl OMITS the nominative AND whose base dst is a common word
// that recurs elsewhere while the term itself was DROPPED for its firing occurrence
// (剑→меч dropped as «клинок», yet an unrelated «меч» sits elsewhere) — this bag-of-words
// presence check then passes and masks the drop. That class is rare on real data (translit
// names do not recur coincidentally) and TOLERABLE only while this stays a FLAGGER: a
// hard-gate promotion (postcheck_gate) MUST re-measure recall on common-noun terms, not
// assume it — the "recall unaffected" phrasing in the D24.4 rationale is imprecise here.
// The remaining inflection_gap (18/55: a plural rendered but only the singular seeded) is a
// SEED completeness fix (Polygon), not a code one. noutRunes is the normalized output
// pre-decomposed.
func dstFormPresent(e *memoryEntry, noutRunes []rune) bool {
if base := normalizeTargetForm(e.dst); base != "" && containsWholeWord(noutRunes, []rune(base)) {
return true
}
for _, f := range e.declForms {
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
}