tradein/geocoder: инвариант «назван чужой топоним + результат внутри ЕКБ = недостоверно» вместо перечисления городов #2590

Closed
opened 2026-07-31 16:09:36 +00:00 by lekss361 · 3 comments
Owner

Архитектурное следствие из #2582 и его deep-review. Закрывает последний канал подмены города — и снимает зависимость от списков раз и навсегда.

Что осталось незакрытым

PR #2589 чинит выбор локальных екатеринбургских реестров (структурный признак топонима вместо списка из 37 городов). Но внешние провайдеры этим гейтом не покрыты ни до, ни после. Живые записи из geocode_cache, все provider=yandex:

'реж, ленина'                        → 56.83953 / 60.61244   ул. Ленина, Екатеринбург
'реж, краснофлотцев'                 → 56.88996 / 60.63184   ул. Краснофлотцев, Екатеринбург
'реж, калинина'                      → 56.89637 / 60.59007
'арамиль, заводская'                 → 56.83043 / 60.55170
'арамиль, красноармейская ул, 118дк1'→ 56.83744 / 60.59764   центр Екатеринбурга
'свердловская обл., г.о. рефтинский' → 56.83744 / 60.59764

Реж, Арамиль, Рефтинский, Дегтярск отсутствуют в SVERDLOVSK_OBLAST_CITIES и не содержат структурной приставки — значит их не видит ни проверка по списку, ни проверка по маркеру.

Почему не «дописать города в список»

Это воспроизводит ровно тот анти-паттерн, который #2582 убирал: список нужно поддерживать вручную, он всегда неполон, а его неполнота выражается не ошибкой, а тихой неверной ценой. Города области — это ещё и посёлки, сёла, городские округа; перечислить их все и удерживать в актуальном состоянии нереально.

Предлагаемое решение — проверка постфактум

Инвариант, не зависящий от полноты каких-либо списков:

Если адрес называет топоним, который не является Екатеринбургом, а геокодер вернул точку внутри границ Екатеринбурга — результат недостоверен.

Последствия при срабатывании: city_ambiguous = True, отказ от уверенности уровня дома (confidence понижается), результат не кэшируется как точный.

Преимущества перед гейтами на входе:

  • закрывает все три канала разом — локальные реестры, Яндекс, Nominatim;
  • не требует знать город по имени: достаточно понять, что назван какой-то топоним и что он не Екатеринбург;
  • деградация честная и видимая пользователю, а не тихая подмена.

Открытый вопрос для реализации: как надёжно определить «в адресе назван топоним» без перечисления. Возможные сигналы — наличие запятой перед уличной частью, слово до первого типа улицы (ул., пр-кт, пер.), сравнение с полным адресом, который вернул провайдер (там город есть почти всегда).

Проверка

Все шесть записей выше должны получить признак недостоверности. Реальные екатеринбургские адреса — и с городом, и без — не должны его получать (замер на выборке geocode_cache, где provider='cache': сейчас там 393 строки, 342 из них «голые» адреса ЕКБ).

Связано: #2582, #2589, аудит #2583.

Архитектурное следствие из #2582 и его deep-review. Закрывает последний канал подмены города — и снимает зависимость от списков раз и навсегда. ## Что осталось незакрытым PR #2589 чинит выбор локальных екатеринбургских реестров (структурный признак топонима вместо списка из 37 городов). Но **внешние провайдеры этим гейтом не покрыты ни до, ни после**. Живые записи из `geocode_cache`, все `provider=yandex`: ``` 'реж, ленина' → 56.83953 / 60.61244 ул. Ленина, Екатеринбург 'реж, краснофлотцев' → 56.88996 / 60.63184 ул. Краснофлотцев, Екатеринбург 'реж, калинина' → 56.89637 / 60.59007 'арамиль, заводская' → 56.83043 / 60.55170 'арамиль, красноармейская ул, 118дк1'→ 56.83744 / 60.59764 центр Екатеринбурга 'свердловская обл., г.о. рефтинский' → 56.83744 / 60.59764 ``` Реж, Арамиль, Рефтинский, Дегтярск отсутствуют в `SVERDLOVSK_OBLAST_CITIES` и не содержат структурной приставки — значит их не видит ни проверка по списку, ни проверка по маркеру. ## Почему не «дописать города в список» Это воспроизводит ровно тот анти-паттерн, который #2582 убирал: список нужно поддерживать вручную, он всегда неполон, а его неполнота выражается не ошибкой, а **тихой неверной ценой**. Города области — это ещё и посёлки, сёла, городские округа; перечислить их все и удерживать в актуальном состоянии нереально. ## Предлагаемое решение — проверка постфактум Инвариант, не зависящий от полноты каких-либо списков: > Если адрес называет топоним, который **не является Екатеринбургом**, а геокодер вернул точку **внутри границ Екатеринбурга** — результат недостоверен. Последствия при срабатывании: `city_ambiguous = True`, отказ от уверенности уровня дома (`confidence` понижается), результат не кэшируется как точный. Преимущества перед гейтами на входе: - закрывает **все три канала разом** — локальные реестры, Яндекс, Nominatim; - не требует знать город по имени: достаточно понять, что назван *какой-то* топоним и что он не Екатеринбург; - деградация честная и видимая пользователю, а не тихая подмена. Открытый вопрос для реализации: как надёжно определить «в адресе назван топоним» без перечисления. Возможные сигналы — наличие запятой перед уличной частью, слово до первого типа улицы (`ул.`, `пр-кт`, `пер.`), сравнение с полным адресом, который вернул провайдер (там город есть почти всегда). ## Проверка Все шесть записей выше должны получить признак недостоверности. Реальные екатеринбургские адреса — и с городом, и без — не должны его получать (замер на выборке `geocode_cache`, где `provider='cache'`: сейчас там 393 строки, 342 из них «голые» адреса ЕКБ). Связано: #2582, #2589, аудит #2583.
Collaborator

Работаю в PR #2837.

Критерий приёмки на проде — записан ДО факта (2026-08-12, до мержа)

Ближайший прогон, который может показать эффект: geocode_missing_listings, next_run_at = 2026-08-12 18:20:58 UTC (расписание снято с прода до мержа; предыдущий прогон 2026-08-11 18:17).

A. Детерминированная проверка (сразу после деплоя, read-only, в живом контейнере). Прогнать _city_substituted по строкам geocode_cache кодом ИЗ КОНТЕЙНЕРА (не из main):

  • ожидание: 1251 ± новые записи строк подпадают под инвариант (замер до фикса: 1245 yandex + 6 nominatim + 0 локальные тиры, 9514 строк всего);
  • на выборке из них geocode() возвращает confidence='locality', city_ambiguous=True;
  • контроль: голый ЕКБ-адрес («Малышева 30») остаётся exact.

Если число разъедется больше чем на прирост кэша за сутки — фикс мерян не на том коде.

B. Поведенческая проверка (после 18:20:58 UTC). В docker logs tradein-scraper за окно прогона искать маркер geocode city substitution (#2590).

У нуля обязана быть причина — заранее оговорено, как читать пустой результат: ноль срабатываний НЕ означает «гейт не работает» и НЕ означает «всё чисто». Проверять так: посчитать, сколько адресов ИЗ ЭТОГО батча (listings WHERE lat IS NULL AND is_active, top-N по listings_count) дают _claimed_locality() is not None и имя ≠ Екатеринбург.

  • 0 таких адресов в батче → «неприменимо», ноль честный;
  • 0 таких адресов и 0 маркеров в логе → гейт не сработал, разбираться.

C. Не является критерием: отсутствие ошибок в GlitchTip, зелёный деплой, SENTRY_RELEASE.

Что этим PR НЕ закрыто (потолки, чтобы не читались как «сделано»)

  1. Локальные ЕКБ-реестры не проверяются: геопортал отдаёт full_address = «Улица, дом» без НП — свидетельства нет, вердикта нет. Дыра #2589 («Реж, Ленина 5» С номером дома идёт в геопортал, а не к внешнему провайдеру) остаётся открытой.
  2. Подмена на соседний город («режевской, ленина» → Берёзовский, живая запись прода) под инвариант не подпадает — результат не внутри Екатеринбурга. Это формулировка самого #2590.
  3. suggest() (автокомплит) инвариантом не покрыт — покрыт только geocode().
  4. Чистка 1251 отравленной записи кэша не делается: они нейтрализованы на чтении, но продолжают лежать в таблице.

Побочный замер (для #2777 / эпика)

572 листинга получили координаты из отравленных записей кэша, 17 активны сегодня; 5 из них стоят в точке 56.83744/60.59764 — яндексовый центроид «Екатеринбург». Экспозиция оценок: у 164 из 1053 отравленный активный листинг в радиусе 1 км, у 459 — в радиусе 2 км (верхняя граница «могли зацепить»).

Работаю в PR #2837. ## Критерий приёмки на проде — записан ДО факта (2026-08-12, до мержа) Ближайший прогон, который может показать эффект: **`geocode_missing_listings`, `next_run_at = 2026-08-12 18:20:58 UTC`** (расписание снято с прода до мержа; предыдущий прогон 2026-08-11 18:17). **A. Детерминированная проверка (сразу после деплоя, read-only, в живом контейнере).** Прогнать `_city_substituted` по строкам `geocode_cache` кодом ИЗ КОНТЕЙНЕРА (не из main): - ожидание: **1251 ± новые записи** строк подпадают под инвариант (замер до фикса: 1245 yandex + 6 nominatim + 0 локальные тиры, 9514 строк всего); - на выборке из них `geocode()` возвращает `confidence='locality'`, `city_ambiguous=True`; - контроль: голый ЕКБ-адрес («Малышева 30») остаётся `exact`. Если число разъедется больше чем на прирост кэша за сутки — фикс мерян не на том коде. **B. Поведенческая проверка (после 18:20:58 UTC).** В `docker logs tradein-scraper` за окно прогона искать маркер `geocode city substitution (#2590)`. **У нуля обязана быть причина** — заранее оговорено, как читать пустой результат: ноль срабатываний НЕ означает «гейт не работает» и НЕ означает «всё чисто». Проверять так: посчитать, сколько адресов ИЗ ЭТОГО батча (`listings WHERE lat IS NULL AND is_active`, top-N по `listings_count`) дают `_claimed_locality() is not None` и имя ≠ Екатеринбург. - 0 таких адресов в батче → «неприменимо», ноль честный; - >0 таких адресов и 0 маркеров в логе → **гейт не сработал**, разбираться. **C. Не является критерием:** отсутствие ошибок в GlitchTip, зелёный деплой, `SENTRY_RELEASE`. ## Что этим PR НЕ закрыто (потолки, чтобы не читались как «сделано») 1. Локальные ЕКБ-реестры не проверяются: геопортал отдаёт `full_address` = «Улица, дом» без НП — свидетельства нет, вердикта нет. Дыра #2589 («Реж, Ленина 5» С номером дома идёт в геопортал, а не к внешнему провайдеру) остаётся открытой. 2. Подмена на соседний город («режевской, ленина» → Берёзовский, живая запись прода) под инвариант не подпадает — результат не внутри Екатеринбурга. Это формулировка самого #2590. 3. `suggest()` (автокомплит) инвариантом не покрыт — покрыт только `geocode()`. 4. Чистка 1251 отравленной записи кэша не делается: они нейтрализованы на чтении, но продолжают лежать в таблице. ## Побочный замер (для #2777 / эпика) 572 листинга получили координаты из отравленных записей кэша, 17 активны сегодня; 5 из них стоят в точке 56.83744/60.59764 — яндексовый центроид «Екатеринбург». Экспозиция оценок: у 164 из 1053 отравленный активный листинг в радиусе 1 км, у 459 — в радиусе 2 км (верхняя граница «могли зацепить»).
Collaborator

PR #2837 смержен, деплой live (контейнер tradein-backend пересоздан 2026-08-12 14:32 UTC, _city_substituted присутствует в /app/app/services/geocoder.py).

A. Прод-верификация числом — выполнена (код ИЗ живого контейнера, только SELECT)

строк в geocode_cache: 9514
  yandex     всего   5646  подмен   1245
  nominatim  всего   3226  подмен      6
  cache      всего    642  подмен      0
ИТОГО подмен: 1251 | разных НП: 365
контроль («малышева 30» — НП не назван): _claimed_locality=None, _city_substituted=False

Совпало с замером до мержа до строки — то есть мерян тот код, что смержен.

Инкремент над уже существовавшим coarse-гейтом — 52 записи

Из 1251 подмены 1199 и так попадали под _geocode_is_coarse (ответ провайдера без номера дома = центроид НП). Реально новое поведение — на 52 записях, где провайдер отдал дом:

  • Урюпинск (Волгоградская обл.) → «с. Горный Щит, ЕКБ»; Анапа (Краснодарский край) → «с. Горный Щит»; Севастополь → «ул. Челюскинцев, ЕКБ»; Камышин → «ул. Тружеников, ЕКБ»; Санкт-Петербург → «ул. Куйбышева, ЕКБ»;
  • дом-уровневые подмены посёлков: Кедровка ×5, Сарапулка, Курганово ×2, Новоалексеевское, Красный, Зелёный Бор.

Ложные срабатывания в этом наборе: 2 из 52 — «уральская, 52, екатеринбург» и «малышева, 8 марта»: первым сегментом названа улица, а не НП, и провайдер её потерял (ответил другой улицей). Оба — не ошибка города; цена = понижение confidence, координаты остаются. Класс потолка зафиксирован в докстринге.

B. Поведенческая проверка — окно ещё не наступило, разметка сделана ДО него

Ближайший прогон geocode_missing_listings2026-08-12 18:20:58 UTC. Замер очереди СЕЙЧАС (до окна, тем же SELECT, что делает сама задача):

адресов в батче (top-200): 200
из них называют НП != Екатеринбург: 5
   посёлок Трубный, ул. Зои Космодемьянской,11   city='Каменск-Уральский'
   Пгт. Сокол, 122                               city='Нижний Тагил'
   посёлок имени Чкалова, ул. Слесарей,8         city='Каменск-Уральский'
   посёлок Трубный, ул. Зои Космодемьянской,10А  city='Каменск-Уральский'
   посёлок УАЗ, Набережная ул.,15                city='Каменск-Уральский'

Предсказание, записанное до факта: у всех пяти заполнена колонка city известным городом области → known_city_hint отдаёт хинт → _ekb_local_tiers_allowed закрывает ЕКБ-реестры, а Nominatim получает город префиксом и, скорее всего, вернёт правильный НП → топоним доживёт → инвариант НЕ сработает. То есть ожидаемый результат этого прогона — 0 маркеров, и это «неприменимо», а не «гейт не работает».

Читать итог 18:20 так:

  • 0 маркеров И все 5 адресов получили координаты вне ЕКБ-bbox → ноль честный, причина «защищено выше по потоку» (хинт из колонки city);
  • 0 маркеров И хоть один из 5 получил координаты внутри ЕКБ-bbox → гейт не сработал, разбираться;
  • ≥1 маркер geocode city substitution (#2590) в docker logs tradein-scraper → сработал на живом трафике.

Основной защитный эффект этого PR — не на ночном батче, а на чтении: 1251 отравленная запись кэша (TTL 90 дней) перестала отдаваться как точная, и это уже подтверждено числом в пункте A.

Осталось открытым (не закрывать issue как «всё сделано»)

  1. Локальные ЕКБ-реестры инвариантом не проверяются — их full_address не называет НП. Дыра #2589 («Реж, Ленина 5» с номером дома → геопортал) открыта.
  2. Подмена на соседний город (Реж → Берёзовский) под формулировку «внутри Екатеринбурга» не подпадает.
  3. suggest() не покрыт.
  4. 1251 запись кэша не вычищена (нейтрализована на чтении; чистка — отдельное решение, обратимого способа удаления сюда не включал сознательно).
PR #2837 смержен, деплой live (контейнер `tradein-backend` пересоздан 2026-08-12 14:32 UTC, `_city_substituted` присутствует в `/app/app/services/geocoder.py`). ## A. Прод-верификация числом — выполнена (код ИЗ живого контейнера, только SELECT) ``` строк в geocode_cache: 9514 yandex всего 5646 подмен 1245 nominatim всего 3226 подмен 6 cache всего 642 подмен 0 ИТОГО подмен: 1251 | разных НП: 365 контроль («малышева 30» — НП не назван): _claimed_locality=None, _city_substituted=False ``` Совпало с замером до мержа до строки — то есть мерян тот код, что смержен. ## Инкремент над уже существовавшим coarse-гейтом — 52 записи Из 1251 подмены **1199** и так попадали под `_geocode_is_coarse` (ответ провайдера без номера дома = центроид НП). Реально новое поведение — на **52** записях, где провайдер отдал дом: - **Урюпинск** (Волгоградская обл.) → «с. Горный Щит, ЕКБ»; **Анапа** (Краснодарский край) → «с. Горный Щит»; **Севастополь** → «ул. Челюскинцев, ЕКБ»; **Камышин** → «ул. Тружеников, ЕКБ»; **Санкт-Петербург** → «ул. Куйбышева, ЕКБ»; - дом-уровневые подмены посёлков: Кедровка ×5, Сарапулка, Курганово ×2, Новоалексеевское, Красный, Зелёный Бор. **Ложные срабатывания в этом наборе: 2 из 52** — «уральская, 52, екатеринбург» и «малышева, 8 марта»: первым сегментом названа улица, а не НП, и провайдер её потерял (ответил другой улицей). Оба — не ошибка города; цена = понижение confidence, координаты остаются. Класс потолка зафиксирован в докстринге. ## B. Поведенческая проверка — окно ещё не наступило, разметка сделана ДО него Ближайший прогон `geocode_missing_listings` — **2026-08-12 18:20:58 UTC**. Замер очереди СЕЙЧАС (до окна, тем же SELECT, что делает сама задача): ``` адресов в батче (top-200): 200 из них называют НП != Екатеринбург: 5 посёлок Трубный, ул. Зои Космодемьянской,11 city='Каменск-Уральский' Пгт. Сокол, 122 city='Нижний Тагил' посёлок имени Чкалова, ул. Слесарей,8 city='Каменск-Уральский' посёлок Трубный, ул. Зои Космодемьянской,10А city='Каменск-Уральский' посёлок УАЗ, Набережная ул.,15 city='Каменск-Уральский' ``` **Предсказание, записанное до факта:** у всех пяти заполнена колонка `city` известным городом области → `known_city_hint` отдаёт хинт → `_ekb_local_tiers_allowed` закрывает ЕКБ-реестры, а Nominatim получает город префиксом и, скорее всего, вернёт правильный НП → топоним доживёт → **инвариант НЕ сработает**. То есть **ожидаемый результат этого прогона — 0 маркеров, и это «неприменимо», а не «гейт не работает»**. Читать итог 18:20 так: - 0 маркеров И все 5 адресов получили координаты вне ЕКБ-bbox → ноль честный, причина «защищено выше по потоку» (хинт из колонки `city`); - 0 маркеров И хоть один из 5 получил координаты внутри ЕКБ-bbox → **гейт не сработал**, разбираться; - ≥1 маркер `geocode city substitution (#2590)` в `docker logs tradein-scraper` → сработал на живом трафике. Основной защитный эффект этого PR — не на ночном батче, а на чтении: 1251 отравленная запись кэша (TTL 90 дней) перестала отдаваться как точная, и это уже подтверждено числом в пункте A. ## Осталось открытым (не закрывать issue как «всё сделано») 1. Локальные ЕКБ-реестры инвариантом не проверяются — их `full_address` не называет НП. Дыра #2589 («Реж, Ленина 5» с номером дома → геопортал) открыта. 2. Подмена на соседний город (Реж → Берёзовский) под формулировку «внутри Екатеринбурга» не подпадает. 3. `suggest()` не покрыт. 4. 1251 запись кэша не вычищена (нейтрализована на чтении; чистка — отдельное решение, обратимого способа удаления сюда не включал сознательно).
lekss361 added the
bug
scope/backend
tradein
labels 2026-08-16 10:25:09 +00:00
Author
Owner

Закрываю по итогам разбора трекера 16.08.2026

Вердикт: сделано кодом.

Доказательство: PR #2837 (смержен 12.08.2026): _city_substituted() в geocoder.py:391-481 реализует чисто структурный инвариант — сравниваются уровни адреса, ни одного имени города в коде. Закрывает Реж, Арамиль, Рефтинский без списка городов.

Независимая проверка. Вердикт проверялся отдельным проходом, задачей которого было именно опровергнуть закрытие, а не подтвердить его — опровергнуть не удалось.

Если что-то из перечисленного всё же живо — переоткройте задачу, разбор мог упустить частный случай.

## Закрываю по итогам разбора трекера 16.08.2026 **Вердикт:** сделано кодом. **Доказательство:** PR #2837 (смержен 12.08.2026): `_city_substituted()` в `geocoder.py:391-481` реализует чисто структурный инвариант — сравниваются уровни адреса, ни одного имени города в коде. Закрывает Реж, Арамиль, Рефтинский без списка городов. **Независимая проверка.** Вердикт проверялся отдельным проходом, задачей которого было именно опровергнуть закрытие, а не подтвердить его — опровергнуть не удалось. Если что-то из перечисленного всё же живо — переоткройте задачу, разбор мог упустить частный случай.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2590
No description provided.