textmachine/backend/internal/membank/memconsistency.go

494 lines
27 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 (
"sort"
"strings"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/text"
)
// memconsistency.go: the book-wide consistency measure (backlog row 406) — "did one term reach the reader
// as ONE rendering, and was that rendering the bank's".
//
// It is a different QUESTION from the post-check next door, and the difference is the whole point. Postcheck
// asks a BOOLEAN of one chunk: is an accepted dst form present ANYWHERE in this output? That question cannot
// see a term rendered the bank's way once and some other way nine times — the one presence satisfies it
// (row 407). This asks two COUNTS of the same span: how many times did the row's source key fire, and how
// many times did an accepted rendering come out. The pair is what makes partial coverage visible, and
// partial coverage is precisely "the term went out more than one way".
//
// It reuses the bank's own machinery rather than standing a second matcher beside it — the source side runs
// the SAME automaton and the SAME two suppressions Select runs (a private matcher of our own would re-earn
// their bugs, and the polygon's hand-rolled one returned three false zeroes by missing inflected lower-case
// forms), and the target side runs the SAME normalizer and the SAME stemmer the post-check accepts forms by.
// That is also why it lives in this package: the row, the automaton and the accepted-form set are unexported,
// and a caller outside cannot reach them without a second implementation of all three.
//
// WHAT IT CANNOT SEE, named rather than implied: the competing rendering itself. A second form is detected
// by the bank's form being ABSENT where the key fired, never by recognising what stands there instead —
// the drafts' proposals live only in the mining-stop projections (pipeline.BankExportProposal.Variants) and
// are not persisted per row, so no reader of the glossary can name them. The consequence is one blind class:
// a rendering that CONTAINS the bank's form in full ("великая церемония открытия апертуры" over "церемония
// открытия апертуры") counts as the bank's form here, because in the target text it is one. Closing that
// needs the variants channel, not a cleverer matcher, and guessing from neighbouring words would be the
// heuristic this file exists to avoid.
// TermShipping is one bank row's fate over one span of shipped text: how often the row's source key fired in
// that span's SOURCE, and how often an accepted rendering of it came out in the span's SHIPPED text.
//
// The two are counted over the SAME span and are meaningful only together. Fired without Shipped is the
// interesting case (the term occurred and its rendering did not); Shipped without Fired is not an error but
// a category — a common-noun dst recurring away from its term, the recall cost dstFormPresent already
// documents.
type TermShipping struct {
ID string // the bank row's stable id — the aggregation key across spans
Src string // the row's source surface
Dst string // the row's approved rendering (raw, as the operator reads it)
// Status is the ENGINE's per-term machine (auto|draft|approved). It is carried because the mechanism
// acts on it — dispositionFor promotes only `approved`, and the post-check counts only that — NOT
// because the report splits terms by it: there is no signed TERM in the product model, a bank is signed
// whole (D39.144). The consistency counts therefore ignore this field, and the book's signing state is
// said once, as a row count. See bookconsistency.go for the finding that distinction uncovered.
Status string
// Via is the LONGEST key that fired for this row in this span, as Select records it: a row can own its
// src and several aliases, and which one fired decides the trust the injection carried.
Via string
// Fired counts non-overlapping occurrences of the row's keys in the source span, after the same
// word-boundary and longest-match suppressions Select applies. It is the denominator of the question.
Fired int
// Shipped is the PRIMARY count — the one every verdict is taken on. It is the post-check's rule plus the
// one relaxation that carries no hazard: a stored decl form is stemmed rather than matched literally.
//
// ⛔ THE PRIMARY COLUMN IS THIS AND NOT THE RELAXED ONE, and the choice is a measurement, not a taste.
// On the only labelled corpus this engine has, stemming the decl forms removes ALL SIX residual false
// flags while the one-rune tolerance removes NONE; and the tolerance has a real, measured hazard on
// multi-word renderings («глава рода Гуюэ» is satisfied by «глаза рода Гуюэ»). A verdict must not rest
// on the relaxation that buys nothing and risks something.
Shipped int
// ShippedStrict is what the post-check's OWN equality counts — the column that says what the engine
// itself would report — and ShippedRelaxed adds the one-rune stem tolerance on top of the primary rule.
// Both are context, never a verdict: the first shows what the shipping path is blind to, the second what
// the stemmer's one-strip limit costs. ⚠ Relaxed is ALMOST always ≥ Shipped; a longer form admitted only
// by the tolerance consumes more words per match, so a contrived form set can invert them (4 in 40 000;
// no plausible glossary row produced it).
ShippedStrict int
ShippedRelaxed int
// Suppressed marks a row whose key fired but was eaten by a LONGER key of equal-or-higher trust
// (suppressContained). The bank does that silently and on purpose — the longer surface is the better
// match — but the consequence is that the shorter row's own canon is never checked: 方源 firing through
// its alias 古月方源 answers for «Фан Юань» and nothing answers for the signed 古月 → «Гуюэ» inside it,
// so «Гу Юэ Фан Юань» passes (backlog row 407, the second root). This column is that silence made
// countable; it is observability and changes no disposition.
Suppressed bool
// SuppressedCanonAbsent lists the suppressed row's own rendering when it is NOT in the shipped span —
// i.e. the longer match ate the row AND the row's canon did not survive into the text. That pair is the
// whole finding: suppression alone is correct behaviour, suppression plus an absent canon is not.
SuppressedCanonAbsent bool
// Blocked marks a row the spoiler window excluded for this chapter. It fired but was never injected, so
// judging its absence would flag the window doing its job. Reported as its own category, not dropped —
// a silent drop is how a denominator quietly stops meaning what it says.
Blocked bool
}
// Absent reports the strong failure OVER THIS SPAN: the key fired here and no accepted rendering came out.
//
// ⚠ IT IS NOT THE BOOK'S VERDICT, and the distinction is the reason this measure exists. A term absent from
// one chapter and present in another is not an absent term — it is a term rendered two ways, which is the
// worse finding and is only visible with both chapters in hand. The book-level classification therefore
// lives in ONE place, the aggregation (pipeline.consistencyScan), and this predicate is what one span says.
func (t TermShipping) Absent() bool { return t.Fired > 0 && t.Shipped == 0 }
// Partial reports the split: some firings got the bank's rendering and some did not, inside ONE span. That
// is more than one rendering of one term in one place, and it is the case the presence-shaped post-check
// cannot reach.
//
// ⚠ It is an UPPER bound on renderings, not a proof of two: a target language may legitimately answer
// several source occurrences with one word (a Russian plural, or a pronoun after the first mention). The
// count is reported as a count, never as a verdict, for exactly that reason — and like Absent, it speaks
// for one span, not for the book.
func (t TermShipping) Partial() bool { return t.Fired > 0 && t.Shipped > 0 && t.Shipped < t.Fired }
// ScanShipping measures one span — a source text and the shipped text produced from it — against every bank
// row, and returns a record for each row that either fired or surfaced. Pure and deterministic: rows come
// back in the bank's frozen entry order, never map order.
//
// Rows with no dst are skipped: MaterializeBank gives them no target surface, so there is nothing to have
// shipped, and counting them would put rows that CANNOT be judged into the denominator of rows that were.
// The caller counts them separately from the glossary — see pipeline.QualityReport.
func (b *Bank) ScanShipping(source, shipped string, chapter int) []TermShipping {
// ZERO-VALUE GUARD, for the same reason Select carries one: Bank is exported, so a caller outside this
// package can construct one whose automaton is nil, and a nil automaton has nothing to match.
if b == nil || b.ac == nil {
return nil
}
fired, via, eaten := b.firingCounts(source, chapter)
tgt := b.tokenizeTarget(shipped)
out := make([]TermShipping, 0, len(fired))
for ei := range b.entries {
e := &b.entries[ei]
if strings.TrimSpace(e.dst) == "" {
continue // no target surface: nothing could have shipped, so the row is not judgeable here
}
n := fired[ei]
strict, primary, relaxed := b.countShipped(e, tgt)
if n == 0 && relaxed == 0 && !eaten[ei] {
continue // the row is absent from this span on every side — not a fact about the span
}
out = append(out, TermShipping{
ID: e.id, Src: e.src, Dst: e.dst, Status: e.status, Via: via[ei],
Fired: n, Shipped: primary, ShippedStrict: strict, ShippedRelaxed: relaxed,
Blocked: spoilerBlocked(e, chapter),
Suppressed: eaten[ei], SuppressedCanonAbsent: eaten[ei] && primary == 0,
})
}
return out
}
// targetText is one shipped span tokenized ONCE, with every word's stem taken ONCE.
//
// ⛔ IT EXISTS FOR A MEASURED REASON, not for tidiness. The scan asks every bank row about every output
// word, and stemming inside that loop re-derived the same few thousand stems once per row: at 200 rows over
// one unit of the cold run's size that measured 4.4 SECONDS, which on a 2283-chapter book is hours for a
// report that is supposed to be free. Hoisting the stems out and rejecting a row whose first word is not in
// the span at all bought ~70×. The COMPARISON is unchanged — stems are what SameStem compares, taken with
// the same stemmer — so no count moves.
//
// ⚠ IT IS NOT LINEAR IN THE SPAN, and an earlier version of this comment said it was. The rejection scans
// the span's stem set for a near neighbour when the exact stem is missing, so the cost still carries a
// rows×span term: measured by the acceptance at roughly 4073 s for 3200 units, not the couple of minutes a
// linear claim implies. Tolerable for a report and named rather than rounded off; a stem-prefix index over
// the span would remove the remaining term if it ever stops being tolerable.
type targetText struct {
words []string
stems []string
// present is the set of stems in this span, for O(1) rejection of a row that cannot be here at all.
// Most rows of a book's bank do not occur in most of its units, and before this they each cost a full
// pass over the span to discover that.
present map[string]bool
// opensSentence[i] marks a word that begins a new sentence, i.e. one a rendering may not reach back
// across. Without it a two-word rendering is satisfied by its halves landing on either side of a full
// stop — «род Бай» by «Он из рода. Бай его не знал» — which inflates the count and can turn a term that
// went out two ways into one that reads clean. That direction HIDES findings, which is why the boundary
// is tracked here and not left as the inherited behaviour of declinedFormPresent (where the same lapse
// only ever made a boolean more generous).
opensSentence []bool
}
// sentenceBreaks are the runes that end a clause or sentence in the normalized target. Deliberately
// punctuation and not a language model: the question is only «may a rendering span this gap», and a full
// stop is the one answer that holds for every target this engine has. The colon and semicolon are included
// on the same reasoning — a multi-word rendering does not straddle them in well-formed text — and the cost
// of being wrong about one of them is a rendering NOT counted, i.e. a louder report, never a quieter one. A paragraph break does not survive NormalizeTargetForm
// (it folds every space run to one space), so it is caught only when it follows a stop — which is the usual
// case and is named here rather than implied.
const sentenceBreaks = ".!?…:;"
func (b *Bank) tokenizeTarget(shipped string) targetText {
norm := text.NormalizeTargetForm(shipped)
out := targetText{}
// Tokenize each SENTENCE with the shared tokenizer rather than writing a second one: the word list must
// be identical to lang.TokenizeWords over the whole span, and splitting first is what adds the boundary
// without changing a single token.
for _, seg := range strings.FieldsFunc(norm, func(r rune) bool { return strings.ContainsRune(sentenceBreaks, r) }) {
w := lang.TokenizeWords(seg)
for i, word := range w {
out.words = append(out.words, word)
out.opensSentence = append(out.opensSentence, i == 0)
}
}
out.stems = make([]string, len(out.words))
out.present = make(map[string]bool, len(out.words))
for i, w := range out.words {
st := b.stemmer.Stem(w)
out.stems[i] = st
out.present[st] = true
}
return out
}
// firingCounts runs the source span
// firingCounts runs the source span through the SAME pipeline Select's match half runs — normalize, the
// shared automaton, the phonetic word-boundary suppression, the trust-aware longest-match suppression — and
// returns per-entry occurrence COUNTS plus the longest key each entry fired on.
//
// The only difference from Select is arity: Select needs to know THAT a row fired, so it keeps one record
// per entry; this needs to know HOW OFTEN, because "the rendering is present" and "the rendering is present
// as often as the term occurs" are the two different questions row 407 is about. Overlapping keys of the
// SAME entry (its src and an alias covering the same characters) are folded to one occurrence, so an entry
// with more aliases does not out-count an entry with fewer on identical text.
func (b *Bank) firingCounts(source string, chapter int) (counts map[int]int, via map[int]string, eaten map[int]bool) {
ntext := []rune(text.NormalizeSourceKey(source))
occ := b.ac.matches(ntext)
occ = b.suppressUnboundedPhonetic(occ, ntext)
// Which entries the longest-match suppression removed, taken as the DIFFERENCE between the two sets
// rather than re-derived by a containment rule of our own — the rule has three conditions (strictly
// longer span, spoiler-valid at this chapter, trust at least as high) and a second copy of it would
// drift from the one that actually fires.
before := b.owners(occ)
occ, _ = b.suppressContained(occ, chapter)
after := b.owners(occ)
eaten = map[int]bool{}
for ei := range before {
if !after[ei] {
eaten[ei] = true
}
}
// Group the surviving spans by the entry that owns the key, then fold spans that overlap within one
// entry. Sorted by (start, -length) so the longest span at a position wins and the shorter nested one
// is dropped — the same preference suppressContained applies across entries.
spans := map[int][]acMatch{}
for _, m := range occ {
k := b.ac.keys[m.keyIdx]
for _, ei := range b.keyOwners[k] {
spans[ei] = append(spans[ei], m)
}
}
counts, via = make(map[int]int, len(spans)), make(map[int]string, len(spans))
for ei, ms := range spans {
sort.Slice(ms, func(i, j int) bool {
if ms[i].start != ms[j].start {
return ms[i].start < ms[j].start
}
return ms[i].end > ms[j].end
})
last := -1
for _, m := range ms {
if m.start < last {
continue // nested in or overlapping the span already counted for this entry
}
last = m.end
counts[ei]++
if k := b.ac.keys[m.keyIdx]; len([]rune(k)) > len([]rune(via[ei])) {
via[ei] = k
}
}
}
return counts, via, eaten
}
// owners is the set of entries any of these key occurrences belongs to.
func (b *Bank) owners(ms []acMatch) map[int]bool {
out := map[int]bool{}
for _, m := range ms {
for _, ei := range b.keyOwners[b.ac.keys[m.keyIdx]] {
out[ei] = true
}
}
return out
}
// countShipped counts non-overlapping occurrences of the entry's accepted renderings in the tokenized
// shipped text, and returns TWO counts rather than one, because the difference between them is a number
// the operator has to see.
//
// STRICT is the post-check's own equality: the SAME union of accepted forms (base dst plus stored decl
// forms, D24.4), the base stemmed and a stored decl form matched literally, exactly as dstFormPresent does.
//
// ⚠ «EXACTLY» HAS TWO MEASURED EXCEPTIONS, and they are named because an unqualified claim here would
// mis-attribute the gap the report prints beside this column. Both live in WORD space versus RUNE space:
// dstFormPresent matches a stored decl form with containsWholeWord over runes, this counts over tokens.
// - Punctuation INSIDE a form is invisible here: a decl form «гу-мастера» is found in «гу мастера», where
// the post-check would not find it. Measured at 23 disagreements in 30 000 random triples, all in this
// direction, so the strict column can be marginally LOOSER than the post-check on a book whose decl
// forms carry punctuation the text does not.
// - A form with NO letters at all (digits only) is dropped by acceptedFormWords and would be found by
// containsWholeWord — the one direction in which strict is tighter.
//
// Neither is reachable on a book with no decl forms, which is every book measured so far (run A: 0 of 69).
//
// ANCHORED relaxes that in the two ways the ladder below names, and they were measured SEPARATELY because
// together they credit the wrong one: a stored decl form is stemmed rather than matched literally, and a
// word's stem may run one rune past the stem it is compared with (lang.NearStems). The first closed all six
// residual false flags of the labelled corpus (the seed lists singular forms, the text uses a plural) and
// carries no hazard. The second closed ELEVEN of the eighteen misses recorded on the paid run of 11.09, and
// it DOES carry one — so it is gated exactly as the note in data/target-ru.txt requires: the window must
// contain a word that matched outright, which a single-word rendering can never have, so the «Синь»/«синий»
// collapse that decl_suffix refuses the soft sign over cannot come back in through here.
//
// ⚠ The two are reported side by side and neither is called the truth. Strict under-counts by the
// stemmer's limit; anchored can over-count where two different words of one phrase merely start alike.
// Publishing one number would have hidden whichever error it carried.
//
// Counting runs in word space rather than rune space because that is where the decl branch already lives.
// On an inert stemmer (a target with no decl_suffix registry) SameStem degrades to exact equality and
// NearStem to the same, so both counts collapse to exact matching and the measure stays meaningful with
// no per-language code.
//
// Longest form first at every position, and each walk skips past a match, so a rendering whose decl form
// contains its base ("гу-мастер третьего оборота" over "гу-мастер") is counted ONCE, not twice.
func (b *Bank) countShipped(e *entry, out targetText) (strict, primary, relaxed int) {
forms := b.acceptedFormWords(e)
if len(forms) == 0 {
return 0, 0, 0
}
// A row whose first word's stem is nowhere in this span cannot match at any position. Checking that
// once is what keeps the scan linear in the span rather than in rows×span.
if !anyFormReachable(forms, out, b.stemmer) {
return 0, 0, 0
}
return countForms(forms, out, b.stemmer, matchStrict),
countForms(forms, out, b.stemmer, matchStemAll),
countForms(forms, out, b.stemmer, matchRelaxed)
}
// anyFormReachable reports whether any accepted form's first word could match somewhere in the span. It is
// deliberately keyed on the STEM and deliberately loose: the relaxed rule accepts a stem one rune off, so a
// stem miss is not proof of absence and the check also passes a form whose first-word stem is a one-rune
// neighbour of one present. Cheap and never the thing that decides a count.
func anyFormReachable(forms []acceptedForm, out targetText, stemmer lang.TargetStemmer) bool {
for _, f := range forms {
if out.present[f.stems[0]] {
return true
}
for st := range out.present {
if stemmer.NearStems(st, f.stems[0]) {
return true
}
}
}
return false
}
// The three matching rules the counters can run under. They are an ordered ladder — each admits everything
// the one before it does — and they are SEPARATE rather than one boolean because the two relaxations have
// different causes and different risks, and a measurement that bundled them would credit the wrong one.
const (
// matchStrict is exactly what dstFormPresent accepts: the base dst stem-aware, a stored decl form
// literally.
matchStrict = iota
// matchStemAll additionally stems the STORED decl forms. It costs nothing in safety — a decl form is
// an author-supplied rendering of this very term — and on the labelled corpus it is what removes the
// singular-only seed gap («гу-червь» seeded, «гу-червей» shipped).
matchStemAll
// matchRelaxed additionally accepts a one-rune-off near stem, anchored by another word of the same
// rendering that matched outright. This is the one with a hazard (data/target-ru.txt's «Синь»/«синий»),
// and the anchor is what contains it.
matchRelaxed
)
// acceptedForm is one rendering the entry may legitimately appear as, tokenized, plus the ONE bit that
// decides how strictly it may be matched. dstFormPresent treats the two sources differently and the
// difference is load-bearing: the base dst is matched stem-aware (declinedFormPresent), a STORED decl form
// only literally. Carrying the distinction is what lets the strict column here mean exactly "what the
// post-check would have said" — without it the column would quietly be a third rule, and the gap printed
// beside it would be attributed to the wrong cause.
type acceptedForm struct {
words []string
stems []string // taken once, with the bank's stemmer — the same values SameStem would compare
base bool // the entry's own dst, the only form the post-check stems
}
// dstFormPresentAnchored is the post-check's presence question asked under the anchored tolerance — the
// same predicate dstFormPresent answers, with the one-rune stem slack countShipped uses. It exists so the
// labelled-corpus harness can measure what the tolerance does to PRECISION on the same rows the ratified
// baseline was measured on, rather than on an argument. Nothing on the paid path calls it.
func dstFormPresentAnchored(b *Bank, e *entry, shipped string, rule int) bool {
return countForms(b.acceptedFormWords(e), b.tokenizeTarget(shipped), b.stemmer, rule) > 0
}
// countForms is one walk over the output. relax picks the anchored rule; see countShipped.
func countForms(forms []acceptedForm, out targetText, stemmer lang.TargetStemmer, rule int) int {
n := 0
for i := 0; i < len(out.words); {
matched := 0
for _, f := range forms {
if len(f.words) == 0 || i+len(f.words) > len(out.words) {
continue
}
if windowMatches(out, i, f, stemmer, rule) {
matched = len(f.words)
break // forms are longest-first, so the first hit is the longest at this position
}
}
if matched == 0 {
i++
continue
}
n++
i += matched
}
return n
}
// windowMatches reports whether a window of output words renders the form. Under the strict rule every word
// must share a stem. Under the relaxed rule a word may instead be a one-rune-off near stem — but the window
// must still contain at least one word that matched OUTRIGHT, and that requirement is the whole safety of
// the relaxation.
//
// ⛔ THE ANCHOR IS WHAT KEEPS «Синь» OUT OF «синий», and it is the ONLY thing that does. A one-word
// rendering has no other word to anchor it, so `anchor` never becomes true and the relaxation can never
// carry it alone — which is exactly the collapse data/target-ru.txt refuses the bare soft sign over. That
// half is verified exhaustively rather than argued: over the shipped book's 2097 distinct words, every
// one-word form against every word, the relaxation admitted nothing the un-relaxed rule did not, against
// 850 opportunities where the relation held.
//
// ⚠ AND IT GUARDS ONLY THAT HALF. In a MULTI-word rendering the anchors are the other words, so one word
// may still be credited to a different word that merely starts alike: «глава рода Гуюэ» is satisfied by
// «глаза рода Гуюэ», anchored by «рода» and «Гуюэ». On the shipped book of run A, NearStems equates 425
// real word pairs SameStem does not — «глава»~«глаз», «воле»~«волна», «вред»~«время» — so this is a
// measured exposure, not a hypothetical one. It does not fire on run A (the text says «глава рода Гуюэ»
// three times and «глаза рода Гуюэ» never), and it is the reason the relaxed column is printed BESIDE the
// strict one and neither is called the truth.
func windowMatches(out targetText, at int, form acceptedForm, stemmer lang.TargetStemmer, rule int) bool {
// A rendering may not reach back across a sentence boundary: its words standing on either side of a
// full stop are two sentences, not one rendering.
for k := 1; k < len(form.words); k++ {
if out.opensSentence[at+k] {
return false
}
}
anchor := false
for k, fw := range form.words {
switch {
case out.words[at+k] == fw:
anchor = true
// A stored decl form is matched literally under the strict rule, because that is all
// dstFormPresent does with it; only the base dst gets the stemmer there.
case !form.base && rule == matchStrict:
return false
case out.stems[at+k] == form.stems[k]:
anchor = true // the stems are pre-taken with the same stemmer SameStem compares by
case rule == matchRelaxed && stemmer.NearStems(out.stems[at+k], form.stems[k]):
// accepted only if some other word anchors this window — checked after the loop, because the
// anchor may be a word we have not reached yet.
default:
return false
}
}
return anchor
}
// acceptedFormWords is the entry's accepted rendering set, tokenized and ordered longest-first. Deduped by
// the tokenization rather than by the raw string, because two raw forms differing only in punctuation or
// case ("Гу-мастер", "гу мастер") are ONE form once tokenized, and counting them twice would let the same
// output words satisfy two forms at one position.
func (b *Bank) acceptedFormWords(e *entry) []acceptedForm {
seen := map[string]bool{}
var out []acceptedForm
add := func(s string, base bool) {
w := lang.TokenizeWords(text.NormalizeTargetForm(s))
if len(w) == 0 {
return
}
k := strings.Join(w, "\x1f")
if seen[k] {
return
}
seen[k] = true
st := make([]string, len(w))
for i, word := range w {
st[i] = b.stemmer.Stem(word)
}
out = append(out, acceptedForm{words: w, stems: st, base: base})
}
add(e.dst, true)
for _, f := range e.declForms {
add(f, false)
}
sort.SliceStable(out, func(i, j int) bool { return len(out[i].words) > len(out[j].words) })
return out
}