textmachine/backend/internal/checks/repair.go

393 lines
16 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 checks
import (
"fmt"
"sort"
"strings"
"unicode"
"textmachine/backend/internal/lang"
)
// repair.go: the POSITION-AWARE half of the defect checkers (pack-16, D39.24) — the same deterministic
// facts the lint functions report, plus WHERE in the text they sit, so a caller can address the minimal
// span instead of the whole unit.
//
// VERDICT-NEUTRAL BY CONSTRUCTION. Not one lint function is touched: this file re-derives the hit with the
// SAME compiled pair patterns and the SAME predicates, and the existing checkers keep returning byte-identical
// counts and details. That is why CheapGateVersion does NOT move (a bump would invalidate every book's
// snapshot and re-bill it, snapshot.go: StyleCheckVersion is folded unconditionally). The equivalence is
// asserted by execution, not by claim — see TestRepairCandidatesAgreeWithLints.
//
// WHY ONLY SOME CLASSES APPEAR HERE. A candidate needs a TRUSTWORTHY anchor on the side that would be
// rewritten. Three of the shipped defect classes do not have one and are deliberately absent:
// - DC2 (千万/数十万) and the 成-percent class fire on `strings.Contains` over the WHOLE unit
// (checkers.go: qianwanFireWord is the bare substring «тысяч», which also matches «тысячелетие»); a unit
// of a few thousand target characters routinely contains an unrelated cue, so a span derived from the
// first occurrence would point at innocent prose. Cardinality is not alignment.
// - the register, ё, dialogue-dash and coverage classes are either chunk-global judgements or scalar
// ratios, so "the defect is HERE" is not defined for them.
// They remain observability through the ordinary lint path; only the anchored classes are addressable.
// RepairClass identifies an addressable defect class. These are ENGINE identifiers (not pair data): the
// pair supplies the patterns that make a class fire, the target supplies its word lists, but the class
// vocabulary is the engine's, exactly like the flag-reason vocabulary of the driver.
type RepairClass string
const (
// RepairDC1TimeUnits is the counted double-hour defect: N个时辰 rendered as N hours instead of 2N.
RepairDC1TimeUnits RepairClass = "dc1_time_units"
// RepairDC1Fractional is the fractional double-hour defect: 半个时辰 (≈1 h) rendered as half an hour.
RepairDC1Fractional RepairClass = "dc1_fractional"
// RepairLatinResidue is a whole Latin word left untranslated in the target output.
RepairLatinResidue RepairClass = "latin_residue"
// RepairBrokenWord is a target word ending in a structurally impossible suffix («-йть»).
RepairBrokenWord RepairClass = "broken_word"
)
// RepairCandidate is one addressable defect: the class, the byte range in the FINAL text that would be
// rewritten, the byte range in the SOURCE that evidences it (zero for a target-only class), and the
// operator-facing detail. Spans are the NARROW match; widening them to a sentence is ExpandToSentence,
// kept separate so the caller can decide how much context a rewrite needs.
type RepairCandidate struct {
Class RepairClass
DstSpan [2]int
SrcSpan [2]int
Detail string
}
// hasSrc reports whether the candidate carries a source-side anchor.
func (c RepairCandidate) hasSrc() bool { return c.SrcSpan[1] > c.SrcSpan[0] }
// RepairCandidates returns every addressable defect of `final` (translated from `source`), in deterministic
// order (class, then span start). It is PURE — no store, no clock, no LLM — so a resumed run re-derives the
// identical list, and it is inert for a book whose pack/target ships no data, exactly like the lints.
//
// UNIQUENESS GUARD (load-bearing): a class contributes a candidate ONLY when its evidence occurs exactly
// once on each side it consults. The shipped checkers pair the FIRST source match with the FIRST target
// match, which is a heuristic, not an alignment; with two or more matches that heuristic can pair unrelated
// occurrences, so the honest answer is to report no addressable candidate and leave the class as a flag.
// This is a deliberate recall sacrifice: a multi-defect unit stays observability-only.
func RepairCandidates(source, final string, cfg CheapGateConfig) []RepairCandidate {
var out []RepairCandidate
c := cfg.Checkers
if c != nil {
out = append(out, c.dc1Candidates(source, final)...)
}
out = append(out, latinResidueCandidates(final, cfg.Allowlist)...)
if c != nil {
out = append(out, c.brokenWordCandidates(final)...)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Class != out[j].Class {
return out[i].Class < out[j].Class
}
return out[i].DstSpan[0] < out[j].DstSpan[0]
})
return out
}
// dc1Candidates yields the counted and the fractional double-hour candidates. Both mirror lintTimeUnits'
// predicates exactly; the counted branch additionally requires that BOTH sides matched exactly once.
func (c *Checkers) dc1Candidates(source, final string) []RepairCandidate {
var out []RepairCandidate
// Fractional probe (checked first, mirroring the lint's order).
if c.halfShichenRE != nil && c.halfShichenFireWord != "" {
src := c.halfShichenRE.FindAllStringIndex(source, -1)
dst := allIndex(final, c.halfShichenFireWord)
if len(src) == 1 && len(dst) == 1 {
out = append(out, RepairCandidate{
Class: RepairDC1Fractional,
SrcSpan: [2]int{src[0][0], src[0][1]},
DstSpan: dst[0],
Detail: fmt.Sprintf("DC1 时辰 (fractional): 半个时辰 ≈ 1 h rendered as %q", c.halfShichenFireWord),
})
return out // the lint returns on the fractional hit too — same single-verdict semantics
}
}
if c.shichenRE == nil || c.ruHoursRE == nil {
return out
}
srcAll := c.shichenRE.FindAllStringSubmatchIndex(source, -1)
dstAll := c.ruHoursRE.FindAllStringSubmatchIndex(final, -1)
if len(srcAll) != 1 || len(dstAll) != 1 {
return out // ambiguous anchor — report nothing addressable rather than guess a pairing
}
n, ok := dcParseCount(group(source, srcAll[0], 1), c.numeral)
if !ok {
return out
}
ruNum, ok := dcParseRuHours(group(final, dstAll[0], 1), c.ruHours)
if !ok {
return out
}
if ruNum != n || ruNum == n*2 {
return out // same fire condition as lintTimeUnits
}
// The target span is the MATCH minus its right-boundary character: ru_hours_re consumes one non-Cyrillic
// rune (or end of text) to bound the hour word, and that rune belongs to the surrounding prose, not to
// the defect. Rewriting it away would silently eat a comma or a space.
dstEnd := dstAll[0][1]
if r := lastRune(final[dstAll[0][0]:dstEnd]); r != 0 && !unicode.Is(unicode.Cyrillic, r) && !unicode.IsDigit(r) {
dstEnd -= len(string(r))
}
out = append(out, RepairCandidate{
Class: RepairDC1TimeUnits,
SrcSpan: [2]int{srcAll[0][0], srcAll[0][1]},
DstSpan: [2]int{dstAll[0][0], dstEnd},
Detail: fmt.Sprintf("DC1 时辰: %d个时辰 rendered as %d hours (counted as hours) instead of ~%d h", n, ruNum, n*2),
})
return out
}
// latinResidueCandidates yields one candidate per leaked lowercase Latin token, using the SAME token rule as
// lintLatinResidue (maximal alphanumeric run, all-lowercase, no digit, >= minLatinResidueLen, not a Roman
// numeral, not allow-listed) but retaining the byte offsets the lint discards. The uniqueness guard is
// per-TOKEN here rather than per-class: each leaked word is its own independent, locally addressable defect.
func latinResidueCandidates(final string, allow map[string]bool) []RepairCandidate {
var out []RepairCandidate
rs := []rune(final)
byteOf := runeByteOffsets(rs)
for i := 0; i < len(rs); {
if !isLatinLetterOrDigit(rs[i]) {
i++
continue
}
j := i
reject := false
for j < len(rs) && isLatinLetterOrDigit(rs[j]) {
if (rs[j] >= '0' && rs[j] <= '9') || (rs[j] >= 'A' && rs[j] <= 'Z') {
reject = true
}
j++
}
tok := string(rs[i:j])
if !reject && len([]rune(tok)) >= minLatinResidueLen && !isRomanNumeral(tok) && !allow[tok] {
out = append(out, RepairCandidate{
Class: RepairLatinResidue,
DstSpan: [2]int{byteOf[i], byteOf[j]},
Detail: "Latin word left untranslated in the target output: " + tok,
})
}
i = j
}
return out
}
// brokenWordCandidates yields one candidate per malformed target word, using the SAME suffix rule as
// lintBrokenWord. It cannot reuse text.TokenizeCyrillic: that tokenizer lower-cases the whole string before
// splitting and returns bare words, so offsets into the ORIGINAL text are structurally unavailable from it.
// This scan therefore walks the original runes and lower-cases only for the comparison.
//
// TARGET-SCRIPT DEBT (inherited, deliberately NOT repaid here — D39.24: «ru-target-долг — задокументированное
// ограничение, НЕ чинить»). The word boundary is unicode.Cyrillic, exactly as the lint twin's tokenizer is
// (text.TokenizeCyrillic). That is a Go-level target assumption no pair data can override, so the class works
// for a Cyrillic target and is silent for any other. Data-driving it HERE alone would be worse than leaving
// it: the scanner and the lint would then tokenise differently, and the verdict-neutrality this file rests on
// (a candidate only where the lint fires) would stop holding. The debt is therefore repayable only at the
// shared tokenizer, as one deliberate change to the target seam — not as a side effect of the repair loop.
func (c *Checkers) brokenWordCandidates(final string) []RepairCandidate {
if len(c.brokenSuffix) == 0 {
return nil
}
var out []RepairCandidate
rs := []rune(final)
byteOf := runeByteOffsets(rs)
for i := 0; i < len(rs); {
if !unicode.Is(unicode.Cyrillic, rs[i]) {
i++
continue
}
j := i
for j < len(rs) && unicode.Is(unicode.Cyrillic, rs[j]) {
j++
}
word := strings.ToLower(string(rs[i:j]))
if j-i >= 4 {
for _, suf := range c.brokenSuffix {
if strings.HasSuffix(word, suf) {
out = append(out, RepairCandidate{
Class: RepairBrokenWord,
DstSpan: [2]int{byteOf[i], byteOf[j]},
Detail: "malformed word ending in «-" + suf + "»: " + word,
})
break
}
}
}
i = j
}
return out
}
// ExpandToSentence widens a byte span to the sentence(s) containing it, using the SHARED terminator data
// (the same table the chunker and the coverage gate segment on), so the widening is pair-agnostic: a target
// whose terminators are not in the data simply widens to the whole text, which the caller's size guard then
// rejects. Returns the widened span, clamped to the text and aligned to rune boundaries.
func ExpandToSentence(text string, span [2]int) [2]int {
if span[0] < 0 || span[1] > len(text) || span[0] >= span[1] {
return span
}
term := lang.DefaultTerminators()
start := 0
for i, r := range text {
if i >= span[0] {
break
}
if term.IsTerminator(r) {
start = i + len(string(r))
}
}
for start < span[0] && isSpaceByte(text[start]) {
start++
}
end := len(text)
for i, r := range text {
if i < span[1] {
continue
}
if term.IsTerminator(r) {
end = i + len(string(r))
break
}
if r == '\n' { // a paragraph break bounds the span even without a terminator
end = i
break
}
}
return [2]int{start, end}
}
// DisjointCandidates expands every candidate to its sentence and drops any whose expanded span INTERSECTS an
// already-accepted one, keeping the first in deterministic order. Overlap is the common case rather than a
// corner: two classes firing inside one sentence expand to the SAME span, and splicing two replacements into
// one range would cut an already-mutated string — at best duplicating a fragment, at worst slicing out of
// bounds or mid-rune in a paid run. Dropping happens BEFORE any call is made, so an unusable candidate is
// never billed for.
func DisjointCandidates(final string, cands []RepairCandidate) []RepairCandidate {
var out []RepairCandidate
for _, c := range cands {
c.DstSpan = ExpandToSentence(final, c.DstSpan)
overlaps := false
for _, kept := range out {
if c.DstSpan[0] < kept.DstSpan[1] && kept.DstSpan[0] < c.DstSpan[1] {
overlaps = true
break
}
}
if !overlaps {
out = append(out, c)
}
}
return out
}
// --- small helpers -------------------------------------------------------------
// group returns submatch n of a FindAllStringSubmatchIndex row, or "" when the group did not participate.
func group(s string, idx []int, n int) string {
if len(idx) < 2*n+2 || idx[2*n] < 0 {
return ""
}
return s[idx[2*n]:idx[2*n+1]]
}
// allIndex returns the byte spans of every occurrence of sub in s (non-overlapping, left to right).
func allIndex(s, sub string) [][2]int {
var out [][2]int
for off := 0; ; {
i := strings.Index(s[off:], sub)
if i < 0 {
return out
}
out = append(out, [2]int{off + i, off + i + len(sub)})
off += i + len(sub)
}
}
// runeByteOffsets maps rune index → byte offset, with a final entry for the end of text.
func runeByteOffsets(rs []rune) []int {
out := make([]int, len(rs)+1)
b := 0
for i, r := range rs {
out[i] = b
b += len(string(r))
}
out[len(rs)] = b
return out
}
// lastRune returns the final rune of s, or 0 for the empty string.
func lastRune(s string) rune {
var last rune
for _, r := range s {
last = r
}
return last
}
func isSpaceByte(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b == '\r' }
// --- positive post-condition helpers (pack-16 §15.2 A) ---------------------------------------------
//
// A repair is accepted only when the class invariant is RESTORED, not merely silenced. These helpers expose
// exactly the predicates the driver's guard needs, over the SAME compiled pair/target data the detectors
// use, so a new pair inherits them without touching Go.
// LatinResidueCount counts leaked lowercase Latin tokens (allowlist-free — the guard compares a span with
// its replacement, and an allow-listed surface is equally allowed on both sides).
func LatinResidueCount(s string) int {
n, _ := lintLatinResidue(s, nil)
return n
}
// BrokenWordCount counts malformed target words in s (0 when the target ships no suffix data).
func BrokenWordCount(c *Checkers, s string) int {
n, _ := c.lintBrokenWord(s)
return n
}
// FractionalUnitPresent reports whether the fractional-unit FIRE word is still present — the defect the
// fractional class repairs. Inert (false) when the pair ships no fractional probe.
func FractionalUnitPresent(c *Checkers, s string) bool {
if c == nil || c.halfShichenFireWord == "" {
return false
}
return strings.Contains(s, c.halfShichenFireWord)
}
// MentionsHourWord reports whether s still STATES a duration in hours — the positive half of a
// fractional-unit repair: the replacement must CONVERT the duration, not delete it. The probe is PAIR DATA
// (`hour_word_re`), not a Go literal, and it carries BOTH word boundaries: without a left boundary the stem
// matches inside «полчаса»/«тотчас»/«сейчас», so a reply that deletes the duration and leaves any «-час»
// filler would satisfy the condition it is supposed to enforce.
//
// A pair that ships the fractional probe but no hour word CANNOT have this invariant asserted, so this
// returns false (reject) rather than true (accept). The runner refuses to enable the class in that state at
// LOAD time, so the rejecting branch is a backstop, not the operating mode.
func MentionsHourWord(c *Checkers, s string) bool {
if c == nil || c.hourWordRE == nil {
return false
}
return c.hourWordRE.MatchString(s)
}
// HasHourWordProbe reports whether the pair ships the positive post-condition the fractional class needs.
func HasHourWordProbe(c *Checkers) bool { return c != nil && c.hourWordRE != nil }
// HoursCountDoubled reports whether the replacement states a duration equal to TWICE the source count the
// counted-hours detector found in the ORIGINAL span. This is the positive assertion the class needs: a reply
// that changes the wrong number to a DIFFERENT wrong number silences the detector but fails here.
func HoursCountDoubled(c *Checkers, original, reply string) bool {
if c == nil || c.ruHoursRE == nil {
return false
}
om := c.ruHoursRE.FindStringSubmatch(original)
rm := c.ruHoursRE.FindStringSubmatch(reply)
if om == nil || rm == nil {
return false // no parsable hours count on one side — cannot assert the invariant, so do not accept
}
oldNum, ok1 := dcParseRuHours(om[1], c.ruHours)
newNum, ok2 := dcParseRuHours(rm[1], c.ruHours)
return ok1 && ok2 && newNum == oldNum*2
}