361 lines
24 KiB
Go
361 lines
24 KiB
Go
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("Пеpвок предок восстал из пепла."); 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»→«123», «、»→«,»), and the export contract fixes them silently. Only
|
||
// genuinely contentless CJK (Han/kana above) is flagged.
|
||
"Цена была 123 золотых.", // 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 золотых.", "Цена была 123 золотых."},
|
||
{"Индекс ABC найден.", "Индекс ABC найден."},
|
||
// ideographic comma/period fold to plain — the clause boundary is kept.
|
||
{"Он вошёл в дом、и дверь закрылась。", "Он вошёл в дом,и дверь закрылась."},
|
||
// a parenthetical Han gloss is stripped and the emptied «()» removed (adversarial review).
|
||
{"Кулак Ло Хань (羅漢拳) был силён.", "Кулак Ло Хань был силён."},
|
||
// 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.", "Цена была 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)
|
||
}
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
}
|