fix(tradein/estimator): граница номера дома в подборе аналогов «тот же дом» #2534

Merged
lekss361 merged 2 commits from fix/tradein-audit-analogs into main 2026-07-26 22:07:01 +00:00
Owner

Правка по аудиту. Влияет на выдачу оценки.

Проблема

estimator.py:4703 — резервная ветка Tier S (когда канонический матч по house_id_fk недоступен или дал меньше трёх результатов) искала аналоги префиксом address ILIKE 'ул. Ленина, 5%'. Границы после номера нет, поэтому под шаблон попадали дома 50, 51, 500 и 5а. Расстояние при этом было захардкожено 0.0 AS distance_m, то есть аномально далёкое «совпадение того же дома» не было видно в данных.

Порог срабатывания низкий: MAX_ANALOGS_PER_ADDRESS = 5 и условие len(tier_s) >= 3 означают, что одного чужого здания достаточно, чтобы его цены попали в выборку с меткой «тот же дом».

В этом же файле правильное решение уже существовало — якорный Tier A (estimator.py:1747) использует regex с границей номера. В резервной ветке его просто не применили.

Что сделано

  • Общий helper _house_boundary_regex(base_no, letter) вынесен из inline-кода Tier A; теперь его вызывают обе ветки.
  • Tier S переписан с префиксного ILIKE на _normalize_building_keystreet / base_no / letter + тот же boundary-regex. Гео-ограничение ST_DWithin сохранено без изменений.
  • 0.0 AS distance_m заменено на реальный ST_Distance — аномалии теперь видны в данных, а не маскируются нулём.

Проверено вручную: _house_boundary_regex(5, None) матчит «5» и «5/2» (корпус), не матчит «50», «51», «500», «15», «5а»; с литерой «204г» матчит только «204г».

Отдельно: «мёртвый» код разрешения конфликтов

Аудит отметил conflict_resolution.py:229 как мёртвый код. Разбор показал, что это не забытая ошибка, а осознанно отложенная интеграция: в волте есть решение Decision_774_Matching_Architecture (2026-05-31, с проверкой по живой БД), где подтверждено, что боевые upsert'ы используют простой COALESCE, а update_canonical_fields — намеренная заглушка под этап, который так и не сделали. Удаление уже запланировано там отдельным узким PR.

Поэтому я не подключал её в боевой путь расчёта цены (это изменило бы выдачу оценок) и не удалял (на ней ~30 тестов). Добавил пояснение в docstring модуля со ссылкой на решение, чтобы следующий читатель не передумывал заново.

Test plan

  • Новый tests/test_estimator_tier_s_house_boundary.py — границы номера, корпус через слэш, литера, плюс проверка формы SQL
  • Обновлён tests/test_same_building_match.py — старая проверка на наличие ILIKE заменена на новую форму
  • uv run pytest -q — 2632 passed, 8 skipped
  • uv run ruff check с проектным конфигом — чисто
  • Не проверял на живой БД, есть ли в проде реальные случаи «Ленина 5 против Ленина 50» в одном радиусе — подтверждено только то, что старый шаблон их допускал, а новый нет
Правка по аудиту. Влияет на выдачу оценки. ## Проблема `estimator.py:4703` — резервная ветка Tier S (когда канонический матч по `house_id_fk` недоступен или дал меньше трёх результатов) искала аналоги префиксом `address ILIKE 'ул. Ленина, 5%'`. Границы после номера нет, поэтому под шаблон попадали **дома 50, 51, 500 и 5а**. Расстояние при этом было захардкожено `0.0 AS distance_m`, то есть аномально далёкое «совпадение того же дома» не было видно в данных. Порог срабатывания низкий: `MAX_ANALOGS_PER_ADDRESS = 5` и условие `len(tier_s) >= 3` означают, что **одного** чужого здания достаточно, чтобы его цены попали в выборку с меткой «тот же дом». В этом же файле правильное решение уже существовало — якорный Tier A (`estimator.py:1747`) использует regex с границей номера. В резервной ветке его просто не применили. ## Что сделано - Общий helper `_house_boundary_regex(base_no, letter)` вынесен из inline-кода Tier A; теперь его вызывают обе ветки. - Tier S переписан с префиксного `ILIKE` на `_normalize_building_key` → `street` / `base_no` / `letter` + тот же boundary-regex. Гео-ограничение `ST_DWithin` сохранено без изменений. - `0.0 AS distance_m` заменено на реальный `ST_Distance` — аномалии теперь видны в данных, а не маскируются нулём. Проверено вручную: `_house_boundary_regex(5, None)` матчит «5» и «5/2» (корпус), не матчит «50», «51», «500», «15», «5а»; с литерой «204г» матчит только «204г». ## Отдельно: «мёртвый» код разрешения конфликтов Аудит отметил `conflict_resolution.py:229` как мёртвый код. Разбор показал, что это **не забытая ошибка, а осознанно отложенная интеграция**: в волте есть решение `Decision_774_Matching_Architecture` (2026-05-31, с проверкой по живой БД), где подтверждено, что боевые upsert'ы используют простой `COALESCE`, а `update_canonical_fields` — намеренная заглушка под этап, который так и не сделали. Удаление уже запланировано там отдельным узким PR. Поэтому я **не подключал** её в боевой путь расчёта цены (это изменило бы выдачу оценок) и **не удалял** (на ней ~30 тестов). Добавил пояснение в docstring модуля со ссылкой на решение, чтобы следующий читатель не передумывал заново. ## Test plan - [x] Новый `tests/test_estimator_tier_s_house_boundary.py` — границы номера, корпус через слэш, литера, плюс проверка формы SQL - [x] Обновлён `tests/test_same_building_match.py` — старая проверка на наличие `ILIKE` заменена на новую форму - [x] `uv run pytest -q` — 2632 passed, 8 skipped - [x] `uv run ruff check` с проектным конфигом — чисто - [ ] Не проверял на живой БД, есть ли в проде реальные случаи «Ленина 5 против Ленина 50» в одном радиусе — подтверждено только то, что старый шаблон их допускал, а новый нет
lekss361 added 2 commits 2026-07-26 20:58:06 +00:00
Audit finding 1 (medium): the Tier S same-building radius-fallback matched
`address ILIKE short_addr + '%'` with no boundary after the house number, so
"ул. Ленина, 5%" collided with "..., 50/51/500/5а" (a different building).
MAX_ANALOGS_PER_ADDRESS=5 + `len(tier_s) >= 3` meant one stray building could
poison the same-building median. Fix reuses the already-vetted anchor Tier A
machinery (_normalize_building_key + new shared _house_boundary_regex helper)
instead of a bare string prefix, and replaces the hardcoded `0.0 AS distance_m`
with a real ST_Distance so anomalously-far "same building" matches are visible
in the data.

_extract_short_addr is left in place (unused by _fetch_analogs now, own tests
still pass) with a note explaining the supersession, rather than deleted, to
keep this PR scoped to the boundary bug.

Audit finding 2 (low): conflict_resolution.py's resolve_house_field/
resolve_listing_field priority system is confirmed dead in the production
merge path (matching/houses.py + listings.py use a simpler COALESCE
newest-wins pattern instead) per vault Decision_774_Matching_Architecture
(2026-05-31, code-archaeology + live-DB verified). Documented this in the
module docstring instead of deleting — removal + its ~30 dedicated tests is
already scoped as an independent "Path 2 / Sub-3" cleanup in that decision,
kept separate from this unrelated Tier-S bugfix. Not wired into the price-calc
path per audit instructions.

Tests: new tests/test_estimator_tier_s_house_boundary.py (boundary regex +
SQL-rendering checks); test_same_building_match.py updated to assert the new
boundary-regex SQL shape instead of the old bare ILIKE. Full suite: 2632
passed, 8 skipped, 1 deselected (same deselect as ci-tradein.yml).
Merge remote-tracking branch 'forgejo/main' into fix/tradein-audit-analogs
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (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 5m5s
18dbca4cd7
lekss361 merged commit 6e132f8986 into main 2026-07-26 22:07:01 +00:00
lekss361 deleted branch fix/tradein-audit-analogs 2026-07-26 22:07:02 +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#2534
No description provided.