diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index 61944307..69d0c07c 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -539,11 +539,10 @@ SB_SMALL_N_THRESHOLD = 10 # anchor_ppm2 > corridor_high × mult не заменяет радиусную медиану. ANCHOR_TIER_C_CORRIDOR_MULT = 1.5 FSD_K = 1.65 # множитель FSD → полуширина диапазона якоря -# #audit-1: low-confidence гейт якоря — n < SB_GATE_MIN_N И FSD > SB_GATE_MAX_FSD -# (или confidence="low") → fallback на радиусную медиану. Здоровые якоря (n≥4, -# FSD<0.15) проходят без изменений. -SB_GATE_MIN_N = 3 -SB_GATE_MAX_FSD = 0.20 +# #audit-1: low-confidence гейт якоря — confidence="low" (FSD > 0.20) → fallback на +# радиусную медиану. #3590: второе условие «n < 3 И FSD > 0.20» (SB_GATE_MIN_N / +# SB_GATE_MAX_FSD) удалено как мёртвое: якорь возвращается только при n ≥ SB_MIN_COMPS, +# а FSD > 0.20 и так даёт confidence="low". # #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются # нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади — @@ -3757,21 +3756,16 @@ def _price_from_inputs( anchor = None # #audit-1: low-confidence gate. - if anchor is not None: - gate_low = anchor["confidence"] == "low" - gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD - if gate_low or gate_thin: - logger.info( - "sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s" - " → suppressed (gate_low=%s gate_thin=%s) → radius fallback", - anchor_tier, - anchor["n"], - anchor["fsd"], - anchor["confidence"], - gate_low, - gate_thin, - ) - anchor = None + if anchor is not None and anchor["confidence"] == "low": + logger.info( + "sb_anchor low-conf gate #audit-1: tier=%s n=%d fsd=%.3f conf=%s" + " → suppressed → radius fallback", + anchor_tier, + anchor["n"], + anchor["fsd"], + anchor["confidence"], + ) + anchor = None # #2661: якорь не построен — сбрасываем tier-флаг ЯВНО. Причин три: # _compute_same_building_anchor вернула None (комплов меньше min_comps, в т.ч. @@ -3798,7 +3792,10 @@ def _price_from_inputs( # к пред-clip списку, а не падаем KeyError. anchor_comps_used = anchor.get("comps") or anchor_comps est_ppm2 = anchor["anchor_ppm2"] - # PREMIUM GUARDRAIL (hard). + # PREMIUM GUARDRAIL (hard). #3590: недостижимо по построению: est_ppm2 ≥ comp_min — + # цена якоря есть взвешенное среднее / медиана / p70 того же пула, минимум + # которого и есть comp_min_ppm2 (инвариант держит тест в + # test_estimator_audit_fixes.py). Защиту оставляем на случай смены формулы якоря. floor_ppm2 = anchor["comp_min_ppm2"] * (1.0 - SB_GUARDRAIL_TOL) if est_ppm2 < floor_ppm2: est_ppm2 = floor_ppm2 diff --git a/tradein-mvp/backend/tests/test_1970_estimator_constants.py b/tradein-mvp/backend/tests/test_1970_estimator_constants.py index a2421aac..78ae32ed 100644 --- a/tradein-mvp/backend/tests/test_1970_estimator_constants.py +++ b/tradein-mvp/backend/tests/test_1970_estimator_constants.py @@ -50,14 +50,15 @@ _PROD_VALUES: dict[str, tuple[str, float]] = { "SB_SMALL_N_THRESHOLD": ("estimate_sb_small_n_threshold", 10), "ANCHOR_TIER_C_CORRIDOR_MULT": ("estimate_anchor_tier_c_corridor_mult", 1.5), "FSD_K": ("estimate_fsd_k", 1.65), - "SB_GATE_MIN_N": ("estimate_sb_gate_min_n", 3), - "SB_GATE_MAX_FSD": ("estimate_sb_gate_max_fsd", 0.20), } -# Булевы выключатели, схлопнутые к боевому True: их OFF-ветки из кода удалены. +# Поля, чьи ветки из кода удалены: выключатели, схлопнутые к боевому True, и мёртвые гейты. _REMOVED_SWITCHES = ( "estimate_dedup_analogs_enabled", # #2378 "estimate_expected_sold_le_asking", # #2380 + # #3590: мёртвый гейт якоря «n < 3 И FSD > 0.20» удалён вместе с константами. + "estimate_sb_gate_min_n", + "estimate_sb_gate_max_fsd", ) diff --git a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py index 83833adc..2b16f508 100644 --- a/tradein-mvp/backend/tests/test_estimator_audit_fixes.py +++ b/tradein-mvp/backend/tests/test_estimator_audit_fixes.py @@ -25,6 +25,11 @@ sys.modules.setdefault("weasyprint", MagicMock()) import pytest # noqa: E402 from app.services.estimator import ( # noqa: E402 + SB_AREA_SIGMA, + SB_FLOOR_SIGMA, + SB_MAD_K, + SB_MIN_COMPS, + SB_ROOMS_MATCH_BOOST, _compute_same_building_anchor, ) @@ -86,32 +91,116 @@ def test_fix1_healthy_anchor_not_suppressed() -> None: assert anchor["fsd"] < 0.20, f"FSD={anchor['fsd']} ожидается < 0.20" -def test_fix1_thin_n_high_fsd_triggers_gate() -> None: - """ngate_max_fsd → гейт срабатывает (проверяем условие).""" - # 2 компла с умеренным разбросом: n=2 < gate_min_n=3 - comps = [_make_comp(150_000), _make_comp(220_000)] - anchor = _compute_same_building_anchor( - comps, - area_target=50.0, - rooms_target=2, - tier="C", - sigma=0.18, - rooms_boost=1.6, - min_comps=1, # разрешаем построить якорь - ) - if anchor is None: - pytest.skip("MAD-clip отсёк — нет якоря, тест не применим") - from app.services.estimator import SB_GATE_MAX_FSD, SB_GATE_MIN_N +def _c(ppm2: float, area: float | None = 50.0, rooms: int | None = 2, **kw: Any) -> dict: + return {"price_per_m2": ppm2, "area_m2": area, "rooms": rooms, **kw} - # Проверяем условие gate_thin напрямую - gate_thin = anchor["n"] < SB_GATE_MIN_N and anchor["fsd"] > SB_GATE_MAX_FSD - # n=2 < 3 — должен сработать если FSD тоже высокий - assert anchor["n"] == 2 - # FSD = 0.07 + 0.25*CV + tier_penalty + n_penalty; n_penalty=0.05 при n<3; - # tier_penalty=0.05 (C); CV = std/mean для 2 элементов - # Ожидаем что n=2 с умеренным разбросом даёт FSD ≈ 0.07+...≥0.20 - if anchor["fsd"] > SB_GATE_MAX_FSD: - assert gate_thin, "gate_thin должен быть True при n=2 и high FSD" + +# (tier, area_target, rooms_target, floor_target, total_floors_target, комплы) +_ANCHOR_POOLS: list[tuple[str, float, int | None, int | None, int | None, list[dict]]] = [ + # Tier A, топ-юнит дороже соседей по дому → uplift к p70. + ( + "A", + 120.0, + 3, + None, + None, + [_c(180_000, 120.0, 3), _c(200_000, 60.0), _c(210_000, 70.0), _c(220_000, 80.0)], + ), + # Tier A, широкий внутридомовой разброс (Хохрякова 48), без uplift. + ( + "A", + 146.2, + 4, + 5, + 14, + [ + _c(399_478, 153.2, 3), + _c(472_298, 110.1, 3), + _c(683_995, 146.2, 4), + _c(510_000, 125.0, 3), + ], + ), + # равные цены: взвешенное среднее обязано не уйти ниже минимума. + ("A", 64.0, 3, None, None, [_c(112_500, 64.0, 3)] * 4), + # Tier C, малая выборка: жёсткий MAD-клип срезает 600k. + ( + "C", + 50.0, + 2, + 3, + 9, + [_c(150_000), _c(155_000), _c(160_000), _c(158_000), _c(152_000), _c(600_000)], + ), + # тир вне A/C, широкий разброс → FSD > 0.20. + ("", 50.0, 2, None, None, [_c(100_000), _c(180_000), _c(260_000), _c(340_000)]), + # вес собран на самом дешёвом компле (площадь, комнаты, этаж), площадь не у всех. + ( + "A", + 40.0, + 1, + 2, + 10, + [ + _c(120_000, 40.0, 1, floor=2, total_floors=10), + _c(130_000, 90.0, 3, floor=9, total_floors=10), + _c(135_000, None, None), + _c(128_000, 70.0, 2), + _c(140_000, 85.0, 3), + ], + ), +] + + +def test_fix1_anchor_invariants_make_thin_gate_and_guardrail_dead() -> None: + """#3590: инварианты, на которых держатся удаление gate_thin и недостижимость + SB_GUARDRAIL_TOL. Если какой-то покраснел — ветка ожила, вернуть и покрыть. + + 1. n ≥ SB_MIN_COMPS: якорь с n < 3 не выходит из функции (гейт «n < 3 И + FSD > 0.20» удалён как недостижимый). + 2. FSD > 0.20 ⇒ confidence == "low": оставшийся гейт по confidence покрывает + и второе условие удалённого гейта. + 3. anchor_ppm2 ≥ comp_min_ppm2: пол comp_min×(1−SB_GUARDRAIL_TOL) не срабатывает. + """ + results = [] + for tier, area, rooms, floor, total_floors, comps in _ANCHOR_POOLS: + a = _compute_same_building_anchor( + comps, + area_target=area, + rooms_target=rooms, + tier=tier, + sigma=SB_AREA_SIGMA, + rooms_boost=SB_ROOMS_MATCH_BOOST, + floor_target=floor, + total_floors_target=total_floors, + floor_sigma=SB_FLOOR_SIGMA, + min_comps=SB_MIN_COMPS, + mad_k=SB_MAD_K, + ) + assert a is not None, (tier, comps) + assert a["n"] >= SB_MIN_COMPS, a + assert a["fsd"] <= 0.20 or a["confidence"] == "low", a + assert a["anchor_ppm2"] >= a["comp_min_ppm2"], a + results.append((len(comps), a)) + + # Пулы проходят ветки, где инвариант мог бы сломаться, а не только тривиальные. + assert any(a["used_uplift"] for _, a in results), "нет пула с uplift к p70" + assert any(a["n"] < n for n, a in results), "нет пула с MAD-клипом" + assert any(a["fsd"] > 0.20 for _, a in results), "нет пула с FSD > 0.20" + + # Три компла — меньше SB_MIN_COMPS: якоря нет вовсе. + three = [_c(150_000), _c(300_000), _c(160_000)] + assert ( + _compute_same_building_anchor( + three, + area_target=50.0, + rooms_target=2, + tier="C", + sigma=SB_AREA_SIGMA, + rooms_boost=SB_ROOMS_MATCH_BOOST, + min_comps=SB_MIN_COMPS, + ) + is None + ) # --------------------------------------------------------------------------- diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index 268adbbd..dd30ae70 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -524,6 +524,37 @@ def test_estimate_anchor_gets_prod_weights() -> None: } +def test_estimate_anchor_range_halfwidth_is_fsd_k_times_fsd() -> None: + """#3590: полуширина видимого диапазона якоря = FSD_K × fsd, FSD_K = 1.65. + + Гейт регрессии диапазон по объявлениям не видит (FSD_K 1.5 меняла 857 сделок + из 1600 при зелёном гейте). Ожидание — литерал 1.65, а не импорт FSD_K: + импорт поехал бы вместе с мутацией и тест остался бы зелёным. + Спред комплов нулевой (min = max = якорь), чтобы диапазон задавал только FSD. + """ + fsd = 0.10 + fake_anchor = { + "anchor_ppm2": 500_000.0, + "anchor_sold_ppm2": 480_000.0, + "fsd": fsd, + "confidence": "medium", + "n": len(_SB_COMPS_PREMIUM), + "cv": 0.02, + "comp_min_ppm2": 500_000.0, + "comp_max_ppm2": 500_000.0, + "used_uplift": False, + "haircut": 0.04, + "comps": list(_SB_COMPS_PREMIUM), + } + with patch("app.services.estimator._compute_same_building_anchor", return_value=fake_anchor): + est = _run_estimate(anchor_comps=_SB_COMPS_PREMIUM, anchor_tier="A") + point = est.median_price_rub + assert point == int(500_000 * 146.2) # headline от якоря, без ремонта и сдвигов + half = 1.65 * fsd + assert abs((point - est.range_low_rub) / point - half) < 1e-6, est.range_low_rub + assert abs((est.range_high_rub - point) / point - half) < 1e-6, est.range_high_rub + + def test_estimate_anchor_fires_without_radius_analogs() -> None: """#691: радиусных аналогов НЕТ (median=0, прод-кейс провала ST_DWithin), но same-building комплы есть → якорь обязан сработать и дать median>0. diff --git a/tradein-mvp/frontend/src/app/mera-public/content.ts b/tradein-mvp/frontend/src/app/mera-public/content.ts index 586e0116..b68988df 100644 --- a/tradein-mvp/frontend/src/app/mera-public/content.ts +++ b/tradein-mvp/frontend/src/app/mera-public/content.ts @@ -370,7 +370,7 @@ export interface SourceGroup { * ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок * — не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV * / Yandex blend», Tier D — когда якоря по дому/≤500 м нет) медиана - * переписывается на `new_median` с весом `estimate_imv_blend_weight`, + * переписывается на `new_median` с весом `IMV_BLEND_WEIGHT`, * объяснение дополняется «Оценка скорректирована по…», а `sources_used` * пополняется `avito_imv`. Умолчать об этом — значит утверждать на публичной * странице то, чего код не делает.