textmachine/backend/internal/pipeline/sanitizer_test.go

205 lines
14 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
}
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(еок предок восстал из пепла."); 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")
}
}