МЕРА: тесты квартального индекса ловят пороги на месте использования (#2379)

Ревью #3556: тест значений держит число в константе, но не то, что код её читает.
Подмена min_n_deals=5 на обоих местах вызова проходила весь сьют. Хуже: Guard-2 и
bimodal-гард снимались целиком (`if False:`) при зелёном test_estimator_quarter_index —
оба теста были пустыми. Guard-2: заглушка отдавала аналогам индекс цели, factor=1.0.
Bimodal: аналоги без кадастра, поправку гасил Guard-5 (#2583) независимо от гарда.

- оба теста получили аналоги с известным уровнем 1.0 — без гарда цена сдвигается;
- границы: доля ровно 0.6 поправку даёт, индекс ровно 2.0 даёт, 2.01 — нет;
- заглушки lookup'ов пишут min_n_deals, тест требует 10 на обоих вызовах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 13:57:04 +05:00
parent 2cfc2eda08
commit bc7fe9b0b9

View file

@ -389,6 +389,7 @@ def _run_estimate_qi(
anchor_tier_override: str | None = None,
analog_indexes: dict[str, float] | None = None,
imv_anchor: dict[str, Any] | None = None,
seen_min_n: list[int] | None = None,
):
"""Запускает estimate_quality с полным stub-пачем I/O; возвращает AggregatedEstimate.
@ -396,6 +397,9 @@ def _run_estimate_qi(
когда тесту нужен ИЗВЕСТНЫЙ уровень аналогов, отличный от целевого: без неё
батч-стаб отдаёт всем кварталам тот же индекс, что и цели, то есть factor=1.0.
Кварталы вне карты не попадают в ответ ровно как в FDW при n_deals < порога.
seen_min_n сюда пишется min_n_deals каждого вызова обоих lookup'ов: порог
фильтрует строки в SQL, и заглушка его иначе теряет.
"""
from app.services.estimator import estimate_quality
@ -406,7 +410,14 @@ def _run_estimate_qi(
# Батч-хелпер возвращает словарь: для каждого переданного квартала — тот же индекс,
# что qi_lookup_result[0], если qi_lookup_result не None; иначе пустой dict.
def _fake_lookup_index(db_arg, *, quarter_cad_number, min_n_deals):
if seen_min_n is not None:
seen_min_n.append(min_n_deals)
return qi_lookup_result
def _fake_lookup_indexes(db_arg, *, quarter_cad_numbers, min_n_deals):
if seen_min_n is not None:
seen_min_n.append(min_n_deals)
if analog_indexes is not None:
return {q: analog_indexes[q] for q in quarter_cad_numbers if q in analog_indexes}
if qi_lookup_result is None:
@ -451,7 +462,7 @@ def _run_estimate_qi(
# Stub singular target-quarter lookup
patch(
"app.services.estimator._lookup_quarter_index",
return_value=qi_lookup_result,
side_effect=_fake_lookup_index,
),
# Stub batched analog-quarter lookup
patch(
@ -532,11 +543,51 @@ def test_guard2_skip_when_majority_analogs_in_target_quarter() -> None:
analogs=analogs,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.5, 30), # high index — but guard-2 should skip
# #2379: уровень аналогов 1.0 — без гарда было бы ×1.5. Без явной карты стаб
# отдаёт аналогам индекс цели, factor=1.0, и тест зеленел при снятом гарде.
analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0},
)
# Медиана НЕ должна изменяться
assert est.median_price_rub == base_median
def test_guard2_ratio_exactly_0_6_still_corrects() -> None:
"""#2379: граница Guard-2 строгая — 3 из 5 аналогов (0.6) в квартале цели не skip."""
analogs = [
_make_listing_qi(
price_per_m2=_BASE_PPM2,
building_cadastral_number=f"{_TARGET_QUARTER if i < 3 else _OTHER_QUARTER}:100",
floor=1 + i,
)
for i in range(5)
]
est = _run_estimate_qi(
analogs=analogs,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.5, 30),
analog_indexes={_TARGET_QUARTER: 1.0, _OTHER_QUARTER: 1.0},
)
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.5)
def test_quarter_lookups_filter_by_ten_deals() -> None:
"""#2379: оба lookup'а (квартал цели и кварталы аналогов) просят индекс от 10 сделок.
Порог уходит параметром в SQL (`n_deals >= :min_n`), в заглушке его не видно:
подмена числа на месте вызова проходила весь сьют.
"""
seen: list[int] = []
est = _run_estimate_qi(
analogs=_ANALOGS_OTHER_QUARTER,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(1.2, 30),
analog_indexes={_OTHER_QUARTER: 1.0},
seen_min_n=seen,
)
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * 1.2) # оба lookup'а вызваны
assert seen == [10, 10]
# ─────────────────────────────────────────────────────────────────────────────
# Sparse fallback: нет строки → no-op
# ─────────────────────────────────────────────────────────────────────────────
@ -659,19 +710,27 @@ def test_c3_one_indexed_analog_is_enough_to_correct() -> None:
# ─────────────────────────────────────────────────────────────────────────────
def test_bimodal_guard_skips_high_index_small_n() -> None:
"""Bimodal guard: price_index=3.5, n_deals=20 → no-op."""
analogs_no_cadnum = [
_make_listing_qi(price_per_m2=_BASE_PPM2, building_cadastral_number=None, floor=4 + i)
for i in range(5)
]
base_median = round(_BASE_PPM2 * _AREA)
@pytest.mark.parametrize(
("target_qi", "expected_factor"),
[
(3.5, 1.0), # index > 2.0 AND n < 50 → bimodal guard, no-op
(2.01, 1.0),
(2.0, 1.8), # ровно 2.0 — не больше порога: поправка есть, factor 2.0 → кламп 1.8
],
)
def test_bimodal_guard_skips_high_index_small_n(target_qi: float, expected_factor: float) -> None:
"""Bimodal guard: price_index > 2.0 при n_deals=20 → no-op.
#2379: аналоги с ИЗВЕСТНЫМ уровнем 1.0. Раньше здесь были аналоги без кадастра —
поправку на них гасит и Guard-5 (#2583), и тест зеленел при снятом bimodal-гарде.
"""
est = _run_estimate_qi(
analogs=analogs_no_cadnum,
analogs=_ANALOGS_OTHER_QUARTER,
dadata_cadnum=f"{_TARGET_QUARTER}:350",
qi_lookup_result=(3.5, 20), # index>2.0 AND n<50 → bimodal guard
qi_lookup_result=(target_qi, 20),
analog_indexes={_OTHER_QUARTER: 1.0},
)
assert est.median_price_rub == base_median
assert est.median_price_rub == round(_BASE_PPM2 * _AREA * expected_factor)
def test_bimodal_guard_allows_high_index_large_n() -> None: