МЕРА: дедуп аналогов и пороги ручной оценки без выключателей (#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:
parent
34642e1dd5
commit
45bd47fdd7
10 changed files with 141 additions and 327 deletions
|
|
@ -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, не кэшируется).
|
||||
|
|
|
|||
|
|
@ -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 — вызывается ПОСЛЕ того, как эти величины уже вычислены
|
||||
из отдельного (не обязательно совпадающего по итоговой длине) пула.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ошибочно поймал дрейф — тест не то мерит"
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue