refactor: dictionary lookups from O(dictionary size) to O(word length)
isWellKnown/isKnownCity scanned the whole stem list (60, then 1111+
after the city dictionary) with startsWith on every call. Reversed the
check: try decreasing-length prefixes of the input word against a
HashSet of stems instead — same result, but bounded by word length,
not dictionary size.
Also pulls the vowel-stripping declension trick (shared verbatim
between NameDictionary and ToponymDictionary since the city dictionary
was added) into one Declension helper, and extracts the {{TYPE:value}}
benchmark-line parser — duplicated between BenchmarkTest and
LargeTextTest since the large-text test was added — into
BenchmarkFixtures.
Verified: same 125 tests pass, benchmark F1 numbers unchanged, native
image under 0.5 CPU/250MB shows no regression at 1000/1800/3000 RPS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e00062ea9f
commit
f553e79c75
@@ -0,0 +1,31 @@
|
|||||||
|
package ru.pdguard.detect;
|
||||||
|
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий приём для словарей, сравнивающих слово из текста с основой из списка:
|
||||||
|
* личные имена ({@link NameDictionary}) и города ({@link ToponymDictionary}).
|
||||||
|
*
|
||||||
|
* <p>Слова на согласную склоняются добавлением окончания («Тамбов» → «Тамбове»,
|
||||||
|
* «Пушкин» → «Пушкина») — там основы из списка достаточно как есть. Слова на
|
||||||
|
* гласную меняют последнюю букву («Москва» → «Москве», «Ольга» → «Ольге») —
|
||||||
|
* для них сравнение идёт по основе без неё.
|
||||||
|
*/
|
||||||
|
final class Declension {
|
||||||
|
|
||||||
|
private Declension() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отбрасывает у основы конечную гласную, которая меняется по падежам.
|
||||||
|
* Слова короче четырёх букв не трогает — короткая основа и так шире
|
||||||
|
* большинства падежных форм.
|
||||||
|
*/
|
||||||
|
static String withoutInflectedEnding(String word) {
|
||||||
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
|
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
|
||||||
|
return lower.substring(0, lower.length() - 1);
|
||||||
|
}
|
||||||
|
return lower;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ import java.io.UncheckedIOException;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -41,7 +41,7 @@ public final class NameDictionary {
|
|||||||
private static final long RECHECK_MILLIS = 1000;
|
private static final long RECHECK_MILLIS = 1000;
|
||||||
|
|
||||||
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
|
private static final List<String> GIVEN_NAME_STEMS = load("/names/given-names.txt").stream()
|
||||||
.map(NameDictionary::withoutInflectedEnding)
|
.map(Declension::withoutInflectedEnding)
|
||||||
.distinct()
|
.distinct()
|
||||||
.sorted(Comparator.comparingInt(String::length).reversed())
|
.sorted(Comparator.comparingInt(String::length).reversed())
|
||||||
.toList();
|
.toList();
|
||||||
@@ -49,15 +49,14 @@ public final class NameDictionary {
|
|||||||
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
|
// творительный падежи образует заменой «-а» на «-ой» («Набиуллиной»), а не
|
||||||
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
|
// дописыванием — без отсечения «а» их startsWith не поймает. Тот же приём,
|
||||||
// что и для личных имён.
|
// что и для личных имён.
|
||||||
private static final List<String> BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream()
|
private static final Set<String> BUNDLED_WELL_KNOWN_STEMS = load("/names/well-known.txt").stream()
|
||||||
.map(NameDictionary::withoutInflectedEnding)
|
.map(Declension::withoutInflectedEnding)
|
||||||
.distinct()
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
.toList();
|
|
||||||
|
|
||||||
private static volatile Path externalFile = Path.of(ConfigProvider.getConfig()
|
private static volatile Path externalFile = Path.of(ConfigProvider.getConfig()
|
||||||
.getOptionalValue("pdguard.well-known-file", String.class)
|
.getOptionalValue("pdguard.well-known-file", String.class)
|
||||||
.orElse("config/well-known.txt"));
|
.orElse("config/well-known.txt"));
|
||||||
private static volatile List<String> wellKnownStems = BUNDLED_WELL_KNOWN_STEMS;
|
private static volatile Set<String> wellKnownStems = BUNDLED_WELL_KNOWN_STEMS;
|
||||||
private static volatile long externalTimestamp;
|
private static volatile long externalTimestamp;
|
||||||
private static volatile long lastCheck;
|
private static volatile long lastCheck;
|
||||||
|
|
||||||
@@ -76,17 +75,6 @@ public final class NameDictionary {
|
|||||||
private NameDictionary() {
|
private NameDictionary() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Отбрасывает у основы конечную гласную, которая меняется по падежам:
|
|
||||||
* Ольга → Ольг (Ольги, Ольге, Ольгой), Николай → Никола (Николая, Николаю).
|
|
||||||
*/
|
|
||||||
private static String withoutInflectedEnding(String stem) {
|
|
||||||
if (stem.length() >= 4 && "аяйь".indexOf(stem.charAt(stem.length() - 1)) >= 0) {
|
|
||||||
return stem.substring(0, stem.length() - 1);
|
|
||||||
}
|
|
||||||
return stem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Есть ли среди слов личное имя из словаря в любом падеже.
|
* Есть ли среди слов личное имя из словаря в любом падеже.
|
||||||
*
|
*
|
||||||
@@ -116,14 +104,22 @@ public final class NameDictionary {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Содержит ли текст упоминание известного человека — из сборки или дописанных сверху. */
|
/**
|
||||||
|
* Содержит ли текст упоминание известного человека — из сборки или дописанных
|
||||||
|
* сверху.
|
||||||
|
*
|
||||||
|
* <p>Проверяются префиксы слова по множеству, а не каждая основа по слову:
|
||||||
|
* при тысяче с лишним записей (столько городов в {@link ToponymDictionary},
|
||||||
|
* тот же приём) перебор списка на каждое слово текста был бы заметен, а
|
||||||
|
* префиксов у слова — не больше, чем в нём букв.
|
||||||
|
*/
|
||||||
public static boolean isWellKnown(String value) {
|
public static boolean isWellKnown(String value) {
|
||||||
refreshIfChanged();
|
refreshIfChanged();
|
||||||
List<String> stems = wellKnownStems;
|
Set<String> stems = wellKnownStems;
|
||||||
for (String word : value.split("\\P{L}+")) {
|
for (String word : value.split("\\P{L}+")) {
|
||||||
String lower = word.toLowerCase(Locale.ROOT);
|
String lower = word.toLowerCase(Locale.ROOT);
|
||||||
for (String stem : stems) {
|
for (int length = lower.length(); length > 0; length--) {
|
||||||
if (lower.startsWith(stem.toLowerCase(Locale.ROOT))) {
|
if (stems.contains(lower.substring(0, length))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,14 +148,14 @@ public final class NameDictionary {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
externalTimestamp = Files.getLastModifiedTime(externalFile).toMillis();
|
externalTimestamp = Files.getLastModifiedTime(externalFile).toMillis();
|
||||||
List<String> merged = new ArrayList<>(BUNDLED_WELL_KNOWN_STEMS);
|
Set<String> merged = new HashSet<>(BUNDLED_WELL_KNOWN_STEMS);
|
||||||
for (String line : Files.readAllLines(externalFile, StandardCharsets.UTF_8)) {
|
for (String line : Files.readAllLines(externalFile, StandardCharsets.UTF_8)) {
|
||||||
String trimmed = withoutInflectedEnding(line.trim());
|
String trimmed = Declension.withoutInflectedEnding(line.trim());
|
||||||
if (!trimmed.isEmpty() && !trimmed.startsWith("#") && !merged.contains(trimmed)) {
|
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
||||||
merged.add(trimmed);
|
merged.add(trimmed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wellKnownStems = List.copyOf(merged);
|
wellKnownStems = Set.copyOf(merged);
|
||||||
LOG.infof("Денилист дополнен из %s: %d имён сверх встроенных",
|
LOG.infof("Денилист дополнен из %s: %d имён сверх встроенных",
|
||||||
externalFile.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
|
externalFile.toAbsolutePath(), merged.size() - BUNDLED_WELL_KNOWN_STEMS.size());
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import java.io.InputStream;
|
|||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.UncheckedIOException;
|
import java.io.UncheckedIOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Словарь городов России — проверка того, что значение, пойманное правилом
|
* Словарь городов России — проверка того, что значение, пойманное правилом
|
||||||
@@ -21,11 +21,9 @@ import java.util.Locale;
|
|||||||
*/
|
*/
|
||||||
public final class ToponymDictionary {
|
public final class ToponymDictionary {
|
||||||
|
|
||||||
private static final List<String> CITY_STEMS = load("/names/cities.txt").stream()
|
private static final Set<String> CITY_STEMS = load("/names/cities.txt").stream()
|
||||||
.map(ToponymDictionary::withoutInflectedEnding)
|
.map(Declension::withoutInflectedEnding)
|
||||||
.distinct()
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
.sorted(Comparator.comparingInt(String::length).reversed())
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
private ToponymDictionary() {
|
private ToponymDictionary() {
|
||||||
}
|
}
|
||||||
@@ -38,29 +36,22 @@ public final class ToponymDictionary {
|
|||||||
* Города на гласную меняют последнюю букву («Москва» → «Москве»), для
|
* Города на гласную меняют последнюю букву («Москва» → «Москве»), для
|
||||||
* них сравнение идёт по основе без неё — так же, как с личными именами
|
* них сравнение идёт по основе без неё — так же, как с личными именами
|
||||||
* в {@link NameDictionary}.
|
* в {@link NameDictionary}.
|
||||||
|
*
|
||||||
|
* <p>Проверяются префиксы значения по множеству, а не каждая из 1111+
|
||||||
|
* основ по значению: перебор списка на каждое совпадение правила был бы
|
||||||
|
* в тысячу раз дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
|
||||||
*/
|
*/
|
||||||
public static boolean isKnownCity(String value) {
|
public static boolean isKnownCity(String value) {
|
||||||
String lower = value.strip().toLowerCase(Locale.ROOT);
|
String lower = value.strip().toLowerCase(Locale.ROOT);
|
||||||
if (lower.isEmpty()) {
|
for (int length = lower.length(); length > 0; length--) {
|
||||||
return false;
|
if (CITY_STEMS.contains(lower.substring(0, length))) {
|
||||||
}
|
|
||||||
for (String stem : CITY_STEMS) {
|
|
||||||
if (lower.startsWith(stem)) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String withoutInflectedEnding(String name) {
|
private static Set<String> load(String resource) {
|
||||||
String lower = name.toLowerCase(Locale.ROOT);
|
|
||||||
if (lower.length() >= 4 && "аяйь".indexOf(lower.charAt(lower.length() - 1)) >= 0) {
|
|
||||||
return lower.substring(0, lower.length() - 1);
|
|
||||||
}
|
|
||||||
return lower;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<String> load(String resource) {
|
|
||||||
try (InputStream in = ToponymDictionary.class.getResourceAsStream(resource)) {
|
try (InputStream in = ToponymDictionary.class.getResourceAsStream(resource)) {
|
||||||
if (in == null) {
|
if (in == null) {
|
||||||
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
|
throw new IllegalStateException("Словарь не найден в сборке: " + resource);
|
||||||
@@ -69,10 +60,7 @@ public final class ToponymDictionary {
|
|||||||
return reader.lines()
|
return reader.lines()
|
||||||
.map(String::trim)
|
.map(String::trim)
|
||||||
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
|
||||||
.distinct()
|
.collect(Collectors.toUnmodifiableSet());
|
||||||
// Длинные основы — раньше: «Санкт-Петербург» не должен проигрывать «Санкт».
|
|
||||||
.sorted(Comparator.comparingInt(String::length).reversed())
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
|
throw new UncheckedIOException("Не удалось прочитать словарь " + resource, e);
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package ru.pdguard;
|
||||||
|
|
||||||
|
import ru.pdguard.core.Span;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Общий разбор размеченных наборов {@code {{ТИП:значение}}} — используется
|
||||||
|
* и {@link BenchmarkTest} (замер качества по строкам), и {@link LargeTextTest}
|
||||||
|
* (те же строки, перемешанные и склеенные в большой текст).
|
||||||
|
*/
|
||||||
|
final class BenchmarkFixtures {
|
||||||
|
|
||||||
|
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
|
||||||
|
|
||||||
|
/** Размеченный пример: чистый текст и эталонные фрагменты. */
|
||||||
|
record Sample(String text, List<Span> gold) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private BenchmarkFixtures() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Читает набор построчно, пропуская пустые строки и комментарии {@code #}. */
|
||||||
|
static List<Sample> load(String resource) {
|
||||||
|
List<Sample> samples = new ArrayList<>();
|
||||||
|
try (InputStream in = BenchmarkFixtures.class.getResourceAsStream(resource);
|
||||||
|
BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
String trimmed = line.trim();
|
||||||
|
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
||||||
|
samples.add(parse(trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("Не удалось прочитать " + resource, e);
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
|
||||||
|
static Sample parse(String line) {
|
||||||
|
StringBuilder text = new StringBuilder(line.length());
|
||||||
|
List<Span> 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 Sample(text.toString(), gold);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,6 @@ import ru.pdguard.detect.NameCascade;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
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.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -21,10 +16,7 @@ import java.util.Comparator;
|
|||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||||
@@ -50,12 +42,6 @@ class BenchmarkTest {
|
|||||||
/** Модель второй ступени; собирается отдельно, см. README. */
|
/** Модель второй ступени; собирается отдельно, см. README. */
|
||||||
private static final String MODEL_PATH = "models/ru-ner-person.bin";
|
private static final String MODEL_PATH = "models/ru-ner-person.bin";
|
||||||
|
|
||||||
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
|
|
||||||
|
|
||||||
/** Размеченный пример: чистый текст и эталонные фрагменты. */
|
|
||||||
private record Sample(String text, List<Span> gold) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Итог замера по одному набору. */
|
/** Итог замера по одному набору. */
|
||||||
private record Result(double fioF1, double overallPrecision, double overallRecall,
|
private record Result(double fioF1, double overallPrecision, double overallRecall,
|
||||||
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
|
double falsePositiveRate, int foundFioSpans, int goldFioSpans) {
|
||||||
@@ -203,7 +189,7 @@ class BenchmarkTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Result measure(Pipeline stage, String resource, String title) {
|
private Result measure(Pipeline stage, String resource, String title) {
|
||||||
List<Sample> samples = load(resource);
|
List<BenchmarkFixtures.Sample> samples = BenchmarkFixtures.load(resource);
|
||||||
Map<String, Score> byType = new LinkedHashMap<>();
|
Map<String, Score> byType = new LinkedHashMap<>();
|
||||||
Score anyType = new Score();
|
Score anyType = new Score();
|
||||||
|
|
||||||
@@ -215,7 +201,7 @@ class BenchmarkTest {
|
|||||||
List<String> missedFio = new ArrayList<>();
|
List<String> missedFio = new ArrayList<>();
|
||||||
List<String> overMasked = new ArrayList<>();
|
List<String> overMasked = new ArrayList<>();
|
||||||
|
|
||||||
for (Sample sample : samples) {
|
for (BenchmarkFixtures.Sample sample : samples) {
|
||||||
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
List<Span> found = stage.findPersonalData(sample.text(), SystemPolicy.DEFAULT);
|
||||||
|
|
||||||
String[] goldChars = paint(sample.text().length(), sample.gold());
|
String[] goldChars = paint(sample.text().length(), sample.gold());
|
||||||
@@ -352,38 +338,4 @@ class BenchmarkTest {
|
|||||||
lines.forEach(line -> out.append(" ").append(line).append('\n'));
|
lines.forEach(line -> out.append(" ").append(line).append('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Sample> load(String resource) {
|
|
||||||
List<Sample> samples = new ArrayList<>();
|
|
||||||
try (InputStream in = BenchmarkTest.class.getResourceAsStream(resource);
|
|
||||||
BufferedReader reader = new BufferedReader(
|
|
||||||
new InputStreamReader(Objects.requireNonNull(in, resource), StandardCharsets.UTF_8))) {
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
String trimmed = line.trim();
|
|
||||||
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
|
|
||||||
samples.add(parse(trimmed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new IllegalStateException("Не удалось прочитать " + resource, e);
|
|
||||||
}
|
|
||||||
return samples;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Разбирает разметку {@code {{ТИП:значение}}} в чистый текст и эталонные фрагменты. */
|
|
||||||
private static Sample parse(String line) {
|
|
||||||
StringBuilder text = new StringBuilder(line.length());
|
|
||||||
List<Span> 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 Sample(text.toString(), gold);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,21 +9,14 @@ import ru.pdguard.detect.NameCascade;
|
|||||||
import ru.pdguard.detect.RuleRegistry;
|
import ru.pdguard.detect.RuleRegistry;
|
||||||
import ru.pdguard.mask.Masker;
|
import ru.pdguard.mask.Masker;
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.IOException;
|
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.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Random;
|
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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
@@ -41,66 +34,27 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
class LargeTextTest {
|
class LargeTextTest {
|
||||||
|
|
||||||
private static final String MODEL_PATH = "models/ru-ner-person.bin";
|
private static final String MODEL_PATH = "models/ru-ner-person.bin";
|
||||||
private static final Pattern MARKUP = Pattern.compile("\\{\\{([A-Z_]+):([^}]*)}}");
|
|
||||||
|
|
||||||
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
|
/** Целевой объём: README оценивает 100 000 токенов как ~400 КБ текста. */
|
||||||
private static final int TARGET_CHARS = 400_000;
|
private static final int TARGET_CHARS = 400_000;
|
||||||
|
|
||||||
private record Line(String text, List<Span> gold) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Строки набора без разметки — чистый текст для перемешивания. */
|
|
||||||
private static List<Line> loadLines() {
|
|
||||||
List<Line> 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<Span> 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 — детерминированный
|
* Перемешивает исходные строки (фиксированный seed — детерминированный
|
||||||
* тест) и склеивает их через перенос строки, пока не наберётся целевой
|
* тест) и склеивает их через перенос строки, пока не наберётся целевой
|
||||||
* объём. Смещения золотых фрагментов пересчитываются под общий текст.
|
* объём. Смещения золотых фрагментов пересчитываются под общий текст.
|
||||||
*/
|
*/
|
||||||
private static Line buildLargeText(int targetChars, long seed) {
|
private static BenchmarkFixtures.Sample buildLargeText(int targetChars, long seed) {
|
||||||
List<Line> pool = new ArrayList<>(loadLines());
|
List<BenchmarkFixtures.Sample> pool = new ArrayList<>(BenchmarkFixtures.load("/benchmark-generated.txt"));
|
||||||
Random random = new Random(seed);
|
Random random = new Random(seed);
|
||||||
StringBuilder text = new StringBuilder(targetChars + 1024);
|
StringBuilder text = new StringBuilder(targetChars + 1024);
|
||||||
List<Span> gold = new ArrayList<>();
|
List<Span> gold = new ArrayList<>();
|
||||||
|
|
||||||
while (text.length() < targetChars) {
|
while (text.length() < targetChars) {
|
||||||
Collections.shuffle(pool, random);
|
Collections.shuffle(pool, random);
|
||||||
for (Line line : pool) {
|
for (BenchmarkFixtures.Sample sample : pool) {
|
||||||
int offset = text.length();
|
int offset = text.length();
|
||||||
text.append(line.text()).append('\n');
|
text.append(sample.text()).append('\n');
|
||||||
for (Span span : line.gold()) {
|
for (Span span : sample.gold()) {
|
||||||
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
|
gold.add(new Span(span.start() + offset, span.end() + offset, span.type(), 0));
|
||||||
}
|
}
|
||||||
if (text.length() >= targetChars) {
|
if (text.length() >= targetChars) {
|
||||||
@@ -108,7 +62,7 @@ class LargeTextTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return new Line(text.toString(), gold);
|
return new BenchmarkFixtures.Sample(text.toString(), gold);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,7 +73,7 @@ class LargeTextTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void roundTripOnLargeMixedText() {
|
void roundTripOnLargeMixedText() {
|
||||||
Line large = buildLargeText(TARGET_CHARS, 1);
|
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 1);
|
||||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||||
new PayloadStore(large.text().length() * 2L, 30));
|
new PayloadStore(large.text().length() * 2L, 30));
|
||||||
|
|
||||||
@@ -146,7 +100,7 @@ class LargeTextTest {
|
|||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
void recallHoldsAtScale() {
|
void recallHoldsAtScale() {
|
||||||
Line large = buildLargeText(TARGET_CHARS, 2);
|
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 2);
|
||||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1L, 30));
|
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(), new PayloadStore(1L, 30));
|
||||||
|
|
||||||
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
|
List<Span> found = pipeline.findPersonalData(large.text(), SystemPolicy.DEFAULT);
|
||||||
@@ -176,7 +130,7 @@ class LargeTextTest {
|
|||||||
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
|
System.out.println("Модель " + model.toAbsolutePath() + " не собрана, пропускаю");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Line large = buildLargeText(TARGET_CHARS, 3);
|
BenchmarkFixtures.Sample large = buildLargeText(TARGET_CHARS, 3);
|
||||||
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
Pipeline pipeline = new Pipeline(new RuleRegistry(), new Masker(),
|
||||||
new PayloadStore(large.text().length() * 2L, 30),
|
new PayloadStore(large.text().length() * 2L, 30),
|
||||||
new NameCascade(Optional.of(MODEL_PATH), 16, 4));
|
new NameCascade(Optional.of(MODEL_PATH), 16, 4));
|
||||||
|
|||||||
Reference in New Issue
Block a user