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 } 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 } 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Продолжение.", } 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 "Судзуки шёл по коридорам, вспоминая вчерашнюю долгую лекцию.", } 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 (адверсариальное ревью) } 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 "Древний Первопред предок восстал из пепла забытых веков.", // junction-duplicated split «Первопред|предок» } 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, no ≥4 junction overlap "На валуне у реки сидел одинокий путник в сером плаще.", // «валуне» is a VALID form (recall gap) // адверсариальное ревью false positives: "Старик старика видел издалека, но узнал не сразу в тумане.", // polyptoton (старик + старика): containment, NOT a split "Город города не выдаёт — так говаривал его дед у очага.", // город + города: containment "Буква ъ раньше называлась ером и стояла в конце многих слов.", // metalinguistic mention of the letter ъ (standalone) "Хорошо хорошо всё это выглядело только со стороны, издалека.", // capitalized emphatic repeat (case-differing) — containment excludes it } 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, NO homoglyph and NO ≥4-rune junction // overlap — the bare «Первок предок» from exp12 — is NOT offline-detectable without a // morphology/dictionary pass (Ф2). It is caught only when the split leaves a junction // overlap («Первопред предок») or an invalid character. This test PINS that gap so a future // stronger heuristic 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) } } // 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") } }