textmachine/backend/internal/membank/mempostcheck.go

294 lines
15 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 membank
import (
"strings"
"unicode"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/text"
)
// 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"` // the INJECTION trust: confirmed | ambiguous (A2: ambiguous injections force a post-check)
// Demoted marks a CONFIRMED injection whose miss was routed to Unverified observability (not counted)
// because its firing key is a single Han rune (#10) — weak substring evidence, not a real consistency
// failure. It disambiguates the otherwise-contradictory disp:"confirmed" that would sit in the Unverified
// list with ConfirmedCount()==0: the demotion is a KEY-evidence axis, orthogonal to the injection trust
// Disp records, so it is a separate flag, not an overload of Disp. omitempty — absent on every non-demoted
// miss, so the wire and the golden are byte-identical unless a demotion actually occurs.
Demoted bool `json:"demoted,omitempty"`
}
// PostcheckResult separates the two kinds of miss BY TYPE rather than by the discipline of whoever calls
// the checker (pack-20 / D39.42 п.4). The rule itself is unchanged and load-bearing: only a CONFIRMED
// miss is a consistency failure. An AMBIGUOUS miss is an auto/draft candidate the model was ENTITLED to
// reject, and counting it would invert the contract — punishing a model that correctly refused an
// unverified suggestion while passing one that obeyed a wrong CONFIRMED injection (external-review major
// #1). What changes is that the rule is now impossible to get wrong at a call site: there is no combined
// slice to filter, so a caller cannot forget to. The Unverified misses are still carried, in full, as
// observability (A2's forced post-check) — they are simply not a count.
//
// Unverified now holds TWO populations, and "entitled to reject" applies to only one: (a) genuinely
// ambiguous/unsigned injections the model WAS entitled to refuse; and (b) CONFIRMED injections DEMOTED here
// by a weak single-Han firing key (#10, PostcheckMiss.Demoted). For (b) "entitled to reject" is false — the
// injection was signed; it is demoted because the KEY evidence (a one-rune substring) is weak, not because
// the model could refuse it. Both are observability and neither counts, but the Demoted flag keeps them
// distinguishable in the detail so (b) is not misread as a licensed refusal.
type PostcheckResult struct {
Confirmed []PostcheckMiss
Unverified []PostcheckMiss
// Shown / Followed are the $0 observability channel of the unverified wire: how many unsigned rows
// were actually PUT IN FRONT of the model here, and how many of those it went along with. Without the
// denominator "3 deviations" is unreadable — 3 out of 3 is a channel nobody follows, 3 out of 90 is
// a model exercising the judgement the unverified section explicitly grants it.
Shown int
Followed int
}
// ConfirmedCount is the actionable consistency-failure count — the ONLY number allowed to gate.
func (r PostcheckResult) ConfirmedCount() int { return len(r.Confirmed) }
// All returns every miss, confirmed first, for the human-facing detail blob.
func (r PostcheckResult) All() []PostcheckMiss {
if len(r.Unverified) == 0 {
return r.Confirmed
}
out := make([]PostcheckMiss, 0, len(r.Confirmed)+len(r.Unverified))
return append(append(out, r.Confirmed...), r.Unverified...)
}
// Empty reports whether nothing at all was missed.
func (r PostcheckResult) Empty() bool { return len(r.Confirmed) == 0 && len(r.Unverified) == 0 }
// Postcheck runs the decl-aware check over the injected records against the model
// output. Pure and deterministic. Returns the misses split by trust (empty = all fired terms present),
// plus the shown/followed counters of the unverified channel.
func (b *Bank) Postcheck(injected []PickedEntry, output string) PostcheckResult {
nout := text.NormalizeTargetForm(output)
var res PostcheckResult
noutRunes := []rune(nout)
outWords := lang.TokenizeWords(nout) // once for the whole chunk; the §3 stem branch reuses it per term
for _, p := range injected {
if !p.valid() { // a caller-built zero record carries no row — nothing to check (pack-16 tail)
continue
}
if p.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
}
present := dstFormPresent(p.entry, noutRunes, outWords, b.stemmer)
if p.Disp != Confirmed {
res.Shown++
if present {
res.Followed++
}
}
if present {
continue
}
m := PostcheckMiss{Src: p.entry.src, Dst: p.entry.dst, Disp: string(p.Disp)}
switch {
case p.Disp == Confirmed && singleHanKeyFired(p.Via):
m.Demoted = true // a confirmed miss on a single-Han substring key → Unverified, not a counted failure (#10)
res.Unverified = append(res.Unverified, m)
case p.Disp == Confirmed:
res.Confirmed = append(res.Confirmed, m)
default:
res.Unverified = append(res.Unverified, m)
}
}
return res
}
// singleHanKeyFired reports whether the firing key is a SINGLE Han ideograph (#10, D39.39). Han has no word
// segmentation, so a one-rune key matches by substring (转 inside 转身) — weak evidence the entity occurred —
// so a dst-absent miss on it is demoted to Unverified observability, not a hard CONFIRMED miss (precision over
// recall). Multi-rune Han is NOT demoted — not because it gets a word boundary (it does not: Han is never
// boundary-checked here, memory.go:536) but because a ≥2-rune substring collides far less, so the evidence is
// statistically stronger. Han-only until the B6 tokenizer (B6 = backlog row 81).
func singleHanKeyFired(via string) bool {
r := []rune(via)
return len(r) == 1 && unicode.Is(unicode.Han, r[0])
}
// 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. NB target ALIASES: the accepted set is base
// dst decl forms — an alternative TARGET rendering (a synonym/nickname the model may legitimately
// use) is NOT matched here, and adding one is a SEED concern (seed it as a decl form / alias), not
// code, on the same footing as the inflection_gap. noutRunes is the normalized output pre-decomposed.
func dstFormPresent(e *entry, noutRunes []rune, outWords []string, stemmer lang.TargetStemmer) bool {
if base := text.NormalizeTargetForm(e.dst); base != "" {
if containsWholeWord(noutRunes, []rune(base)) {
return true
}
// §3: accept an OBLIQUE case of the base when the seed listed no decl forms (the measured 142/142-null
// noise). A component-wise stem match — «горы Цинмао» for «гора Цинмао», «мечом» for «меч» — with no
// assumption about which word inflects. Inert for a target with no decl_suffix registry.
if declinedFormPresent(outWords, base, stemmer) {
return true
}
}
for _, f := range e.declForms {
if f != "" && containsWholeWord(noutRunes, []rune(f)) {
return true
}
}
return false
}
// declinedFormPresent reports whether the base rendering appears in the tokenized output with each of its
// words possibly inflected — a window of outWords the same length as base's words where every component
// shares its stem. Conservative (needs the whole phrase present, in order) and inert without a stemmer.
func declinedFormPresent(outWords []string, base string, stemmer lang.TargetStemmer) bool {
if !stemmer.Enabled() {
return false
}
bw := lang.TokenizeWords(base)
n := len(bw)
if n == 0 || n > len(outWords) {
return false
}
for i := 0; i+n <= len(outWords); i++ {
all := true
for k := 0; k < n; k++ {
if !stemmer.SameStem(outWords[i+k], bw[k]) {
all = false
break
}
}
if all {
return true
}
}
return false
}
// containsWholeWord reports whether form occurs in hay 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(hay)·len(form)) — fine, the
// post-check runs once per chunk over a handful of short forms.
func containsWholeWord(hay, form []rune) bool {
n := len(form)
if n == 0 || n > len(hay) {
return false
}
for i := 0; i+n <= len(hay); i++ {
if !text.RunesEqual(hay[i:i+n], form) {
continue
}
if (i == 0 || !unicode.IsLetter(hay[i-1])) && (i+n == len(hay) || !unicode.IsLetter(hay[i+n])) {
return true
}
}
return false
}
// SpoilerLeak is one rendering the spoiler window REJECTED for this chapter that appeared in the output
// anyway — the self-inflicted spoiler the windows exist to prevent (D21 п.3 / research/15 §2.2).
type SpoilerLeak struct {
Src string `json:"src"`
Dst string `json:"dst"`
// Since/Until are the window that excluded the row, so the operator sees at a glance whether the
// leak is a premature reveal (before since_ch) or a stale one (after until_ch).
SinceCh int `json:"since_ch"`
UntilCh int `json:"until_ch"`
}
// SpoilerLeaks checks the chunk's REJECTED records against the model output: the row's key fired here,
// the window said the chapter must not know this rendering yet, and the rendering is in the output all
// the same. It is the reveal half of D21 п.3, built on the machinery that already exists — the selection
// records its rejects, and dstFormPresent already answers "is this rendering here" decl-aware — so it
// needs no schema and no second matcher.
//
// SCOPE, ratified (D39.55): "a leak on a FIRED key". Two classes are therefore OUT, and out on purpose
// rather than by oversight:
//
// - a STICKY carry rejected by the window (Sticky==true). Its key did NOT fire in this chunk, so the
// model was not looking at the entity here; counting it would silently widen the ratified scope on
// the back of the pack-19 fix that put those carries into Rejected at all;
// - a SOURCE-ANCHORED reveal — an identity twist whose source surface never occurs (the text says
// "the stranger", not the name). Nothing fires, so nothing is rejected, so there is nothing to
// check. Closing that class needs a target-side index of post-reveal renderings, which is a
// different mechanism and not this pack's.
//
// Pure and deterministic (rejected order, which Select fixes). Observability only — never a disposition.
func (b *Bank) SpoilerLeaks(rejected []PickedEntry, output string) []SpoilerLeak {
norm := text.NormalizeTargetForm(output)
nout := []rune(norm)
outWords := lang.TokenizeWords(norm)
var out []SpoilerLeak
for _, p := range rejected {
if !p.valid() || p.Sticky {
continue
}
if strings.TrimSpace(p.entry.dst) == "" {
continue // nothing to leak
}
if !dstFormPresent(p.entry, nout, outWords, b.stemmer) {
continue
}
out = append(out, SpoilerLeak{
Src: p.entry.src, Dst: p.entry.dst,
SinceCh: p.entry.sinceCh, UntilCh: p.entry.untilCh,
})
}
return out
}