159 lines
6.5 KiB
Go
159 lines
6.5 KiB
Go
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)
|
||
}
|