gendesign/tradein-mvp/backend/tests/test_estimator_kitchen_ceiling_2012.py
bot-backend d39e136d88
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m36s
fix(tradein/estimator): kitchen_area_m2/ceiling_height_m/is_apartments comp-scoring (#2012)
Three independent feature flags, all default OFF pending live A/B
backtest (no DB access in worker/main sessions this round — see PR
body for exact before/after commands to run once available):

- estimate_kitchen_area_signal_enabled / estimate_ceiling_height_signal_enabled:
  house_type/year_built have a user-input target to compare candidates
  against; kitchen_area_m2/ceiling_height_m have neither a target
  (TradeInEstimateInput doesn't collect them) nor ground-truth on
  rosreestr deals (backtest DealSample lacks these columns). Inventing
  an external "typical" constant would be unfounded, so the penalty is
  self-referential: deviation from the CANDIDATE POOL's own median in
  Tier H/W relevance_score. NULL-safe (no penalty, excluded from
  median) and sparse-safe (fully skipped when <5 non-null values in
  the pool).
- estimate_is_apartments_filter_enabled: hard-filter in _COMMON_WHERE +
  Tier W, symmetric to the existing novostroyki-guard (#1186). NULL
  passes through untouched, only explicit true excluded.

Review fixup: _stratify_candidates requires its input pre-sorted by
relevance_score (its per-source guaranteed-quota selection depends on
that order). _apply_kitchen_ceiling_signal mutates relevance_score
in-place after the SQL fetch but before stratify — added an explicit
re-sort in both call sites (Tier H, Tier W) so the diversity-quota
selection sees the post-adjustment order once either flag ships live.
Currently a no-op (flags off), but was a latent footgun for the first
PR to flip one on.
2026-07-04 11:21:54 +03:00

248 lines
11 KiB
Python
Raw 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.

"""#2012 — kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring.
Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
ceiling_height_m / is_apartments НЕ читал вовсе для отбора/скоринга аналогов.
Три независимых флага, все default OFF:
- estimate_kitchen_area_signal_enabled / estimate_ceiling_height_signal_enabled
("мягкие корректировки") — pure-Python self-referential pool-median
deviation penalty (см. _adjust_relevance_by_pool_deviation). НЕТ target-
значения для сравнения (ни TradeInEstimateInput, ни `deals` его не несут),
поэтому — в отличие от house_type/year_built — штраф считается от МЕДИАНЫ
ПУЛА кандидатов, а не от target.
- estimate_is_apartments_filter_enabled — hard-filter в _COMMON_WHERE (+ Tier W
inline copy), симметричный novostroyki-guard #1186. SQL-фрагмент проверяется
на сгенерированном тексте (mock db, паттерн test_estimator_radius_dedup_1871.py)
— полный radius-путь требует PostGIS+БД.
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest
import app.services.estimator as est
# --------------------------------------------------------------------------- #
# _adjust_relevance_by_pool_deviation — pure, no DB
# --------------------------------------------------------------------------- #
def _cands(values: list[float | None], key: str = "kitchen_area_m2") -> list[dict]:
return [{key: v, "relevance_score": 0.0} for v in values]
def test_pool_deviation_null_safe_missing_key_not_penalized_or_counted() -> None:
# 5 candidates carry a value (>= min_n), 2 don't -> those 2 stay untouched.
cands = _cands([9.0, 9.0, 9.0, 9.0, 9.0, None, None])
est._adjust_relevance_by_pool_deviation(
cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
)
for c in cands[:5]:
assert c["relevance_score"] == 0.0 # at the pool median -> no penalty
for c in cands[5:]:
assert c["relevance_score"] == 0.0 # missing value -> untouched, not 0-diff
def test_pool_deviation_sparse_safe_skips_when_below_min_n() -> None:
# Only 3 candidates carry a value, min_n=5 -> signal skipped for the WHOLE pool.
cands = _cands([5.0, 20.0, 5.0])
est._adjust_relevance_by_pool_deviation(
cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
)
assert all(c["relevance_score"] == 0.0 for c in cands)
def test_pool_deviation_penalizes_far_from_median() -> None:
# Median of [8, 9, 9, 9, 10] = 9. Deviant 20 -> penalty = |20-9|/3.0 = 3.667,
# clamped to max_penalty=1.0.
cands = _cands([8.0, 9.0, 9.0, 9.0, 10.0, 20.0])
est._adjust_relevance_by_pool_deviation(
cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
)
assert cands[0]["relevance_score"] == pytest.approx(1.0 / 3.0) # |8-9|/3
assert cands[1]["relevance_score"] == 0.0 # at median
assert cands[-1]["relevance_score"] == 1.0 # clamped
def test_pool_deviation_max_penalty_clamp() -> None:
cands = _cands([1.0, 1.0, 1.0, 1.0, 1.0, 100.0])
est._adjust_relevance_by_pool_deviation(
cands, key="kitchen_area_m2", scale=1.0, max_penalty=0.5, min_n=5
)
assert cands[-1]["relevance_score"] == 0.5
def test_pool_deviation_additive_not_overwriting_existing_score() -> None:
"""Mutation ADDS to relevance_score (e.g. house_type SQL penalty already there),
it never overwrites it."""
cands = [
{"kitchen_area_m2": 9.0, "relevance_score": 1.5}, # e.g. house_type mismatch
{"kitchen_area_m2": 9.0, "relevance_score": 1.5},
{"kitchen_area_m2": 9.0, "relevance_score": 0.0},
{"kitchen_area_m2": 9.0, "relevance_score": 0.0},
{"kitchen_area_m2": 20.0, "relevance_score": 0.0},
]
est._adjust_relevance_by_pool_deviation(
cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
)
assert cands[0]["relevance_score"] == pytest.approx(1.5) # at median, +0
assert cands[-1]["relevance_score"] == pytest.approx(1.0) # median=9, |20-9|/3 clamped to 1.0
def test_pool_deviation_missing_relevance_score_key_defaults_to_zero() -> None:
cands = [{"kitchen_area_m2": v} for v in [8.0, 9.0, 9.0, 9.0, 10.0]]
est._adjust_relevance_by_pool_deviation(
cands, key="kitchen_area_m2", scale=3.0, max_penalty=1.0, min_n=5
)
assert all("relevance_score" in c for c in cands)
# --------------------------------------------------------------------------- #
# _apply_kitchen_ceiling_signal — flag wiring
# --------------------------------------------------------------------------- #
def _mixed_pool() -> list[dict]:
return [
{"kitchen_area_m2": 8.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
{"kitchen_area_m2": 9.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
{"kitchen_area_m2": 9.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
{"kitchen_area_m2": 9.0, "ceiling_height_m": 2.7, "relevance_score": 0.0},
{"kitchen_area_m2": 20.0, "ceiling_height_m": 4.5, "relevance_score": 0.0},
]
def test_apply_signal_noop_when_both_flags_off() -> None:
cands = _mixed_pool()
with (
patch.object(est.settings, "estimate_kitchen_area_signal_enabled", False),
patch.object(est.settings, "estimate_ceiling_height_signal_enabled", False),
):
est._apply_kitchen_ceiling_signal(cands)
assert all(c["relevance_score"] == 0.0 for c in cands)
def test_apply_signal_kitchen_only() -> None:
cands = _mixed_pool()
with (
patch.object(est.settings, "estimate_kitchen_area_signal_enabled", True),
patch.object(est.settings, "estimate_ceiling_height_signal_enabled", False),
):
est._apply_kitchen_ceiling_signal(cands)
assert cands[-1]["relevance_score"] > 0.0 # kitchen outlier (20) penalized
for c in cands[1:4]:
assert c["relevance_score"] == 0.0 # at pool median (9.0) -> untouched
def test_apply_signal_ceiling_only() -> None:
cands = _mixed_pool()
with (
patch.object(est.settings, "estimate_kitchen_area_signal_enabled", False),
patch.object(est.settings, "estimate_ceiling_height_signal_enabled", True),
):
est._apply_kitchen_ceiling_signal(cands)
assert cands[-1]["relevance_score"] > 0.0 # ceiling outlier penalized
for c in cands[:4]:
assert c["relevance_score"] == 0.0
# --------------------------------------------------------------------------- #
# Defaults
# --------------------------------------------------------------------------- #
def test_new_flags_default_off() -> None:
from app.core.config import settings
assert settings.estimate_kitchen_area_signal_enabled is False
assert settings.estimate_ceiling_height_signal_enabled is False
assert settings.estimate_is_apartments_filter_enabled is False
# --------------------------------------------------------------------------- #
# is_apartments hard-filter — SQL-fragment (mock db, no PostGIS/DB needed)
# --------------------------------------------------------------------------- #
def _capture_tier_sql_and_params(*, is_apartments_filter: bool) -> list[tuple[str, dict]]:
"""Runs _fetch_analogs with a mock db, returns (sql_text, params) per tier.
target_house_id + short_addr + year/floors are all set so SQL for ALL four
tiers renders (each tier returns [] -> fallthrough to the next).
"""
captured: list[tuple[str, dict]] = []
db = MagicMock()
def side_effect(*args, **kwargs): # type: ignore[no-untyped-def]
params = args[1] if len(args) > 1 else {}
captured.append((str(args[0].text), dict(params)))
result = MagicMock()
result.mappings.return_value.all.return_value = []
return result
db.execute.side_effect = side_effect
with patch.object(est.settings, "estimate_is_apartments_filter_enabled", is_apartments_filter):
est._fetch_analogs(
db,
lat=56.83,
lon=60.6,
rooms=2,
area=50.0,
radius_m=2000,
full_address="г Екатеринбург, ул Малышева, д 30",
year_built=2010,
house_type="монолит",
total_floors=20,
target_house_id=123,
)
return captured
def test_all_four_tiers_render_with_is_apartments_guard() -> None:
calls = _capture_tier_sql_and_params(is_apartments_filter=False)
assert len(calls) == 4, "ожидаем S-canonical, S-fallback, H, W"
def test_is_apartments_bind_param_present_in_every_tier() -> None:
calls = _capture_tier_sql_and_params(is_apartments_filter=False)
for i, (sql, params) in enumerate(calls):
assert ":is_apartments_filter" in sql, f"tier#{i} без is_apartments-гварда"
assert "is_apartments_filter" in params, f"tier#{i}: параметр не передан"
def test_is_apartments_null_safe_guard_sql_present() -> None:
calls = _capture_tier_sql_and_params(is_apartments_filter=True)
for i, (sql, _params) in enumerate(calls):
assert "IS NOT TRUE" in sql, f"tier#{i}: гейт по флагу отсутствует"
assert "is_apartments IS NULL" in sql, f"tier#{i}: NULL-safe пропуск отсутствует"
assert "is_apartments = false" in sql, f"tier#{i}: явный False-пропуск отсутствует"
def test_is_apartments_param_value_reflects_setting() -> None:
calls_off = _capture_tier_sql_and_params(is_apartments_filter=False)
for i, (_sql, params) in enumerate(calls_off):
assert params["is_apartments_filter"] is False, f"tier#{i}"
calls_on = _capture_tier_sql_and_params(is_apartments_filter=True)
for i, (_sql, params) in enumerate(calls_on):
assert params["is_apartments_filter"] is True, f"tier#{i}"
def test_no_bind_param_double_colon_cast() -> None:
"""psycopg3-инвариант: только column::type (не :bind::type)."""
import re
bad = re.compile(r":[a-z_]+::[a-z]")
for i, (sql, _params) in enumerate(_capture_tier_sql_and_params(is_apartments_filter=True)):
assert not bad.search(sql), f"tier#{i}: найден запрещённый :bind::type"
if __name__ == "__main__": # pragma: no cover
raise SystemExit(pytest.main([__file__, "-q"]))