393 lines
16 KiB
Go
393 lines
16 KiB
Go
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
|
||
}
|