fix(estimator): enable split-home disclosure at validated threshold 1.2 (audit #1871)
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / openapi-codegen-check (pull_request) Has been skipped

Включает wide-corridor disclosure (PR #1880, был default OFF) — но с
ИСПРАВЛЕННЫМ порогом по prod-данным.

Проверка на trade_in_estimates (60д): corridor_pct = (range_high-range_low)/
median_price имеет median≈0.48, p90≈0.93, p99≈1.38; **30.7% оценок > 0.6**.
Старый порог 0.6 прилепил бы caveat «дом разбит на секции» к ~трети оценок —
большинство НЕ split-дома (широкий коридор бывает от разных причин). Это была
бы ложная атрибуция (дезинформация).

- estimate_wide_corridor_threshold 0.6 → 1.2: genuine split-дома из аудита =
  148-170% (1.48-1.70) → 1.2 ловит только экстремальный хвост (>p99), не трогая
  нормальную оценочную неопределённость.
- estimate_wide_corridor_disclosure_enabled False → True (включаем после
  валидации). ENV-override сохранён (kill-switch).
- Формулировка смягчена: «дом разбит на секции» → «вероятно, дом разбит на
  секции разной этажности ИЛИ разнородный фонд» — мы ИНФЕРИМ split по ширине
  коридора, не доказываем структурно.
- Логика не тронута: фаерит только Tier A + corridor>threshold, только понижает
  confidence + дописывает explanation; point/median/range не трогает.

Тесты: обновлены default-ассерты (флаг ON, порог 1.2) + 2 behavioral boundary-
теста (фаерит при pct>threshold, НЕ фаерит при умеренном 1.045<1.2 — доказывает
что raise убрал false-positive flood). 13 passed.

Refs #1871
This commit is contained in:
Light1YT 2026-06-24 01:54:58 +05:00
parent f0b689318e
commit 77a3d3f75a
3 changed files with 42 additions and 13 deletions

View file

@ -191,17 +191,21 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_RADIUS_DEDUP_ENABLED. # ENV: ESTIMATE_RADIUS_DEDUP_ENABLED.
estimate_radius_dedup_enabled: bool = True estimate_radius_dedup_enabled: bool = True
# ── #1871 P2: split-дома wide-corridor disclosure (default OFF) ──────────── # ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом # Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
# дробится на несколько house_id). На split-доме разной этажности comp_min..max # дробится на несколько house_id). На split-доме разной этажности comp_min..max
# растягивается через несколько ценовых режимов → коридор range_low/high # растягивается через несколько ценовых режимов → коридор range_low/high
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True + # 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. True +
# Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем # Tier A + corridor_pct > threshold → понижаем confidence на ступень и дописываем
# disclosure в explanation. НЕ трогает point/median/range. default False # disclosure в explanation. НЕ трогает point/median/range.
# (no-regress, включаем осознанно). ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED. # ENV: ESTIMATE_WIDE_CORRIDOR_DISCLOSURE_ENABLED.
estimate_wide_corridor_disclosure_enabled: bool = False estimate_wide_corridor_disclosure_enabled: bool = True
# Порог ширины коридора (range_high-range_low)/median_price для disclosure (60%). # Порог ширины коридора (range_high-range_low)/median_price для disclosure.
estimate_wide_corridor_threshold: float = 0.6 # 1.2 (120%): по prod-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6
# фаерил бы на ~31% оценок (широкий коридор ≠ split-дом, ложная атрибуция).
# Genuine split-дома из аудита = 148-170% (1.48-1.70) → 1.2 ловит только
# экстремальный хвост (>p99), не трогая нормальную оценочную неопределённость.
estimate_wide_corridor_threshold: float = 1.2
# ── Mera-audit fix-1: Cian valuation sanity bounds ──────────────────────── # ── Mera-audit fix-1: Cian valuation sanity bounds ────────────────────────
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999). # API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).

View file

@ -2507,9 +2507,9 @@ async def estimate_quality(
if corridor_pct > settings.estimate_wide_corridor_threshold: if corridor_pct > settings.estimate_wide_corridor_threshold:
confidence = _downgrade_confidence(confidence) confidence = _downgrade_confidence(confidence)
explanation = (explanation or "") + ( explanation = (explanation or "") + (
" Широкий ценовой диапазон: дом разбит на секции разной " " Очень широкий ценовой диапазон по дому (вероятно, дом "
"этажности — оценка ориентировочная, уточните по конкретной " "разбит на секции разной этажности или разнородный фонд) — "
"секции." "оценка ориентировочная, уточните по конкретной секции."
) )
# ── #651: IMV / Yandex BLEND (killer accuracy fix) — SECONDARY, Tier D only ── # ── #651: IMV / Yandex BLEND (killer accuracy fix) — SECONDARY, Tier D only ──

View file

@ -67,12 +67,15 @@ _DISCLOSURE_MARKER = "разбит на секции"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_disclosure_flag_default_off() -> None: def test_disclosure_flag_default_on() -> None:
assert settings.estimate_wide_corridor_disclosure_enabled is False # Включено после валидации порога на prod-данных (corridor_pct median≈0.48).
assert settings.estimate_wide_corridor_disclosure_enabled is True
def test_threshold_default_0_6() -> None: def test_threshold_default_1_2() -> None:
assert settings.estimate_wide_corridor_threshold == 0.6 # 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
assert settings.estimate_wide_corridor_threshold == 1.2
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -98,6 +101,28 @@ def test_flag_off_no_change() -> None:
assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "") assert _DISCLOSURE_MARKER not in (off.confidence_explanation or "")
# Boundary-тесты вокруг ПРОД-порога: _WIDE_SPLIT_COMPS даёт коридор ≈1.045
# (estimator MAD-фильтрует более широкие спреды, поэтому проверяем границу
# через сам threshold, а не пытаемся сконструировать коридор >1.2).
def test_corridor_fires_when_above_threshold() -> None:
"""corridor_pct (≈1.045) > threshold (1.0) → disclosure фаерит (механизм работает)."""
on = _run(flag=True, anchor_tier="A", threshold=1.0)
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
assert pct > 1.0
assert _DISCLOSURE_MARKER in (on.confidence_explanation or "")
def test_moderate_corridor_no_fire_at_prod_threshold_1_2() -> None:
"""Прод-дефолт 1.2: умеренный коридор (0.6<pct<1.2) НЕ фаерит — нет ложной
атрибуции split-дома на ~31% оценок, которую дал бы старый порог 0.6."""
on = _run(flag=True, anchor_tier="A", threshold=1.2) # _WIDE_SPLIT_COMPS ≈ 1.045
pct = (on.range_high_rub - on.range_low_rub) / on.median_price_rub
assert 0.6 < pct < 1.2, "moderate-fixture должен быть между 0.6 и 1.2"
assert _DISCLOSURE_MARKER not in (on.confidence_explanation or "")
def test_does_not_touch_point_median_range() -> None: def test_does_not_touch_point_median_range() -> None:
"""Disclosure НЕ трогает point/median/range — только confidence+explanation.""" """Disclosure НЕ трогает point/median/range — только confidence+explanation."""
off = _run(flag=False, anchor_tier="A") off = _run(flag=False, anchor_tier="A")