diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index dd6c962a..11b7dff1 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -2874,8 +2874,15 @@ def _price_from_inputs( # Диапазон расширяем даже если медиану не двигали. range_high = new_range_high - # Display-only IMV summary when headline built by same-building anchor. - if anchor_tier is not None and avito_imv_summary is None: + # Display-only IMV summary — когда карточку не заполнил blend выше. + # #2656: условие было `anchor_tier is not None and ...`. После сброса залипшего + # флага оставалась щель: комплы якоря добыты (тир был "C"), якорь не построен, а + # headline подавлен/нулевой → blend не срабатывает (ему нужны listings_clean и + # median_price > 0), старый блок тоже (tier уже None) — и пользователь ТЕРЯЛ + # карточку IMV, которую видел раньше. Гейт по tier снят: `avito_imv_summary is + # None` уже гарантирует, что двойного заполнения не будет. Display-only — + # median/expected_sold/ranges блок не трогает. + if avito_imv_summary is None: if imv_anchor is not None and imv_anchor.get("recommended_price"): _disp_mc = int(imv_anchor["market_count"]) if imv_anchor.get("market_count") else None avito_imv_summary = AvitoImvSummary( diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index aadf2193..69a065c4 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1471,6 +1471,7 @@ def _make_call_stub( label: str, coerce: Callable[[Any], Any], on_exhausted: Any = _UNRECORDED, + unrecorded_counter: list[int] | None = None, ) -> Callable[[Any], Any]: """Build an ORDER-based (FIFO) replay stub from recorded ``[arg, return]`` pairs. @@ -1496,6 +1497,13 @@ def _make_call_stub( остаётся строгим НАМЕРЕННО: лишний вызов там означал бы, что реплей взял другой коэффициент выкупа, т.е. молча другие деньги. + ``unrecorded_counter`` — одноэлементный список-счётчик таких «промахов»; + ``replay_fixture`` выносит сумму в метрику ``unrecorded_lookup_calls``, а та + попадает в baseline целым числом (сравнивается ТОЧНО). Иначе ослабление + выключило бы канарейку «реплей разошёлся с захватом» навсегда и для всех + будущих PR: с закоммиченным числом новое расхождение всё так же валит гейт, + а перезахват фикстуры с прода доведёт его до нуля. + ``coerce`` maps each JSON-plain recorded return back to the live callable's return type (tuple / dict) so unpacking at the call site behaves identically. """ @@ -1510,6 +1518,8 @@ def _make_call_stub( f"{label}: replay made call #{idx + 1} but fixture recorded only " f"{len(returns)} — control flow diverged from capture" ) + if unrecorded_counter is not None: + unrecorded_counter[0] += 1 idx += 1 return on_exhausted ret = returns[idx] @@ -1534,7 +1544,11 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: ``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). Deterministic: same fixture → identical dict. + drift, not silently honoured). ЕДИНСТВЕННОЕ исключение — + ``estimate_dedup_analogs_enabled``, пиннится в False на время реплея: это не + настройка точности, а условие воспроизводимости ЗАХВАЧЕННОГО контрольного + потока (см. комментарий у пина ниже). Deterministic: same fixture → identical + dict. """ est = _import_estimator_full() m = est.m @@ -1543,58 +1557,75 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: predictions: list[Prediction] = [] sold_ppm2_all: list[float] = [] pred_ppm2_all: list[float] = [] + unrecorded: list[int] = [0] # #2656: счётчик lookup-вызовов без записи в фикстуре - for rec in deals: - kw = dict(rec["kwargs"]) - sold_ppm2_all.append(float(rec["sold_ppm2"])) - kw["geo"] = est.GeocodeResult(**kw["geo"]) - kw["ratio_resolver"] = _make_call_stub( - rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return - ) - # #2656: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых - # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт - # Guard-1a на 19 из 277 сделок, а прод-фикстура захвачена ДО правки. Следствие, - # которое надо знать при чтении метрик: для этих 19 сделок квартальный индекс в - # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает - # эффект правки. Уйдёт при следующем перезахвате фикстуры с прода. - kw["quarter_index_lookup"] = _make_call_stub( - rec.get("qi_calls") or [], - label="quarter_index_lookup", - coerce=_coerce_qi_return, - on_exhausted=None, - ) - kw["quarter_indexes_lookup"] = _make_call_stub( - rec.get("qis_calls") or [], - label="quarter_indexes_lookup", - coerce=_coerce_qis_return, - on_exhausted={}, - ) + # #2656: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-source дедупом (#2087 H4 был + # no-op по умолчанию на момент захвата), а с #2173 дефолт ON. Реплей обязан идти + # по ЗАХВАЧЕННОМУ контрольному потоку: с активным дедупом _dedup_cross_source + # подрезал бы listings до quarter_indexes_lookup и записанная последовательность + # вызовов разъехалась бы. Пин ЗДЕСЬ, а не только в CI-гейте: гейт монкипатчил флаг + # сам, а документированная регенерация baseline (--from-fixture --update-baseline) + # — нет, и с #2173 писала baseline, который тест не совпал бы НИКОГДА. + _dedup_saved = m.settings.estimate_dedup_analogs_enabled + m.settings.estimate_dedup_analogs_enabled = False + try: + for rec in deals: + kw = dict(rec["kwargs"]) + sold_ppm2_all.append(float(rec["sold_ppm2"])) + kw["geo"] = est.GeocodeResult(**kw["geo"]) + kw["ratio_resolver"] = _make_call_stub( + rec.get("ratio_calls") or [], label="ratio_resolver", coerce=_coerce_ratio_return + ) + # #2656: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых + # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт + # Guard-1a на 19 из 277 сделок, а прод-фикстура захвачена ДО правки. Следствие, + # которое надо знать при чтении метрик: для этих 19 сделок квартальный индекс в + # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает + # эффект правки — счётчик unrecorded_lookup_calls ниже это число и держит. + # Уйдёт (до нуля) при следующем перезахвате фикстуры с прода. + kw["quarter_index_lookup"] = _make_call_stub( + rec.get("qi_calls") or [], + label="quarter_index_lookup", + coerce=_coerce_qi_return, + on_exhausted=None, + unrecorded_counter=unrecorded, + ) + kw["quarter_indexes_lookup"] = _make_call_stub( + rec.get("qis_calls") or [], + label="quarter_indexes_lookup", + coerce=_coerce_qis_return, + on_exhausted={}, + unrecorded_counter=unrecorded, + ) - pr = m._price_from_inputs(**kw) + pr = m._price_from_inputs(**kw) - es_ppm2 = float(pr.expected_sold_per_m2) if pr.expected_sold_per_m2 is not None else None - es_price = float(pr.expected_sold_price) if pr.expected_sold_price is not None else None - r_low = ( - float(pr.expected_sold_range_low) if pr.expected_sold_range_low is not None else None - ) - r_high = ( - float(pr.expected_sold_range_high) if pr.expected_sold_range_high is not None else None - ) - prediction = Prediction( - deal_id=int(rec["deal_id"]), - rooms=rec["rooms"], - area_m2=float(rec["area_m2"]), - sold_ppm2=float(rec["sold_ppm2"]), - median_ppm2=float(pr.median_ppm2), - confidence=pr.confidence, - anchor_tier=pr.anchor_tier, - expected_sold_ppm2=es_ppm2, - expected_sold_price=es_price, - range_low=r_low, - range_high=r_high, - ) - predictions.append(prediction) - pred_ppm2_all.append(prediction.median_ppm2) + _es_ppm2_raw = pr.expected_sold_per_m2 + _es_price_raw = pr.expected_sold_price + _r_low_raw = pr.expected_sold_range_low + _r_high_raw = pr.expected_sold_range_high + es_ppm2 = float(_es_ppm2_raw) if _es_ppm2_raw is not None else None + es_price = float(_es_price_raw) if _es_price_raw is not None else None + r_low = float(_r_low_raw) if _r_low_raw is not None else None + r_high = float(_r_high_raw) if _r_high_raw is not None else None + prediction = Prediction( + deal_id=int(rec["deal_id"]), + rooms=rec["rooms"], + area_m2=float(rec["area_m2"]), + sold_ppm2=float(rec["sold_ppm2"]), + median_ppm2=float(pr.median_ppm2), + confidence=pr.confidence, + anchor_tier=pr.anchor_tier, + expected_sold_ppm2=es_ppm2, + expected_sold_price=es_price, + range_low=r_low, + range_high=r_high, + ) + 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) @@ -1609,6 +1640,10 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: "ask_median_ppm2": ask_median, "spread_pct": spread_pct, } + # #2656: сколько lookup-вызовов фикстура ответить не смогла (см. _make_call_stub). + # Целое → baseline сравнивает ТОЧНО: рост = новый путь разошёлся с захватом (гейт + # падает громко), падение до 0 = фикстуру перезахватили и ослабление можно снять. + metrics["unrecorded_lookup_calls"] = unrecorded[0] return metrics diff --git a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json index a040e084..ce167d78 100644 --- a/tradein-mvp/backend/tests/fixtures/backtest_baseline.json +++ b/tradein-mvp/backend/tests/fixtures/backtest_baseline.json @@ -150,5 +150,6 @@ "sharpness": { "median_rel_width": 0.743, "n": 269 - } + }, + "unrecorded_lookup_calls": 19 } diff --git a/tradein-mvp/backend/tests/test_freshness_filter_2656.py b/tradein-mvp/backend/tests/test_freshness_filter_2656.py index e84a50c0..6a884e9c 100644 --- a/tradein-mvp/backend/tests/test_freshness_filter_2656.py +++ b/tradein-mvp/backend/tests/test_freshness_filter_2656.py @@ -39,7 +39,7 @@ os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost: from app.core.config import LISTINGS_FRESH_DAYS from app.services import estimator as est_mod -from app.services.estimator import _fetch_anchor_comps, _price_from_inputs +from app.services.estimator import _fetch_analogs, _fetch_anchor_comps, _price_from_inputs from app.services.geocoder import GeocodeResult from app.tasks import asking_to_sold_ratio as ratio_mod @@ -53,6 +53,12 @@ _TIER_C_MARK = "# ── Tier C: micro-radius" _TIER_A_SRC = _ANCHOR_SRC[: _ANCHOR_SRC.index(_TIER_C_MARK)] _TIER_C_SRC = _ANCHOR_SRC[_ANCHOR_SRC.index(_TIER_C_MARK) :] +# Tier W не использует _COMMON_WHERE — у него ПОБАЙТОВАЯ inline-копия того же блока +# (комментарий на месте требует держать пути в синхроне). Самое вероятное место +# следующего расхождения — ровно того сорта, что чинит этот PR, поэтому тоже под тестом. +_ANALOGS_SRC = inspect.getsource(_fetch_analogs) +_TIER_W_SRC = _ANALOGS_SRC[_ANALOGS_SRC.index("# ── Tier W: wide") :] + _REDERIVE_SQL = str(ratio_mod._REDERIVE_SQL.text) _ASK_SIDE_SQL = _REDERIVE_SQL[ _REDERIVE_SQL.index("ask_side AS") : _REDERIVE_SQL.index("per_bucket AS") @@ -62,17 +68,22 @@ _ASK_GLOBAL_SQL = _REDERIVE_SQL[ ] -# ── 1. Предикат стоит во всех четырёх местах ───────────────────────────────── +# ── 1. Предикат стоит во всех местах, где читаются listings ────────────────── def test_freshness_predicate_present_in_all_four_places() -> None: - """Все четыре денежные выборки несут ТОТ ЖЕ предикат свежести, что _COMMON_WHERE. + """Все денежные выборки несут ТОТ ЖЕ предикат свежести, что _COMMON_WHERE. - Falsifiable: на непропатченном коде FALSE во всех четырёх (там только + Четыре починенных места + два уже защищённых (_COMMON_WHERE и его inline-копия + в Tier W): их держим под тем же тестом, потому что снятие предиката ТАМ — та же + дыра, только с другой стороны. + + Falsifiable: на непропатченном коде FALSE во всех четырёх починенных (там только `is_active = true`), TRUE после правки. """ places = { "_COMMON_WHERE (эталон, радиусный путь)": est_mod._COMMON_WHERE, + "_fetch_analogs Tier W (inline-копия _COMMON_WHERE)": _TIER_W_SRC, "_fetch_anchor_comps Tier A": _TIER_A_SRC, "_fetch_anchor_comps Tier C": _TIER_C_SRC, "asking_to_sold_ratio ask_side": _ASK_SIDE_SQL, @@ -233,10 +244,11 @@ def _price( anchor_comps: list[dict[str, Any]], anchor_tier_fetched: str | None, imv_anchor: dict[str, Any] | None = None, + n_listings: int = 6, ) -> est_mod.PricingResult: listings = [ {"price_per_m2": 200_000.0, "address": f"ул. Ленина, {i + 5}", "source": "avito"} - for i in range(6) + for i in range(n_listings) ] return _price_from_inputs( listings=listings, @@ -303,3 +315,25 @@ def test_sticky_anchor_tier_no_longer_mutes_imv_blend() -> None: assert pr.anchor_tier is None assert pr.median_price > 10_000_000, "IMV-blend заглушен залипшим anchor_tier" assert pr.avito_imv_summary is not None + + +def test_imv_card_survives_when_headline_suppressed_and_anchor_absent() -> None: + """Карточка Avito IMV не исчезает в щели «тир добыт, якоря нет, headline подавлен». + + Раньше её заполнял display-only блок под условием `anchor_tier is not None`. После + сброса залипшего флага открылась щель: blend не срабатывает (нужен median_price > 0), + старый блок тоже (tier уже None) — и пользователь ТЕРЯЛ карточку, которую видел + раньше. n=3 объявления (< HEADLINE_LISTINGS_MIN_N) → headline подавлен, median 0. + Отображение, не деньги: median/expected_sold блок не трогает. + """ + pr = _price( + anchor_comps=[_anchor_comp(150_000.0), _anchor_comp(155_000.0)], + anchor_tier_fetched="C", + imv_anchor={"recommended_price": 14_000_000}, + n_listings=3, + ) + + assert pr.anchor_tier is None + assert pr.median_price == 0 # headline подавлен гейтом достаточности + assert pr.avito_imv_summary is not None, "карточка IMV потеряна" + assert pr.avito_imv_summary.recommended_price == 14_000_000