fix(tradein/estimator): гео-ограничение якорного тира — не брать чужие города (#2581) #2586

Merged
lekss361 merged 1 commit from fix/tradein-anchor-geo-scope into main 2026-07-31 15:08:24 +00:00
Owner

Closes #2581. Критический дефект: цена завышалась в 4-5 раз, помеченная самым доверенным тиром.

Якорный тир «тот же дом» подбирал объявления по всей области без географического ограничения. «Серов, ул. Ленина, 5» матчился с екатеринбургской улицей Ленина (из ~40 200 активных объявлений ~40 191 — Екатеринбург) → 8-9 млн вместо ~1.7 млн, с текстом «по аналогам из того же дома». Все страховки — коридор сделок, минимальный радиус, понижение за грубую геолокацию, MAD-отсечка — отключены структурно именно при anchor_tier == 'A'. Хуже: сработавший якорь глушил честный областной путь по сделкам Росреестра, который требует anchor_tier is None.

Решение: ST_DWithin(geom::geography, ..., 1000м) — та же константа, что уже одобрена для Tier S (f9ae6f0c). Строковый матч по улице и номеру дома уже задаёт «тот же дом»; радиус нужен лишь чтобы отсечь межгородские коллизии (Серов↔ЕКБ — сотни километров), а не для внутридомовой точности. house_id_fk намеренно не используется — Tier A его избегает, один дом дробится на несколько записей.

Объявления без координат исключены (geom IS NOT NULL), не берутся вслепую: координаты есть у >99.97% (см. f9ae6f0c), а слепое включение вернуло бы часть риска.

_normalize_building_key не тронут — городской токен в ключе сломал бы матчинг адресов без города в тексте (анонимные объявления Авито) и внёс бы риск разночтений в написании. Коллизия закрыта на уровне SQL, что надёжнее.

Починен слепой тест-страж: db=MagicMock() не был сконфигурирован для якорного запроса, и db.execute(...).mappings().all() тривиально возвращал [] — поэтому тир 'A' никогда не активировался и регрессия была невидима. Плюс два новых теста, которые не мокают якорный запрос, а гоняют реальную логику через фейковый db.execute с настоящей фильтрацией по расстоянию.

Доказательство регрессии (откат только фикса, тесты новые):

FAILED test_non_ekb_anchor_not_leaked_from_ekb_street_collision
  headline=186400 must come from the honest deal corridor (30_000)
  assert 186400 == 30000 ... analog_tier='same_building'

186 400 ₽/м² — ровно диапазон «150-190 тыс» из issue. После восстановления фикса 12/12 зелёные.

Екатеринбург не деградировал: контрольный тест — 4 объявления в одном доме ЕКБ по-прежнему формируют якорь, headline ~145 тыс ₽/м², одинаково на старом и новом коде.

Также исправлен ложный комментарий :1900-1908 («same-building anchor pool для oblast не формируется») — он был неверен и до фикса.

uv run pytest -q: 2842 passed, 1 pre-existing fail. ruff чисто.

Closes #2581. **Критический дефект: цена завышалась в 4-5 раз**, помеченная самым доверенным тиром. Якорный тир «тот же дом» подбирал объявления по всей области без географического ограничения. «Серов, ул. Ленина, 5» матчился с екатеринбургской улицей Ленина (из ~40 200 активных объявлений ~40 191 — Екатеринбург) → **8-9 млн вместо ~1.7 млн**, с текстом «по аналогам из того же дома». Все страховки — коридор сделок, минимальный радиус, понижение за грубую геолокацию, MAD-отсечка — отключены структурно именно при `anchor_tier == 'A'`. Хуже: сработавший якорь глушил честный областной путь по сделкам Росреестра, который требует `anchor_tier is None`. **Решение:** `ST_DWithin(geom::geography, ..., 1000м)` — та же константа, что уже одобрена для Tier S (f9ae6f0c). Строковый матч по улице и номеру дома уже задаёт «тот же дом»; радиус нужен лишь чтобы отсечь межгородские коллизии (Серов↔ЕКБ — сотни километров), а не для внутридомовой точности. `house_id_fk` намеренно не используется — Tier A его избегает, один дом дробится на несколько записей. Объявления без координат исключены (`geom IS NOT NULL`), не берутся вслепую: координаты есть у >99.97% (см. f9ae6f0c), а слепое включение вернуло бы часть риска. `_normalize_building_key` не тронут — городской токен в ключе сломал бы матчинг адресов без города в тексте (анонимные объявления Авито) и внёс бы риск разночтений в написании. Коллизия закрыта на уровне SQL, что надёжнее. **Починен слепой тест-страж:** `db=MagicMock()` не был сконфигурирован для якорного запроса, и `db.execute(...).mappings().all()` тривиально возвращал `[]` — поэтому тир 'A' никогда не активировался и регрессия была невидима. Плюс два новых теста, которые **не мокают** якорный запрос, а гоняют реальную логику через фейковый `db.execute` с настоящей фильтрацией по расстоянию. **Доказательство регрессии** (откат только фикса, тесты новые): ``` FAILED test_non_ekb_anchor_not_leaked_from_ekb_street_collision headline=186400 must come from the honest deal corridor (30_000) assert 186400 == 30000 ... analog_tier='same_building' ``` 186 400 ₽/м² — ровно диапазон «150-190 тыс» из issue. После восстановления фикса 12/12 зелёные. **Екатеринбург не деградировал:** контрольный тест — 4 объявления в одном доме ЕКБ по-прежнему формируют якорь, headline ~145 тыс ₽/м², одинаково на старом и новом коде. Также исправлен ложный комментарий `:1900-1908` («same-building anchor pool для oblast не формируется») — он был неверен и до фикса. `uv run pytest -q`: 2842 passed, 1 pre-existing fail. ruff чисто.
lekss361 added 1 commit 2026-07-31 14:43:20 +00:00
fix(tradein/estimator): гео-ограничение якорного тира — не брать чужие города (#2581)
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m13s
23b8816863
Tier A (_fetch_anchor_comps, "тот же дом") матчил по normalized street+house
(_normalize_building_key намеренно дропает город) БЕЗ единого гео-предиката —
единственный запрос к listings в файле без ST_DWithin/города/house_id_fk/LIMIT.
"Серов, ул. Ленина, 5" получал якорь по ЕКБ-объявлениям (~40 191 из ~40 200
активных листингов — ЕКБ) с самым доверенным тиром 'A', завышая цену в 4-5х;
хуже — anchor_tier='A' блокировал честный #oblast-D deals-headline-fallback
(гейт `anchor_tier is None`), так что область не могла получить даже
резервную ДКП-оценку.

Фикс мирроит уже одобренный geo-bound для Tier S (f9ae6f0c, #oblast-D):
ST_DWithin(geom::geography, subject_point, ANCHOR_TIER_A_RADIUS_M) от
lat/lon субъекта, radius = DEFAULT_RADIUS_M (1000м, тот же константа что и
Tier S) — строковый match уже устанавливает "та же улица+дом", радиус нужен
только чтобы отсечь РЕАЛЬНО кросс-городские коллизии (ЕКБ/Серов — сотни км),
не для внутридомовой точности. Tier A целиком гейтится на lat/lon субъекта
(как Tier C) — без них геопредикат невозможен; в проде geo всегда есть
(_empty_estimate возвращается раньше при неудачном geocode).

Объявления без geom: `AND geom IS NOT NULL` + ST_DWithin (NULL → exclude,
не blind-include) — >99.97% листингов имеют координаты (см. f9ae6f0c),
исключение погрешности не создаёт.

_normalize_building_key НЕ тронут (город по-прежнему не входит в ключ) —
добавление городского токена сломало бы матчинг источников без города в
адресе (Avito anonymous) и внесло бы новый normalization-риск; кросс-
городская коллизия закрыта на SQL-уровне надёжнее.

Поправлен ложный комментарий _band_haircut (:1900-1908): "same-building
anchor pool для oblast не формируется" был неверен даже ДО фикса (Tier A
не имел гео-фильтра вовсе — либо матчил ЕКБ, либо честно матчил местные
листинги, если они были).

Тест-страж test_estimator_deals_headline_fallback_oblast_d.py использовал
голый db=MagicMock() для _fetch_anchor_comps — db.execute(...).mappings().all()
на unconfigured MagicMock тривиально возвращает [] (MagicMock __iter__ default),
так что anchor_tier никогда не мог стать 'A' и регрессия было бы невидима.
Пропатчено явно (документирует допущение) + добавлен новый тест, который
НЕ мокает _fetch_anchor_comps — гоняет реальную SQL-логику через фейковый
db.execute, симулирующий настоящую ST_DWithin-фильтрацию по haversine-
дистанции. На pre-fix коде (git stash) даёт headline=186 400 ₽/м² вместо
честных 30 000 (deal corridor) — воспроизводит репортнутый баг buквально.
EKB control-тест подтверждает: несколько объявлений в одном доме ЕКБ
по-прежнему формируют Tier A anchor (n=4, ~145k ₽/м²) — не деградировало.

test_tier_a_primary_1774.py: позитивные Tier A тесты теперь передают
lat/lon субъекта (гейт требует их) — тесты на python-side novostroyki-
гейтинг (ожидающие tier=None) оставлены с lat=None (конечный результат
не меняется: Tier A целиком пропускается без lat/lon, как и Tier C раньше).

Full suite: 2842 passed, 9 skipped, 1 pre-existing failure (test_search_cache_hit,
не связан). Ruff чист.
lekss361 merged commit 996c4509ff into main 2026-07-31 15:08:24 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
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#2586
No description provided.