textmachine/backend/internal/checks/checkers.go

675 lines
32 KiB
Go
Raw Permalink 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"
"regexp"
"sort"
"strconv"
"strings"
"unicode"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/text"
)
// checkers.go: the WS5 defect-class checkers (DC1 时辰 double-hour units, DC2 千万/数十万 magnitude scale, DC6
// register negative-list) + the pack-13 general checkers (percent scale, Latin residue, broken word) —
// deterministic, $0 OBSERVABILITY flaggers on the source↔FINAL text, ported from ws5_checkers_verify.py.
// Like the four cheap style gates they are NEVER a disposition (a hit is recorded, never drops a chunk),
// tuned PRECISION over recall.
//
// PAIR-AGNOSTIC (pair-14 data-out): this file no longer holds any language literal. Every DETECTION pattern,
// lookup table and wordlist is DATA. The PAIR checkers read the pair pack configs/langpacks/<pair>/dc-checkers.txt
// (lang.DCCheckerData): the src↔tgt ones (DC1/DC2/percent — they compare a source token to the rendering). The
// DC6 register blocklist is NOT here: it is a genre/BOOK property (book.yaml register_blocklist → CheapGateConfig,
// D39.79 Q4), so a second zh→ru book of another genre ships its own without a Go or pair edit. The TARGET-general
// checker (broken word) runs on ANY →target output and reads the
// embedded target data (lang.TargetChecks "broken_suffix"). The ALGORITHM (compare counts, ×2 hours,
// suppress-if-ok, whole-word match) stays here. A pair/target that ships no data runs the relevant sub-checker
// inert (empty → 0, the no-pack golden path). Version rides the langpack Version() (data) + CheapGateVersion
// (algorithm) — a data or rule edit is a loud --resnapshot.
// Checkers is the compiled, per-run checker spec: the pair's DETECTION patterns compiled ONCE + its lookup
// tables + the target-general lists, resolved from the langpack. A nil receiver, a nil pattern or an empty
// table leaves that sub-checker inert. Built once per run (CompileCheckers), carried in CheapGateConfig.
type Checkers struct {
numeral map[rune]int
ruHours map[string]int
registerNeg []string
brokenSuffix []string // target-general (any →ru output)
// speechVerb is the target's SPOKEN-attribution vocabulary («сказал», «ответил», «крикнул»): the
// verbs that make a chevron-quoted line real dialogue rather than a thought. It is target data
// because the convention it encodes — thought in «…», speech aloud with «—» — is a fact about the
// target's typography, not about the source pair or the algorithm.
speechVerb []string
// translitInterjection is the TARGET blocklist of transliterated JP/EN fillers («нани», «десу», …) that
// must be translated, not romanised — target data now (was a Go literal), the whole-word matching stays
// generic. A target that ships none runs the sub-check inert.
translitInterjection []string
// innerMarker vetoes speechVerb: a qualifier («про себя», «мысленно») that makes the line inner
// speech even under a speech verb — «пробормотал про себя» is a thought said with a speaking verb.
innerMarker []string
yoHomograph map[string]bool // target-general: ё↔е homograph whitelist (cheapgates yofikator)
magnitudeStem map[string]int // target-general: ru magnitude word stem → base-10 exponent (cheapgates)
// The pack-19 voice flagger's TARGET data (voice.go): the T/V surfaces and the plural veto over
// them, the generic self-reference pronouns, and the reply markers this target sets speech with.
// All NORMALIZED at compile time so they compare against normalized output directly. A target that
// ships none of them leaves the flagger inert.
tvInformal, tvFormal, tvPluralVeto, selfRefGeneric []string
replyDash, replyOpen, replyClose string
// speechCue is the SOURCE-side quoted-turn alphabet (pair langpack). nil → the source-reply
// denominator is not measured.
speechCue *lang.SpeechCue
shichenRE, ruHoursRE, chengRE, decimalFractionRE *regexp.Regexp
qianwanOKRE, shushiwanOKRE, shushiwanFireRE *regexp.Regexp
qianwanSrc, qianwanFireWord, qianwanVetoWord string
shushiwanSrc, percentWord string
// halfShichenRE / halfShichenFireWord are the FRACTIONAL-unit probe of the DC1 family: the count group of
// shichenRE accepts a numeral only, so a fractional unit word (半 «half») never reaches the count parser and
// the most frequent form of the unit is invisible to the whole class. The probe is a plain src-pattern +
// target fire-word pair (the DC2 idiom), so the pair supplies BOTH or the sub-check stays inert — a pack
// without these keys behaves exactly as before this field existed.
halfShichenRE *regexp.Regexp
halfShichenFireWord string
// hourWordRE is the target's BARE hour word (both boundaries explicit). It is not a detection pattern:
// it is the positive post-condition a fractional-unit REPAIR must satisfy, and it lives with its
// siblings because "how this target renders the double-hour" is one fact with one home.
hourWordRE *regexp.Regexp
// dc1UnitHours is how many target hours ONE source time-unit is worth (zh 时辰 = 2). It used to be a
// literal `n * 2` in the DC1 algorithm — a pair fact hiding in generic Go, which would silently lie to a
// pair whose unit is not a double-hour. Data now (nit of generality, pre-run hygiene 25.07).
dc1UnitHours int
// msg holds the pair's DETAIL templates ({name} placeholders). Every pair-gated checker renders its
// detail line from here instead of a Go format string, so the engine file carries no pair literal.
msg map[string]string
// active reports whether the TARGET ships readability data at all (tc.HasData()). It is the data-driven
// replacement for the old isRuTarget Go predicate: the layer-7 readability/sanitizer gates fire only for a
// target WITH data and stay inert (by ABSENCE, not a language branch) for one without — a →en book runs
// the whole layer 7 as a no-op with no Go change. TargetActive() reads it.
active bool
// wordScript is the target's WORD ALPHABET (data-driven: the "word_script" name resolved to a Unicode
// range via wordScripts). It is the boundary the lexical checkers tokenize on (the yofikator, the broken-
// word lint AND its repair-candidate twin sit on this ONE seam, so they can never drift — the repayment of
// the target-tokenizer debt at repair.go). nil for a target with no lexical data → the scans stay inert.
wordScript *unicode.RangeTable
// The output-sanitizer DETECTION patterns, compiled PER-RUN from the target's data (was a package-init var
// keyed to a hardcoded "ru"). SanitizeOutput is now a *Checkers method reading these, so a target ships its
// OWN sanitizer patterns; a target with no data leaves them nil and the sub-detector returns "" (inert).
sanPreamble []*regexp.Regexp // ORDERED leading service-preamble shapes (preview = first match)
sanTrailingNote *regexp.Regexp // trailing note/annotation header
sanEditMeta *regexp.Regexp // editor meta-commentary anywhere
sanInvalidSign *regexp.Regexp // impossible Cyrillic sign bigram
// sourceScripts is the SOURCE language's declared writing script(s) (lang.LangScripts), carried here so the
// per-run repair script-guard shares the ONE source-script notion the echo detector uses (data-driven, no
// hard-coded Han+kana). Set after construction (SetSourceScripts) because the source lang is a runner fact,
// not target/pair data; nil → the guard measures no script (inert), like the echo detector.
sourceScripts []*unicode.RangeTable
}
// SetSourceScripts records the run's SOURCE writing script(s) (from lang.LangScripts). Called by the runner
// after the target/pair checkers compile; a nil receiver is a no-op.
func (c *Checkers) SetSourceScripts(s []*unicode.RangeTable) {
if c != nil {
c.sourceScripts = s
}
}
// SourceScripts returns the run's declared SOURCE writing script(s) (nil for an unknown source → the repair
// script-guard measures nothing rather than guessing).
func (c *Checkers) SourceScripts() []*unicode.RangeTable {
if c == nil {
return nil
}
return c.sourceScripts
}
// TargetActive reports whether the target ships readability data — the data-driven layer-7 gate that replaced
// the isRuTarget predicate. A nil receiver / a target with no data → false (the gate is inert BY ABSENCE).
func (c *Checkers) TargetActive() bool { return c != nil && c.active }
// TargetScriptNonLatin reports whether the target writes in a non-Latin word script — the data-driven guard
// for the Latin-residue detector (a Latin run is a leak only when the target itself is not Latin-written). A
// nil word script (no lexical data) → false, so the detector stays inert for a target that declares nothing.
func (c *Checkers) TargetScriptNonLatin() bool {
return c != nil && c.wordScript != nil && c.wordScript != unicode.Latin
}
// tokenizeWords splits target text into lower-cased word-script tokens (combining marks folded away, #11),
// on the target's DECLARED script. Empty word script → nil (a target with no lexical data does not tokenize).
// The single seam the yofikator, the broken-word lint AND its repair-candidate twin share.
func (c *Checkers) tokenizeWords(s string) []string {
if c == nil || c.wordScript == nil {
return nil
}
return text.TokenizeScript(s, c.wordScript)
}
// CompileCheckers resolves the checker spec from the pair pack (dc) and the target data (tc). A malformed
// regex in the pack is a corrupt pack → panic (deterministic, caught by the checker/golden tests). dc==nil
// (a no-pack book) → the pair sub-checkers are inert; tc still supplies the target-general lists.
func CompileCheckers(dc *lang.DCCheckerData, tc lang.TargetChecks) *Checkers {
c := &Checkers{
brokenSuffix: tc.List("broken_suffix"),
speechVerb: tc.List("speech_verb"),
translitInterjection: tc.List("translit_interjection"),
innerMarker: tc.List("inner_marker"),
yoHomograph: listToSet(tc.List("yo_homograph")),
magnitudeStem: listToStemExp(tc.List("magnitude_stem")),
// pack-19 voice data. Normalized here so every comparison in voice.go is against the same form
// the output is normalized into; the reply markers stay RAW because they are matched on the
// un-normalized line (normalization folds the dash variants and eats the line breaks).
tvInformal: normalizeList(tc.List("tv_informal")),
tvFormal: normalizeList(tc.List("tv_formal")),
tvPluralVeto: normalizeList(tc.List("tv_plural_veto")),
selfRefGeneric: normalizeList(tc.List("self_ref_generic")),
replyDash: first(tc.List("reply_dash")),
replyOpen: first(tc.List("reply_open")),
replyClose: first(tc.List("reply_close")),
active: tc.HasData(),
}
if ws := first(tc.List("word_script")); ws != "" {
rt, ok := lang.ScriptRange(ws)
if !ok {
panic(fmt.Sprintf("checks: target word_script %q has no Unicode range (add it to lang.scriptRanges)", ws))
}
c.wordScript = rt
}
if tc.HasData() {
// The output-sanitizer patterns are TARGET data, compiled here per-run (a target ships its own).
c.sanPreamble = compileSanitizerREs(tc, "sanitizer_preamble")
c.sanTrailingNote = compileSanitizerRE(tc, "sanitizer_trailing_note")
c.sanEditMeta = compileSanitizerRE(tc, "sanitizer_edit_meta")
c.sanInvalidSign = compileSanitizerRE(tc, "sanitizer_invalid_sign")
}
if dc != nil {
c.numeral, c.ruHours, c.registerNeg = dc.Numeral, dc.RuHours, dc.RegisterNeg
p := dc.Patterns
c.shichenRE = mustPairRE(p, "shichen_re")
c.ruHoursRE = mustPairRE(p, "ru_hours_re")
c.chengRE = mustPairRE(p, "cheng_re")
c.decimalFractionRE = mustPairRE(p, "decimal_fraction_re")
c.qianwanOKRE = mustPairRE(p, "qianwan_ok_re")
c.shushiwanOKRE = mustPairRE(p, "shushiwan_ok_re")
c.shushiwanFireRE = mustPairRE(p, "shushiwan_fire_re")
c.qianwanSrc, c.qianwanFireWord, c.qianwanVetoWord = p["qianwan_src"], p["qianwan_fire_word"], p["qianwan_veto_word"]
c.shushiwanSrc, c.percentWord = p["shushiwan_src"], p["percent_word"]
c.halfShichenRE = mustPairRE(p, "halfshichen_re")
c.halfShichenFireWord = p["halfshichen_fire_word"]
c.hourWordRE = mustPairRE(p, "hour_word_re")
c.dc1UnitHours = mustPairRatio(dc.Ratios, "dc1_unit_in_hours")
// dc6_register is NOT required (D39.79 Q4): the DC6 detail is a generic Go diagnostic now and the
// register blocklist moved to the book config, so a pair pack no longer ships a genre-bearing message.
c.msg = mustPairMessages(dc.Messages,
"dc1_fractional", "dc1_counted", "dc2_qianwan", "dc2_shushiwan", "percent_scale")
}
return c
}
// mustPairRatio reads a required numeric pair relation. A missing/zero ratio is a corrupt pack, not a
// tolerable default: the DC1 comparison is MEANINGLESS without it (a silent 0 would make every rendering
// look wrong), so it fails the same way a malformed regex does — loudly, at compile, named.
func mustPairRatio(ratios map[string]int, key string) int {
v, ok := ratios[key]
if !ok || v <= 0 {
panic(fmt.Sprintf("checks: pair pack dc-checkers is missing the positive ratio %q (a detector without its unit relation cannot judge anything)", key))
}
return v
}
// mustPairMessages reads the required DETAIL templates. A pair that ships detection data but no message
// for it is a corrupt pack by the same argument: a checker that fires and cannot say WHAT it found produces
// an empty observability line, which reads downstream as "nothing was wrong".
func mustPairMessages(msgs map[string]string, keys ...string) map[string]string {
out := make(map[string]string, len(keys))
for _, k := range keys {
v := msgs[k]
if strings.TrimSpace(v) == "" {
panic(fmt.Sprintf("checks: pair pack dc-checkers is missing the detail template %q (a detector with no message is a silent detector)", k))
}
out[k] = v
}
return out
}
// renderMsg substitutes `{name}` placeholders in a pair DETAIL template. kv is a flat name,value sequence
// (an odd tail is a programming error and panics — the templates and their call sites are compiled
// together). Pure and deterministic: one pass, no map iteration.
func renderMsg(tpl string, kv ...string) string {
if len(kv)%2 != 0 {
panic("checks: renderMsg wants name,value pairs")
}
if len(kv) == 0 {
return tpl
}
rep := make([]string, 0, len(kv))
for i := 0; i < len(kv); i += 2 {
rep = append(rep, "{"+kv[i]+"}", kv[i+1])
}
return strings.NewReplacer(rep...).Replace(tpl)
}
// listToSet turns an ordered value list into a membership set (target wordlists).
func listToSet(xs []string) map[string]bool {
m := make(map[string]bool, len(xs))
for _, x := range xs {
m[x] = true
}
return m
}
// listToStemExp parses `stem<TAB>exp` values (the magnitude_stem list carries a second tab-field) into a
// stem→exponent map. A malformed value is a corrupt embed → panic (deterministic, caught by tests).
func listToStemExp(xs []string) map[string]int {
m := make(map[string]int, len(xs))
for _, x := range xs {
f := strings.SplitN(x, "\t", 2)
if len(f) != 2 {
panic(fmt.Sprintf("checks: magnitude_stem wants `stem<TAB>exp`, got %q", x))
}
v, err := strconv.Atoi(strings.TrimSpace(f[1]))
if err != nil {
panic(fmt.Sprintf("checks: magnitude_stem exponent %q: %v", f[1], err))
}
m[f[0]] = v
}
return m
}
// DCCheckerData returns the pack's checker data, or nil when the book has no pack (nil-safe helper for the
// compile step, which runs even for a no-langpack book).
func DCCheckerData(p *lang.Pack) *lang.DCCheckerData {
if p != nil {
return p.DCCheckers
}
return nil
}
// CompileCheckersFor is CompileCheckers over a whole pack — the form the runner uses, because the
// pack-19 voice flagger needs the SOURCE-side speech-cue table too and a nil pack must still yield the
// target-general checkers. Kept as a second entry point rather than a third parameter so no existing
// caller (every checker test) has to learn about a table it does not use.
func CompileCheckersFor(p *lang.Pack, tc lang.TargetChecks) *Checkers {
c := CompileCheckers(DCCheckerData(p), tc)
if p != nil {
c.speechCue = p.SpeechCue
}
return c
}
// normalizeList folds a target wordlist into the matching form (NFC, lower, ё→е), dropping entries that
// normalize to nothing. Deduped, authored order preserved.
func normalizeList(xs []string) []string {
var out []string
seen := map[string]bool{}
for _, x := range xs {
n := text.NormalizeTargetForm(x)
if n == "" || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
return out
}
// first returns the first entry of a single-valued data category, or "" when the target states none.
func first(xs []string) string {
if len(xs) == 0 {
return ""
}
return xs[0]
}
// mustPairRE compiles a pair detection pattern by key; a missing key → nil (inert sub-checker), a malformed
// regex → panic (a corrupt pack, not a silent no-op — the same fail-loud discipline the loader keeps).
func mustPairRE(p map[string]string, key string) *regexp.Regexp {
s := p[key]
if s == "" {
return nil
}
re, err := regexp.Compile(s)
if err != nil {
panic(fmt.Sprintf("checks: langpack checker pattern %q is not a valid regex: %v", key, err))
}
return re
}
// --- DC-1: 时辰 (double-hour) unit checker (ws5.shichen_checker) -----------------------------------
// lintTimeUnits flags a 时辰 (=2h) unit error: N个时辰 rendered as N часов (the count copied as hours)
// instead of ~2N hours (三个时辰 → «три часа» should be ~6h). It fires ONLY on an explicit mismatch — a
// paraphrase with no hours count is a valid rendering, not a defect. Pure and deterministic. Inert when the
// pair ships no DC1 pattern (the shichen_re / ru_hours_re detection patterns are pair langpack DATA).
func (c *Checkers) lintTimeUnits(source, final string) (int, []string) {
if c == nil {
return 0, nil
}
// Fractional-unit probe FIRST: 半个时辰 carries no numeral, so the counted branch below can never see it
// (its count group matches a numeral only) — yet 半 is the most frequent 时辰 form in a real corpus.
// Rendering it as «полчаса» halves the duration. Data-gated on BOTH keys (empty-probe guard, pack-15):
// a pair that ships neither key runs this inert, exactly as before the probe existed.
if c.halfShichenRE != nil && c.halfShichenFireWord != "" &&
c.halfShichenRE.MatchString(source) && strings.Contains(final, c.halfShichenFireWord) {
return 1, []string{renderMsg(c.msg["dc1_fractional"], "fire_word", c.halfShichenFireWord)}
}
if c.shichenRE == nil || c.ruHoursRE == nil {
return 0, nil
}
m := c.shichenRE.FindStringSubmatch(source)
if m == nil {
return 0, nil
}
n, ok := dcParseCount(m[1], c.numeral)
if !ok {
return 0, nil
}
hm := c.ruHoursRE.FindStringSubmatch(final)
if hm == nil {
return 0, nil // no explicit hours rendering → a valid paraphrase, not a defect
}
ruNum, ok := dcParseRuHours(hm[1], c.ruHours)
if !ok {
return 0, nil
}
expectedHours := n * c.dc1UnitHours
if ruNum == n && ruNum != expectedHours {
return 1, []string{renderMsg(c.msg["dc1_counted"],
"n", strconv.Itoa(n), "rendered", strconv.Itoa(ruNum),
"expected", strconv.Itoa(expectedHours), "ratio", strconv.Itoa(c.dc1UnitHours))}
}
return 0, nil
}
// dcParseCount parses the DC1 count group: an Arabic digit string or a single small CJK numeral (looked up
// in the pair's DCCheckerData.Numeral, empty for a no-pack book → CJK counts don't resolve, the check is inert).
func dcParseCount(s string, dcNum map[rune]int) (int, bool) {
if v, err := strconv.Atoi(s); err == nil {
return v, true
}
r := []rune(s)
if len(r) == 1 {
if v, ok := dcNum[r[0]]; ok {
return v, true
}
}
return 0, false
}
// dcParseRuHours parses the DC1 hours group: an Arabic digit string or a Russian count word (pair data).
func dcParseRuHours(s string, dcRu map[string]int) (int, bool) {
if v, err := strconv.Atoi(s); err == nil {
return v, true
}
if v, ok := dcRu[s]; ok {
return v, true
}
return 0, false
}
// --- DC-2: number-scale magnitude checker (ws5.magnitude_checker, 千万 / 数十万) --------------------
// lintMagnitudeScale flags a 千万 (10^7) / 数十万 (~several×10^5) magnitude rendered at a WRONG smaller
// scale. A CORRECT rendering anywhere in the chunk (the ok-suppressor) suppresses the flag (ws5 reference
// parity). The ok-suppressors are case-INsensitive (their data carries the (?i)); the FIRE predicates are
// case-SENSITIVE literal Contains (the reference does not pass re.I to the inner searches). ⚠ 千万 is also
// stock HYPERBOLE whose «тысячи» rendering is in-register (§5 A4). Observability only. All probes/patterns
// are pair langpack DATA — inert when the pair ships no DC2.
func (c *Checkers) lintMagnitudeScale(source, final string) (int, []string) {
if c == nil {
return 0, nil
}
var flags []string
// EMPTY-PROBE GUARD (pack-15): a probe word missing from the pair data must leave the sub-check
// INERT, never spurious. strings.Contains(x, "") is TRUE for every x, so an empty fire word would fire
// this flag on every chunk whose source carries 千万, and an empty veto word would (silently) disable it
// — a data slip that reads as a checker bug. Both words are required for the probe to run at all.
if c.qianwanOKRE != nil && c.qianwanSrc != "" && c.qianwanFireWord != "" && c.qianwanVetoWord != "" &&
strings.Contains(source, c.qianwanSrc) && !c.qianwanOKRE.MatchString(final) {
if strings.Contains(final, c.qianwanFireWord) && !strings.Contains(final, c.qianwanVetoWord) { // case-sensitive, per reference
flags = append(flags, c.msg["dc2_qianwan"])
}
}
if c.shushiwanOKRE != nil && c.shushiwanFireRE != nil && c.shushiwanSrc != "" && strings.Contains(source, c.shushiwanSrc) && !c.shushiwanOKRE.MatchString(final) {
if c.shushiwanFireRE.MatchString(final) {
flags = append(flags, c.msg["dc2_shushiwan"])
}
}
return len(flags), flags
}
// --- DC-6: register-lexicon negative-list (ws5.register_checker) ----------------------------------
// lintRegisterLexicon flags whole-word occurrences of an out-of-register lexeme in the FINAL text. The
// blocklist is the union of two sources, so the ALGORITHM stays generic while the DATA is where it belongs:
// - bookBlocklist — the BOOK's register_blocklist (book.yaml → CheapGateConfig, D39.79 Q4): a genre/book
// property («терем»…), NOT a pair fact, so a second zh→ru book of another genre ships its own or none;
// - c.registerNeg — an OPTIONAL pair-level register list (the zh-ru pack ships none after Q4; kept so a pair
// that ever has a target-register fact independent of any book can still express it in data).
//
// Both are lower-cased (book on load, pair by pack convention) and whole-word matched against the target's
// word-letter set. Empty union → inert (the «inert without data» contract). The detail is a generic English
// diagnostic in Go (like the other gates) — the pair pack no longer carries a genre-bearing message.
func (c *Checkers) lintRegisterLexicon(final string, bookBlocklist []string) (int, []string) {
if c == nil || (len(c.registerNeg) == 0 && len(bookBlocklist) == 0) {
return 0, nil
}
low := []rune(strings.ToLower(final))
hitSet := map[string]bool{}
scan := func(words []string) {
for _, w := range words {
wr := []rune(w)
if len(wr) == 0 {
continue
}
for i := 0; i+len(wr) <= len(low); i++ {
if !text.RunesEqual(low[i:i+len(wr)], wr) {
continue
}
if (i == 0 || !c.isTargetWordLetter(low[i-1])) && (i+len(wr) == len(low) || !c.isTargetWordLetter(low[i+len(wr)])) {
hitSet[w] = true
}
}
}
}
scan(c.registerNeg)
scan(bookBlocklist)
if len(hitSet) == 0 {
return 0, nil
}
hits := make([]string, 0, len(hitSet))
for w := range hitSet {
hits = append(hits, w)
}
sort.Strings(hits)
// Source-neutral wording: a hit may come from EITHER the book register_blocklist OR the optional pair-level
// register_neg (the union above), so the detail must not name one config knob for a hit from the other.
return len(hits), []string{"register: out-of-register target lexis flagged: " + strings.Join(hits, ", ")}
}
// isTargetWordLetter reports whether r is a letter of the TARGET's declared word script — the register-match
// word boundary, data-driven off c.wordScript instead of a hardcoded Cyrillic predicate (строка 79, D39.39).
// Falls back to any-letter when the target declares no word script.
func (c *Checkers) isTargetWordLetter(r rune) bool {
if c.wordScript != nil {
return unicode.Is(c.wordScript, r)
}
return unicode.IsLetter(r)
}
// sourceHasDenseScript reports whether the run's DECLARED source is written in a dense (CJK/kana/Hangul)
// script — the magnitude checker's source-gate (строка 79, D39.39): the 万/億/兆 markers are Han, so a
// declared non-dense source cannot carry a magnitude. Routes through lang.IsDenseScript (the single dense
// registry). Nil scripts (undeclared source) → false, and the caller keeps content-inertness as the fallback.
func (c *Checkers) sourceHasDenseScript() bool {
for _, s := range c.sourceScripts {
if lang.IsDenseScript(s) {
return true
}
}
return false
}
// --- percent-scale checker (成 = tenths) -----------------------------------------------------------
//
// In Chinese, 成 is one tenth: 六成 = 60%, 六成六 = 66%. A common error renders this as a decimal FRACTION
// instead of a percentage (a ~100× scale error). Precision over recall: it fires only when the source has a
// «<count>成[<count>]» (cheng_re, pair data), the output has NO percent form (percent_word / «%»), AND the
// output carries a decimal-fraction cue (decimal_fraction_re). Inert when the pair ships no percent patterns.
func (c *Checkers) lintPercentScale(source, final string) (int, []string) {
if c == nil || c.chengRE == nil || c.decimalFractionRE == nil {
return 0, nil
}
m := c.chengRE.FindStringSubmatch(source)
if m == nil {
return 0, nil
}
low := strings.ToLower(final)
if (c.percentWord != "" && strings.Contains(low, c.percentWord)) || strings.Contains(final, "%") {
return 0, nil // the output uses a percent form — the scale is handled correctly
}
if !c.decimalFractionRE.MatchString(low) {
return 0, nil // no fraction cue — the magnitude was paraphrased, not mis-scaled
}
tens, _ := dcParseCount(m[1], c.numeral)
pct := tens * 10
if m[2] != "" {
if ones, ok := dcParseCount(m[2], c.numeral); ok {
pct += ones
}
}
return 1, []string{renderMsg(c.msg["percent_scale"],
"tens", m[1], "ones", m[2], "pct", strconv.Itoa(pct))}
}
// --- Latin residue in the Russian output -----------------------------------------------------------
//
// A whole Latin WORD left untranslated in a non-Latin target — a leaked word OR a capitalised proper
// noun/brand OR a bank-marker fragment, all leaks (in a Russian target a Latin proper noun should be
// transliterated). Language-general (Latin is not pair data). Defect #6 (D39.39): the old capital-reject is
// dropped; a legitimately-kept brand lives on the per-project allowlist, not on a "has a capital" heuristic.
// Roman numerals are excluded case-insensitively (ALL-CAPS «II» now reaches the check). Metrics: package report.
//
// minLatinResidueLen is the Р4 recall CEILING, recorded inline (not by pointer): measured THRESHOLD=2 yields
// 0 label changes (recall 0.75→0.75 — the one corpus homoglyph «гy» is a MIXED-script token caught by
// tk.mixed, not a length case), so 3 stays the ratified floor. The sweep lives in the package report.
const minLatinResidueLen = 3
func lintLatinResidue(final string, allow map[string]bool) (int, []string) {
hits := map[string]bool{}
rs := []rune(final)
for i := 0; i < len(rs); {
if !isLatinLetterOrDigit(rs[i]) {
i++
continue
}
j := i
hasDigit := false // a token with an inner digit (v1, x2) is not a leaked word
for j < len(rs) && isLatinLetterOrDigit(rs[j]) {
if rs[j] >= '0' && rs[j] <= '9' {
hasDigit = true
}
j++
}
tok := string(rs[i:j])
i = j
low := strings.ToLower(tok)
if !hasDigit && len([]rune(tok)) >= minLatinResidueLen && !isRomanNumeral(low) && !allow[tok] && !allow[low] {
hits[tok] = true
}
}
if len(hits) == 0 {
return 0, nil
}
surfaces := make([]string, 0, len(hits))
for s := range hits {
surfaces = append(surfaces, s)
}
sort.Strings(surfaces)
return len(surfaces), []string{"Latin word left untranslated in the target output: " + strings.Join(surfaces, ", ")}
}
// isLatinLetterOrDigit reports whether r is an ASCII Latin letter or digit (the alphanumeric-token alphabet).
func isLatinLetterOrDigit(r rune) bool {
return (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
}
// isRomanNumeral reports whether a token (folded to lower by the caller) is a Roman numeral (all chars in
// ivxlcdm) — «iii»/«II» read as a numeral, not a leaked word; excluded to hold precision.
func isRomanNumeral(tok string) bool {
for _, r := range tok {
switch r {
case 'i', 'v', 'x', 'l', 'c', 'd', 'm':
default:
return false
}
}
return true
}
// --- broken word forms (target-general) ------------------------------------------------------------
//
// Flags a target word ending in a structurally-impossible suffix — for ru, «-йть», which no well-formed
// Russian word does (the shape of a mangled infinitive, «войть» for «войти»). The suffix set is TARGET data
// (lang.TargetChecks "broken_suffix"), so the ALGORITHM is language-general; a target with no suffix data
// flags nothing. Zero false-positive by construction (only a structural signature, no dictionary).
func (c *Checkers) lintBrokenWord(final string) (int, []string) {
if c == nil || len(c.brokenSuffix) == 0 {
return 0, nil
}
seen := map[string]bool{}
var det []string
for _, w := range c.tokenizeWords(final) {
wl := len([]rune(w))
for _, suf := range c.brokenSuffix {
if wl >= 4 && strings.HasSuffix(w, suf) && !seen[w] {
seen[w] = true
det = append(det, "malformed word: the target has no well-formed word ending in «-"+suf+"»: "+w)
}
}
}
sort.Strings(det)
return len(det), det
}
// isSpokenChevronLine reports whether a chevron-led line is SPOKEN dialogue, by looking for one of the
// target's speech-attribution verbs in it.
//
// The polarity is the load-bearing choice. Asking "is this a THOUGHT?" needs an open-ended list — a live
// 2-chapter run produced «понимал» and «размышлял» within minutes, and thought verbs have no natural
// boundary — and every miss becomes a false flag on correct typography. Asking "is this SPOKEN?" needs a
// small closed list, and every miss becomes SILENCE on a real style clash. Precision over recall is the
// gate's stated bias, so the second failure mode is the right one to have.
//
// A nil receiver / a target with no `speech_verb` data returns false: the mixing rule then never fires,
// which is the same "inert without data" contract the pair checkers follow.
//
// GATED residual (D39.78 verdict-triple — DEFERRED): the verb is searched over the WHOLE line, so a
// sentence-final citation with a stray speech verb elsewhere («„Бессмертный!" — так его прозвали … хотя сам
// он так и не сказал бы») is a latent false positive the flat-citation guard does not cover. It is NOT fixed
// here because the class has ZERO instances in the labelled corpus (the one measured K4b fp is a comma-joined
// THOUGHT, a different shape); scoping the verb to the post-«—» attribution would touch a frozen-number path
// speculatively. Closing it needs a chevron-attribution extractor (speechAttribution is dash-only) AND a
// corpus measurement gate first — build only on measured evidence, per the mandate.
func (c *Checkers) isSpokenChevronLine(line string) bool {
if c == nil || len(c.speechVerb) == 0 {
return false
}
// The inner-marker veto matches WHOLE-WORD (lineHasInnerMarker → containsWholeWordPhrase), so «в уме» does
// NOT muffle a real clash inside «в умении» — the same substring FN the dash-side veto already avoids (row
// 93). The verb probe below stays substring (its whole-line-citation residual is the separate DEFERRED gate
// above). Corpus has 0 instances of the «в умении» shape, so labels are unmoved: a boundary fix, not a metric one.
if c.lineHasInnerMarker(line) {
return false // «пробормотал ПРО СЕБЯ» — a speaking verb qualified into a thought
}
low := strings.ToLower(line)
for _, verb := range c.speechVerb {
if strings.Contains(low, verb) {
return true
}
}
return false
}