"""Display-consistency fix — показанные карточки аналогов согласованы с n_analogs и не содержат ни MAD-clip-отсечённых ценовых выбросов, ни кросс-source дублей одного физлота (live QA прод-смоук: «Екатеринбург, ул. Малышева, 51»). Два независимых root cause (см. PR): 1. `n_analogs` считался ПОСЛЕ MAD-clip (_compute_same_building_anchor: n = len выживших ppm2), но отображаемые карточки строились из ИСХОДНОГО (пред-clip) anchor_comps — клип-отсечённый ценовой выброс оставался видимым в UI, даже когда он не участвовал в headline/n_analogs. Фикс: anchor_comps_used теперь = anchor["comps"] (тот же пост-clip пул, что дал n). 2. Статистический кросс-source дедуп (_dedup_cross_source) требует совпадения ЦЕНОВОГО бакета (price_bucket) — реальный кросс-пост одного физлота на разных площадках почти всегда имеет небольшой ценовой дрейф (перевыставили дешевле/ дороже), который может увести пару в разные бакеты → дубль выживает в популяции. Фикс: отдельный display-only дедуп (_dedup_display_lots) — тот же физический ключ (building/floor/area), но БЕЗ price_bucket — ловит именно эти остаточные дубли ТОЛЬКО на уровне отображаемых карточек, не трогая n_analogs/median/cv (frozen backtest regression gate не задет — см. tests/test_backtest_regression_gate.py, который эту ветку кода не вызывает). Test A воспроизводит буквальный прод-паттерн (кросс-source дубль-пара, которая ЦЕЛИКОМ является ценовым выбросом, — «Гоголя 18» из QA-смоука): MAD-clip убирает ОБЕ копии из anchor["comps"] → они пропадают из карточек естественным образом (fix #1 самодостаточен) → len(analogs) == n_analogs И нет дубль-адресов. Test B покрывает менее тривиальный случай — дубль-пара НЕ является ценовым выбросом (обычная цена, просто с небольшим дрейфом между площадками) и переживает MAD-clip как 2 отдельные записи (её и ловит fix #2, display-only дедуп). Здесь len(analogs) < n_analogs ЧЕСТНО (статистика не трогается) — тест документирует именно это, а не искусственное равенство. Полный estimate-путь со всеми I/O застаблен через harness _run_estimate из test_same_building_anchor.py (anchor_tier="A" → same-building anchor ветка, где живёт MAD-clip). """ from __future__ import annotations import importlib.util import os from datetime import UTC, datetime from pathlib import Path from typing import Any # Settings требует DATABASE_URL при инициализации (fail-fast, C-3). os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") from app.services import estimator _ANCHOR_TEST = Path(__file__).parent / "test_same_building_anchor.py" _spec = importlib.util.spec_from_file_location("_anchor_harness_display_consistency", _ANCHOR_TEST) assert _spec is not None and _spec.loader is not None _h = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(_h) def _comp( *, source: str, address: str, ppm2: float, area_m2: float = 146.2, rooms: int = 4, scraped_at: datetime | None = None, ) -> dict[str, Any]: """Минимальный same-building comp-dict (без floor/total_floors → нейтральный floor-вес; area_m2 по умолчанию = target площадь _make_payload(), area-вес ~1.0). """ return { "source": source, "source_url": f"https://{source}.example/offer/{address}/{ppm2}", "address": address, "area_m2": area_m2, "rooms": rooms, "price_per_m2": ppm2, "price_rub": ppm2 * area_m2, "scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC), "photo_urls": [], } # --------------------------------------------------------------------------- # # Test A — outlier дубль-пара («Гоголя 18»-паттерн): MAD-clip убирает ОБЕ копии # --------------------------------------------------------------------------- # def test_outlier_duplicate_pair_excluded_from_both_count_and_cards() -> None: """5 обычных комплов (разные дома, MAD-clip не трогает) + кросс-source дубль-пара одного физлота, которая ЦЕЛИКОМ ценовой выброс (~4.4× медианы, как «Гоголя 18» в QA-смоуке) → MAD-clip убирает ОБЕ копии из anchor["comps"]. Т.к. карточки теперь строятся из anchor["comps"] (не из исходного anchor_comps), выброс не просачивается в UI — количество карточек равно n_analogs, и обеих копий дубля нет ни в счётчике, ни в карточках. """ clean = [ _comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0), _comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0), _comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0), _comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0), _comp(source="cian", address="Екатеринбург, ул. Хохрякова, 58", ppm2=215_000.0), ] # Кросс-source дубль одного физлота — ~4.4-4.5× дороже остальных (целиком # ценовой выброс, как реальный прод-кейс «Гоголя 18»). outlier_dup = [ _comp(source="avito", address="Екатеринбург, ул. Гоголя, 18", ppm2=900_000.0), _comp(source="domklik", address="Екатеринбург, ул. Гоголя, 18", ppm2=920_000.0), ] est = _h._run_estimate(anchor_comps=clean + outlier_dup, anchor_tier="A") # Выброс не участвовал в headline/n_analogs (MAD-clip) — фикс #1 не менял эту # часть: сверяем, что n_analogs действительно = 5 (7 - 2 отсечённых). assert est.n_analogs == 5 # Главный инвариант display-consistency: показанные карточки == n_analogs. assert len(est.analogs) == est.n_analogs == 5 # Ни одна из копий выброса не попала в карточки. shown_addresses = {a.address for a in est.analogs} assert "Екатеринбург, ул. Гоголя, 18" not in shown_addresses shown_ppm2 = {a.price_per_m2 for a in est.analogs} assert 900_000 not in shown_ppm2 assert 920_000 not in shown_ppm2 # Нет дублей адресов среди карточек. assert len(shown_addresses) == len(est.analogs) # --------------------------------------------------------------------------- # # Test B — non-outlier дубль-пара («Пушкина 9»-паттерн): переживает MAD-clip, # ловится ТОЛЬКО display-only дедупом (price_bucket-строгий стат-дедуп её # пропускает из-за ценового дрейфа, бакет-straddle). # --------------------------------------------------------------------------- # def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None: """4 обычных компла + кросс-source дубль-пара ОБЫЧНОЙ цены (небольшой дрейф 2-25к, «Пушкина 9»-паттерн из QA-смоука) — НЕ выброс, переживает MAD-clip как 2 отдельные записи (n_analogs их считает по отдельности, статистика не тронута). display-only дедуп (без price_bucket) схлопывает пару в ОДНУ карточку — карточек становится МЕНЬШЕ n_analogs (честно: это НЕ баг, а следствие того, что стат-пайплайн и display-дедуп используют разные ключи по дизайну — см. docstring _dedup_display_lots). Инвариант, который держит этот тест: адрес дубля встречается в карточках РОВНО один раз. """ clean = [ _comp(source="cian", address="Екатеринбург, ул. Хохрякова, 48", ppm2=195_000.0), _comp(source="avito", address="Екатеринбург, ул. Хохрякова, 50", ppm2=200_000.0), _comp(source="domklik", address="Екатеринбург, ул. Хохрякова, 52", ppm2=205_000.0), _comp(source="yandex", address="Екатеринбург, ул. Хохрякова, 54", ppm2=210_000.0), ] # Один физлот, кросс-source (yandex/domklik), лёгкий ценовой дрейф ~1.6% — # достаточно, чтобы price_bucket (_DEDUP_PRICE_BUCKET_RUB=100_000 руб.) # развёл пару по РАЗНЫМ бакетам (311 vs 316), но НЕ выброс (в пределах # MAD-clip диапазона остальных 4). dup_a = _comp( source="yandex", address="Екатеринбург, ул. Пушкина, 9", ppm2=219_000.0, area_m2=142.0, scraped_at=datetime(2026, 6, 1, tzinfo=UTC), ) dup_b = _comp( source="domklik", address="Екатеринбург, ул. Пушкина, 9", ppm2=222_500.0, area_m2=142.0, scraped_at=datetime(2026, 6, 3, tzinfo=UTC), # свежее → представитель ) # sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару — # иначе тест проверял бы не то, что заявлено в docstring. assert estimator.settings.estimate_dedup_analogs_enabled is True deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b]) assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит" est = _h._run_estimate(anchor_comps=[*clean, dup_a, dup_b], anchor_tier="A") # Статистика ЧЕСТНО считает обе копии (стат-дедуп их не поймал, MAD-clip не # выброс) — n_analogs = 6. assert est.n_analogs == 6 # display-only дедуп схлопнул пару → карточек МЕНЬШЕ n_analogs. assert len(est.analogs) == 5 assert len(est.analogs) < est.n_analogs # Инвариант: адрес дубля встречается РОВНО один раз среди карточек. shown_addresses = [a.address for a in est.analogs] assert shown_addresses.count("Екатеринбург, ул. Пушкина, 9") == 1 # Представитель — свежайший scraped_at (domklik, 222_500). pushkina_card = next(a for a in est.analogs if a.address == "Екатеринбург, ул. Пушкина, 9") assert pushkina_card.price_per_m2 == 222_500 # --------------------------------------------------------------------------- # # Unit-level: _dedup_display_lots — прямые проверки поведения helper'а # --------------------------------------------------------------------------- # def _lot( *, source: str, address: str = "ул. Ленина, 5", area: float = 60.0, price: float = 12_000_000.0, floor: int | None = 5, scraped_at: datetime | None = None, ) -> dict[str, Any]: return { "source": source, "address": address, "area_m2": area, "price_rub": price, "price_per_m2": price / area if area else 0, "floor": floor, "scraped_at": scraped_at or datetime(2026, 6, 1, tzinfo=UTC), "photo_urls": [], } def test_dedup_display_lots_collapses_price_drifted_cross_source_pair() -> None: # Тот же building/floor/area, РАЗНЫЙ source, цена отличается на 20% — стат- # дедуп (price_bucket) не поймал бы такой дрейф, display-дедуп игнорирует # цену вовсе. lots = [ _lot(source="avito", price=12_000_000.0, scraped_at=datetime(2026, 6, 1, tzinfo=UTC)), _lot(source="cian", price=14_400_000.0, scraped_at=datetime(2026, 6, 5, tzinfo=UTC)), ] out = estimator._dedup_display_lots(lots) assert len(out) == 1 assert out[0]["source"] == "cian" # свежайший scraped_at def test_dedup_display_lots_same_source_pair_not_merged() -> None: # Тот же building/floor/area, ОДИНАКОВЫЙ source, разная цена — НЕ кросс-пост # (либо два разных реальных юнита, случайно совпавших по округлённой # площади/этажу), display-дедуп НЕ должен их схлопывать (require_diff_source # guard). lots = [ _lot(source="cian", price=12_000_000.0), _lot(source="cian", price=13_000_000.0), ] out = estimator._dedup_display_lots(lots) assert len(out) == 2 def test_dedup_display_lots_different_addresses_stay_distinct() -> None: lots = [ _lot(source="avito", address="ул. Ленина, 5", price=12_000_000.0), _lot(source="cian", address="ул. Мира, 3", price=12_000_000.0), ] out = estimator._dedup_display_lots(lots) assert len(out) == 2 def test_dedup_display_lots_noop_under_two_lots() -> None: lots = [_lot(source="avito")] out = estimator._dedup_display_lots(lots) assert out is lots