textmachine/backend/internal/pipeline/sanitizer_test.go

224 lines
16 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)
}
}
// 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")
}
}