diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index f48d8d40..a7d3e79d 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -265,9 +265,11 @@ class Settings(BaseSettings): # ТУ ЖЕ цену → нулевой вклад в дисперсию) → cv/коридор НЕ сужаются. Это фикс # ЧЕСТНОСТИ СЧЁТА (n_analogs не раздут ×3 кросс-постами, source_counts по # физлотам), accuracy-нейтральный, а НЕ рычаг сужения cv (рычаг cv→коридор — - # estimate_sb_clip_after_weight, уже ON). Default False (байт-идентично) — флаг - # готов к монкипатч-бэктесту/canary. ENV: ESTIMATE_DEDUP_ANALOGS_ENABLED. - estimate_dedup_analogs_enabled: bool = False + # estimate_sb_clip_after_weight, уже 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 # ── #1871 P2: split-дома wide-corridor disclosure (default ON, порог 1.2) ── # Tier A (same-building) матчит по address-regex (намеренно НЕ house_id — дом diff --git a/tradein-mvp/backend/tests/test_backtest_regression_gate.py b/tradein-mvp/backend/tests/test_backtest_regression_gate.py index 15c19299..51a26de8 100644 --- a/tradein-mvp/backend/tests/test_backtest_regression_gate.py +++ b/tradein-mvp/backend/tests/test_backtest_regression_gate.py @@ -25,8 +25,11 @@ 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" @@ -73,7 +76,16 @@ def test_fixture_and_baseline_committed() -> None: assert _BASELINE_PATH.exists(), f"frozen baseline missing: {_BASELINE_PATH}" -def test_backtest_regression_gate() -> None: +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) 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 diff --git a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py index 9ce1cb94..725922d4 100644 --- a/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py +++ b/tradein-mvp/backend/tests/test_estimator_dedup_cross_source_2087.py @@ -90,11 +90,28 @@ def test_phys_key_area_bucket_tolerates_rounding() -> None: # --------------------------------------------------------------------------- # -# _dedup_cross_source — флаг OFF (byte-identical) +# Флаг: default ON (#2173) vs явный OFF (byte-identical) # --------------------------------------------------------------------------- # +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 + 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"), diff --git a/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py b/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py index bc4beb94..af2cf793 100644 --- a/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py +++ b/tradein-mvp/backend/tests/test_estimator_n_analogs_priced.py @@ -26,9 +26,13 @@ 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 # направляет estimate в radius-aggregation ветку (где живёт фикс #2). @@ -39,6 +43,16 @@ _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 моделирует листинг без ₽/м². diff --git a/tradein-mvp/backend/tests/test_same_building_anchor.py b/tradein-mvp/backend/tests/test_same_building_anchor.py index c6ffce6f..c01d283f 100644 --- a/tradein-mvp/backend/tests/test_same_building_anchor.py +++ b/tradein-mvp/backend/tests/test_same_building_anchor.py @@ -24,7 +24,9 @@ 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, @@ -1045,8 +1047,15 @@ def test_anchor_n_analogs_syncs_to_anchor_count() -> None: assert "по 4 аналогам" in (est.confidence_explanation or "") -def test_radius_path_n_analogs_unchanged() -> None: - """#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется).""" +def test_radius_path_n_analogs_unchanged(monkeypatch: pytest.MonkeyPatch) -> None: + """#695 контроль: без якоря n_analogs = радиусный счёт (поведение не меняется). + + Проверяет radius-passthrough vs anchor-override — ортогонально кросс-source + дедупу (#2087 H4, default ON с #2173). 5 ИДЕНТИЧНЫХ листингов — это ровно + патологический кросс-пост, который дедуп схлопнул бы в 1; пиним флаг OFF, + чтобы мерить именно #695-инвариант, не полагаясь на прод-default (теперь ON). + """ + monkeypatch.setattr(estimator.settings, "estimate_dedup_analogs_enabled", False) radius = [_make_listing(price_per_m2=200_000.0) for _ in range(5)] est = _run_estimate(anchor_comps=[], anchor_tier=None, radius_analogs=radius) assert est.n_analogs == 5