fix(tradein/matching): честность тиров сопоставления домов (#2674) #2688
No reviewers
Labels
No labels
Fable 5 ревью
GG-форсайт
admin
analytics
auth
automation
bug
business
chore
ci
compliance
data
data-moat
docs
duplicate
dx
enhancement
feedback/max
generative
needs-discussion
needs-human
observability
pause-bots
performance
priority/p0
priority/p1
priority/p2
priority/p3
scope/backend
scope/db
scope/devops
scope/frontend
scope/qa
scrapers
security
site-finder
stage/1
stage/2
status/blocked
status/done
status/needs-analysis
status/needs-fix
status/qa
status/ready
status/review
status/wip
tech-debt
tradein
ux
week ревью 1
wontfix
ИРД
вторичка
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: lekss361/gendesign#2688
Loading…
Add table
Reference in a new issue
No description provided.
Delete branch "fix/2674-matching-tiers-honesty"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Все числа — read-only с прод
tradein-postgres, 2026-08-05/06.Разбор трёх находок #2674
1. Кадастровый номер пуст у всех объявлений → фильтр поиска снят
listings.cadastral_number(кадастр квартиры) — 0 из 93 408. Единственный писатель, парсер Циана (providers/cian/serp.py:872), читаетoffer["cadastralNumber"]— ключа в ответе нет. Ни одна другая площадка кадастр даже не парсит.building_cadastral_numberзаполнено 28 504 (30.5%) — и все 28 504 на 100% пришли из локального гео-зеркала ЕГРН, а не от площадок (джойн кcad_buildings_local, вне зеркала ноль).Решение: снят фильтр
has_kadastr(services/search_query.py,schemas/search.py). Предикатcadastral_number IS NOT NULLмог вернуть только пустую выдачу — обещание качества данных, которого нет. Колонка и парсер оставлены: писатель рабочий, источник пуст. Фронтенд параметр не шлёт, лишний query-param FastAPI игнорирует.2. Тир по кадастру: тир оставлен, приёмник сужен
«Недостижим по построению» подтвердилось наполовину: путь скрейпинга кадастр в матчер передаёт, тир мёртв из-за отсутствия данных, а не структуры.
Дешёвый фикс «подать накопленные 28 504 гео-кадастра» отвергнут числами: KNN-заполнение (ближайшее здание ≤50 м) не инъективно ни в одну сторону — 656 из 3 260 значений накрывают >1 здание ГАР (20.1%), 751 из 2 864 зданий получают >1 значение (26.2%). Тир с
confidence = 1.0на таком ключе — over-merge на пятой части корпуса.Но «оставленный рабочий приёмник» сам оказался отложенной миной, и она обезврежена. Приёмник не различал два кадастра:
cad = building_cadastral_number or cadastral_number. Начни Циан отдаватьoffer["cadastralNumber"]— это кадастр квартиры, у каждой свой. Tier 0 не сматчил бы никогда → New-house INSERT → номер квартиры вhouses.cadastral_number, и попутно снят P1-страж «безномерный адрес без кадастра не создаём» (cadтам же разрешает создание). Две квартиры одного дома → два дома, то самое дробление.Параметр
cadastral_numberубран изmatch_or_create_house, ProtocolHouseMatcher,RealMatcherAdapterи обоих вызывающих (scraper_kit/base.py,scripts/backfill_listing_sources.py). Вlistingsоба поля пишутся как раньше — из ключа дома ушло только ложное.Заодно исправлено ложное утверждение в шапке
tasks/cadastral_geo_match.py, будто «Tier-0 трактует building_cadastral_number как подсказку» — Tier 0 отдаёт 1.0.3. Тир по ФИАС: удалён в пути создания, оставлен в read-only
match_or_create_house(house_fias_id=…)— параметра нет ни в Protocol, ни в адаптере, ни у двух прямых вызывающих. Передать было некому. Удалён.match_house_readonly(house_fias_id=…)— источник реальный:estimator.py:3436передаётpayload.target_fias_id or dadata.house_fias_id. Оставлен, замер подтверждает срабатывания.Подключать ФИАС в скрейпинг не стали: у скрейпера только адрес, резолв = DaData на каждое объявление в горячем пути.
Оценка: насколько хуже стало сопоставление
Верхние тиры — ноль за всю историю.
house_sources, 49 502 строки: fingerprint 58.97%, new 22.65%, geo_proximity 18.36%,cadastr_exact0,fias_exact0. 100% домов сматчено слабее.Дробление измерено: 653 кластера / 1 434 дома / 781 лишняя строка (8.3% таблицы
houses), 6 389 объявлений на дублях, худший случай 6 записей на здание. Виновники: fingerprint 2 550, geo_proximity 1 560, new 1 294. Это #1772 и прямое следствие мёртвых верхних тиров.Склейку измерить не удалось — единственный доступный ключ (KNN-кадастр) сам даёт 20% ложных совпадений.
Схлопывание этих 781 в PR не входит (см. ниже). Замер остаётся в силе как оценка ущерба.
Снятое: слияние по
COALESCE(house_fias_id, gar_house_guid)Первая версия расширяла ключ дедупа, считая
gar_house_guidнезависимым наблюдением идентичности. Он им не является, ревью право.gar_flats_loader._MATCH_SQL:Левая часть побайтово равна ключу канон-прохода (
_CANON_KEY_EXPR). Значит guid — детерминированная функция канон-адреса, а не второе наблюдение: два дома получают один guid тогда и только тогда, когда у них один канон.А проход по этому ключу идёт с выключенным гео-стражем («идентичность старше близости»). Для 764 пар из 781 это круговой аргумент: идентичность здесь и есть адрес. Канон-проход отказывается слить два дома в 6 км — этот сливал их же за «общий UUID», выданный за тот же адрес. Защита #2187 обходилась боковой дверью.
Сверх того:
gar_pickберётDISTINCT ON (canon)— одна ГАР-строка на канон, а 20.3% канонов накрывают несколько зданий (советская20→ 34), и ЕКБ-фильтр стоит только на стороне ГАР. Кедровка/Советская 17 уехала бы в ЕКБ за 16 км. Страж расхождения идентификаторов сравнивает только старое поле и на этих 781 не вычисляется ни разу — защиты не было вообще.Задача не в доработке, а в другом замысле: нужен ключ, независимый от канон-адреса, либо гео-страж, включённый и для этого прохода. Отдельным issue.
Добавлено по ревью
listing_cnt DESC NULLS LAST(house_dedup_merge). Счётчик приходит изLEFT JOIN listing_counts→ у дома без объявлений он NULL, аDESCв Postgres — NULLS FIRST, то есть пустая запись обгоняла запись со 192 объявлениями вопреки задокументированному правилу «most linked listings». Дефект предсуществующий и живой для канон-прохода, который пары даёт; последствие не косметическое — объявления переезжают на запись, на которую корпус никогда не ссылался, а COALESCE-перенос полей неполон.Сторож границы вызова для живого ФИАС-тира. Прежние проверки структурные — видят имя параметра в сигнатуре. Уберут аргумент на настоящей границе (
estimator.estimate_quality→match_house_readonly) — сигнатура цела, тесты зелёные, тир снова мёртв. Новый тест смотрит на сам вызов.Снято преувеличение. Формулировка «тест ловит класс, который эпик считает неуловимым» убрана: структурная проверка ловит подслучай и обходится двумя способами (объявить параметр везде и не пробросить в теле; не передать на настоящей границе). Доказательство рядом —
building_cadastral_numberэту проверку проходит при нуле срабатываний из 49 502.Тесты
tests/test_matching_tier_reachability_2674.py— сверка сигнатуры матчера с границей вызова (Protocol + адаптер), guard на квартирный кадастр, guard на реальную передачу ФИАС.Удалены два теста Tier 0.5 — они были зелёными ровно потому, что обходили границу вызова.
Фальсификация (патч-метод: откат
app/+scripts/+packages/при сохранённых тестах):test_create_path_matcher_params_are_all_reachable_from_the_boundary→ FAILEDtest_fias_tier_is_gone_from_create_path_but_alive_in_readonly→ FAILEDtest_house_key_never_accepts_flat_cadastre→ FAILEDtest_keeper_listing_count_puts_nulls_last→ FAILEDtest_no_unsatisfiable_cadastral_filter→ FAILED (отдельным прогоном, откатsearch_query.py)Честно:
test_readonly_fias_tier_is_actually_fed_by_its_callerпри откате зелёный — он сторожитestimator.py, который PR не меняет. Это регрессионный сторож, а не проверка фикса; краснеть ему нечем, пока аргумент на месте.Полный прогон: 3 507 passed, 9 skipped, 1 failed. Упавший —
test_search_api.py::test_search_cache_hit(401 вместо 200), предсуществующий: падает и на неизменённомorigin/main(проверено откатом файла).Test plan
pytest tests/ -q— 3 507 passed, 1 предсуществующий failhas_kadastrв теле (параметр игнорируется)house_dedup_mergeне даёт всплескаlosers_deleted(ключ не менялся, изменился только порядок выбора keeper'а)Что НЕ входит
listings.cadastral_number— для database-expert.match_or_create_listing(matching/listings.py) мёртв целиком — ноль прод-вызывающих, отдельная находка.Refs #2674