146 lines
7.1 KiB
Go
146 lines
7.1 KiB
Go
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 a boundary regexp would miss.
|
||
//
|
||
// The accepted set is the UNION of the base dst AND the stored decl forms (D24.4 — D24.3 is
|
||
// the max_tokens floor, a different fix; the union is the post-check verdict change):
|
||
// 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
|
||
}
|