diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 96b802bd..746fedee 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -200,6 +200,55 @@ def _load_city_price_bands(db: Session) -> dict[str, tuple[int, int]]: return bands +# Правдоподобный диапазон года постройки МКД (guard на входе — Mera-audit 2026-08-02). +# Нижняя граница 1917: массовая многоквартирная застройка в РФ/СССР началась +# после революции — год раньше почти гарантированно ошибка источника +# (house_metadata OSM/кадастр смешивают год постройки дома с годом основания +# места/памятника на тех же координатах — прод-инцидент 2026-08: house_metadata +# отдал year_built=1829 для обычной вторички, см. vault fixes). Верхняя граница +# — текущий год + 3: допуск на цели trade-in со строящимся домом (год сдачи по +# ДДУ известен заранее, но не более чем на несколько лет вперёд). +# Год вне диапазона трактуем как ОТСУТСТВУЮЩИЙ (None), а НЕ клампим к границе — +# хедонический фактор (_price_from_inputs, #2002) экстраполирует regression fit +# far вне обучающей выборки (COHORTS ниже даже не определяет когорту раньше +# 1955 — модель никогда не видела осмысленного объёма домов старше этого), и +# estimate_hedonic_factor_min=0.75 в таком случае не защита, а маскировка +# выхода за диапазон под видом уверенной −25% поправки. «Не знаем год» — +# честный сигнал, который просто отключает year-term фактора (нейтрален). +MIN_PLAUSIBLE_BUILD_YEAR = 1917 +MAX_PLAUSIBLE_BUILD_YEAR_LEAD = 3 # текущий год + N — допуск на стройки + + +def _sanitize_build_year( + year: int | None, *, house_id: int | None = None, address: str | None = None +) -> int | None: + """Отбрасывает неправдоподобный год постройки, трактуя его как «неизвестен». + + Валидный диапазон — [MIN_PLAUSIBLE_BUILD_YEAR, текущий год + LEAD]. Год вне + диапазона логируется на WARNING (с идентификатором дома — house_id либо + адрес) и заменяется на None, а не клампится к границе: клампинг превращает + заведомый мусор источника (house_metadata OSM/кадастр, либо year_built из + payload — ge=1800 в схеме пропускает подобные значения) в уверенный вход + для хедонической поправки (_price_from_inputs), хотя физического смысла + у результата нет. + """ + if year is None: + return None + max_year = datetime.now(UTC).year + MAX_PLAUSIBLE_BUILD_YEAR_LEAD + if year < MIN_PLAUSIBLE_BUILD_YEAR or year > max_year: + logger.warning( + "estimate: implausible year_built=%s dropped (house_id=%s, address=%s) — " + "valid range [%s, %s]", + year, + house_id, + address, + MIN_PLAUSIBLE_BUILD_YEAR, + max_year, + ) + return None + return year + + # Когорта по году постройки — типизация массовой застройки РФ. # Используется как hard-filter в Tier 0 _fetch_analogs (PR 9, 2026-05-24). # Если target_year не задан — cohort = None → фильтр отключён, Tier 0 пропускается. @@ -3335,6 +3384,16 @@ async def estimate_quality( if target_house_type is None: target_house_type = house_meta.house_type + # 2b. Mera-audit 2026-08-02: неправдоподобный год (payload user-input ge=1800/le=2100 в схеме, + # либо house_metadata OSM/кадастр — прод-инцидент year_built=1829) — на + # «неизвестен» ДО того как target_year уйдёт в cohort-фильтр (ниже), + # _fetch_analogs house-match scoring и хедонический фактор + # (_price_from_inputs, #2002). Единая точка входа — все три места ниже + # используют этот же target_year. + target_year = _sanitize_build_year( + target_year, house_id=target_house_id, address=payload.address + ) + # 3. Four-tier fallback (PR 9 — added Tier 0 with cohort filter): # 0) 1km + ±15% area + cohort match (year_built — если задан) # a) 1km + ±15% area (без cohort — drop fallback) diff --git a/tradein-mvp/backend/tests/test_estimator_implausible_year.py b/tradein-mvp/backend/tests/test_estimator_implausible_year.py new file mode 100644 index 00000000..5268003c --- /dev/null +++ b/tradein-mvp/backend/tests/test_estimator_implausible_year.py @@ -0,0 +1,187 @@ +"""Guard against implausible year_built poisoning the hedonic correction (Mera-audit 2026-08-02). + +house_metadata (OSM/кадастр, best-effort enrichment) и +TradeInEstimateInput.year_built (payload, схема допускает ge=1800) могут +отдать явно ошибочный год постройки МКД — прод-инцидент: house_metadata +year_built=1829 для обычной вторички (см. vault fixes). + +Без валидации этот год уходит в хедонический year+area фактор +(_price_from_inputs, #2002), который экстраполирует regression fit далеко за +пределы обучающей выборки (COHORTS не определяет когорту раньше 1955 — см. +estimator.py) и упирается в нижний кламп estimate_hedonic_factor_min=0.75 — +выкупная цена режется на фиксированные −25% без физического смысла. + +_sanitize_build_year() отсекает год вне +[MIN_PLAUSIBLE_BUILD_YEAR, текущий год + MAX_PLAUSIBLE_BUILD_YEAR_LEAD] на +входе, трактуя его как «неизвестен» (None) — хедонический year-term +становится нейтральным (эквивалент year=2000, см. test_estimator_hedonic.py +::test_target_year_none_is_neutral), а не клампится к произвольной границе. + +NOTE: importing app.services.estimator pulls app.core.config.Settings which +requires DATABASE_URL. Set it BEFORE importing app modules (см. паттерн +test_estimator_hedonic.py). +""" + +from __future__ import annotations + +import os +from datetime import UTC, datetime + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.services import estimator +from app.services.geocoder import GeocodeResult + +# ── _sanitize_build_year: unit-level ──────────────────────────────────────── + + +def test_implausible_low_year_dropped_to_none(caplog: pytest.LogCaptureFixture) -> None: + """год=1829 (прод-инцидент house_metadata) → трактуется как отсутствующий.""" + with caplog.at_level("WARNING"): + result = estimator._sanitize_build_year(1829, house_id=42, address="ул. Тестовая, 1") + assert result is None + assert any("1829" in r.message for r in caplog.records) + assert any("42" in r.message for r in caplog.records) + + +def test_plausible_year_unchanged() -> None: + """год=1960 (хрущёвка) — валиден, работает как раньше (без изменений).""" + assert estimator._sanitize_build_year(1960) == 1960 + + +def test_future_year_beyond_lead_dropped() -> None: + """год = текущий+10 (далеко за допуском для строек) → отбрасывается.""" + future_year = datetime.now(UTC).year + 10 + assert estimator._sanitize_build_year(future_year) is None + + +def test_near_future_year_within_lead_kept() -> None: + """год = текущий + LEAD (граница допуска для строек) — остаётся валидным.""" + near_future = datetime.now(UTC).year + estimator.MAX_PLAUSIBLE_BUILD_YEAR_LEAD + assert estimator._sanitize_build_year(near_future) == near_future + + +def test_none_year_unchanged() -> None: + """Отсутствие года — поведение НЕ меняется (уже было честным «не знаем»).""" + assert estimator._sanitize_build_year(None) is None + + +def test_boundary_year_min_plausible_kept() -> None: + """MIN_PLAUSIBLE_BUILD_YEAR сам — валиден (inclusive).""" + year = estimator.MIN_PLAUSIBLE_BUILD_YEAR + assert estimator._sanitize_build_year(year) == year + + +def test_boundary_year_below_min_dropped() -> None: + """MIN_PLAUSIBLE_BUILD_YEAR - 1 — уже невалиден.""" + assert estimator._sanitize_build_year(estimator.MIN_PLAUSIBLE_BUILD_YEAR - 1) is None + + +# ── price impact via _price_from_inputs (hermetic, no DB) — #1966-стиль ──── + + +def _geo() -> GeocodeResult: + return GeocodeResult( + lat=56.838, + lon=60.597, + full_address="ул. Тестовая, 1", + provider="nominatim", + confidence="approximate", + ) + + +def _lots(ppm2: float, n: int = 7) -> list[dict]: + """n unique-address lots all at the same ₽/m² → median_ppm2 == ppm2.""" + return [ + {"price_per_m2": ppm2, "address": f"ул. Тестовая, {i + 1}", "source": "avito"} + for i in range(n) + ] + + +def _price(*, target_year: int | None, area_m2: float = 50.0) -> estimator.PricingResult: + """Pure radius-only spine call (no anchor / dkp / imv) with a forced ratio. + + Зеркалит helper из test_estimator_hedonic.py — прогоняет ровно тот же + вызов _price_from_inputs, который estimate_quality делает после + _sanitize_build_year(target_year, ...). + """ + + def ratio_resolver(appm2: float | None) -> tuple[float | None, str | None]: + return 0.85, "per_rooms" + + return estimator._price_from_inputs( + listings=_lots(100_000.0), + area_m2=area_m2, + rooms=2, + repair_state=None, + floor=5, + total_floors=10, + target_year=target_year, + analog_tier="W", + fallback_used=False, + area_widened=False, + anchor_comps=[], + anchor_tier_fetched=None, + dkp_raw=None, + imv_anchor=None, + imv_eval=None, + yandex_val_present=False, + cian_val_present=False, + ratio_resolver=ratio_resolver, + quarter_index_lookup=lambda q: None, + quarter_indexes_lookup=lambda qs: {}, + target_house_cadnum=None, + dadata_coarse=False, + geo=_geo(), + dadata_qc_geo=None, + ) + + +def test_price_not_cut_after_sanitizing_1829(monkeypatch: pytest.MonkeyPatch) -> None: + """Прод-репро (Mera-audit 2026-08-02): год=1829 без guard'а клампит хедонический фактор в + пол estimate_hedonic_factor_min=0.75 (−25% к цене). После sanitize + (estimate_quality прогоняет target_year через _sanitize_build_year ДО + _price_from_inputs) год трактуется как отсутствующий — фактор нейтрален, + цена НЕ порезана. + """ + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + + # "Было бы" без фикса: 1829 идёт в хедонику напрямую. + unfixed = _price(target_year=1829) + # "Стало" с фиксом: estimate_quality сначала санитайзит год. + sanitized_year = estimator._sanitize_build_year(1829) + assert sanitized_year is None + fixed = _price(target_year=sanitized_year) + + assert unfixed.expected_sold_price is not None + assert fixed.expected_sold_price is not None + + ratio_only = round(unfixed.median_price * 0.85) + factor_before = unfixed.expected_sold_price / ratio_only + factor_after = fixed.expected_sold_price / ratio_only + + # До фикса — кламп ровно в пол (фиксированная −25% недоплата). + assert factor_before == pytest.approx(estimator.settings.estimate_hedonic_factor_min, abs=1e-3) + # После фикса — год «неизвестен», фактор около нейтрали (НЕ 0.75). + assert factor_after > 0.95 + assert fixed.expected_sold_price > unfixed.expected_sold_price + + # Совпадает байт-в-байт с явным "год не указан" (test_target_year_none_is_neutral). + none_year = _price(target_year=None) + assert fixed.expected_sold_price == none_year.expected_sold_price + assert fixed.expected_sold_per_m2 == none_year.expected_sold_per_m2 + + +def test_year_1960_hedonic_unaffected_by_guard(monkeypatch: pytest.MonkeyPatch) -> None: + """год=1960 (валидная хрущёвка) — guard не меняет хедоническую поправку.""" + monkeypatch.setattr(estimator.settings, "estimate_hedonic_correction_enabled", True) + sanitized_year = estimator._sanitize_build_year(1960) + assert sanitized_year == 1960 + + before = _price(target_year=1960) + after = _price(target_year=sanitized_year) + + assert before.expected_sold_price == after.expected_sold_price + assert before.expected_sold_per_m2 == after.expected_sold_per_m2