МЕРА: дедуп аналогов и пороги ручной оценки без выключателей (#2378)

Флаг estimate_dedup_analogs_enabled снят: кросс-source дедуп работает всегда.
На проде 17.09 флаг = True во всех трёх контейнерах (backend/scraper/tgbot),
ENV-оверрайда нет; фикстура бэктеста захвачена с True, поэтому пин флага в
реплее и monkeypatch в гейте больше не нужны.

Числовые пороги estimate_wide_corridor_threshold и три
estimate_manual_review_* перенесены в estimator.py константами модуля с
прежними значениями (1.2 / 20 000 000 / 1.9 / 250 000). _manual_review больше
не принимает settings. Осиротевшие комментарии Settings к уже снятым в #2475
флагам (#1871 P1.2, P2 radius-dedup) удалены.

Тесты: OFF-тест дедупа удалён; два теста, пинившие дедуп OFF ради изоляции,
получили разные площади у аналогов (разные физлоты). Регрессионный гейт и
реплей бэктеста по сделкам (1600, из них 615 радиусных) побитово те же.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-09-17 12:29:26 +05:00
parent 34642e1dd5
commit 45bd47fdd7
10 changed files with 141 additions and 327 deletions

View file

@ -589,60 +589,6 @@ class Settings(BaseSettings):
# Дефолт 6 (консервативно); аудит предложил 3 — конфигурируемо.
estimate_price_trend_max_age_months: int = 6
# ── #1871 P1.2: ghost-anchor confidence floor ─────────────────────────────
# True (дефолт) = форсировать confidence='low' + добавлять caveat в explanation
# когда n_analogs == 0 (нет радиусных/anchor-аналогов) но confidence не 'low'.
# Защита от ghost-anchor: внешние оценочные сервисы (yandex_valuation,
# cian_valuation, avito_imv) могут дать median без единого реального рыночного
# аналога → headline выглядит достоверным при нулевой реальной базе.
# ── #2002 #4: manual-review recommendation (derived FLAG, НЕ ценовой сигнал) ─
# Помечает оценки, которые НЕ стоит авто-оффэрить — нужна ручная оценка
# человеком. Research: элит/премиум-премия unit-level и под-доверена (зависит
# от отделки/вида, чего нет в данных сделок). Триггеры: премиальный дом,
# высокая стоимость, низкая уверенность, слишком широкий диапазон цены.
# Чисто метаданные — не трогает median/expected_sold/ranges (gate byte-stable).
estimate_manual_review_high_value_rub: int = 20_000_000 # ≥ этого — ручная оценка
estimate_manual_review_wide_range_ratio: float = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна
# (премия за отделку/вид/класс — unit-level, отсутствует в данных сделок).
estimate_manual_review_elite_ppm2: int = 250000
# ── #1871 P2: radius-tier (source, source_id) dedup ───────────────────────
# Radius-путь _fetch_analogs (Tier S/H/W) кэпит только per-address
# (rn_addr <= MAX_ANALOGS_PER_ADDRESS), но (source, source_id)-дубли делят один
# address и выживают на разных rn_addr рангах → раздувают n_analogs (prod
# 2026-06-23: yandex 48, cian 9, n1 5 excess). Anchor-путь дедупит по
# (source, source_id) — radius нет. Добавляет rn_dup=1 фильтр в каждом тире
# (freshest scraped_at на (source, source_id|source_url|ctid)).
# ── #2087 H4: кросс-source физический дедуп аналогов ──────────────────────
# Radius-дедуп выше ловит только повторы ВНУТРИ одного source (source, source_id).
# Один физический лот кросс-постится на avito+cian+domklik (разные source, разные
# source_id) → radius-дедуп его НЕ схлопывает → он считается несколько раз →
# раздувает n_analogs И cv (→ шире коридор), может смещать медиану. Прод-аудит
# #2087: лот 80м²/265000₽/м² = N1+Домклик+Циан (×3); «14 аналогов» → ~6-7 уникальных.
# True схлопывает дубли по ФИЗИЧЕСКОМУ ключу до подсчёта n_analogs/median/cv:
# building (building_cadastral_number | нормализованный address)
# + floor + area_bucket (round(area_m2), ~±0.5 м²)
# + price_bucket (round(price_rub / 100000), ~±0.5% @21М / ~±2% @2.5М).
# Из группы остаётся ОДИН представитель (свежайший scraped_at), НЕ суммируем;
# n_analogs/median/cv/source_counts/sources_used считаются по физическим лотам
# («лот считается один раз»).
#
# Бэктест #1966 (400 ДКП, radius-путь, full spine, OFF vs ON): MAPE 13.89% →
# 13.89%, coverage 83.33% → 83.33%, bias 3.83% → 3.83%, median width 0.743 →
# 0.743, median cv 0.0988 → 0.0988; avg n_analogs 27.64 → 27.57. Дедуп отработал
# 107× на 335 оценках, но снимает лишь identical-price кросс-посты (дубли имеют
# ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс
# ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по
# физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор
# post-weight MAD-clip, уже ON). Default ON (#2173): бэктест #1966 OFF vs
# ON accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%, median width/cv
# без изменений), меняется только user-visible n_analogs — перестаёт быть раздутым
# кросс-постингом ×3. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED (=false откатывает).
estimate_dedup_analogs_enabled: bool = True
# ── #2012: kitchen_area_m2 / ceiling_height_m / is_apartments comp-scoring ──
# Follow-up к #2007/#2008/#2009 (промоутят поля в колонки). До этой правки
# estimator читал house_type ТОЛЬКО как soft-penalty, а kitchen_area_m2 /
@ -725,20 +671,6 @@ class Settings(BaseSettings):
# ENV: ESTIMATE_IS_APARTMENTS_FILTER_ENABLED.
estimate_is_apartments_filter_enabled: bool = False
# ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ──
# Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом
# дробится на несколько house_id). На split-доме разной этажности comp_min..max
# растягивается через несколько ценовых режимов → коридор range_low/high
# 148%/170%. Коридор честно широкий, но юзер видит 170% без объяснения. Tier A +
# corridor_pct > threshold → понижаем confidence на ступень и дописываем
# disclosure в explanation. НЕ трогает point/median/range.
# Порог ширины коридора (range_high-range_low)/median_price для disclosure.
# 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 ────────────────────────
# API-ответ Cian иногда возвращает garbage-значения (999_999 или 9_999_999_999).
# sale_price_rub вне [min, max] → результат отбрасывается (return None, не кэшируется).

View file

@ -55,7 +55,7 @@ from scraper_kit.proxy_errors import caused_by_no_proxy
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.config import LISTINGS_FRESH_DAYS, Settings, settings
from app.core.config import LISTINGS_FRESH_DAYS, settings
from app.core.db import SessionLocal, run_db_thread
from app.schemas.trade_in import (
AggregatedEstimate,
@ -416,6 +416,31 @@ PRICE_SEGMENTS_PPM2: tuple[tuple[str, float], ...] = (
("премиум", float("inf")),
)
# ── #1970: калибровочные константы движка (бывшие поля Settings estimate_*) ──
# Раньше жили в Settings и формально переопределялись через ENV. Ни одно ни разу
# не переопределялось: на проде 17.09.2026 (tradein-backend/scraper/tgbot) живые
# значения совпадают с дефолтами, перенесёнными сюда. Переключателей, которые
# реально переключаются, среди них нет, поэтому это код, а не конфиг: поменять
# число = правка здесь + регрессионный гейт бэктеста
# (tests/test_backtest_regression_gate.py).
# #1871 P2 (#2378): split-дома, disclosure широкого коридора. Tier A матчит по
# address-regex (намеренно НЕ house_id — дом дробится на несколько house_id), и на
# split-доме разной этажности comp_min..max растягивается через несколько ценовых
# режимов → коридор 148%/170%. Tier A + (range_high-range_low)/median_price > порога
# → confidence на ступень ниже + disclosure в explanation; point/median/range не
# трогает. 1.2: по прод-данным corridor_pct median≈0.48, p90≈0.93 — порог 0.6 фаерил
# бы на ~31% оценок; genuine split-дома аудита = 1.48-1.70 → 1.2 ловит только хвост.
WIDE_CORRIDOR_THRESHOLD = 1.2
# #2002 #4 (#2378): пороги рекомендации ручной оценки (_manual_review). Чисто
# метаданные — median/expected_sold/ranges не трогают.
MANUAL_REVIEW_HIGH_VALUE_RUB = 20_000_000 # expected_sold ≥ этого — ручная оценка
MANUAL_REVIEW_WIDE_RANGE_RATIO = 1.9 # range_high/range_low ≥ — неопределённо
# asking ₽/м² ≥ этого → дорогой сегмент, авто-оценка консервативна (премия за
# отделку/вид/класс — unit-level, отсутствует в данных сделок).
MANUAL_REVIEW_ELITE_PPM2 = 250_000
# #699: санитизация ДКП-выбросов (Росреестр `deals`). В сырых сделках встречаются
# нерыночные/битые записи — доли, сделки с обременением, опечатки этажа/площади —
# которые шумят actual_deals (display) и dkp_corridor/expected_sold. Абсолютные
@ -3394,7 +3419,7 @@ def _price_from_inputs(
"""
# 2b. Кросс-source физический дедуп (#2087 H4) — ДО outlier-фильтра и
# агрегации, чтобы n_analogs/median/cv считались по уникальным лотам, а не
# по кросс-постам. No-op при estimate_dedup_analogs_enabled=False.
# по кросс-постам.
listings = _dedup_cross_source(listings)
# #2265 gap (live QA 2026-07-03): якорный пул (_fetch_anchor_comps, Tier A
# same_building / Tier C micro_radius) строится ОТДЕЛЬНЫМ raw SQL и раньше
@ -3726,7 +3751,7 @@ def _price_from_inputs(
# #1871 P2: split-дома wide-corridor disclosure.
if anchor_tier == "A" and median_price > 0:
corridor_pct = (range_high - range_low) / median_price
if corridor_pct > settings.estimate_wide_corridor_threshold:
if corridor_pct > WIDE_CORRIDOR_THRESHOLD:
confidence = _downgrade_confidence(confidence)
explanation = (explanation or "") + (
" Очень широкий ценовой диапазон по дому (вероятно, дом "
@ -5641,7 +5666,6 @@ async def estimate_quality(
confidence,
range_low,
range_high,
settings,
# asking-сторона: тот же headline median_price_per_m2 (=int(median_ppm2)),
# что уходит в ответ ниже. НЕ expected_sold_per_m2 — он занижен на элите.
asking_ppm2=int(median_ppm2),
@ -5781,8 +5805,8 @@ def _is_premium_building(db: Session, house_id: int | None) -> tuple[bool, int |
None) premium_building флаг не выставится, даже если дом объективно элитный по
рынку своего города. Это не тихая потеря сигнала: `_manual_review` (см. ниже,
строки ~3944-3993) всё равно ловит такие объекты по АБСОЛЮТНОЙ стоимости
`estimate_manual_review_high_value_rub` (expected_sold_price порога) и
`estimate_manual_review_elite_ppm2` (asking /м² порога) независимо от
`MANUAL_REVIEW_HIGH_VALUE_RUB` (expected_sold_price порога) и
`MANUAL_REVIEW_ELITE_PPM2` (asking /м² порога) независимо от
premium_building. То есть oblast-элита не помечается как «премиальный дом», но
почти всегда подхватывается value-based триггером той же функции.
"""
@ -5819,7 +5843,6 @@ def _manual_review(
confidence: str,
range_low: int | None,
range_high: int | None,
settings: Settings,
asking_ppm2: int | None = None,
) -> tuple[bool, list[str]]:
"""#2002 #4: рекомендация ручной оценки — производный ФЛАГ, НЕ цена.
@ -5849,15 +5872,13 @@ def _manual_review(
reasons: list[str] = []
if premium_building:
reasons.append("премиальный дом — премия зависит от отделки/вида (не в данных сделок)")
high_value = settings.estimate_manual_review_high_value_rub
if expected_sold_price and expected_sold_price >= high_value:
if expected_sold_price and expected_sold_price >= MANUAL_REVIEW_HIGH_VALUE_RUB:
reasons.append("высокая стоимость (≥20 млн ₽)")
if confidence == "low":
reasons.append("низкая уверенность оценки")
wide_ratio = settings.estimate_manual_review_wide_range_ratio
if range_low and range_high and range_high / range_low >= wide_ratio:
if range_low and range_high and range_high / range_low >= MANUAL_REVIEW_WIDE_RANGE_RATIO:
reasons.append("широкий диапазон цены")
if asking_ppm2 and asking_ppm2 >= settings.estimate_manual_review_elite_ppm2:
if asking_ppm2 and asking_ppm2 >= MANUAL_REVIEW_ELITE_PPM2:
reasons.append(
"элитный/дорогой сегмент — авто-оценка консервативна: премия за отделку, "
"вид и класс дома не отражена в данных сделок, фактическая цена может быть выше"
@ -7655,8 +7676,7 @@ def _union_find_phys_dedup(
не дедупятся остаются уникальными.
include_price=True статистический ключ (влияет на n_analogs/median/cv,
гейтится флагом estimate_dedup_analogs_enabled со стороны вызывающего
_dedup_cross_source). include_price=False display-only ключ без
вызывающий _dedup_cross_source). include_price=False display-only ключ без
price_bucket (см. _dedup_display_lots) ценовой дрейф между кросс-постами
не мешает схлопнуть их в одну UI-карточку; ВЗАМЕН требует РАЗНЫЙ source у
пары для merge (require_diff_source) без цены единственный оставшийся
@ -7777,13 +7797,14 @@ def _dedup_cross_source(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Статистический кросс-source дедуп (#2087 H4 / #2265) — ВЛИЯЕТ на n_analogs/
median/cv/source_counts/sources_used (представитель несёт свой source лот
считается один раз; source кросс-поста, не ставший представителем, выпадает
из выборки по нему нет независимой ценовой точки). Флаг
estimate_dedup_analogs_enabled OFF no-op (байт-идентичный проход, нужно
для frozen backtest regression gate). Ключ включает price_bucket
из выборки по нему нет независимой ценовой точки). Работает всегда: флаг-выключатель
снят (#2378) — бэктест #1966 OFF vs ON был
accuracy-идентичен (MAPE 13.89%, coverage 83.33%, bias 3.83%), дедуп меняет
только честность счёта n_analogs. Ключ включает price_bucket
(_lot_dedup_components include_price=True по умолчанию) см.
_union_find_phys_dedup.
"""
if not settings.estimate_dedup_analogs_enabled or len(lots) < 2:
if len(lots) < 2:
return lots
return _union_find_phys_dedup(lots, include_price=True)
@ -7829,9 +7850,8 @@ def _dedup_display_lots(lots: list[dict[str, Any]]) -> list[dict[str, Any]]:
случай: перевыставили на другой площадке дешевле/дороже) без такого
дубля-выброса, ошибочно попавшего в карточки как «отдельный аналог».
Независим от settings.estimate_dedup_analogs_enabled: тот флаг управляет
статистикой (median/n_analogs), этот только честностью UI-карточек и
может/должен оставаться активным даже если стат-дедуп выключен. НЕ трогает
Независим от _dedup_cross_source: тот управляет статистикой
(median/n_analogs), этот только честностью UI-карточек. НЕ трогает
n_analogs/median/cv вызывается ПОСЛЕ того, как эти величины уже вычислены
из отдельного (не обязательно совпадающего по итоговой длине) пула.
"""

View file

@ -1975,12 +1975,11 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
/ ``sharpness`` / ``confidence_order`` / ``headline`` and DROPS the volatile
``params`` block. Touches NO DB / network and does NOT consult
``settings_at_capture`` it prices against the live committed
``estimator.settings`` defaults (so a settings change is caught as a metric
drift, not silently honoured). ЕДИНСТВЕННОЕ исключение
``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не
настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного
потока (см. комментарий у пина ниже). Deterministic: same fixture identical
dict.
``estimator.settings`` defaults and module constants (so a change is caught as a
metric drift, not silently honoured). Кросс-source дедуп (#2087 H4) с #2378
работает всегда, поэтому реплеится только фикстура, захваченная с дедупом ON
(закоммиченная такая); более старая упадёт на «control flow diverged from
capture» её надо перезахватить. Deterministic: same fixture identical dict.
"""
est = _import_estimator_full()
m = est.m
@ -1991,26 +1990,6 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
pred_ppm2_all: list[float] = []
unrecorded: list[int] = [0] # #2661: счётчик lookup-вызовов без записи в фикстуре
# #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был
# no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти
# по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source
# подрезал бы listings до quarter_indexes_lookup и записанная последовательность
# вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг
# сам, а документированная регенерация baseline (--from-fixture --update-baseline)
# — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА.
# #3251: РАНЬШЕ здесь стоял жёсткий `= False`. Это верно только для фикстуры,
# захваченной ДО #2173 (когда дедуп был выключен по умолчанию), и делает
# НЕРЕПЛЕИБЕЛЬНОЙ любую свежую фикстуру: при dedup=True на захвате часть сделок
# уходит под sufficiency-гейт с median_ppm2<=0 и ratio_resolver для них не
# вызывается, а реплей с dedup=False их оценивает и упирается в
# «control flow diverged from capture». Берём значение ИЗ ФИКСТУРЫ (fallback
# False — для старых фикстур без settings_at_capture поведение прежнее).
_dedup_capture = bool(
(fixture.get("settings_at_capture") or {}).get("estimate_dedup_analogs_enabled", False)
)
_dedup_saved = m.settings.estimate_dedup_analogs_enabled
m.settings.estimate_dedup_analogs_enabled = _dedup_capture
try:
for rec in deals:
kw = dict(rec["kwargs"])
sold_ppm2_all.append(float(rec["sold_ppm2"]))
@ -2066,9 +2045,6 @@ def replay_fixture(fixture: dict[str, Any], *, pi_report: bool = False) -> dict[
predictions.append(prediction)
pred_ppm2_all.append(prediction.median_ppm2)
finally:
m.settings.estimate_dedup_analogs_enabled = _dedup_saved
# The fixture stores ONLY priced deals, so n_no_prediction is 0 here.
metrics = _compute_full_metrics(predictions, n_no_prediction=0, pi_report=pi_report)

View file

@ -25,11 +25,8 @@ import math
import os
from pathlib import Path
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from app.services import estimator
from scripts.backtest_estimator import load_fixture, replay_fixture
_FIXTURES = Path(__file__).parent / "fixtures"
@ -76,16 +73,7 @@ def test_fixture_and_baseline_committed() -> None:
assert _BASELINE_PATH.exists(), f"frozen baseline missing: {_BASELINE_PATH}"
def test_backtest_regression_gate(monkeypatch: pytest.MonkeyPatch) -> None:
# The frozen fixture records the injected-callback control flow captured with
# cross-source physical dedup OFF (#2087 H4 was a no-op default at capture time).
# Dedup is now default ON (#2173), but this gate is a byte-identical REPLAY of a
# frozen OFF capture — with dedup active _dedup_cross_source would trim listings
# before quarter_indexes_lookup and the recorded call sequence would diverge. Pin
# the flag OFF so the replay follows the captured control flow. (Accuracy-neutral:
# backtest #1966 OFF vs ON is identical; dedup only trims user-visible n_analogs,
# so the OFF baseline stays the valid spine regression reference.)
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
def test_backtest_regression_gate() -> None:
fixture = load_fixture(_FIXTURE_PATH)
baseline = json.loads(_BASELINE_PATH.read_text(encoding="utf-8"))
# Round-trip the replay output through JSON before comparing: the committed

View file

@ -172,7 +172,6 @@ def test_non_outlier_cross_source_duplicate_collapses_in_cards_only() -> None:
# sanity: стат-дедуп (price_bucket-строгий) ДЕЙСТВИТЕЛЬНО пропускает пару —
# иначе тест проверял бы не то, что заявлено в docstring.
assert estimator.settings.estimate_dedup_analogs_enabled is True
deduped_upstream = estimator._dedup_cross_source([dup_a, dup_b])
assert len(deduped_upstream) == 2, "price_bucket dedup ошибочно поймал дрейф — тест не то мерит"

View file

@ -3,7 +3,7 @@
Проверяет:
- композиты физического ключа (_lot_dedup_components) что образуют / не образуют;
- _dedup_cross_source: кросс-source дубли схлопываются в 1 представителя
(свежайший scraped_at), не-дубли не трогаются, флаг OFF = байт-идентично;
(свежайший scraped_at), не-дубли не трогаются;
- выбор представителя детерминирован (freshest scraped_at).
No DB / network: помощники оперируют plain-dict'ами. DATABASE_URL нужен только
@ -111,45 +111,28 @@ def test_phys_key_area_bucket_tolerates_rounding() -> None:
# --------------------------------------------------------------------------- #
# Флаг: default ON (#2173) vs явный OFF (byte-identical)
# Дедуп без выключателя (#2378)
# --------------------------------------------------------------------------- #
def test_dedup_default_is_on() -> None:
# #2173: default флага теперь ON (в проде дедуп активен без ENV-override).
# Без monkeypatch settings отражает config-default → дедуп схлопывает
# кросс-пост-тройку одного физлота. Явно НЕ монкипатчим — проверяем именно
# поведение по умолчанию.
assert estimator.settings.estimate_dedup_analogs_enabled is True
# #2378: выключателя больше нет — дедуп работает всегда и схлопывает
# кросс-пост-тройку одного физлота.
lots = [
_lot(source="avito"),
_lot(source="cian"),
_lot(source="domklik"),
]
out = estimator._dedup_cross_source(lots)
assert len(out) == 1 # дедуп активен по умолчанию
def test_dedup_flag_off_is_noop(monkeypatch: pytest.MonkeyPatch) -> None:
# OFF-путь проверяем ЯВНЫМ override (не полагаясь на default, который теперь ON):
# при флаге OFF проход байт-идентичен — возвращается тот же объект.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
lots = [
_lot(source="avito"),
_lot(source="cian"),
_lot(source="domklik"),
]
out = estimator._dedup_cross_source(lots)
assert out is lots # тот же объект → нулевое влияние на выборку
assert len(out) == 1
# --------------------------------------------------------------------------- #
# _dedup_cross_source — флаг ON
# _dedup_cross_source
# --------------------------------------------------------------------------- #
def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def test_dedup_collapses_cross_source_triple() -> None:
# Один физлот 67м²/21.995М на floor 4 — кросс-пост avito+cian+domklik.
lots = [
_lot(source="avito", scraped_at=datetime(2026, 6, 1, tzinfo=UTC)),
@ -162,8 +145,7 @@ def test_dedup_collapses_cross_source_triple(monkeypatch: pytest.MonkeyPatch) ->
assert out[0]["source"] == "cian"
def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def test_dedup_keeps_distinct_units() -> None:
# Разные физлоты: разный этаж / площадь / цена / здание → не дубли.
lots = [
_lot(source="avito", floor=4, area=67.0, price=21_995_000),
@ -176,8 +158,7 @@ def test_dedup_keeps_distinct_units(monkeypatch: pytest.MonkeyPatch) -> None:
assert len(out) == 5 # ничего не схлопнулось
def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def test_dedup_preserves_first_seen_order() -> None:
# relevance-порядок из _fetch_analogs должен сохраниться: группа занимает
# позицию первого появления.
lots = [
@ -191,8 +172,7 @@ def test_dedup_preserves_first_seen_order(monkeypatch: pytest.MonkeyPatch) -> No
assert [o["address"] for o in out] == ["ул. Мира 3", "ул. Ленина 5", "ул. Гагарина 9"]
def test_dedup_lots_without_key_stay_unique(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def test_dedup_lots_without_key_stay_unique() -> None:
# Нет площади → ключ None → не дедупим, даже если оба «пустые».
lots = [
_lot(source="avito", area=0),
@ -225,10 +205,9 @@ def test_extract_street_token_strips_house_and_noise() -> None:
assert _street_token(None) == ""
def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_collapses_three_source_formats() -> None:
# Прод-дефект (оценка d5fc3841): один физлот 33.6 м²/этаж 3/4.0М показан
# как 3 аналога — cadnum пуст у всех троих, адреса в трёх форматах.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="cian",
@ -259,10 +238,9 @@ def test_dedup_collapses_three_source_formats(monkeypatch: pytest.MonkeyPatch) -
assert len(out) == 1 # один физлот, не три
def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_merges_cadnum_asymmetry_same_street() -> None:
# У одного cadnum есть, у другого нет, адрес совпадает → сливаются по
# street-композиту (#2265: union по любому совпавшему ключу).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="cian",
@ -285,12 +263,9 @@ def test_dedup_merges_cadnum_asymmetry_same_street(monkeypatch: pytest.MonkeyPat
assert len(out) == 1
def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_distinct_cadnums_same_metrics_do_not_merge() -> None:
# Две башни на одной улице с одинаковыми floor/area/price, но РАЗНЫМИ
# кадастрами — авторитетный сигнал разных зданий → НЕ сливаем.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="cian",
@ -313,12 +288,9 @@ def test_dedup_distinct_cadnums_same_metrics_do_not_merge(
assert len(out) == 2
def test_dedup_same_street_different_metrics_stay_distinct(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_same_street_different_metrics_stay_distinct() -> None:
# Разные лоты на одной улице с разной ценой/площадью/этажом → остаются
# разными (защита от over-merge при отсутствии cadnum).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
_lot(source="avito", address="улица Сыромолотова, 11", area=45.0, price=6_500_000, floor=8),
@ -350,9 +322,8 @@ def test_extract_street_token_numbered_not_degraded() -> None:
assert _street_token("ул. Белинского 86") == "белинского"
def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_collapses_numbered_street_three_formats() -> None:
# «8 Марта» в трёх source-форматах, cadnum пуст → один физлот.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="cian",
@ -383,10 +354,9 @@ def test_dedup_collapses_numbered_street_three_formats(monkeypatch: pytest.Monke
assert len(out) == 1
def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_house_no_guard_blocks_street_union() -> None:
# Пустой cadnum, ОДНА улица/этаж/area/price, но РАЗНЫЕ извлечённые номера
# дома → house-guard не даёт слить (both-present-different).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(source="cian", address="улица Сыромолотова, 11", area=33.6, price=4_000_000, floor=3),
_lot(source="avito", address="улица Сыромолотова, 13", area=33.6, price=4_000_000, floor=3),
@ -395,10 +365,9 @@ def test_dedup_house_no_guard_blocks_street_union(monkeypatch: pytest.MonkeyPatc
assert len(out) == 2
def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_house_no_guard_order_stable() -> None:
# Устойчивость к порядку: домклик без номера сливается с одним из номеров,
# два разных номера остаются раздельными независимо от позиции «пустого».
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
base = dict(area=33.6, price=4_000_000, floor=3, cad=None)
lots = [
_lot(source="domklik", address="улица Сыромолотова", **base), # house ""
@ -410,10 +379,9 @@ def test_dedup_house_no_guard_order_stable(monkeypatch: pytest.MonkeyPatch) -> N
assert len(out) == 2
def test_dedup_empty_cadnum_and_street_stay_separate(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_empty_cadnum_and_street_stay_separate() -> None:
# Анти-катастрофа: пустой cadnum И нераспознанный адрес (нет типа улицы) при
# идентичных метриках → НЕ сливаем (нет ни одного ключа → каждый уникален).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(source="cian", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
_lot(source="avito", address="ЖК Дружба", area=40.0, price=5_000_000, floor=6, cad=None),
@ -485,7 +453,7 @@ def test_extract_street_token_extended_same_token_across_formats() -> None:
assert domklik == avito == cian == "рассветная"
def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_collapses_domklik_cian_name_order() -> None:
# Живой пример несклейки (оценка 23b8ada1): domklik «Рассветная улица, 6 к1»
# + cian «улица Рассветная, 6к1» — тот же физлот (эт.13, ~38.5 м², 6.599М),
# cadnum пуст (domklik всегда NULL). Расширенный парсер даёт обоим токен
@ -493,7 +461,6 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
# адреса используют одну и ту же глухую («к1») family корпуса, и после
# #2436 нормализуются к одинаковой «6/1» — house-guard видит равные номера
# независимо от пробела перед «к» (см. _normalize_house_no).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="domklik",
@ -519,7 +486,7 @@ def test_dedup_collapses_domklik_cian_name_order(monkeypatch: pytest.MonkeyPatch
assert out[0]["source"] == "cian" # представитель — свежайший scraped_at
def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_collapses_domklik_cian_area_straddle() -> None:
# #2291 класс 3: тот же физлот «Рассветная 6к1», но area округляется в
# СОСЕДНИЕ area_bucket банкирским round() — 38.5 → 38 (округление к чётному),
# 39.0 → 39. До фикса разные area_bucket → street-owner lookup никогда не
@ -527,7 +494,6 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
# должен склеить их в один физлот. Оба адреса — глухая («к1») family
# корпуса → после #2436 нормализуются к одинаковой «6/1» (см.
# _normalize_house_no), house-guard не блокирует merge независимо от пробела.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="domklik",
@ -552,11 +518,10 @@ def test_dedup_collapses_domklik_cian_area_straddle(monkeypatch: pytest.MonkeyPa
assert len(out) == 1 # склеились несмотря на area straddle 38.5/39.0
def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_collapses_avito_glued_suffix() -> None:
# (б) avito с глухим суффиксом «Рассветная ул.,6к1» + cian «улица Рассветная,
# 6к1» → тот же токен «рассветная», house у обоих канонизируется в «6/1»
# (см. #2436 _normalize_house_no) → guard видит равные номера → склейка.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(source="avito", address="Рассветная ул.,6к1", area=38.5, price=6_599_000, floor=13),
_lot(source="cian", address="улица Рассветная, 6к1", area=38.5, price=6_599_000, floor=13),
@ -565,11 +530,10 @@ def test_dedup_collapses_avito_glued_suffix(monkeypatch: pytest.MonkeyPatch) ->
assert len(out) == 1
def test_dedup_mkr_fallback_does_not_cross_with_street(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_mkr_fallback_does_not_cross_with_street() -> None:
# Префикс «mkr » изолирует микрорайонный токен от одноимённого уличного:
# «Светлый микрорайон» (mkr) и «улица Светлая» (street) — РАЗНЫЕ ключи, не
# сливаются даже при совпадении метрик.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(source="cian", address="Светлый микрорайон, 10", area=40.0, price=5_000_000, floor=6),
_lot(source="avito", address="улица Светлая, 10", area=40.0, price=5_000_000, floor=6),
@ -622,11 +586,10 @@ def test_parse_street_house_bare_street_requires_earlier_mkr_segment(addr: str)
assert estimator._parse_street_house(addr) == ("", "")
def test_dedup_collapses_domclick_bare_street_mkr_misfire(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_collapses_domclick_bare_street_mkr_misfire() -> None:
# Класс 1 (#2291) e2e: DomClick «Заречный м-н, Готвальда, 24» раньше
# ключевался по району и никогда не сливался с avito/cian, где «готвальда»
# распознаётся явной веткой (тип-слово «улица»).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="domclick",
@ -660,10 +623,9 @@ def test_parse_street_house_yo_ye_fold() -> None:
)
def test_dedup_collapses_yo_ye_variant(monkeypatch: pytest.MonkeyPatch) -> None:
def test_dedup_collapses_yo_ye_variant() -> None:
# Класс 2 (#2291) e2e: один и тот же физлот в двух source-форматах —
# «Шевелёва» (avito) и «Шевелева» (cian) — теперь схлопывается в один.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(source="avito", address="улица Шевелёва, 12", area=42.0, price=5_200_000, floor=4),
_lot(source="cian", address="улица Шевелева, 12", area=42.0, price=5_200_000, floor=4),
@ -753,13 +715,10 @@ def test_parse_street_house_ambiguous_glued_digit_runs_limitation() -> None:
assert estimator._parse_street_house("ул. Ленина, 7500 м до метро") == ("ленина", "7500")
def test_dedup_collapses_avito_glued_metro_suffix_house_match(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_collapses_avito_glued_metro_suffix_house_match() -> None:
# e2e: avito-лот с приклеенным названием станции (дом «2» до фикса терялся,
# извлекалось «15») + чистый cian-адрес того же дома «2» → house-guard
# больше не блокирует street-union (оба извлекают «2») → один физлот.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="avito",
@ -782,14 +741,11 @@ def test_dedup_collapses_avito_glued_metro_suffix_house_match(
assert len(out) == 1
def test_dedup_glued_metro_noise_does_not_cause_over_merge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_glued_metro_noise_does_not_cause_over_merge() -> None:
# Анти-регрессия: фикс не должен сделать guard_house снисходительнее для
# адресов с глухим метро-шумом в общем случае — ДЕЙСТВИТЕЛЬНО разные дома
# («24» vs «26»), каждый одет в приклеенное название станции разной длины,
# ДОЛЖНЫ остаться раздельными (guard всё ещё блокирует both-present-different).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="avito",
@ -863,15 +819,12 @@ def test_parse_street_house_all_corpus_notations_agree(notation: str) -> None:
assert estimator._parse_street_house(f"ул. Ленина, {notation}") == ("ленина", "65/4")
def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus() -> None:
# Точный репортированный баг: avito-подобный адрес («...65к4...», глухая
# нотация) + domklik-подобный адрес («...65/4...», slash-нотация) — тот же
# физлот, без кадастра ни у кого, тот же этаж/площадь/цена-бакет — ДОЛЖНЫ
# схлопнуться в один. union() зависит от порядка (кто станет root) —
# проверяем ОБА порядка следования лотов.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
def _pair(first_source: str, second_source: str) -> list[dict]:
by_source = {
@ -903,14 +856,11 @@ def test_dedup_collapses_avito_glued_vs_domklik_slash_corpus(
assert len(out_domklik_first) == 1
def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus() -> None:
# Адверсариальный кейс: нормализация НЕ должна расширять сеть слияний за
# пределы genuine corpus-notation эквивалентности. «65к4» (-> «65/4») и
# «65г» (литер, не тронут) — РАЗНЫЕ дома на одной улице, слияния быть не
# должно.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="avito",
@ -933,13 +883,10 @@ def test_dedup_liter_suffix_still_blocks_merge_from_different_corpus(
assert len(out) == 2
def test_dedup_house_no_guard_blocks_different_corpus_numbers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def test_dedup_house_no_guard_blocks_different_corpus_numbers() -> None:
# Нормализация — не blanket same-street merge: «65/4» vs «65/5» (или
# «65к4» vs «65к5») — genuinely разные здания, guard_house обязан
# по-прежнему их разделять после нормализации.
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", True)
lots = [
_lot(
source="domklik",

View file

@ -15,7 +15,6 @@
from __future__ import annotations
import os
from types import SimpleNamespace
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
@ -33,20 +32,6 @@ ELITE_REASON = (
)
def _settings(
*,
high_value: int = 20_000_000,
wide_ratio: float = 1.9,
elite_ppm2: int = 250_000,
) -> SimpleNamespace:
"""Duck-typed заглушка Settings — helper читает только эти поля."""
return SimpleNamespace(
estimate_manual_review_high_value_rub=high_value,
estimate_manual_review_wide_range_ratio=wide_ratio,
estimate_manual_review_elite_ppm2=elite_ppm2,
)
# ── Каждый триггер в изоляции ────────────────────────────────────────────────
@ -57,7 +42,6 @@ def test_premium_building_alone() -> None:
confidence="high",
range_low=7_500_000,
range_high=8_500_000,
settings=_settings(),
)
assert rec is True
assert reasons == [PREMIUM_REASON]
@ -70,7 +54,6 @@ def test_high_value_alone() -> None:
confidence="high",
range_low=24_000_000,
range_high=26_000_000,
settings=_settings(),
)
assert rec is True
assert reasons == [HIGH_VALUE_REASON]
@ -83,7 +66,6 @@ def test_low_confidence_alone() -> None:
confidence="low",
range_low=4_500_000,
range_high=5_500_000,
settings=_settings(),
)
assert rec is True
assert reasons == [LOW_CONF_REASON]
@ -96,7 +78,6 @@ def test_wide_range_alone() -> None:
confidence="high",
range_low=5_000_000,
range_high=10_000_000, # ratio 2.0 ≥ 1.9
settings=_settings(),
)
assert rec is True
assert reasons == [WIDE_RANGE_REASON]
@ -114,7 +95,6 @@ def test_elite_ppm2_alone() -> None:
confidence="high",
range_low=11_000_000,
range_high=13_000_000, # ratio ~1.18 < 1.9
settings=_settings(),
asking_ppm2=300_000, # ≥ 250_000
)
assert rec is True
@ -129,7 +109,6 @@ def test_elite_ppm2_boundary_inclusive() -> None:
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=250_000,
)
assert rec is True
@ -144,7 +123,6 @@ def test_elite_ppm2_just_below_threshold() -> None:
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=249_999,
)
assert rec is False
@ -159,7 +137,6 @@ def test_elite_ppm2_none_safe() -> None:
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=None,
)
assert rec is False
@ -175,7 +152,6 @@ def test_elite_ppm2_zero_safe() -> None:
confidence="high",
range_low=11_000_000,
range_high=13_000_000,
settings=_settings(),
asking_ppm2=0,
)
assert rec is False
@ -190,7 +166,6 @@ def test_elite_ppm2_combined_with_other_triggers() -> None:
confidence="low",
range_low=10_000_000,
range_high=25_000_000, # ratio 2.5 ≥ 1.9
settings=_settings(),
asking_ppm2=400_000,
)
assert rec is True
@ -214,7 +189,6 @@ def test_multiple_reasons_combined() -> None:
confidence="low",
range_low=10_000_000,
range_high=25_000_000, # ratio 2.5 ≥ 1.9
settings=_settings(),
)
assert rec is True
# порядок детерминирован: premium → high_value → low_conf → wide_range
@ -228,7 +202,6 @@ def test_all_clear_no_reasons() -> None:
confidence="medium",
range_low=9_000_000,
range_high=11_000_000, # ratio ~1.22 < 1.9
settings=_settings(),
)
assert rec is False
assert reasons == []
@ -245,7 +218,6 @@ def test_high_value_boundary_inclusive() -> None:
confidence="medium",
range_low=19_000_000,
range_high=21_000_000,
settings=_settings(),
)
assert rec is True
assert reasons == [HIGH_VALUE_REASON]
@ -258,7 +230,6 @@ def test_high_value_just_below_threshold() -> None:
confidence="medium",
range_low=19_000_000,
range_high=21_000_000,
settings=_settings(),
)
assert rec is False
assert reasons == []
@ -272,7 +243,6 @@ def test_wide_range_boundary_inclusive() -> None:
confidence="high",
range_low=10_000_000,
range_high=19_000_000, # ratio == 1.9
settings=_settings(),
)
assert rec is True
assert reasons == [WIDE_RANGE_REASON]
@ -285,7 +255,6 @@ def test_wide_range_just_below_threshold() -> None:
confidence="high",
range_low=10_000_000,
range_high=18_900_000, # ratio 1.89 < 1.9
settings=_settings(),
)
assert rec is False
assert reasons == []
@ -311,7 +280,6 @@ def test_wide_range_div_guard(range_low: int | None, range_high: int | None) ->
confidence="high",
range_low=range_low,
range_high=range_high,
settings=_settings(),
)
assert rec is False
assert WIDE_RANGE_REASON not in reasons
@ -332,7 +300,6 @@ def test_recommended_equals_bool_reasons_invariant() -> None:
confidence=conf,
range_low=9_000_000,
range_high=11_000_000,
settings=_settings(),
)
assert rec is bool(reasons)

View file

@ -26,12 +26,9 @@ from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services import estimator
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
# estimate путь со всеми I/O застабленными). anchor_comps=[] + anchor_tier=None
@ -43,16 +40,6 @@ _h = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_h)
@pytest.fixture(autouse=True)
def _pin_dedup_off(monkeypatch: pytest.MonkeyPatch) -> None:
# Эти тесты проверяют инвариант #2 «n_analogs = число priced-аналогов»,
# ортогональный кросс-source физдедупу (#2087 H4, default ON с #2173).
# Синтетические аналоги делят один адрес/этаж/площадь → дедуп схлопнул бы
# близкие по цене как кросс-посты и исказил счётчик. Пиним флаг OFF, чтобы
# мерить именно #2 в изоляции (не полагаясь на прод-default, который теперь ON).
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
def _listing(price_per_m2: float | None, area_m2: float = 60.0) -> dict[str, Any]:
"""Радиусный аналог; price_per_m2=None моделирует листинг без ₽/м².
@ -103,8 +90,10 @@ def test_n_analogs_counts_only_priced_radius_analogs() -> None:
_listing(price_per_m2=210_000.0),
_listing(price_per_m2=215_000.0),
_listing(price_per_m2=225_000.0),
_listing(price_per_m2=None),
_listing(price_per_m2=None),
# Разная площадь → разные физлоты: кросс-source дедуп (#2087 H4, всегда ON
# с #2378) не схлопывает два безценовых листинга в один.
_listing(price_per_m2=None, area_m2=61.0),
_listing(price_per_m2=None, area_m2=62.0),
]
est = _h._run_estimate(
anchor_comps=[],
@ -141,7 +130,8 @@ def test_n_analogs_all_priceless_yields_zero_and_low() -> None:
def test_n_analogs_all_priced_unchanged() -> None:
"""Контроль: все аналоги С ценой → n_analogs = их число (поведение не меняется,
зеркало test_radius_path_n_analogs_unchanged для priced-only выборки)."""
radius = [_listing(price_per_m2=200_000.0 + i * 1_000) for i in range(5)]
# Разная площадь → 5 разных физлотов, кросс-source дедуп их не схлопывает.
radius = [_listing(price_per_m2=200_000.0 + i * 1_000, area_m2=60.0 + i) for i in range(5)]
est = _h._run_estimate(
anchor_comps=[],
anchor_tier=None,

View file

@ -22,8 +22,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/
import pytest
from app.core.config import settings
from app.services.estimator import _downgrade_confidence
from app.services.estimator import WIDE_CORRIDOR_THRESHOLD, _downgrade_confidence
# Переиспользуем harness _run_estimate / _make_payload из anchor-теста (полный
# estimate путь со всеми I/O застабленными).
@ -48,7 +47,7 @@ def _payload(): # type: ignore[no-untyped-def]
def _run(*, anchor_tier: str, comps=None, threshold: float = 0.6): # type: ignore[no-untyped-def]
with patch.object(settings, "estimate_wide_corridor_threshold", threshold):
with patch("app.services.estimator.WIDE_CORRIDOR_THRESHOLD", threshold):
return _h._run_estimate(
anchor_comps=_WIDE_SPLIT_COMPS if comps is None else comps,
anchor_tier=anchor_tier,
@ -67,7 +66,7 @@ _DISCLOSURE_MARKER = "разбит на секции"
def test_threshold_default_1_2() -> None:
# 1.2: 0.6 фаерил бы на ~31% оценок (широкий коридор ≠ split-дом);
# genuine split-дома аудита = 148-170% → 1.2 ловит только экстремальный хвост.
assert settings.estimate_wide_corridor_threshold == 1.2
assert WIDE_CORRIDOR_THRESHOLD == 1.2
# ---------------------------------------------------------------------------

View file

@ -24,9 +24,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/
from unittest.mock import AsyncMock, MagicMock, patch
import anyio
import pytest
from app.services import estimator
from app.services.estimator import (
_compute_same_building_anchor,
_geocode_is_coarse,
@ -1040,16 +1038,14 @@ def test_anchor_n_analogs_syncs_to_anchor_count() -> None:
assert "по 4 аналогам" in (est.confidence_explanation or "")
def test_radius_path_n_analogs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None:
def test_radius_path_n_analogs_unchanged() -> None:
"""#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется).
Проверяет radius-passthrough vs anchor-override ортогонально кросс-source
дедупу (#2087 H4, default ON с #2173). 5 ИДЕНТИЧНЫХ листингов — это ровно
патологический кросс-пост, который дедуп схлопнул бы в 1; пиним флаг OFF,
чтобы мерить именно #695-инвариант, не полагаясь на прод-default (теперь ON).
дедупу (#2087 H4, всегда ON с #2378). Площади разные, поэтому это 5 разных
физлотов: 5 ИДЕНТИЧНЫХ листингов дедуп честно схлопнул бы в 1.
"""
monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False)
radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)]
radius = [_make_listing(price_per_m2=200_000.0, area_m2=60.0 + i) for i in range(5)]
est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius)
assert est.n_analogs == 5