feat: settlements from the 2020-2021 census, not just the 1111 official cities

The city dictionary only covered cities with formal status (~1100).
Real client addresses are village/settlement, and those never had
an anchor to match on: "рп. Ильинское", "с. Кукуево" from the spec
were undetectable no matter how good the dictionary got, because
ADDRESS_CITY only recognized "г./город".

Replaces cities.txt with settlements.txt (79,362 names, tochno.st's
processing of Rosstat's 2020-2021 census + CIS capitals) and extends
the rule's anchors to рп./пгт/с./д./дер./х./ст-ца/станица/аул/сл./
слобода/аал. isKnownCity renamed to isKnownSettlement to match what
it actually validates now.

Verified: native image startup time and idle memory unchanged despite
the ~70x larger dictionary (prefix-set lookup from the last pass is
O(word length), not O(dictionary size)); no regression in existing
benchmark false-positive thresholds or load-test latency at 1000/1800/
3000 RPS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Максименко Никита Владимирович
2026-09-21 22:21:27 +03:00
co-authored by Claude Sonnet 5
parent f553e79c75
commit c1a1319088
6 changed files with 79564 additions and 1155 deletions
@@ -378,11 +378,17 @@ public class RuleRegistry {
.groups(1)
.vetoedBy(ORGANISATION_NEARBY),
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
// Не только «г.»: перепись, на которой проверяется словарь, покрывает
// сёла, посёлки, деревни, хутора и станицы — «рп. Ильинское», «с. Кукуево»
// из ТЗ без этих якорей не нашлись бы вообще, город там ни при чём.
Rule.of(ADDRESS_CITY, "(?iu:\\bг\\.|\\bгор\\.|\\bгород|\\bрп\\.|\\bпгт\\.?|\\bп\\.|\\bс\\.|\\bсело\\b"
+ "|\\bд\\.|\\bдеревня\\b|\\bдер\\.|\\bх\\.|\\bхутор\\b|\\bст-ца|\\bстаница|\\bаул\\b"
+ "|\\bсл\\.|\\bслобода\\b|\\bаал\\b)\\s?(\\p{Lu}[\\p{L}-]{1,30})\\b", 73)
.groups(1)
.validatedBy(ToponymDictionary::isKnownCity)
.validatedBy(ToponymDictionary::isKnownSettlement)
.vetoedBy(ORGANISATION_NEARBY)
.anchoredBy("г.", "гор", "город"),
.anchoredBy("г.", "гор", "город", "рп.", "пгт", "п.", "с.", "село", "д.", "деревня",
"дер.", "х.", "хутор", "ст-ца", "станица", "аул", "сл.", "слобода", "аал"),
Rule.of(ADDRESS_STREET,
"(?iu:\\bул\\.|\\bулиц\\p{L}*|\\bпр-т|\\bпроспект\\p{L}*|\\bпер\\.|\\bпереул\\p{L}*"
@@ -11,9 +11,16 @@ import java.util.Set;
import java.util.stream.Collectors;
/**
* Словарь городов России — проверка того, что значение, пойманное правилом
* {@code ADDRESS_CITY}, действительно похоже на существующий город, а не на
* произвольное слово с заглавной буквы после «г.».
* Словарь населённых пунктов России — проверка того, что значение, пойманное
* правилом {@code ADDRESS_CITY}, действительно похоже на существующий город,
* село, посёлок или другой населённый пункт, а не на произвольное слово с
* заглавной буквы после якоря.
*
* <p>Не только официальные города (~1100 по классификатору): перепись
* добавляет сёла, деревни, хутора, станицы — «рп. Ильинское», «с. Кукуево»
* из ТЗ находятся ровно за счёт неё. Какой конкретно тип населённого пункта
* стоит перед названием, определяет якорь самого правила в {@link RuleRegistry},
* а не этот словарь — он только подтверждает, что название реальное.
*
* <p>Сравнение по началу слова, а не точным совпадением: падежные окончания
* («в Москве», «из Казани») тем самым покрываются без отдельного разбора
@@ -21,7 +28,7 @@ import java.util.stream.Collectors;
*/
public final class ToponymDictionary {
private static final Set<String> CITY_STEMS = load("/names/cities.txt").stream()
private static final Set<String> SETTLEMENT_STEMS = load("/names/settlements.txt").stream()
.map(Declension::withoutInflectedEnding)
.collect(Collectors.toUnmodifiableSet());
@@ -29,22 +36,23 @@ public final class ToponymDictionary {
}
/**
* Похоже ли значение на название города из словаря в любом падеже.
* Похоже ли значение на название населённого пункта из словаря в любом
* падеже.
*
* <p>Города на согласную склоняются добавлением окончания («Тамбов» →
* <p>Названия на согласную склоняются добавлением окончания («Тамбов» →
* «Тамбове»), поэтому начало слова из словаря — уже достаточный признак.
* Города на гласную меняют последнюю букву («Москва» → «Москве»), для
* Названия на гласную меняют последнюю букву («Москва» → «Москве»), для
* них сравнение идёт по основе без неё — так же, как с личными именами
* в {@link NameDictionary}.
*
* <p>Проверяются префиксы значения по множеству, а не каждая из 1111+
* <p>Проверяются префиксы значения по множеству, а не каждая из ~80 000
* основ по значению: перебор списка на каждое совпадение правила был бы
* в тысячу раз дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
* на порядки дороже, чем нужно — префиксов у слова не больше, чем в нём букв.
*/
public static boolean isKnownCity(String value) {
public static boolean isKnownSettlement(String value) {
String lower = value.strip().toLowerCase(Locale.ROOT);
for (int length = lower.length(); length > 0; length--) {
if (CITY_STEMS.contains(lower.substring(0, length))) {
if (SETTLEMENT_STEMS.contains(lower.substring(0, length))) {
return true;
}
}