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
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).
187 lines
8.5 KiB
Python
187 lines
8.5 KiB
Python
"""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
|