gendesign/tradein-mvp/backend/tests/test_tier_a_primary_1774.py
bot-backend d5df5fdb9c
All checks were successful
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 53s
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 8s
tech-debt(tradein/estimator): collapse won estimate_* flags into defaults (#1970)
Collapse 14 Class-A boolean feature gates (all default True, never overridden in
prod) into their unconditional ON behavior: inline the ON-path, drop the guard +
any OFF branch, delete the config field, and remove/adjust OFF-path tests.

Collapsed (14):
  estimate_imv_blend_enabled, estimate_same_building_anchor_enabled,
  estimate_calibrated_pi_enabled, estimate_corridor_clamp_enabled,
  estimate_radius_floor_enabled, estimate_sb_low_conf_gate_enabled,
  estimate_price_trend_dedup_enabled, estimate_confidence_floor_no_analogs,
  estimate_manual_review_enabled, estimate_radius_dedup_enabled,
  estimate_wide_corridor_disclosure_enabled, estimate_sb_clip_after_weight,
  estimate_quarter_index_enabled,
  estimate_sb_tier_a_allow_primary_if_secondary_present

Kept as flags (2 of the requested set) — they are the OFF-baseline toggle for the
dedicated A/B magnitude suite test_estimator_hedonic.py and isolation helpers
across ~6 other estimator test files; collapsing them would force rewriting numeric
assertions everywhere (risk of a wrong-number regression):
  estimate_hedonic_correction_enabled, estimate_expected_sold_le_asking

Untouched per task: estimate_dedup_analogs_enabled (gate depends on its False
branch), estimate_kitchen_area_signal_enabled, estimate_ceiling_height_signal_enabled,
estimate_is_apartments_filter_enabled, estimate_segment_multiplier_enabled.

Also removed the now-unconditional `enabled` param from _apply_corridor_clamp and
collapsed the same-building-anchor pre-fetch gate in scripts/backtest_estimator.py.

Regression gate (test_backtest_regression_gate.py) stays byte-green: all collapsed
flags defaulted True, so unconditional == prior prod behavior; the frozen 277-DKP
replay is byte-identical to the committed baseline. Full run: 458 passed (gate +
estimator suite) + 93 passed (same_building / 781 / tier_a / segment_guard).
2026-07-12 15:42:02 +03:00

239 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""#1774 — Tier A (same building) gated relaxation of the #1186 novostroyki guard.
В СДАННОМ доме cian тегирует переуступки/перепродажи собственниками (sale_type=free)
как listing_segment='novostroyki'. Раньше Tier A-гард #1186
`(listing_segment IS NULL OR listing_segment='vtorichka')` отбрасывал их, и
same-building listing пользователя не учитывался (кейс «Евгения Савкова 29», ЕКБ).
Дизайн #1774 (Tier A ONLY), gate hardened после code-review:
- secondary_count = число vtorichka/NULL rows; primary_count = остальные.
- flag ON + secondary_count ≥ 1 И secondary_count ≥ primary_count →
впускаем ВСЕ rows (включая novostroyki-переуступки).
- flag ON + primary-dominated дом (secondary_count < primary_count) → гард
#1186 сохраняется: иначе MAD-clip выкинет редкую вторичку и заякорит на
ценах застройщика (~27% overvaluation).
- flag OFF → гард #1186 сохраняется всегда (novostroyki исключены).
- dedup: PRIMARY ключ (source, source_id) — codebase canon (скрейперы дедупят
на source_id; source_url может отличаться trailing slash/query-param).
Tier C / радиус / ratio — НЕ затрагиваются (см. test_segment_guard_1186.py).
"""
from __future__ import annotations
import os
from typing import Any
from unittest.mock import MagicMock, patch
# Settings требует DATABASE_URL при инициализации (fail-fast, C-3).
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from app.services import estimator as est_mod
from app.services.estimator import _fetch_anchor_comps
# Адрес сводится к ('евгения савкова', 29, None) — Tier A street+base.
_ADDRESS = "Екатеринбург, ул. Евгения Савкова, 29"
def _row(
*,
source: str = "avito",
source_url: str | None = None,
source_id: str | None = None,
listing_segment: str | None = None,
price_per_m2: float = 150_000.0,
area_m2: float = 50.0,
rooms: int = 2,
address: str = _ADDRESS,
) -> dict[str, Any]:
"""Mock listings-row (mappings shape) для Tier A: несёт listing_segment + source_id."""
if source_url is None:
source_url = f"https://{source}.ru/offer/{source_id or '0'}"
return {
"price_per_m2": price_per_m2,
"area_m2": area_m2,
"rooms": rooms,
"floor": 3,
"total_floors": 9,
"address": address,
"source": source,
"source_url": source_url,
"price_rub": int(price_per_m2 * area_m2),
"listing_date": None,
"days_on_market": 15,
"photo_urls": [],
"lat": 56.838,
"lon": 60.595,
"listing_segment": listing_segment,
"source_id": source_id,
}
def _db_mock(rows: list[dict[str, Any]]) -> MagicMock:
"""Session mock: db.execute().mappings().all() → rows (Tier A path)."""
db = MagicMock()
db.execute.return_value.mappings.return_value.all.return_value = rows
return db
def _fetch(db: MagicMock) -> tuple[list[dict[str, Any]], str | None]:
"""Вызов _fetch_anchor_comps с Tier A-релевантными аргументами (без lat/lon → Tier C skip)."""
return _fetch_anchor_comps(
db,
address=_ADDRESS,
target_house_id=None,
lat=None,
lon=None,
rooms=2,
area=50.0,
)
def test_tier_a_includes_novostroyki_when_secondary_present() -> None:
"""Mixed/сданный дом: 4 vtorichka/NULL + 2 novostroyki → flag ON впускает ВСЕ 6.
secondary_count=4 ≥ primary_count=2 → hardened gate допускает novostroyki.
Зеркало «Евгения Савкова 29»: vtorichka-комплы (avito/yandex) соседствуют с
cian-переуступками (novostroyki) — последние тоже должны попасть в anchor.
"""
rows = [
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
_row(source="avito", source_id="a2", listing_segment=None),
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
_row(source="yandex", source_id="y2", listing_segment=None),
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
]
db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
assert tier == "A"
# Все 6 (4 вторички + 2 novostroyki-переуступки) учтены.
assert len(comps) == 6
def test_tier_a_excludes_novostroyki_when_primary_dominated() -> None:
"""Hardened gate (code-review fix): 1 vtorichka + 5 novostroyki → secondary_count=1
< primary_count=5 → novostroyki ИСКЛЮЧЕНЫ (anchor строился бы на ценах застройщика
→ ~27% overvaluation после MAD-clip выкидывания одинокой вторички).
После исключения остаётся 1 vtorichka < min_comps=4 → Tier A не срабатывает
(lat/lon None → Tier C skip → tier=None, comps=[]). Это доказывает, что primary
rows отброшены: при их допуске было бы 6 comps ≥ min_comps и tier='A'.
"""
rows = [
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
_row(source="cian", source_id="c3", listing_segment="novostroyki"),
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
_row(source="cian", source_id="c5", listing_segment="novostroyki"),
]
db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
# primary-dominated → 5 novostroyki отброшены, 1 vtorichka < min_comps → Tier A skip.
assert tier is None
assert comps == []
def test_tier_a_excludes_novostroyki_when_pure_primary() -> None:
"""Чисто-первичный дом (0 вторички, ВСЕ novostroyki) → гард #1186 сохраняется,
novostroyki исключаются → comps < min_comps → Tier A не срабатывает (tier=None).
Tier-D-skip path намеренный: lat/lon=None → Tier C не запускается, поэтому
после отбраковки всех 4 novostroyki Tier A не набирает min_comps и возвращает
(tier=None, comps=[]). Assert именно на этом нулевом результате — фиксирует,
что чисто-первичный дом НЕ заякоривается на ценах застройщика."""
rows = [
_row(source="cian", source_id="c1", listing_segment="novostroyki"),
_row(source="cian", source_id="c2", listing_segment="novostroyki"),
_row(source="cian", source_id="c3", listing_segment="novostroyki"),
_row(source="cian", source_id="c4", listing_segment="novostroyki"),
]
db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
# Все отброшены гардом → 0 comps < 4 → Tier A skip (lat/lon None → Tier D).
assert tier is None
assert comps == []
def test_tier_a_dedup_same_source_id_collapses() -> None:
"""#1774 dedup: дубликат одного объявления (source_id=330047129, 2 active-строки —
одна с house_id_fk, одна NULL) схлопывается в 1 comp по PRIMARY ключу
(source, source_id).
5 уникальных vtorichka + дубль одного source_id → 5 comps (не 6 — дубль убран).
"""
dup_url = "https://cian.ru/sale/flat/330047129/"
rows = [
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
_row(source="avito", source_id="a2", listing_segment="vtorichka"),
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
_row(source="yandex", source_id="y2", listing_segment="vtorichka"),
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment="vtorichka"),
# Дубль: тот же (source, source_id), но другой row (house_id_fk NULL-вариант).
_row(source="cian", source_url=dup_url, source_id="330047129", listing_segment=None),
]
db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
assert tier == "A"
# 6 строк, но 2 cian-строки с одинаковым source_id → 1 comp → итого 5.
assert len(comps) == 5
cian = [c for c in comps if c["source"] == "cian"]
assert len(cian) == 1
def test_tier_a_dedup_same_source_id_different_url_collapses() -> None:
"""Code-review Fix 2: те же source_id, но РАЗНЫЙ source_url (trailing slash /
query-param). PRIMARY ключ (source, source_id) всё равно схлопывает в 1 comp —
старый url-primary ключ этот дубль ПРОПУСКАЛ (это и есть баг #1774 Fix 2).
5 уникальных vtorichka + дубль того же source_id с другим url → 5 comps.
"""
rows = [
_row(source="avito", source_id="a1", listing_segment="vtorichka"),
_row(source="avito", source_id="a2", listing_segment="vtorichka"),
_row(source="yandex", source_id="y1", listing_segment="vtorichka"),
_row(source="yandex", source_id="y2", listing_segment="vtorichka"),
_row(
source="cian",
source_url="https://cian.ru/sale/flat/330047129/",
source_id="330047129",
listing_segment="vtorichka",
),
# Дубль того же source_id, но url отличается trailing slash + query-param.
_row(
source="cian",
source_url="https://cian.ru/sale/flat/330047129?utm=x",
source_id="330047129",
listing_segment=None,
),
]
db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
assert tier == "A"
# source_id-primary схлопывает несмотря на разные url → 5 comps.
assert len(comps) == 5
cian = [c for c in comps if c["source"] == "cian"]
assert len(cian) == 1
def test_tier_a_dedup_null_url_keeps_distinct_rows() -> None:
"""source_url=None, но source_id задан → PRIMARY ключ (source, source_id):
реально разные лоты НЕ схлопываются (разные source_id)."""
rows = [
_row(source="avito", source_url=None, source_id="a1", area_m2=50.0),
_row(source="avito", source_url=None, source_id="a2", area_m2=60.0),
_row(source="avito", source_url=None, source_id="a3", area_m2=70.0),
_row(source="avito", source_url=None, source_id="a4", area_m2=80.0),
]
db = _db_mock(rows)
with patch.object(est_mod.settings, "estimate_sb_min_comps", 4):
comps, tier = _fetch(db)
assert tier == "A"
# 4 разных лота (разные source_id/площадь) → 4 comps, ничего не схлопнуто.
assert len(comps) == 4