Deep-review fast-follows on the non-EKB city gate:
1. Word-boundary the "екатеринбург" short-circuit in _names_non_ekb_city
(new _EKATERINBURG_RE = \bекатеринбург\b, .search() instead of substring
`in`). A bare-substring check matched "Екатеринбургское шоссе" (a real
street in satellite towns like Pervouralsk) as if it named EKB, wrongly
keeping EKB-only local tiers on for a non-EKB address. Still returns False
for the existing homonym false-positive test case (word boundary is
satisfied there — "екатеринбург" appears as a standalone token).
2. Gate suggest()'s Tier 1 (same root cause as geocode()): the EKB-only
cadastral matchers _cadastral_house_match / _cadastral_forward_sync ran
unconditionally, so a non-EKB oblast autocomplete query could surface an
EKB building via street+house collision. Gated behind
`not _names_non_ekb_city(query)`, mirroring geocode()'s use_local_ekb.
External suggest tiers (DaData/Yandex/Nominatim) untouched — still the
path for non-EKB autocomplete.
3. Extended SVERDLOVSK_OBLAST_CITIES with unambiguous, deal-heavy oblast
cities: алапаевск, сухой лог, кушва, красноуральск, карпинск, нижняя
тура, верхний тагил, нижние серги. Deliberately did NOT add "лесной"
(reviewer flagged as reviewer-optional, left to judgment): DB check
(ekb_geoportal_buildings) confirms a real EKB street named exactly
"Лесной" (3 buildings) — an exact whole-word collision indistinguishable
from ЗАТО Лесной by word-boundary matching alone (unlike "Серова"/"Серов",
which are different word forms). Adding it would misclassify a bare
EKB address ("Лесной, 5", no "Екатеринбург" mention) as non-EKB.
Tests: word-boundary EKB regression test, suggest() gating test, two new
gazetteer entries (Верхняя Пышма multi-word, Сухой Лог).
Prod-cache verification (deep-review) found 3 false-positives: EKB addresses
that literally contain "екатеринбург" but also happen to contain a homonym
of another oblast city inside a neighborhood/posyolok name — ЖК "Заречный"
and пос. Сысерть, both administratively inside Ekaterinburg, not the ZATO
Zarechny / town Sysert they share a name with. These were gated off the
EKB-only local tiers; if Nominatim were momentarily down, a real EKB address
would return None — a latent regression not present before this PR.
Add an explicit-city short-circuit at the top of _names_non_ekb_city: if the
normalized address contains "екатеринбург", return False immediately (EKB
tiers stay on). Cannot affect the true-positive (non-EKB) cases, which never
contain "екатеринбург".
geocode() ran EKB-only local tiers (2a _geoportal_house_match on
ekb_geoportal_buildings, 2c _cadastral_house_match, 2d
_cadastral_forward_sync — all strictly EKB or EKB-dominated) BEFORE the
already oblast-aware external providers (Yandex/Nominatim via OBLAST66_BBOX
+ region cross-check, shipped in c0cbdc2f). _parse_street_house drops the
city, so any non-EKB oblast street+house that collides with an EKB building
(e.g. "проспект Ленина 1" exists in both Nizhny Tagil and EKB) short-circuited
to EKB coordinates.
Add _names_non_ekb_city() — reuses the existing SVERDLOVSK_OBLAST_CITIES
gazetteer (minus Ekaterinburg) and word-boundary matching from
_has_oblast_marker/_DISTRICT_PREFIXES — to detect when an address explicitly
names a different oblast city. Gate tiers 2a/2c/2d behind
`use_local_ekb = not _names_non_ekb_city(address)`; EKB/bare addresses keep
the exact same code path (byte-identical ordering/logic, only additive
gating). Non-EKB addresses fall straight through to Yandex/Nominatim, which
already handle oblast-wide geocoding correctly.
Verified gendesign_cad_buildings is 99.85% EKB-scoped (47043/47111 rows);
ekb_geoportal_buildings is 100% EKB by construction — gating loses no
meaningful local coverage for non-EKB cities.
Follow-up (not in this PR): geocode_cache has rows poisoned by the old
behavior (non-EKB addresses cached with EKB coordinates) — needs a purge.