gendesign/tradein-mvp/backend/tests/test_estimator_implausible_year.py
bot-backend 86afbf9e6f
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 2m40s
fix(tradein/estimate): не применять хедонику к неправдоподобному году постройки
house_metadata (OSM/кадастр) отдаёт year_built без валидации; на проде
встретился 1829, который клампился в нижний пол хедонического фактора
(estimate_hedonic_factor_min=0.75) и резал выкупную цену на фикс. -25%
без физического смысла — модель никогда не видела осмысленного объёма
домов старше 1955 (см. COHORTS). Год вне [1917, текущий+3] теперь
трактуется как отсутствующий (None, нейтральный year-term) вместо
клампа, с WARNING-логом по house_id/адресу.

Единая точка входа _sanitize_build_year() в estimate_quality() перед
cohort-фильтром, house-match scoring и хедоническим фактором — покрывает
оба источника года (payload.year_built и house_meta.year_built).
2026-08-02 14:04:11 +03:00

187 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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