textmachine/backend/internal/pipeline/regressionguard.go

159 lines
6.5 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
"fmt"
"sort"
"unicode"
)
// regressionguard.go: the post-reflow regression guard (D38 infra-pack, research/18 §C#5) — two
// deterministic OBSERVABILITY flaggers over the DRAFT→FINAL transform, opt-in like a QA gate
// (Gates.RegressionGuard) but NEVER a disposition change. The reflow editor legitimately
// restructures and merges sentences, so a HARD skip would false-flag a correct edit; instead a hit
// is recorded in the cheap-gate observability channel (retrieval-state n_style_flags) and surfaced
// in the report for a human. Both flaggers are tuned PRECISION over recall.
//
// (a) length collapse — the final is drastically SHORTER than the draft (>regressionMaxShrinkPct
// of non-space chars), catching a reflow that dropped whole sentences/paragraphs (draft
// 5000 → final 600). Reflow SUMMARISES structure into paragraphs; it must not delete content.
// (b) number drift — a MULTI-DIGIT arabic number in the draft is ABSENT from the final, or a
// new one APPEARS: 四成四=44%→«четыре десятых» (the 44 vanished), or a hallucinated figure.
// Single digits are EXCLUDED (they are routinely spelled out — «5»→«пять» — a legit reflow),
// and grouped triples are stitched (10 000 ≡ 10000), so the signal is high precision.
//
// HONEST recall gap: a number rendered as WORDS on BOTH sides (черновик «сорок четыре» → финал
// «четыре десятых») is invisible here — offline word-number alignment is a Ф2 concern. And a
// legit reflow that converts a multi-digit figure to words («44%»→«сорок четыре процента») will
// show as a disappeared number: a FALSE observability flag, tolerable ONLY because this never
// drops the chunk (a human glance dismisses it). Precision over recall, observability over gating.
const (
// regressionMaxShrinkPct: a final shorter than the draft by MORE than this percent of non-space
// characters is a collapse signal. 40% — a reflow that keeps content while merging lines shrinks
// only slightly (Russian is ~fertility-neutral draft→final); losing ~half the characters is a
// strong omission signal, not a merge. Tuned precision-over-recall; a code const (a change is a
// loud cheapGateVersion bump), not config, so the gate carries only an on/off switch.
regressionMaxShrinkPct = 40
// regressionMinChars: below this the draft is too short for a percentage ratio to be meaningful
// (a 3-word draft legitimately halving is noise), so the length flagger stays silent.
regressionMinChars = 200
)
// regressionGuardResult is the guard's per-chunk outcome (folded into the cheap-gate observability
// result). LengthCollapse/NumberDrift are 0/1 counts; Detail carries the human-readable lines.
type regressionGuardResult struct {
LengthCollapse int
NumberDrift int
Detail []string
}
// runRegressionGuard compares the DRAFT (first-stage translator output) with the FINAL (last-stage
// reflow output). Pure and deterministic (sorted detail), so a resume re-derives identical counts.
func runRegressionGuard(draft, final string) regressionGuardResult {
var r regressionGuardResult
// (a) length collapse — non-space chars, mirroring the coverage gate's length metric.
dn, fn := nonSpaceRuneCount(draft), nonSpaceRuneCount(final)
if dn >= regressionMinChars && fn*100 < dn*(100-regressionMaxShrinkPct) {
r.LengthCollapse = 1
r.Detail = append(r.Detail, fmt.Sprintf(
"reflow-регрессия: коллапс длины черновик→финал %d→%d непробельных символов (%d%%, порог %d%%) — возможен пропуск",
dn, fn, 100-fn*100/dn, regressionMaxShrinkPct))
}
// (b) number drift — multi-digit arabic tokens present on one side only.
dNums, fNums := arabicNumberTokens(draft), arabicNumberTokens(final)
disappeared := numberSetDiff(dNums, fNums)
appeared := numberSetDiff(fNums, dNums)
if len(disappeared) > 0 || len(appeared) > 0 {
r.NumberDrift = len(disappeared) + len(appeared)
if len(disappeared) > 0 {
r.Detail = append(r.Detail, "reflow-регрессия: числа черновика отсутствуют в финале (возможен пропуск/дрейф разряда): "+joinPreview(disappeared))
}
if len(appeared) > 0 {
r.Detail = append(r.Detail, "reflow-регрессия: в финале появились числа, которых не было в черновике (возможна добавка): "+joinPreview(appeared))
}
}
return r
}
// nonSpaceRuneCount counts non-whitespace runes — the length metric for the collapse flagger.
func nonSpaceRuneCount(s string) int {
n := 0
for _, r := range s {
if !unicode.IsSpace(r) {
n++
}
}
return n
}
// arabicNumberTokens returns the MULTI-DIGIT (≥2) arabic integers in text, stitching grouping
// separators (space / NBSP / comma) between runs of exactly three digits so "10 000" reads as one
// "10000", not "10" and "000". Single-digit numbers are dropped (routinely spelled out in prose).
func arabicNumberTokens(text string) []string {
rs := []rune(text)
var out []string
for i := 0; i < len(rs); {
if !isASCIIDigit(rs[i]) {
i++
continue
}
j := i
for j < len(rs) && isASCIIDigit(rs[j]) {
j++
}
digits := string(rs[i:j])
// Stitch " ddd" / " ddd" / ",ddd" grouped triples.
for j < len(rs) {
if rs[j] == ' ' || rs[j] == ' ' || rs[j] == ',' {
k := j + 1
g := 0
for k < len(rs) && isASCIIDigit(rs[k]) {
k++
g++
}
if g == 3 {
digits += string(rs[j+1 : k])
j = k
continue
}
}
break
}
if len(digits) >= 2 {
out = append(out, digits)
}
i = j
}
return out
}
// numberSetDiff returns the distinct members of a that do not appear in b, sorted. A SET diff (not
// a multiset): a number present on both sides is covered even if its multiplicity differs, keeping
// the precision-over-recall bias (a repeated number is not a drift signal on its own).
func numberSetDiff(a, b []string) []string {
inB := make(map[string]bool, len(b))
for _, x := range b {
inB[x] = true
}
seen := map[string]bool{}
var out []string
for _, x := range a {
if !inB[x] && !seen[x] {
seen[x] = true
out = append(out, x)
}
}
sort.Strings(out)
return out
}
// joinPreview renders a sorted number list compactly for the detail line (bounded).
func joinPreview(nums []string) string {
const max = 8
if len(nums) > max {
return preview(fmt.Sprintf("%v …(+%d)", nums[:max], len(nums)-max))
}
return fmt.Sprintf("%v", nums)
}