МЕРА/оценщик: диапазон якоря закреплён тестом на FSD_K, мёртвый гейт gate_thin удалён (#3590)
Some checks failed
CI Trade-In / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 18s
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 / frontend-checks (pull_request) Successful in 1m57s
CI Trade-In / backend-tests (pull_request) Failing after 6m18s

FSD_K задаёт видимый клиенту диапазон range_low_rub/range_high_rub, но гейт
регрессии его не видит: FSD_K=1.5 меняла 857 сделок из 1600 при зелёном гейте.
Новый тест подменяет _compute_same_building_anchor якорем с известным fsd и
нулевым спредом комплов и проверяет полуширину 1.65 × fsd на результате
estimate_quality. Ожидание задано литералом, а не импортом FSD_K: импорт
поехал бы вместе с мутацией.

gate_thin («n < 3 И FSD > 0.20») недостижим дважды: якорь возвращается только
при n ≥ SB_MIN_COMPS = 4, а FSD > 0.20 и так даёт confidence="low", то есть
срабатывает gate_low. В ответ API флаг не уходил (только в лог), поэтому ветка
удалена вместе с SB_GATE_MIN_N и SB_GATE_MAX_FSD. Поля Settings остаются в
проверке «не вернулись».

SB_GUARDRAIL_TOL оставлен и помечен как недостижимый по построению: цена якоря
есть взвешенное среднее, медиана или p70 того же пула, так что
est_ppm2 ≥ comp_min.

Тавтологичный test_fix1_thin_n_high_fsd_triggers_gate (min_comps=1 и копия
формулы) заменён тестом инвариантов на шести пулах: n ≥ SB_MIN_COMPS,
FSD > 0.20 ⇒ low, anchor_ppm2 ≥ comp_min_ppm2. Покрытие ветвей uplift, клипа и
FSD > 0.20 проверяется явно.

В комментарии content.ts старое имя estimate_imv_blend_weight → IMV_BLEND_WEIGHT.

Поведение на проде не меняется: гейт регрессии зелёный без перегенерации
baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 18:40:47 +05:00
parent 98f587add6
commit 66dfb030e7
5 changed files with 168 additions and 50 deletions

View file

@ -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

View file

@ -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",
)

View file

@ -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:
"""n<gate_min_n И FSD>gate_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×(1SB_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
)
# ---------------------------------------------------------------------------

View file

@ -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.

View file

@ -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`. Умолчать об этом значит утверждать на публичной
* странице то, чего код не делает.