From a758c5ef05757cec4b6e17fa121a931e175f78f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20=D0=92=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=B8=D0=BC=D0=B8=D1=80=D0=BE=D0=B2=D0=B8=D1=87?= Date: Mon, 21 Sep 2026 21:24:27 +0300 Subject: [PATCH] test: independent generated benchmark + tests on large mixed text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit benchmark-generated.txt: a fresh dataset (not used to tune the rules) covering every PD type from the spec plus format variations — date order, "серия ... номер ...", register-independence. Surfaced two real gaps worth tracking: FIO detection needs a capitalized first letter, so "клиент иванова мария петровна" (all lowercase) isn't found; the NER cascade flags plain capitalized nouns ("Портрет", "Рим") as names when the base rules alone don't. LargeTextTest: the existing large-text test just repeated one email+card sentence 4000 times, so 27 of 28 PD types never ran at scale. Replaces it with ~400,000 characters built from shuffled lines of the new dataset — recall on it holds at 0.970, matching small-scale numbers, and the NER cascade stays sub-second on the whole thing. Co-Authored-By: Claude Sonnet 5 --- src/test/java/ru/pdguard/BenchmarkTest.java | 20 ++ src/test/java/ru/pdguard/LargeTextTest.java | 192 ++++++++++++++++++++ src/test/resources/benchmark-generated.txt | 121 ++++++++++++ 3 files changed, 333 insertions(+) create mode 100644 src/test/java/ru/pdguard/LargeTextTest.java create mode 100644 src/test/resources/benchmark-generated.txt diff --git a/src/test/java/ru/pdguard/BenchmarkTest.java b/src/test/java/ru/pdguard/BenchmarkTest.java index ab3ddc7..46c6490 100644 --- a/src/test/java/ru/pdguard/BenchmarkTest.java +++ b/src/test/java/ru/pdguard/BenchmarkTest.java @@ -142,6 +142,26 @@ class BenchmarkTest { String.format("полнота на втором отложенном наборе упала до %.3f", result.overallRecall())); } + /** + * Независимый сгенерированный набор — покрывает все типы ПД из ТЗ и вариации + * написания, не встречавшиеся ни в одном из остальных наборов. Правила под + * него не настраивались; пороги низкие по той же причине, что и у второго + * отложенного набора — тест ловит обвал, а не сторожит достигнутое значение. + */ + @Test + void detectionQualityOnGeneratedSet() { + Pipeline stage = Files.isReadable(Path.of(MODEL_PATH)) + ? new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(10_000_000L, 30), + new NameCascade(Optional.of(MODEL_PATH), 16, 4)) + : pipeline; + Result result = measure(stage, "/benchmark-generated.txt", "сгенерированный набор"); + + assertTrue(result.fioF1() >= 0.70, + String.format("F1 по ФИО на сгенерированном наборе упал до %.3f", result.fioF1())); + assertTrue(result.overallRecall() >= 0.70, + String.format("полнота на сгенерированном наборе упала до %.3f", result.overallRecall())); + } + /** * Тот же отложенный набор, но со включённой второй ступенью. Модели нет — * проверка пропускается: в сборке без модели сервис работает на одних правилах. diff --git a/src/test/java/ru/pdguard/LargeTextTest.java b/src/test/java/ru/pdguard/LargeTextTest.java new file mode 100644 index 0000000..37b088a --- /dev/null +++ b/src/test/java/ru/pdguard/LargeTextTest.java @@ -0,0 +1,192 @@ +package ru.pdguard; + +import org.junit.jupiter.api.Test; +import ru.pdguard.config.SystemPolicy; +import ru.pdguard.core.PayloadStore; +import ru.pdguard.core.Pipeline; +import ru.pdguard.core.Span; +import ru.pdguard.detect.NameCascade; +import ru.pdguard.detect.RuleRegistry; +import ru.pdguard.mask.Masker; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Random; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Качество и скорость на большом тексте — не повторе одного и того же + * предложения, а перемешанных строках из {@code benchmark-generated.txt} + * (все типы ПД вперемешку с чистым текстом), растянутых до объёма из ТЗ + * (около 100 000 токенов, ~400 КБ по оценке из README). + * + *

Раздутый повтором одной строки текст проверяет только то, что цикл не + * падает на объёме: под маской всегда один и тот же тип, а остальные правила + * не задействуются вовсе. Здесь размер и разнообразие проверяются вместе. + */ +class LargeTextTest { + + private static final String MODEL_PATH = "models/ru-ner-person.bin"; + private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}"); + + /** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */ + private static final int TARGET_CHARS = 400_000; + + private record Line(String text, List gold) { + } + + /** Строки набора без разметки — чистый текст для перемешивания. */ + private static List loadLines() { + List lines = new ArrayList<>(); + try (InputStream in = LargeTextTest.class.getResourceAsStream("/benchmark-generated.txt"); + BufferedReader reader = new BufferedReader( + new InputStreamReader(Objects.requireNonNull(in), StandardCharsets.UTF_8))) { + String raw; + while ((raw = reader.readLine()) != null) { + String trimmed = raw.trim(); + if (!trimmed.isEmpty() && !trimmed.startsWith("#")) { + lines.add(parse(trimmed)); + } + } + } catch (IOException e) { + throw new IllegalStateException(e); + } + return lines; + } + + private static Line parse(String line) { + StringBuilder text = new StringBuilder(line.length()); + List gold = new ArrayList<>(); + Matcher m = MARKUP.matcher(line); + int cursor = 0; + while (m.find()) { + text.append(line, cursor, m.start()); + int start = text.length(); + text.append(m.group(2)); + gold.add(new Span(start, text.length(), m.group(1), 0)); + cursor = m.end(); + } + text.append(line, cursor, line.length()); + return new Line(text.toString(), gold); + } + + /** + * Перемешивает исходные строки (фиксированный seed — детерминированный + * тест) и склеивает их через перенос строки, пока не наберётся целевой + * объём. Смещения золотых фрагментов пересчитываются под общий текст. + */ + private static Line buildLargeText(int targetChars, long seed) { + List pool = new ArrayList<>(loadLines()); + Random random = new Random(seed); + StringBuilder text = new StringBuilder(targetChars + 1024); + List gold = new ArrayList<>(); + + while (text.length() < targetChars) { + Collections.shuffle(pool, random); + for (Line line : pool) { + int offset = text.length(); + text.append(line.text()).append('\n'); + for (Span span : line.gold()) { + gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0)); + } + if (text.length() >= targetChars) { + break; + } + } + } + return new Line(text.toString(), gold); + } + + /** + * Маскирование и обратное преобразование на большом тексте дают + * побайтово тот же результат, что и исходный текст — при объёме на + * порядок больше, чем в остальных тестах, и с разнородным содержимым, + * а не одним повторяющимся предложением. + */ + @Test + void roundTripOnLargeMixedText() { + Line large = buildLargeText(TARGET_CHARS, 1); + Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), + new PayloadStore(large.text().length() * 2L, 30)); + + long maskStarted = System.nanoTime(); + String masked = pipeline.process(large.text(), "large-mixed-1", SystemPolicy.DEFAULT); + long maskMillis = (System.nanoTime() - maskStarted) / 1_000_000; + + long unmaskStarted = System.nanoTime(); + String restored = pipeline.process(masked, "large-mixed-1", SystemPolicy.DEFAULT); + long unmaskMillis = (System.nanoTime() - unmaskStarted) / 1_000_000; + + assertEquals(large.text(), restored, "демаскирование не восстановило исходный текст"); + assertTrue(maskMillis < 5000, "маскирование " + large.text().length() + " знаков заняло " + maskMillis + " мс"); + assertTrue(unmaskMillis < 1000, "демаскирование заняло " + unmaskMillis + " мс"); + + System.out.printf("%nБольшой текст: %d знаков, маскирование %d мс, демаскирование %d мс%n", + large.text().length(), maskMillis, unmaskMillis); + } + + /** + * Полнота детекции не должна проседать на объёме: каждый золотой + * фрагмент из перемешанных строк обязан быть найден в общем потоке + * текста, а не только когда он единственный в маленькой строке. + */ + @Test + void recallHoldsAtScale() { + Line large = buildLargeText(TARGET_CHARS, 2); + Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1L, 30)); + + List found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT); + int hit = 0; + for (Span gold : large.gold()) { + if (found.stream().anyMatch(f -> f.type().equals(gold.type()) && f.overlaps(gold))) { + hit++; + } + } + double recall = large.gold().isEmpty() ? 1.0 : (double) hit / large.gold().size(); + System.out.printf("%nПолнота на большом тексте: %d из %d (%.3f)%n", hit, large.gold().size(), recall); + + assertTrue(recall >= 0.85, + String.format("полнота на большом тексте упала до %.3f (%d/%d)", recall, hit, large.gold().size())); + } + + /** + * Вторая ступень ограничена числом кандидатов на запрос + * ({@code pdguard.ner.max-candidates}), поэтому объём текста не должен + * превращать её в квадратичную нагрузку — проверяем на том же большом + * тексте, что и остальные тесты, а не на маленьком образце. + */ + @Test + void nameCascadeStaysBoundedOnLargeText() throws IOException { + Path model = Path.of(MODEL_PATH); + if (!Files.isReadable(model)) { + System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю"); + return; + } + Line large = buildLargeText(TARGET_CHARS, 3); + Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), + new PayloadStore(large.text().length() * 2L, 30), + new NameCascade(Optional.of(MODEL_PATH), 16, 4)); + + long started = System.nanoTime(); + pipeline.process(large.text(), "large-cascade-1", SystemPolicy.DEFAULT); + long millis = (System.nanoTime() - started) / 1_000_000; + + System.out.printf("%nБольшой текст со второй ступенью: %d знаков за %d мс%n", + large.text().length(), millis); + assertTrue(millis < 5000, "со второй ступенью обработка заняла " + millis + " мс"); + } +} diff --git a/src/test/resources/benchmark-generated.txt b/src/test/resources/benchmark-generated.txt new file mode 100644 index 0000000..453eb38 --- /dev/null +++ b/src/test/resources/benchmark-generated.txt @@ -0,0 +1,121 @@ +# СГЕНЕРИРОВАННЫЙ НЕЗАВИСИМЫЙ НАБОР. Правила по нему не настраивались — составлен +# после того, как код уже был написан, специально для проверки. Имена, номера +# документов и формулировки не пересекаются с benchmark.txt, benchmark-holdout.txt +# и benchmark-holdout2.txt. Номера карт/ИНН/СНИЛС подобраны так, чтобы проходить +# контрольную сумму — иначе строка проверяла бы не детектор, а качество случайных +# цифр. Покрывает все типы ПД из ТЗ, вариации написания дат и серии/номера, +# регистронезависимость, companion-правило (CVV/PIN/DATE без других ПД рядом) +# и ложные срабатывания (адрес отделения банка, известные исторические фигуры). + +# --- ФИО: порядок слов, инициалы, ролевые слова --- +Заявление подал {{FIO:Смирнов Артём Викторович}} +{{FIO:Виктория Романовна Кузнецова}} оформила вклад на три месяца +Документы подписал {{FIO:Соколов А.П.}} +С уважением, {{FIO:Т.Н. Морозова}} +Клиент {{FIO:Дмитрий Волков}} закрыл счёт досрочно +Заёмщик {{FIO:Орлова}} внесла платёж по графику +Созаёмщик {{FIO:Николай Беляев}} подтвердил согласие +Поручитель {{FIO:Егоров}} не ответил на звонок +ФИО: {{FIO:Григорьев Максим Олегович}} +Ф.И.О. {{FIO:Лебедева Полина Сергеевна}} +Обращение принял оператор, на имя {{FIO:Захарова}} оформлена заявка +Комиссия в составе {{FIO:Козлов В.С.}}, {{FIO:Морозова Т.Н.}}, {{FIO:Беляев Н.А.}} +{{FIO:ИВАНОВ ИВАН ИВАНОВИЧ}} обратился повторно +{{FIO:Марк Соловьёв}} и {{FIO:Анна Григорьева}} подали заявку вместе +Клиент {{FIO:иванова мария петровна}} обратилась повторно + +# --- Ложные срабатывания: известные фигуры, адрес отделения, обычные слова --- +Собрание сочинений Толстого стоит на верхней полке +Портрет Пушкина висит в кабинете литературы +Мы изучаем сонеты Шекспира на этой неделе +Иван Грозный правил в шестнадцатом веке +Третий Рим — известная историческая концепция +Ближайшее отделение банка находится по адресу: г. Казань, ул. Баумана, д. 7 +Юридический адрес банка: г. Москва, ул. Ильинка, д. 9 +Реквизиты головного офиса опубликованы на сайте банка +Расскажи, как оформить кредитную карту онлайн +Какая процентная ставка по вкладу на полгода +Составь напоминание про оплату коммунальных услуг + +# --- Даты: числом в разном порядке, словом, с разными якорями --- +Дата рождения {{BIRTH_DATE:15.03.1990}} +Дата рождения {{BIRTH_DATE:1990-03-15}} +Родился {{BIRTH_DATE:05.11.1988}} в {{BIRTH_PLACE:Новосибирске}} +{{BIRTH_DATE:22.07.1979}} г.р. +Дата рождения {{BIRTH_DATE:двенадцатого мая тысяча девятьсот восемьдесят пятого года}} +Дата выдачи {{PASSPORT_DATE:12.05.2015}} +Дата выдачи паспорта {{PASSPORT_DATE:03.09.2018}} + +# --- Паспорт, серия/номер разными способами, орган выдачи, код подразделения --- +Паспорт гражданина РФ {{PASSPORT:4510 654321}} +Паспорт {{PASSPORT:4511987654}} +серия {{PASSPORT:4512}} номер {{PASSPORT:112233}} +серии {{PASSPORT:45 13}} за номером {{PASSPORT:445566}} +Код подразделения {{DEPT_CODE:770-123}} +к/п {{DEPT_CODE:500011}} +выдан {{PASSPORT_ISSUER:ОУФМС России по г. Москве}} 12.05.2015 +выдан {{PASSPORT_ISSUER:Отделом УФМС по Республике Татарстан}}, действителен + +# --- Документы, удостоверяющие личность, кроме паспорта РФ --- +Водительское удостоверение {{DRIVER_LICENSE:5014 778899}} +в/у {{DRIVER_LICENSE:5015112233}} +Загранпаспорт {{FOREIGN_PASSPORT:71 2233445}} +Военный билет {{MILITARY_ID:АБ 3344556}} +Свидетельство о рождении {{BIRTH_CERTIFICATE:II-АБ №778899}} +Полис ОМС {{MEDICAL_POLICY:1122334455667788}} + +# --- Гражданство, место рождения --- +Гражданство {{CITIZENSHIP:РФ}} +Гражданин {{CITIZENSHIP:России}} +Гражданство {{CITIZENSHIP:Республики Беларусь}} +Место рождения {{BIRTH_PLACE:город Тверь}} + +# --- Адрес: составляющие по отдельности и вместе --- +Индекс {{ADDRESS_POSTCODE:614000}} +Проживает по адресу: г. {{ADDRESS_CITY:Пермь}}, ул. {{ADDRESS_STREET:Ленина}}, д. {{ADDRESS_HOUSE:14}}, кв. {{ADDRESS_FLAT:56}} +Зарегистрирован по адресу: {{ADDRESS_POSTCODE:220015}}, г. {{ADDRESS_CITY:Минск}}, ул. {{ADDRESS_STREET:Немига}}, д. {{ADDRESS_HOUSE:3}} +Страна проживания {{ADDRESS_COUNTRY:Казахстан}} +Курьер привезёт документы на Малую Бронную улицу к вечеру + +# --- Email, телефон --- +Свяжитесь по адресу {{EMAIL:artem.smirnov90@example.com}} +Напишите на {{EMAIL:v.kuznetsova@mail.example}} +Телефон {{PHONE:+7 903 445-67-12}} +Позвоните на {{PHONE:8 (912) 334-56-78}} +Мобильный: {{PHONE:+79261234567}} + +# --- ИНН, СНИЛС, карта: контрольная сумма --- +ИНН {{INN:7707083893}} +ИНН физического лица {{INN:501001233116}} +СНИЛС {{SNILS:112-233-445-95}} +Номер карты {{CARD:4276 1234 5678 1235}} +Оплата картой {{CARD:4276123456781235}} прошла успешно +Держатель карты {{CARDHOLDER:IVAN PETROV}} +на имя {{CARDHOLDER:ANNA VOLKOVA}} + +# --- CVV/PIN: companion-правило (одни, без карты рядом, не маскируются) --- +CVV 482 +Код проверки 917 +PIN 4821 +Пин-код 773311 +Оплата картой {{CARD:4276123456781235}}, CVV {{CVV:482}}, держатель {{CARDHOLDER:IVAN PETROV}} +Оплата картой {{CARD:4276123456781235}}, пин-код {{PIN:4821}} не подошёл + +# --- Дата без якоря: companion-правило --- +Встреча назначена на 14.02.2026 +Клиент {{FIO:Соколов А.П.}} заходил {{DATE:14.02.2026}} + +# --- Сложные предложения: несколько типов ПД в одной строке --- +Клиент {{FIO:Гаврилов Степан Ильич}}, паспорт {{PASSPORT:4520 998877}}, тел. {{PHONE:+7 985 221-33-44}}, e-mail {{EMAIL:s.gavrilov@example.com}} +{{FIO:Кузнецова Виктория Романовна}}, {{BIRTH_DATE:03.04.1992}} г.р., зарегистрирована по адресу: г. {{ADDRESS_CITY:Тверь}}, ул. {{ADDRESS_STREET:Советская}}, д. {{ADDRESS_HOUSE:21}}, кв. {{ADDRESS_FLAT:9}} +Заёмщик {{FIO:Петров Виктор Николаевич}}, гражданство {{CITIZENSHIP:РФ}}, ИНН {{INN:7707083893}}, СНИЛС {{SNILS:112-233-445-95}} + +# --- Обычные банковские вопросы без ПД --- +Как заблокировать карту при утере +Чем отличается ипотека от потребительского кредита +Подготовь черновик ответа на жалобу клиента +Объясни, что такое кредитный рейтинг +Сколько действует справка о доходах +Как подключить SMS-информирование +Расскажи про программу лояльности банка +Какие тарифы действуют для малого бизнеса