textmachine/backend/internal/pipeline/sanitizer.go

623 lines
34 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 pipeline
import (
"fmt"
"regexp"
"sort"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
"golang.org/x/text/width"
)
// sanitizer.go: the output-sanitizer (D30.3) — a deterministic verdict-axis gate on the
// FINAL chunk text that catches the "instant unreadability" defect classes NO existing
// gate detects (exp12 root-cause + flagman §5). It is OPT-IN (Gates.Sanitizer.Enabled),
// following the coverage gate's discipline: when enabled a defect flips the chunk to
// flagged (D2 flag+skip — the contaminated output never commits to TM/export), else it is
// a no-op. Pure and deterministic (no time/rand, sorted detail) so a resume re-derives the
// identical verdict — its rule version folds into the snapshot only when enabled
// (sanitizerVersion, mirroring coverageSnapshot), and moves to verdictSnapshotID once
// content-addressed resume lands (D15.2).
//
// Six classes, each tuned PRECISION over recall (fire only on a high-confidence signal;
// the ambiguous middle stays silent — a false flag turns a good chunk into an export
// placeholder, worse than a missed defect for an opt-in readability gate):
//
// 1. leading service preamble — «Вот отредактированный перевод: …» (gemini 6/6, exp12 §4.1)
// 2. trailing note/edit block — appended «Примечание:/Внесённые правки:/Что изменено:» meta
// 3. markdown ### header — «### Глава 1» (8/54 finals of stage A — D29 finding)
// 4. Latin-script insertion — an untranslated Latin phrase/heavy Latin in the ru output
// 5. broken word form — homoglyph-mixed token or an invalid Cyrillic sign bigram
// 6. CJK-leak in the final — a stray Han ideograph / kana / fullwidth glyph in the ru output
// («…охотники,却有 деньги!» arms/C/17.0 — D38.1; below the ≥15%
// echo threshold classify() already catches, so a sparse leak)
//
// DISPOSITION-BY-CLASS (D38 infra-pack): the classes split into two tiers so a chunk is not
// silently lost to an export placeholder when the leak is a REMOVABLE cosmetic span (D35.4a —
// ch5/ch20 chapter openers dropped whole for a leading «### Глава N»):
// - COSMETIC (strippable, exported + flagged for a human, cosmeticOnly()==true): the markdown
// header (3) and the CJK-leak (6). The leak sits in a bounded span stripCosmetic() removes
// deterministically, leaving readable prose; the chunk is flagged sanitizer_stripped (a
// "auto-cleaned, verify" signal), NOT dropped.
// - SUBSTANTIVE (dropped to a placeholder, current behaviour): the preamble (1), trailing
// note (2), Latin insertion (4) and broken word (5). Their contamination has no reliable
// removable boundary (a preamble may swallow real narration; a Latin clause / broken token is
// lost content), so the output is not trusted — flagged sanitizer_defect + skipped.
//
// HONEST recall gap (documented, precision over recall): the broken-word class catches only
// the two ZERO-false-positive signatures (invalid Cyrillic sign bigrams, mixed Cyrillic/Latin
// token). The junction-duplication split heuristic («Первопред предок») was REMOVED (D33.1б): it
// false-flagged ordinary prose sharing a morphemic boundary. A word broken WITHOUT an invalid
// character («Первок предок» split into two individually-valid tokens) or a grammatical-but-wrong
// form («валуне») is NOT offline-detectable without a morphology/dictionary pass (Ф2) — left silent.
// sanitizerVersion versions the rules below (folded into the snapshot when the gate is
// enabled, like cheapGateVersion/coverageGateVersion): editing any pattern shifts the
// resolved disposition of flagged chunks, so a bump is a loud --resnapshot. [D15.2 note:
// this is a VERDICT version — once content-addressed resume lands it moves to
// verdictSnapshotID and a rule edit re-classifies free instead of re-paying the book.]
// v2 (D33.1/33.2): tail edit-summary «Основные правки для справки:» now flags; the
// junction-duplication split detector was removed (false-flagged prose); markdown/heavy-Latin
// narrowed. A verdict-axis change → loud --resnapshot + golden re-capture (invariant №8).
// v3 (D38 infra-pack): added the CJK-leak class (Han/kana/fullwidth in the ru final) and the
// cosmetic-vs-substantive disposition split (the markdown header and CJK-leak are now STRIPPED
// and exported flagged, not dropped to an empty placeholder — D35.4a). Both shift the resolved
// disposition/export of a flagged chunk → a loud --resnapshot + golden re-capture (invariant №8).
// v4 (D38 infra-pack, adversarial review): stripCosmetic now FOLDS content-bearing fullwidth ASCII
// («»→«123», not deleted) and ideographic comma/period, space-replaces dropped ideographs (no
// word-merge), removes an emptied bracket pair, and no longer reformats a legit «2 : 1» — all shift
// a stripped chunk's EXPORT text → a loud --resnapshot + golden re-capture (invariant №8).
// v5 (D39 слой 6, export-contract): the detector is now a PURE detector over the export-normalised
// text — the RECOVERABLE wrong-glyph renderings (fullwidth ASCII «»→«123», ideographic space
// U+3000, «、»→«,» «。»→«.») are folded by the shared recoverableFold (reusing the x/text width.Fold
// primitive instead of the hand-rolled «r-0xFEE0» arithmetic, closing L5-stripcosmetic-duplicates-nfkc;
// deliberately NOT full NFKC, which would clobber «…»/«№») and therefore NO LONGER FIRE the CJK-leak
// class — only genuinely contentless CJK (Han/kana/CJK brackets & symbols) does. stripCosmetic
// delegates that fold too. This shifts which chunks flag and a stripped chunk's export → a loud
// --resnapshot + golden re-capture (invariant №8). [exportNormalize itself is an EPHEMERAL export
// projection, not a persisted input; only stripCosmetic's output rides a checkpoint, versioned here.]
const sanitizerVersion = "sanitizer-v5"
// sanitizer tuning constants (versioned by sanitizerVersion).
const (
// trailingRegionLines: a note/edit header counts only when it sits in the LAST N
// non-empty lines (a meta block the model appended), so a legitimate mid-narrative
// «Примечание» in dialogue does not fire.
trailingRegionLines = 6
// latinPhraseWords: a run of this many SPACE-adjacent Latin word-tokens is a leaked
// untranslated phrase/sentence (a couple of proper names never reaches it). Set to 5
// (адверсариальное ревью): a ≤4-word quoted foreign motto («sic transit gloria mundi»)
// is legit source-preserved content and must NOT be dropped. ⚠ ACCEPTED recall/precision
// edge: a deliberately-quoted foreign phrase of ≥5 words («to be or not to be») still
// trips it — rare, and the gate is opt-in (a redrive surfaces it to a human).
latinPhraseWords = 5
// latinHeavyShare / latinHeavyTokens: heavy Latin contamination — Latin letters are a
// large share of all letters AND there are several Latin tokens (both required, so a
// short chunk with one long Latin brand name does not trip the share alone).
latinHeavyShare = 0.25
latinHeavyTokens = 5
)
// sanitizerResult is the per-chunk outcome: a count per class plus deterministic detail.
type sanitizerResult struct {
Preamble int `json:"preamble,omitempty"`
TrailingNote int `json:"trailing_note,omitempty"`
MarkdownHeader int `json:"markdown_header,omitempty"`
LatinInsert int `json:"latin_insert,omitempty"`
BrokenWord int `json:"broken_word,omitempty"`
CJKLeak int `json:"cjk_leak,omitempty"`
Detail []string `json:"detail,omitempty"`
}
func (s sanitizerResult) total() int {
return s.Preamble + s.TrailingNote + s.MarkdownHeader + s.LatinInsert + s.BrokenWord + s.CJKLeak
}
// cosmeticOnly reports whether the ONLY classes that fired are the STRIPPABLE cosmetic ones
// (markdown header and/or CJK-leak) — the strip-and-export tier (D35.4a). A single substantive
// class (preamble/trailing-note/latin/broken-word) pulls the whole chunk into the skip tier: its
// contamination has no reliable removable boundary, so the output is not trusted even if a
// cosmetic leak also happens to be present. False when nothing fired.
func (s sanitizerResult) cosmeticOnly() bool {
return s.total() > 0 && s.Preamble == 0 && s.TrailingNote == 0 && s.LatinInsert == 0 && s.BrokenWord == 0
}
// summary is the human-readable disposition detail for a sanitizer flag (deterministic —
// Detail is sorted in sanitizeOutput). Kept short (the first two findings) for the log/row.
func (s sanitizerResult) summary() string {
if len(s.Detail) == 0 {
return "output-sanitizer defect"
}
if len(s.Detail) > 2 {
return strings.Join(s.Detail[:2], "; ") + fmt.Sprintf("; …(+%d)", len(s.Detail)-2)
}
return strings.Join(s.Detail, "; ")
}
// sanitizeOutput runs all six classes over one chunk's FINAL translated text.
func sanitizeOutput(text string) sanitizerResult {
var r sanitizerResult
if det := detectLeadingPreamble(text); det != "" {
r.Preamble++
r.Detail = append(r.Detail, det)
}
if det := detectTrailingNote(text); det != "" {
r.TrailingNote++
r.Detail = append(r.Detail, det)
}
if n, det := detectMarkdownHeaders(text); n > 0 {
r.MarkdownHeader = n
r.Detail = append(r.Detail, det...)
}
if det := detectLatinInsertion(text); det != "" {
r.LatinInsert++
r.Detail = append(r.Detail, det)
}
if n, det := detectBrokenWords(text); n > 0 {
r.BrokenWord = n
r.Detail = append(r.Detail, det...)
}
if n, det := detectCJKLeak(text); n > 0 {
r.CJKLeak = n
r.Detail = append(r.Detail, det...)
}
sort.Strings(r.Detail)
return r
}
// --- 1. leading service preamble ------------------------------------------------
// leadingPreamblePatterns match a service preamble at the very START of the output — the
// «Вот отредактированный перевод: …» leak (gemini 6/6, exp12 §4.1). PRECISION over recall
// (адверсариальное ревью: a lax rule dropped clean chapter openings like «Вот такой вариант
// развития — самый вероятный.», «Вот его вариант: он молча ушёл.»). Two guards make it tight:
// 1. terminator is a COLON only — the em-dash «—» is ubiquitous in narration, so it is NOT
// a terminator (a service preamble is a labeled prefix ending in «:»);
// 2. the phrase must be a genuine TRANSLATION/EDIT service label, not merely «lead + common
// noun»: either an edit-adjective (отредактированн/исправленн/улучшенн) + a перевод/текст/
// вариант noun, or «перевод фрагмента/текста/…», or «ниже приведён … перевод/текст».
//
// Go RE2 \w/\b are ASCII-only, so Cyrillic uses [а-яё].
var leadingPreamblePatterns = []*regexp.Regexp{
// «Вот/Представляю/Привожу … отредактированный/исправленный перевод/текст/вариант …:»
// (the edit-adjective front-gate holds precision, so the tail-to-colon may be long — the
// gemini leak carries «… с соблюдением всех терминов из глоссария:»).
regexp.MustCompile(`(?is)^\s*(?:вот|представляю|привожу|держите)\s+[^\n:]{0,40}?(?:отредактированн|исправленн|улучшенн)[а-яё]+\s+(?:перевод|текст|вариант)[а-яё]*[^\n:]{0,120}?:`),
// «Отредактированный/Исправленный перевод/текст/вариант …:» (label at the very start)
regexp.MustCompile(`(?is)^\s*(?:отредактированн|исправленн|улучшенн)[а-яё]+\s+(?:перевод|текст|вариант)[а-яё]*[^\n:]{0,120}?:`),
// «(Вот) перевод фрагмента/текста/отрывка/главы/черновика:»
regexp.MustCompile(`(?is)^\s*(?:вот\s+)?перевод\s+(?:фрагмента|текста|отрывка|главы|черновика)\s*:`),
// «Ниже приведён/представлен … перевод/отредактированный текст:»
regexp.MustCompile(`(?is)^\s*ниже\s+(?:привед[её]н|представлен|дан|след)[а-яё]*\s+[^\n:]{0,40}?(?:перевод|отредактированн|текст)[а-яё]*[^\n:]{0,20}?:`),
}
func detectLeadingPreamble(text string) string {
t := strings.TrimSpace(text)
for _, re := range leadingPreamblePatterns {
if loc := re.FindStringIndex(t); loc != nil {
return "ведущая служебная преамбула: " + preview(strings.TrimSpace(t[:loc[1]]))
}
}
return ""
}
// --- 2. trailing note / edit block ----------------------------------------------
// trailingNoteRE matches a note/edit meta-HEADER at line start: «Примечание:», «Заметки
// переводчика», «Комментарий:», «(прим. перев.)». PRECISION over recall (адверсариальное
// ревью: «Комментатор захлёбывался…», «Заметка белела на столе.», «Пояснения не требовалось.»,
// «Сноска объясняла обычай.» are ordinary narrative nouns/gerunds — NOT headers). So the header
// word MUST be followed by a COLON, or be «примечание/заметки/комментарий + переводчика/редактора»,
// or be the «(прим. перев./ред.)» marker. Only counted inside the trailing region (below).
var trailingNoteRE = regexp.MustCompile(`(?i)^[*_>\s-]{0,4}(?:(?:примечани[ея]|заметк[аи]|комментари[йяю]|пояснени[ея]|сноск[аи])\s*:|(?:примечани[ея]|заметк[аи]|комментари[йяю])\s+(?:переводчик|редактор)[а-яё]*|прим\.\s*(?:перев|ред)\.?)`)
// editMetaAnywhereRE matches UNAMBIGUOUS editor meta-commentary that is a defect wherever it
// appears (not only trailing): «Внесённые правки:», «Что изменено:», «Список правок:», and the
// gemini tail leak «Основные правки для справки: …» (D33.1а — the A5/5_1 defect the gate exists
// to catch, exp12:229: the leading-preamble rule caught the head form «Вот отредактированный
// перевод:» but NOT this trailing edit-summary). The generic «Изменения:/Правки:» alone were
// REMOVED (адверсариальное ревью — «Изменения — вот что пугало его.» / «Правка — дело тонкое.»
// are common narrative openers), keeping only editor-specific compounds that never occur in prose.
// «основн…правк» is gated to «… для справки» OR a colon so a bare narrative «Основные правки внёс
// редактор» (no summary label) does not fire.
var editMetaAnywhereRE = regexp.MustCompile(`(?im)^[*_>\s-]{0,4}(?:(?:внесённ|внесен)[а-яё]+\s+правк|основн[а-яё]+\s+правк[а-яё]*(?:\s+для\s+справк[а-яё]+|\s*:)|что\s+(?:было\s+)?(?:изменен|исправлен)|список\s+(?:правок|изменений)|список\s+внесённых)`)
func detectTrailingNote(text string) string {
if loc := editMetaAnywhereRE.FindStringIndex(text); loc != nil {
return "утёкший блок правок редактора: " + preview(strings.TrimSpace(firstLineAt(text, loc[0])))
}
lines := nonEmptyLines(text)
start := len(lines) - trailingRegionLines
if start < 0 {
start = 0
}
for _, ln := range lines[start:] {
if trailingNoteRE.MatchString(ln) {
return "хвостовой блок заметок/примечаний: " + preview(strings.TrimSpace(ln))
}
}
return ""
}
// --- 3. markdown ### header -----------------------------------------------------
// markdownHeaderRE matches a markdown ATX header line («### Глава», «# Title»): 16 hashes,
// a space, then a LETTER or DIGIT. «#1» / «№5» / a lone «#» do not match (no space+text); and
// (D33.2) a decorative scene-break rendered with hashes — «# # #», «## ***», «### ---» — no
// longer matches, since the char after the hashes is punctuation, not a header word/number.
var markdownHeaderRE = regexp.MustCompile(`(?m)^\s{0,3}#{1,6}\s+[\p{L}\p{Nd}]`)
func detectMarkdownHeaders(text string) (int, []string) {
var det []string
n := 0
for _, ln := range strings.Split(text, "\n") {
if markdownHeaderRE.MatchString(ln) {
n++
det = append(det, "markdown-заголовок в выходе: "+preview(strings.TrimSpace(ln)))
}
}
return n, det
}
// --- 4. Latin-script insertion --------------------------------------------------
// detectLatinInsertion flags an untranslated Latin PHRASE (≥ latinPhraseWords Latin words
// separated only by SINGLE SPACES — a real «the quick brown fox», not an alphanumeric id) OR
// heavy Latin contamination (Latin ≥ latinHeavyShare of all letters AND ≥ latinHeavyTokens
// Latin tokens). Requiring space-adjacency is load-bearing: a hex/id run like «138fd5c384e3»
// or «a1b2c3d4» yields several single-letter Latin tokens with NO space between them, which
// must NOT read as a Latin phrase (a false positive the golden fixture's tags exposed). A
// handful of Latin proper names stays silent.
func detectLatinInsertion(text string) string {
toks := scriptTokens(text)
maxRun, run, heavyLatTokens := 0, 0, 0
for _, tk := range toks {
switch {
case tk.lat:
if tk.adjacent && run > 0 {
run++ // a Latin word separated from the previous Latin word by one space
} else {
run = 1 // phrase start (digit/punct separator or a non-Latin predecessor)
}
// Heavy-Latin count EXCLUDES capitalized/brand tokens (D33.2): «iPhone», «iPad»,
// «MacBook», «Google» are legit proper nouns, so a list of 5+ brands must not trip the
// heavy-share gate. A genuinely leaked English clause is lowercase past its first word,
// so it still clears latinHeavyTokens (and a real multi-word Latin PHRASE is caught by
// maxRun below, which still counts every Latin token).
if !hasUpperLatin(tk.text) {
heavyLatTokens++
}
if run > maxRun {
maxRun = run
}
case tk.cyr || tk.mixed:
run = 0
}
}
if maxRun >= latinPhraseWords {
return "латинская вставка (фраза из подряд идущих латинских слов)"
}
latinLetters, cyrLetters := 0, 0
for _, ru := range text {
if unicode.Is(unicode.Latin, ru) {
latinLetters++
} else if unicode.Is(unicode.Cyrillic, ru) {
cyrLetters++
}
}
total := latinLetters + cyrLetters
if total > 0 && heavyLatTokens >= latinHeavyTokens && float64(latinLetters)/float64(total) >= latinHeavyShare {
return "латиница-врезки: тяжёлая доля латиницы в ru-выходе"
}
return ""
}
// hasUpperLatin reports whether s contains an uppercase Latin letter — the brand/proper-noun
// signature («iPhone», «MacBook», «Google») excluded from the heavy-Latin token count (D33.2).
func hasUpperLatin(s string) bool {
for _, ru := range s {
if unicode.Is(unicode.Latin, ru) && unicode.IsUpper(ru) {
return true
}
}
return false
}
// --- 5. broken word forms -------------------------------------------------------
// invalidSignRE matches Cyrillic sign bigrams that never occur in valid Russian: a hard/soft
// sign at a word START that is FOLLOWED BY A LETTER (a broken token like «ьзал»), or two signs
// adjacent (ъь/ьъ/ьь/ъъ). Requiring the following letter excludes a STANDALONE «ъ»/«ь» — a
// metalinguistic mention of the letter itself, «Буква ъ называлась ером.» (адверсариальное
// ревью nit). High precision otherwise: word-initial-sign-then-letter and doubled signs are
// impossible in well-formed text. \P{L} (non-letter), not RE2's ASCII-only \b.
var invalidSignRE = regexp.MustCompile(`(?i)(?:^|\P{L})[ъь][а-яё]|[ъь][ъь]`)
func detectBrokenWords(text string) (int, []string) {
var det []string
seen := map[string]bool{}
add := func(s string) {
if !seen[s] {
seen[s] = true
det = append(det, s)
}
}
// (a) invalid Cyrillic sign bigrams.
if invalidSignRE.MatchString(text) {
add("невалидная кириллическая биграмма (мягкий/твёрдый знак в начале слова или подряд)")
}
// (b) mixed Cyrillic+Latin within one letter-run token (homoglyph artifact).
// NOTE: the junction-duplication split detector («Первопред предок») was REMOVED (D33.1б).
// It fired identically on ordinary prose whose adjacent tokens merely share a ≥4-rune
// morphemic boundary («около колонны/колодца», «стало талой» — «около» is a frequent
// preposition → flag-storm on 25 legit chapters), while its only synthetic target had an
// overlap fraction (4/9) INDISTINGUISHABLE from those false positives (4/5): no threshold on
// overlap length or share separates them, and the canonical «Первок предок» was never caught
// anyway (recall gap — TestSanitizerBrokenWordRecallGap). Deterministic split-duplication with
// no invalid sign / homoglyph needs a morphology pass (Ф2), so it stays silent here.
for _, tk := range scriptTokens(text) {
if tk.mixed {
add("смешение кириллицы и латиницы в одном слове (гомоглиф): " + preview(tk.text))
}
}
return len(det), det
}
// --- 6. CJK-leak in the final --------------------------------------------------
// isCJKLeakRune reports whether r is a genuinely CONTENTLESS CJK glyph that has NO place in a Russian
// final AND cannot be recovered by the export normaliser: a Han ideograph, kana, or a CJK
// symbol/bracket/punctuation (U+3001303F — «「」『』【】〜※»). It is ALWAYS consulted on
// recoverableFold-ed text (detectCJKLeak/stripCosmetic fold first), so the RECOVERABLE wrong-glyph
// forms are already gone before this runs: fullwidth ASCII «!1» folded to «!1», the ideographic
// space U+3000 folded to a plain space, and «、»/«。» mapped to «,»/«.». Those must NOT fire — the
// export contract silently fixes them (D39 слой 6); only the contentless class remains here. ACCEPTED
// NARROWING (precision over recall): the old code fired on the WHOLE U+FF00FFEF block; width.Fold now
// recovers the common fullwidth-ASCII/symbol forms to real glyphs (so they no longer flag), but a few
// RARE legacy forms in that block (halfwidth hangul jamo, halfwidth arrows/symbols, the katakana
// middle dot) fold to non-CJK glyphs this predicate does not catch and ship un-flagged — an
// extremely improbable artifact in zh/ja→ru output, traded for not over-flagging the common case. In
// clean Russian prose every one of these is count-0, so the class is high precision. The intrinsic
// classify() already flags a ≥15%-CJK output as an ECHO (cjk_artifact, substantive) BEFORE the
// sanitizer runs, so this class only ever sees a SPARSE leak in otherwise-Russian text.
func isCJKLeakRune(r rune) bool {
switch {
case unicode.Is(unicode.Han, r):
return true
case unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r):
return true
case r >= 0x3001 && r <= 0x303F: // CJK symbols & punctuation, EXCLUDING U+3000 (ideographic space)
return true
}
return false
}
// detectCJKLeak counts maximal runs of leak runes in the final text and returns a deduped detail per
// distinct run. It runs over the RECOVERABLE-FOLD of the text (D39 слой 6): a fullwidth «123» or a
// «、» is a wrong-glyph rendering the export contract silently fixes, so it is NOT a leak — only a
// genuinely contentless Han/kana/CJK-bracket run is. A single run («却有») is enough to fire — the
// class is COSMETIC, so the whole chunk is not lost: stripCosmetic removes the run and the remainder
// exports (flagged for review).
func detectCJKLeak(text string) (int, []string) {
var det []string
seen := map[string]bool{}
n := 0
rs := []rune(recoverableFold(text))
for i := 0; i < len(rs); {
if !isCJKLeakRune(rs[i]) {
i++
continue
}
j := i
for j < len(rs) && isCJKLeakRune(rs[j]) {
j++
}
n++
run := string(rs[i:j])
if !seen[run] {
seen[run] = true
det = append(det, "CJK-утечка в ru-выходе: "+preview(run))
}
i = j
}
return n, det
}
// --- deterministic strip of the COSMETIC classes (D35.4a export fix) -------------
// leadingMarkdownStripRE matches the leading markdown ATX prefix on a line (optional indent, 16
// hashes, blank(s)) directly before a header WORD/number — the same shape markdownHeaderRE fires
// on. It captures the first header rune so ReplaceAll keeps the heading text, dropping only the
// «### » artifact («### Глава 5» → «Глава 5»). A decorative «### ---» scene break (punctuation
// after the hashes) does NOT match, so it is left intact (and it never fired the class either).
var leadingMarkdownStripRE = regexp.MustCompile(`(?m)^[ \t]{0,3}#{1,6}[ \t]+([\p{L}\p{Nd}])`)
// --- export-contract normalization (D39 слой 6, D29.1a) --------------------------
// recoverableFold folds the RECOVERABLE wrong-glyph Unicode renderings a Russian final can carry into
// their intended characters — the shared primitive of the export contract. It is a WIDTH fold
// (width.Fold: fullwidth ASCII «!12» → «!12», the ideographic space U+3000 → a plain space,
// halfwidth kana → fullwidth kana) plus the two CJK punctuation glyphs the width fold leaves alone
// («、» → «,», «。» → «.»). It reuses the x/text width.Fold primitive instead of the old hand-rolled
// «r-0xFEE0» arithmetic (L5-stripcosmetic-duplicates-nfkc) — but deliberately NOT full norm.NFKC,
// which would over-fold legitimate Russian typography (NFKC decomposes «…»→«...», «№²½»→…), a
// regression the golden strip contract forbids. Used by exportNormalize (every final), stripCosmetic
// (before dropping the contentless leak) and detectCJKLeak (which flags only what SURVIVES the fold —
// a genuinely contentless Han/kana/CJK-bracket, never a foldable glyph). Pure.
func recoverableFold(text string) string {
return ideographicPunctFold.Replace(width.Fold.String(text))
}
// ideographicPunctFold maps the two CJK punctuation glyphs the width fold does NOT touch to their
// ASCII equivalents (a «、»/«。» in a Russian final is an unambiguous wrong-glyph rendering of «,»/«.»).
var ideographicPunctFold = strings.NewReplacer("、", ",", "。", ".")
// exportNormalize is the export-contract normalization (D29.1a / target arch слой 6) applied to
// EVERY final text before it ships (chunkrun.ChunkOutcome.FinalText), clean OR flagged-stripped — the
// fix for a cosmetic defect being normalised on a flagged chunk but shipped RAW on a clean one
// (L5-normalization-fused-to-flag-path). It folds the recoverable wrong glyphs (recoverableFold),
// strips a stray combining stress mark on a Cyrillic base («источа́ло»→«источало»,
// L5-diacritics-class-lost), and trims trailing horizontal whitespace per line (never a newline, so
// paragraph structure is kept). It is a PURE, IDEMPOTENT projection over the final text: it never
// touches the wire, the stored checkpoint or the disposition — only the exported bytes — so it is
// NOT a snapshot/verdict input (recomputed each run; a change re-projects existing checkpoints for
// free, no re-bill). Deliberately conservative on whitespace: it does NOT collapse internal doubled
// spaces (a clean chunk's spacing is left as the model wrote it).
func exportNormalize(text string) string {
out := recoverableFold(text)
// NFC composes a canonically-decomposed base+mark (a «й» stored as и+U+0306, a «ё» as е+U+0308)
// BEFORE the combining-strip below, so the strip only removes the NON-composable stress accents
// (U+0301 on a vowel), never corrupting «й»/«ё» into «и»/«е». NFC is canonical-only, so it leaves
// «…» and other compatibility characters intact (unlike NFKC).
out = norm.NFC.String(out)
out = stripCombiningOnCyrillic(out)
return trailingHSpaceRE.ReplaceAllString(out, "")
}
// stripCombiningOnCyrillic drops a combining mark (unicode.Mn) that sits directly on a CYRILLIC base
// — the leaked stress-accent defect the exp12 flagman named («источа́ло» → «источало»,
// L5-diacritics-class-lost). Narrow and zero-FP for Russian prose (which never carries combining
// stress marks); a combining mark on a Latin base (a foreign name «é») is preserved. Runs AFTER NFKC
// (recoverableFold), which has already composed any canonically-composable base+mark.
func stripCombiningOnCyrillic(s string) string {
var b strings.Builder
b.Grow(len(s))
prevCyrillic := false
for _, r := range s {
if unicode.Is(unicode.Mn, r) {
if prevCyrillic {
continue // drop the stress accent on a Cyrillic vowel; leave prevCyrillic set
}
b.WriteRune(r)
continue
}
b.WriteRune(r)
prevCyrillic = unicode.Is(unicode.Cyrillic, r)
}
return b.String()
}
// stripCosmetic deterministically removes the two STRIPPABLE cosmetic leak classes — leading
// markdown headers and CONTENTLESS CJK-leak runs — and tidies the whitespace the removal leaves, so
// the remainder is readable. It is a PURE function (resume re-derives the identical export), invoked
// ONLY on output the sanitizer classified as cosmeticOnly (classifyOutput guarantees the result is
// non-empty and clean — sanitizeOutput(stripCosmetic(x)).total()==0 — before tagging it
// sanitizer_stripped). It never crosses newlines when tidying, so paragraph structure is kept.
func stripCosmetic(text string) string {
// 1) markdown header prefixes → keep the heading text, drop the «#### » artifact.
out := leadingMarkdownStripRE.ReplaceAllString(text, "$1")
// 2) fold the RECOVERABLE wrong-glyph renderings (fullwidth ASCII «»→«123», U+3000→space,
// «、»→«,» «。»→«.») via the shared export-contract fold — no longer a hand-rolled NFKC
// (L5-stripcosmetic-duplicates-nfkc). After this only genuinely contentless CJK remains to drop.
out = recoverableFold(out)
// 3) drop the contentless CJK-leak runes (Han/kana/CJK brackets & symbols) the fold cannot
// recover, each leaving a SINGLE SPACE so adjacent Russian words do not merge («нашёл特产древний»
// → «нашёл древний», not «нашёлдревний»).
var b strings.Builder
b.Grow(len(out))
for _, r := range out {
if isCJKLeakRune(r) {
b.WriteByte(' ')
} else {
b.WriteRune(r)
}
}
out = b.String()
// 4) tidy the seams the removal created, per line (horizontal whitespace only — never a
// newline): collapse doubled spaces, drop a bracket/quote pair the strip emptied («(羅漢拳)» →
// «()» → removed), drop a space now sitting before closing punctuation or after an opening
// bracket/quote (never-correct Russian spacing), and trim trailing blanks. ':' is deliberately
// NOT in the closing-punct set: a space-before-colon is legitimate in a Russian ratio/score/time
// («счёт 2 : 1») and must not be reformatted (adversarial review — false positive).
out = multiSpaceRE.ReplaceAllString(out, " ")
out = emptyBracketRE.ReplaceAllString(out, "")
out = spaceBeforePunctRE.ReplaceAllString(out, "$1")
out = spaceAfterOpenRE.ReplaceAllString(out, "$1")
out = multiSpaceRE.ReplaceAllString(out, " ") // an emptied bracket may leave a doubled space
out = trailingHSpaceRE.ReplaceAllString(out, "")
return out
}
var (
multiSpaceRE = regexp.MustCompile(`[ \t]{2,}`)
emptyBracketRE = regexp.MustCompile(`\([ \t]*\)|«[ \t]*»|\[[ \t]*\]`)
spaceBeforePunctRE = regexp.MustCompile(`[ \t]+([,.!?;…»)\]])`)
spaceAfterOpenRE = regexp.MustCompile(`([«(\[])[ \t]+`)
trailingHSpaceRE = regexp.MustCompile(`(?m)[ \t]+$`)
)
type scriptToken struct {
text string
cyr bool // Cyrillic-only
lat bool // Latin-only
mixed bool // both scripts in one letter run (homoglyph artifact)
adjacent bool // separated from the previous token by exactly a single space (junction check)
}
// scriptTokens splits text into maximal letter-run tokens (letters only; digits/hyphens/
// spaces/punctuation are separators), tagging Cyrillic-only / Latin-only / mixed-script and
// whether the token is separated from the previous token by exactly a single space
// (adjacency — a duplication artifact sits inside a phrase, not across punctuation/newline).
func scriptTokens(text string) []scriptToken {
var out []scriptToken
var b strings.Builder
hasLat, hasCyr := false, false
gap := "" // separator run since the previous token: "" none yet, " " one space, "x" other
flush := func() {
if b.Len() > 0 {
out = append(out, scriptToken{
text: b.String(),
cyr: hasCyr && !hasLat,
lat: hasLat && !hasCyr,
mixed: hasLat && hasCyr,
adjacent: gap == " ",
})
b.Reset()
gap = ""
}
hasLat, hasCyr = false, false
}
for _, ru := range text {
switch {
case unicode.Is(unicode.Latin, ru):
b.WriteRune(ru)
hasLat = true
case unicode.Is(unicode.Cyrillic, ru):
b.WriteRune(ru)
hasCyr = true
default:
flush()
if ru == ' ' && gap == "" {
gap = " "
} else {
gap = "x" // newline / punctuation / tab / a second space breaks adjacency
}
}
}
flush()
return out
}
// --- shared helpers -------------------------------------------------------------
func nonEmptyLines(text string) []string {
var out []string
for _, ln := range strings.Split(text, "\n") {
if strings.TrimSpace(ln) != "" {
out = append(out, ln)
}
}
return out
}
// firstLineAt returns the line containing byte offset off.
func firstLineAt(text string, off int) string {
start := strings.LastIndexByte(text[:off], '\n') + 1
end := strings.IndexByte(text[off:], '\n')
if end < 0 {
return text[start:]
}
return text[start : off+end]
}