textmachine/backend/internal/pipeline/sanitizer_test.go

489 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 (
"strings"
"testing"
)
// sanitizer_test.go — the output-sanitizer (D30.3) unit suite. Per the gate discipline
// (precision over recall) EVERY class asserts BOTH firing on the exp12/flagman defect and
// NON-firing on legitimate prose, so a future rule edit that starts false-flagging real
// Russian text fails loudly here.
func TestSanitizerLeadingPreamble(t *testing.T) {
fire := []string{
// gemini 6/6 leak (exp12 §4.1) — the canonical target.
"Вот отредактированный перевод с соблюдением всех терминов из глоссария:\n\nСудзуки шёл по коридору.",
"Вот отредактированный перевод:\nТекст главы.",
"Вот перевод фрагмента: Судзуки открыл дверь.",
"Ниже приведён отредактированный текст:\nГлава началась.",
"Отредактированный перевод: Судзуки вошёл.",
"Представляю исправленный вариант перевода: он ушёл.",
}
for _, s := range fire {
if got := sanitizeOutput(s); got.Preamble == 0 {
t.Errorf("preamble not detected in %q", preview(s))
}
}
clean := []string{
"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.",
"Вот и настал тот день, которого он так ждал.", // «Вот и…» narrative — no service phrase
"Вот дом стоял на холме, окружённый старыми соснами.", // «Вот дом…»
"— Вот твой перевод, — сказал он и протянул конверт.", // dialogue about a translation, not a preamble label
"Перевод стрелки часов занял у него всё утро.", // «Перевод» as a common noun, no service label
"Ниже по реке стоял старый мост, к которому вела тропа.", // «Ниже…» geographic
// адверсариальное ревью false positives — must NOT fire (em-dash terminator + common nouns):
"Вот такой вариант развития событий — самый вероятный.", // вот + вариант + em-dash, no edit-adjective
"Ниже текста была подпись — размашистая.", // ниже + текст + em-dash, not «ниже приведён … перевод:»
"Вот новая версия карты — с пометками отца.", // вот + версия + em-dash
"Итоговый текст письма — три строки.", // итоговый + текст + em-dash
"Вот его вариант: он молча ушёл.", // вот + вариант + COLON, but no edit-adjective / service phrase
}
for _, s := range clean {
if got := sanitizeOutput(s); got.Preamble != 0 {
t.Errorf("preamble FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
func TestSanitizerTrailingNote(t *testing.T) {
fire := []string{
"Судзуки открыл дверь.\n\nПримечание: имя героя оставлено без перевода.",
"Он ушёл в туман.\n\nВнесённые правки: заменил «фракции» на «школы».",
"Глава закончилась.\n\nЧто изменено: исправлена терминология гу.",
"Текст главы.\n\n*Примечание переводчика:* здесь игра слов.",
"Финал сцены.\n\nЗаметки редактора: стиль приведён к норме.",
"Список правок: 1) термины; 2) вёрстка.\nТекст главы дальше.", // edit-meta anywhere
// D33.1а — the gemini A5/5_1 TAIL leak (exp12:229): the head-preamble rule missed it.
// Reproduces the leaked block STRUCTURE (header + edit list), not the copyrighted chapter.
"Судзуки закрыл дверь книгохранилища и медленно пошёл прочь.\n\n" +
"Основные правки для справки:\n- унифицированы термины культивации;\n- «фракции» заменены на «школы».",
"Текст главы.\n\nОсновные правки: приведены имена к глоссарию.", // colon-header variant
}
for _, s := range fire {
if got := sanitizeOutput(s); got.TrailingNote == 0 {
t.Errorf("trailing note not detected in %q", preview(s))
}
}
clean := []string{
"Судзуки открыл дверь и вошёл в тёмный зал, где ждала тишина.",
"Примечательно, что никто не заметил его исчезновения в ту ночь.", // «Примечательно» ≠ «Примечание:»
"Он оставил заметку на столе и вышел, не оглядываясь назад.", // «заметку» mid-narrative, not a header
"— Что изменилось? — спросил он, оглядывая пустую комнату.", // dialogue, not «Что изменено:»
// адверсариальное ревью false positives — ordinary narrative nouns/gerunds ending a chapter:
"Комментатор захлёбывался от восторга, махая руками над головой.", // «Комментатор» ≠ «Комментарий:»
"Комментируя случившееся, старик лишь развёл руками в тишине.", // «Комментируя» gerund
"Заметка белела на столе, и никто не решался её тронуть.", // «Заметка» + no colon
"Пояснения не требовалось: всё было ясно без единого слова.", // «Пояснения» + no colon (the colon comes later)
"Сноска в конце страницы объясняла древний обычай клана.", // «Сноска» narrative subject
"Изменения — вот что пугало его больше всего на этом свете.", // «Изменения —» common narrative opener
"Правка — дело тонкое, и старый мастер знал это лучше всех.", // «Правка —» aphorism
"Основные правки внёс редактор газеты, и статья пошла в набор.", // «Основные правки» + no «для справки»/colon (D33.1а precision guard)
}
for _, s := range clean {
if got := sanitizeOutput(s); got.TrailingNote != 0 {
t.Errorf("trailing note FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
func TestSanitizerMarkdownHeader(t *testing.T) {
fire := []string{
"### Глава 1\n\nСудзуки шёл по коридору.",
"# Пролог\n\nТекст.",
"Текст первой сцены.\n\n## Часть вторая\n\nПродолжение.",
"### 1. Вступление\n\nТекст.", // digit after hashes is still a header (D33.2)
}
for _, s := range fire {
if got := sanitizeOutput(s); got.MarkdownHeader == 0 {
t.Errorf("markdown header not detected in %q", preview(s))
}
}
clean := []string{
"Он купил дом №5 на этой улице за бесценок прошлой весной.", // «№5» not a header
"Комната #1 была заперта, и ключа ни у кого не нашлось.", // «#1» no space+text
"Язык C# он выучил ещё в университете, но давно забросил.", // «C#» mid-line
"Судзуки шёл по коридорам, вспоминая вчерашнюю долгую лекцию.",
// D33.2 — decorative scene-breaks rendered with hashes: punctuation after the hashes,
// not a header word/number → must NOT fire.
"Сцена оборвалась.\n\n# # #\n\nНачалась новая.", // hash-spaced scene break
"Он умолк.\n\n## ***\n\nУтро выдалось ясным.", // hashes then asterisks
"Конец главы.\n\n### ---\n\nСледующий день.", // hashes then dashes
}
for _, s := range clean {
if got := sanitizeOutput(s); got.MarkdownHeader != 0 {
t.Errorf("markdown header FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
func TestSanitizerLatinInsertion(t *testing.T) {
fire := []string{
"Судзуки открыл книгу и прочитал: the quick brown fox jumps over.", // ≥4-word latin phrase
"He walked into the ancient library at dawn and found nothing.", // whole latin sentence
}
for _, s := range fire {
if got := sanitizeOutput(s); got.LatinInsert == 0 {
t.Errorf("latin insertion not detected in %q", preview(s))
}
}
clean := []string{
"Судзуки купил iPhone и MacBook, чтобы работать в дороге по ночам.", // 2 latin brand names
"На экране светилась надпись Google, и он нажал на неё пальцем.", // single latin name
"Он listened внимательно.", // 1 stray latin word
"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.",
"Идентификатор 138fd5c384e3 и код a1b2c3d4e5f6 записаны в журнале.", // hex/id: single-letter latin tokens separated by digits — NOT a phrase
"На гербе была выбита надпись: sic transit gloria mundi.", // 4-word quoted Latin motto — legit preserved (адверсариальное ревью)
// D33.2 — a list of 5+ capitalized brands is legit proper nouns, not a Latin leak: the
// heavy-count excludes capitalized tokens (this fired under the pre-D33.2 heavy-share gate).
"Он купил iPhone, iPad, MacBook, Apple Watch и Google Pixel вчера.",
}
for _, s := range clean {
if got := sanitizeOutput(s); got.LatinInsert != 0 {
t.Errorf("latin insertion FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
func TestSanitizerBrokenWord(t *testing.T) {
fire := []string{
"Стольь высокая башня терялась в облаках над городом.", // doubled soft sign ьь
"Он вошёл в ьзал и замер на пороге, не решаясь шагнуть.", // soft sign word-initial
}
for _, s := range fire {
if got := sanitizeOutput(s); got.BrokenWord == 0 {
t.Errorf("broken word not detected in %q", preview(s))
}
}
// Mixed Cyrillic+Latin homoglyph in one token (latin 'p'/'e'/'o' inside a Cyrillic word).
if got := sanitizeOutput(еок предок восстал из пепла."); got.BrokenWord == 0 {
t.Errorf("mixed-script homoglyph not detected")
}
clean := []string{
"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю лекцию.",
"— Хорошо, хорошо, — устало ответил он, поднимая обе руки вверх.", // emphatic exact repeat, NOT a split
"Молоко около дома скисло за одну жаркую летнюю ночь под навесом.", // adjacent words
"На валуне у реки сидел одинокий путник в сером плаще.", // «валуне» is a VALID form (recall gap)
// адверсариальное ревью false positives:
"Старик старика видел издалека, но узнал не сразу в тумане.", // polyptoton (старик + старика)
"Город города не выдаёт — так говаривал его дед у очага.", // город + города
"Буква ъ раньше называлась ером и стояла в конце многих слов.", // metalinguistic mention of the letter ъ (standalone)
"Хорошо хорошо всё это выглядело только со стороны, издалека.", // capitalized emphatic repeat
// D33.1б — the removed junction-duplication detector false-flagged these: adjacent tokens
// sharing an incidental ≥4-rune morphemic boundary («коло», «тало»), NOT a broken split.
"Он остановился около колонны и поднял голову к тёмному своду.", // «около колонны» — overlap «коло», no containment
"Путник склонился около колодца и зачерпнул ледяной воды.", // «около колодца» — overlap «коло»
"К утру всё стало талой водой и растеклось по камням двора.", // «стало талой» — overlap «тало»
}
for _, s := range clean {
if got := sanitizeOutput(s); got.BrokenWord != 0 {
t.Errorf("broken word FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
// TestSanitizerBrokenWordRecallGap documents the honest boundary of the DETERMINISTIC
// minimum (D30.3: precision over recall, «это ФЛАГГЕР»): a word split into two
// individually-valid tokens with NO invalid sign and NO homoglyph — the bare «Первок предок»
// from exp12 — is NOT offline-detectable without a morphology/dictionary pass (Ф2). Since the
// junction-duplication heuristic was removed (D33.1б: it false-flagged prose), such a split is
// caught only when it leaves an invalid character or a mixed-script token. This test PINS that
// gap so a future morphology-based detector is a conscious change, not an accident.
func TestSanitizerBrokenWordRecallGap(t *testing.T) {
if got := sanitizeOutput("Первок предок восстал из пепла забытых веков."); got.BrokenWord != 0 {
t.Errorf("unexpected: the pure-orthographic split «Первок предок» is documented as a recall gap, but it fired: %v", got.Detail)
}
}
// TestSanitizerCleanNarrativePassesEntirely guards the whole gate against false positives
// on a realistic clean Russian passage with dialogue, names and numbers.
func TestSanitizerCleanNarrative(t *testing.T) {
passage := strings.Join([]string{
"Судзуки медленно шёл по длинным коридорам Академии магии.",
"",
"— Сегодня я наконец открою дверь книгохранилища, — прошептал он.",
"",
"За окном лил серебристый дождь, собираясь в ручьи на брусчатке двора.",
"Башня библиотеки тонула в тумане, и вершины её не было видно.",
}, "\n")
if got := sanitizeOutput(passage); got.total() != 0 {
t.Fatalf("clean narrative flagged by the sanitizer: %+v", got.Detail)
}
}
// TestSanitizerCJKLeak — the D38.1 class: a stray Han/kana/fullwidth glyph in the ru final.
func TestSanitizerCJKLeak(t *testing.T) {
fire := []string{
// The span the orchestrator verified (arms/C/17.0): a raw Han run mid-Russian-sentence.
"«…охотники,却有 на это лишние деньги!»",
"Судзуки нашёл 特产 древнего клана.", // single Han run
"Разряд资质乙等 остался неясен.", // Han glued to Cyrillic
"Он сказал: すごい, и замолчал.", // kana leak (ja book)
}
for _, s := range fire {
if got := sanitizeOutput(s); got.CJKLeak == 0 {
t.Errorf("CJK-leak not detected in %q: %+v", preview(s), got)
}
}
clean := []string{
"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю долгую лекцию.",
"— Сегодня я открою дверь книгохранилища, — прошептал он, сжимая ключ.",
"На гербе была латинская надпись, но он её не разобрал в темноте.",
"Цена была 123 золотых, и он расплатился не торгуясь.", // ASCII digits — not fullwidth
// U+3000 ideographic space alone is an INDENT artifact, NOT a content leak → must NOT fire
// (research/18 §C#4); the export contract normalises it, but it does not flag the chunk.
" Судзуки медленно шёл по длинному коридору к башне.",
// D39 слой 6: RECOVERABLE wrong-glyph renderings — fullwidth ASCII «123» and the ideographic
// comma «、» — no longer FIRE the leak class. The detector is a pure detector over the folded
// text (fullwidth «»→«123», «、»→«,»), and the export contract fixes them silently. Only
// genuinely contentless CJK (Han/kana above) is flagged.
"Цена была золотых.", // fullwidth digits → silently folded to «123»
"Он вошёл в дом、и дверь закрылась.", // ideographic comma (U+3001) → silently folded to «,»
}
for _, s := range clean {
if got := sanitizeOutput(s); got.CJKLeak != 0 {
t.Errorf("CJK-leak FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
// TestSanitizerCosmeticOnly pins the strip-vs-skip disposition matrix (D35.4a): only the markdown
// header and CJK-leak classes are strippable; any substantive class pulls the chunk into skip.
func TestSanitizerCosmeticOnly(t *testing.T) {
cosmetic := []string{
"### Глава 7\n\nСудзуки открыл седьмую дверь и замер на пороге.", // markdown only
"Судзуки нашёл 特产 древнего клана в тёмном углу книгохранилища.", // cjk only
"### Пролог\n\nСудзуки увидел 却有 на алтаре забытого клана.", // BOTH cosmetic classes
}
for _, s := range cosmetic {
got := sanitizeOutput(s)
if got.total() == 0 || !got.cosmeticOnly() {
t.Errorf("expected cosmeticOnly for %q: %+v", preview(s), got)
}
}
substantive := []string{
"Вот перевод фрагмента: Судзуки открыл дверь.", // preamble (substantive)
// a LEADING preamble co-occurring with a CJK leak → the substantive class wins (skip, not strip).
"Вот перевод фрагмента: Судзуки увидел 特产 на алтаре забытого клана.",
"Судзуки открыл дверь.\n\nПримечание: имя героя оставлено как есть.", // trailing note
"Он прочитал: the quick brown fox jumps over lazy dog today.", // latin phrase
}
for _, s := range substantive {
if got := sanitizeOutput(s); got.total() == 0 || got.cosmeticOnly() {
t.Errorf("expected NOT cosmeticOnly (substantive) for %q: %+v", preview(s), got)
}
}
}
// TestStripCosmetic pins the exact deterministic strip output — the "экспортит текст БЕЗ ведущего
// ###" contract — and that the result is itself clean (the classifyOutput guarantee).
func TestStripCosmetic(t *testing.T) {
cases := []struct{ in, want string }{
// markdown header prefix removed, heading text kept.
{"### Глава 7\n\nСудзуки открыл дверь.", "Глава 7\n\nСудзуки открыл дверь."},
{"# Пролог\nТекст главы.", "Пролог\nТекст главы."},
// Han run removed; the seam is tidied (no double space, no space-before-comma), no word-merge.
{"«…охотники,却有 на это лишние деньги!»", "«…охотники, на это лишние деньги!»"},
{"Судзуки нашёл 特产 древнего клана.", "Судзуки нашёл древнего клана."},
{"нашёл特产древний", "нашёл древний"}, // no spaces in source → strip leaves a space, not a merge
// CONTENT-BEARING fullwidth ASCII is FOLDED, not deleted (adversarial review MAJOR): the number survives.
{"Цена была золотых.", "Цена была 123 золотых."},
{"Индекс найден.", "Индекс ABC найден."},
// ideographic comma/period fold to plain — the clause boundary is kept.
{"Он вошёл в дом、и дверь закрылась。", "Он вошёл в дом,и дверь закрылась."},
// a whitelisted CJK term gloss is now PRESERVED (D39.2 T2 — owner keeps glosses); a co-occurring
// BARE Han run (no head, not in parens) is still stripped, and the gloss parens do not empty out.
{"Кулак Ло Хань (羅漢拳) ударил 特产 врага.", "Кулак Ло Хань (羅漢拳) ударил врага."},
// a legit ratio «2 : 1» is NOT reformatted (colon dropped from the tidy set).
{"### Итог\n\nСчёт был 2 : 1 却有 в пользу гостей.", "Итог\n\nСчёт был 2 : 1 в пользу гостей."},
// U+3000 ideographic space normalised to a plain space (the indent artifact).
{" Судзуки шёл.", " Судзуки шёл."},
}
for _, c := range cases {
got := stripCosmetic(c.in)
if got != c.want {
t.Errorf("stripCosmetic(%q) = %q, want %q", c.in, got, c.want)
}
if san := sanitizeOutput(got); san.total() != 0 {
t.Errorf("stripCosmetic(%q) is not clean: %+v", c.in, san.Detail)
}
}
}
// TestExportNormalize pins the export-contract (D39 слой 6, D29.1a): every final text is normalised
// before it ships — a CLEAN chunk (which the sanitizer never flags) now gets its cosmetic Unicode
// artifacts fixed, not shipped raw (L5-normalization-fused-to-flag-path). Width fold + CJK-punct map
// + combining-stress strip; «…»/«№»/«й»/«ё» preserved; idempotent.
func TestExportNormalize(t *testing.T) {
cases := []struct{ in, want string }{
// The finding's exact case: a clean chunk with only a U+3000 indent shipped raw before.
{" Судзуки медленно шёл к башне.", " Судзуки медленно шёл к башне."},
// fullwidth ASCII folded (a clean chunk, never flagged) — the number/letters survive.
{"Цена была золотых, индекс .", "Цена была 123 золотых, индекс ABC."},
// ideographic comma/period → ASCII on a CLEAN chunk (no Han → sanitizer never fired).
{"Он вошёл в дом、дверь закрылась。", "Он вошёл в дом,дверь закрылась."},
// combining stress accent on a Cyrillic base dropped (L5-diacritics-class-lost): «источа́ло».
{"Он источа́ло тепло за́мка.", "Он источало тепло замка."},
// legit typography PRESERVED (the NFKC-would-clobber regression the width fold avoids).
{"Он ждал… но никто не пришёл. № 5, ½ часа.", "Он ждал… но никто не пришёл. № 5, ½ часа."},
// trailing horizontal whitespace trimmed per line; the newline is kept.
{"Первая строка. \nВторая строка.", "Первая строка.\nВторая строка."},
}
for _, c := range cases {
got := exportNormalize(c.in)
if got != c.want {
t.Errorf("exportNormalize(%q) = %q, want %q", c.in, got, c.want)
}
if again := exportNormalize(got); again != got {
t.Errorf("exportNormalize not idempotent for %q: %q → %q", c.in, got, again)
}
}
// A DECOMPOSED «й» (и U+0438 + combining breve U+0306) / «ё» (е U+0435 + diaeresis U+0308) must be
// COMPOSED by NFC, NOT corrupted to «и»/«е» by the combining-stress strip — the load-bearing reason
// NFC runs BEFORE stripCombiningOnCyrillic in exportNormalize.
if got := exportNormalize("\u0438\u0306"); got != "\u0439" { // decomposed й → composed «й»
t.Errorf("decomposed й corrupted by the combining strip: %q (want «й» U+0439)", got)
}
if got := exportNormalize("\u0435\u0308"); got != "\u0451" { // decomposed ё → composed «ё»
t.Errorf("decomposed ё corrupted by the combining strip: %q (want «ё» U+0451)", got)
}
}
// TestSanitizerCJKGlossWhitelist pins the CJK-gloss whitelist (D39.2 T2, owner decision 16.07: KEEP
// glosses): a balanced CJK run inside parens directly after a Cyrillic/Latin head word is NOT a leak —
// detectCJKLeak does not flag it and stripCosmetic/exportNormalize preserve its bytes; a bare Han run
// (no head, line start, after punctuation, CJK punctuation inside, nested paren) is STILL a leak.
func TestSanitizerCJKGlossWhitelist(t *testing.T) {
// Whitelisted glosses: NOT flagged, PRESERVED verbatim by both strip and normalize.
preserved := []string{
"Кулак Ло Хань (羅漢拳) был силён.", // the owner's canonical case (ASCII parens)
"Он изучил дар (資質) древнего клана.", // gloss with no space before paren-adjacent head
"Приём Ло Хань(羅漢拳) сработал.", // head directly adjacent to «(» (no space)
"Два приёма: Кулак (拳) и Нога (腿) в бою.", // two glosses in a row — both preserved
"Стиль Тайцзи (太極拳 tàijíquán) он знал.", // Han + pinyin with tone marks and a space
"Техника Zhang (張三豐) древних мастеров.", // Latin head word, Han gloss
"Название клана(羅漢)на воротах.", // FULLWIDTH parens — preserved verbatim, not folded
"Кулак (羅漢拳) был силён.", // NBSP between head and paren (finding B) — term kept
"Приём (五行八卦掌 wu xing ba gua zhang) стар.", // 5 space-separated PINYIN syllables (finding A)
"Он изучил приём (九阳真经 jiǔyángzhēnjīng) там.", // JOINED pinyin of a 4-char term (D39.4 finding 2)
}
for _, s := range preserved {
if got := sanitizeOutput(s); got.CJKLeak != 0 {
t.Errorf("gloss FALSE-flagged as leak in %q: %v", preview(s), got.Detail)
}
if got := stripCosmetic(s); got != s {
t.Errorf("stripCosmetic altered a preserved gloss:\n in %q\n got %q", s, got)
}
if got := exportNormalize(s); got != s {
t.Errorf("exportNormalize altered a preserved gloss:\n in %q\n got %q", s, got)
}
}
// FINDING A: a space-separated pinyin gloss must NOT trip the Latin-insertion class and DROP the whole
// chunk (detectLatinInsertion runs gloss-masked). The chunk must be fully clean (nothing fired).
if got := sanitizeOutput("Приём (五行八卦掌 wu xing ba gua zhang) стар."); got.total() != 0 {
t.Errorf("pinyin gloss must not flag the chunk (finding A), got %+v", got)
}
// FINDING B: a DOUBLE space between head and paren — the gloss TERM is kept and the chunk not flagged
// (the tidy normalises the double space to one, so it is not byte-verbatim, hence a separate check).
if got := sanitizeOutput("Кулак (羅漢拳) был силён."); got.CJKLeak != 0 {
t.Errorf("double-space gloss FALSE-flagged (finding B): %+v", got)
}
if got := stripCosmetic("Кулак (羅漢拳) был силён."); !strings.Contains(got, "羅漢拳") {
t.Errorf("double-space gloss term dropped (finding B): %q", got)
}
// D39.4 finding 1: a U+3000 (ideographic-space) gap binds the term to the gloss — the term is kept
// and the chunk not flagged (the U+3000 in the head normalises to a plain space, so not byte-verbatim).
if got := sanitizeOutput("Кулак (羅漢拳) был силён."); got.CJKLeak != 0 {
t.Errorf("U+3000-bound gloss FALSE-flagged (finding 1): %+v", got)
}
if got := stripCosmetic("Кулак (羅漢拳) был силён."); !strings.Contains(got, "羅漢拳") {
t.Errorf("U+3000-bound gloss term dropped (finding 1): %q", got)
}
// NOT a whitelisted gloss → the CJK stays a leak (bare run / no letter head / CJK punctuation /
// nesting / a CLAUSE too long to be a term — finding D).
leak := []string{
"Судзуки нашёл 特产 древнего клана.", // bare Han, no parens at all
"(羅漢拳) — так назвали приём.", // paren gloss at LINE START, no letter head → leak
"Он сказал: (太極) и ушёл.", // after «:» + space, no letter head → leak
"Приём 5 (羅漢拳) в списке.", // digit head, not a letter → not a gloss
"Клан (羅、漢) распался.", // CJK PUNCTUATION «、» inside the parens → not a term gloss
"Он ушёл (却有很多事情要做), но вернулся.", // 8-CJK CLAUSE, not a term gloss (finding D) → leak
"Техника (九字真言破邪法) была забыта тогда.", // 7 CJK > maxGlossCJKRunes → leak (finding D bound)
"Кулак (拳 the iron fist that breaks the sky) взлетел.", // F2: English clause after a token Han → leak
"Приём (拳 antidisestablishmentarianism) стар.", // F2: single over-long non-pinyin Latin word → leak
"Стиль (拳 iron fist of sky) забыт.", // F2: non-proportional Latin (3 toks vs 1 CJK) → leak
}
for _, s := range leak {
if got := sanitizeOutput(s); got.CJKLeak == 0 {
t.Errorf("expected a CJK leak (not a valid gloss) in %q: %+v", preview(s), got)
}
}
// The emptied-bracket regression must NOT reappear: a preserved gloss keeps its parens and term, so
// stripCosmetic never yields «()» — and a real bare leak next to a gloss strips cleanly.
if got := stripCosmetic("Кулак (羅漢拳) и 特产 рядом."); got != "Кулак (羅漢拳) и рядом." {
t.Errorf("mixed gloss+leak strip = %q, want the gloss kept and the bare leak dropped", got)
}
// D39.4 finding 1: a U+3000-BOUND gloss must survive the strip of a CO-OCCURRING bare leak — the
// fold-first detector and the original-bytes strip must agree the U+3000 gloss is protected.
if got := stripCosmetic("Кулак (羅漢拳) и 特产 рядом."); !strings.Contains(got, "羅漢拳") {
t.Errorf("finding 1: U+3000-bound gloss destroyed alongside a bare leak: %q", got)
}
}
// TestSanitizerFoldFirstBypass pins F1 (D39.4): a FULLWIDTH variation of a signature must be detected,
// not slip past the ASCII regexes while exportNormalize manufactures the ASCII defect in the export.
// All six classes detect over the export-normalised (folded) text.
func TestSanitizerFoldFirstBypass(t *testing.T) {
fire := map[string]string{
"fullwidth-colon-preamble": "Вот отредактированный перевод:\n\nСудзуки шёл.", // U+FF1A colon
"fullwidth-markdown": " Глава 5\n\nСудзуки открыл дверь.", // U+FF03 hash ×3
"fullwidth-trailing-note": "Судзуки ушёл.\n\nПримечаниеимя оставлено как есть.", // U+FF1A trailing
"fullwidth-latin-phrase": "Он прочитал: today.", // fullwidth latin
}
for name, s := range fire {
if got := sanitizeOutput(s); got.total() == 0 {
t.Errorf("F1 %s: a fullwidth signature slipped past the detector (bypass): %q → %+v", name, preview(s), got)
}
}
}
// TestSanitizerExportNormalizeInvariant pins the F1 gate/export-contract invariant: for any text the
// sanitizer calls CLEAN, its export-normalised form is ALSO clean — the detector and exportNormalize
// agree, so no fullwidth defect is manufactured in the export of a "clean" chunk. Runs over the whole
// legitimate-prose corpus the class tests use.
func TestSanitizerExportNormalizeInvariant(t *testing.T) {
corpus := []string{
"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю долгую лекцию.",
"— Сегодня я открою дверь книгохранилища, — прошептал он, сжимая ключ.",
"Цена была 123 золотых, и он расплатился не торгуясь.",
" Судзуки медленно шёл по длинному коридору к башне.",
"Цена была золотых.",
"Он вошёл в дом、и дверь закрылась.",
"Он источа́ло тепло за́мка.",
"Он ждал… но никто не пришёл. № 5, ½ часа.",
"Кулак Ло Хань (羅漢拳) был силён.",
"Стиль Тайцзи (太極拳 tàijíquán) он знал.",
"Он купил iPhone, iPad, MacBook, Apple Watch и Google Pixel вчера.",
"На гербе была выбита надпись: sic transit gloria mundi.",
}
for _, s := range corpus {
if sanitizeOutput(s).total() != 0 {
continue // not a clean input — the invariant is stated for clean verdicts
}
if got := sanitizeOutput(exportNormalize(s)); got.total() != 0 {
t.Errorf("F1 invariant: clean %q became a defect after exportNormalize: %+v", preview(s), got)
}
}
}
// TestSanitizerDeterministic asserts the verdict is a pure function of the text (resume-safe).
func TestSanitizerDeterministic(t *testing.T) {
s := "### Заголовок\nВот отредактированный перевод: текст. Стольь странно.\nПримечание: правка."
a, b := sanitizeOutput(s), sanitizeOutput(s)
if a.total() != b.total() || strings.Join(a.Detail, "|") != strings.Join(b.Detail, "|") {
t.Fatalf("sanitizeOutput not deterministic: %v vs %v", a, b)
}
if a.total() == 0 {
t.Fatalf("expected the multi-defect fixture to flag")
}
}