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//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 `stemexp` 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 `stemexp`, 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 // «成[]» (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 }