diff --git a/tradein-mvp/backend/app/services/estimator.py b/tradein-mvp/backend/app/services/estimator.py index c41dbc19..f89e6ea6 100644 --- a/tradein-mvp/backend/app/services/estimator.py +++ b/tradein-mvp/backend/app/services/estimator.py @@ -3027,7 +3027,19 @@ def _price_from_inputs( gate_thin, ) anchor = None - anchor_tier = None + + # #2661: якорь не построен — сбрасываем tier-флаг ЯВНО. Причин три: + # _compute_same_building_anchor вернула None (комплов меньше min_comps, в т.ч. + # после MAD-клипа, см. #oblast-E выше), гейт Tier C #1795 или low-conf гейт + # #audit-1 выше. Раньше сброс делал только последний из трёх, и в остальных + # случаях anchor_tier залипал равным anchor_tier_fetched ("C"/"A") при радиусном + # headline. Флаг читают IMV-blend (`anchor_tier is None`, ниже), quarter-index + # #764 Guard-1a, radius-floor от ДКП-коридора, corridor-clamp (Tier A exempt) и + # api_analog_tier — залипший флаг молча глушил их все, будто headline построил + # якорь. Замер: 154 из 996 сделок теряют tier-флаг этой правкой, но ценообразование + # у них не двигается (расхождение ровно в 0.000%) — чинится именно флаг. + if anchor is None: + anchor_tier = None if anchor is not None: # #694: якорь мутирует headline — UI-аналоги должны отражать ЭТИ комплы. @@ -3207,8 +3219,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 выше. + # #2661: условие было `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( @@ -3537,6 +3556,10 @@ def _price_from_inputs( # and blocked this fallback even with a large, valid ДКП corridor # available (observed: 677 deals for one fixture case). `anchor is None` # is the ground truth of whether the anchor actually produced a headline. + # #2661 update: тот залипший флаг теперь сбрасывается у источника (см. `if + # anchor is None: anchor_tier = None` в anchor-блоке выше), т.е. два условия + # стали эквивалентны. Гард оставлен на `anchor is None` НАМЕРЕННО — это + # по-прежнему прямая проверка факта «якорь дал headline», а не производный флаг. if ( median_ppm2 <= 0 and anchor is None diff --git a/tradein-mvp/backend/scripts/backtest_estimator.py b/tradein-mvp/backend/scripts/backtest_estimator.py index 30be3429..2c272a42 100644 --- a/tradein-mvp/backend/scripts/backtest_estimator.py +++ b/tradein-mvp/backend/scripts/backtest_estimator.py @@ -1462,8 +1462,16 @@ def load_fixture(path: str) -> dict[str, Any]: return json.loads(Path(path).read_text(encoding="utf-8")) +_UNRECORDED = object() # sentinel: «на лишний вызов ответа нет» → RuntimeError + + def _make_call_stub( - calls: list[Any], *, label: str, coerce: Callable[[Any], Any] + calls: list[Any], + *, + 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. @@ -1478,6 +1486,24 @@ def _make_call_stub( stays correct if a call site ever loops. Calling the stub MORE times than recorded raises RuntimeError — control flow diverged from capture. + ``on_exhausted`` (#2661) ослабляет ЭТО последнее правило для конкретной + callable: значение возвращается вместо RuntimeError, когда фикстура записала + меньше вызовов, чем сделал реплей. Нужно, когда правка РАЗБЛОКИРОВАЛА путь, + которого при захвате фикстуры не было (сброс залипшего ``anchor_tier`` открыл + quarter-index-гейт `Guard-1a` на часть сделок) — у прод-фикстуры на такие + вызовы ответа нет и взять его негде, пока фикстуру не перезахватят с прода. + Использовать ТОЛЬКО для lookup'ов, у которых «промах» — валидное состояние + (quarter-index: None/{} = индекса нет → блок no-op). ``ratio_resolver`` + остаётся строгим НАМЕРЕННО: лишний вызов там означал бы, что реплей взял + другой коэффициент выкупа, т.е. молча другие деньги. + + ``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. """ @@ -1487,10 +1513,15 @@ def _make_call_stub( def _stub(_arg: Any) -> Any: nonlocal idx if idx >= len(returns): - raise RuntimeError( - f"{label}: replay made call #{idx + 1} but fixture recorded only " - f"{len(returns)} — control flow diverged from capture" - ) + if on_exhausted is _UNRECORDED: + raise RuntimeError( + 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] idx += 1 return ret @@ -1513,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 @@ -1522,46 +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] # #2661: счётчик 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 - ) - kw["quarter_index_lookup"] = _make_call_stub( - rec.get("qi_calls") or [], label="quarter_index_lookup", coerce=_coerce_qi_return - ) - kw["quarter_indexes_lookup"] = _make_call_stub( - rec.get("qis_calls") or [], label="quarter_indexes_lookup", coerce=_coerce_qis_return - ) + # #2661: фикстура захвачена с ВЫКЛЮЧЕННЫМ кросс-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 + ) + # #2661: quarter-index-lookup'ы отвечают «промах» (None / {}) на вызовы, которых + # в фикстуре нет — см. _make_call_stub. Сброс залипшего anchor_tier открыл гейт + # Guard-1a на часть сделок, а прод-фикстура захвачена ДО правки. Следствие, + # которое надо знать при чтении метрик: для этих сделок квартальный индекс в + # реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает + # эффект правки — счётчик 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) @@ -1576,6 +1640,10 @@ def replay_fixture(fixture: dict[str, Any]) -> dict[str, Any]: "ask_median_ppm2": ask_median, "spread_pct": spread_pct, } + # #2661: сколько 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 b11e827e..e7b389d1 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": 0 } diff --git a/tradein-mvp/backend/tests/test_estimator_price_spine.py b/tradein-mvp/backend/tests/test_estimator_price_spine.py index fd38466b..162fa2cc 100644 --- a/tradein-mvp/backend/tests/test_estimator_price_spine.py +++ b/tradein-mvp/backend/tests/test_estimator_price_spine.py @@ -200,8 +200,10 @@ def test_same_building_anchor_tier_a_mutates_headline() -> None: def test_tier_c_corridor_gate_suppresses_anchor() -> None: """Tier C anchor ppm2 >> corridor_high × mult → anchor suppressed. - anchor_tier remains "C" in the result (gate sets anchor=None but doesn't - reset anchor_tier); headline stays at the radius median. + #2661: anchor_tier теперь СБРАСЫВАЕТСЯ в None (раньше гейт ставил anchor=None, + но оставлял залипший "C" — и этот флаг молча глушил IMV-blend/quarter-index/ + radius-floor, будто headline построил якорь). Headline как и раньше остаётся + радиусной медианой. """ # 5 comps at 300k ppm2; corridor_high=150k; gate threshold=150k×1.5=225k. # 300k > 225k → suppressed. @@ -215,8 +217,8 @@ def test_tier_c_corridor_gate_suppresses_anchor() -> None: ratio=None, ) - # Tier C gate sets anchor=None but leaves anchor_tier="C". - assert pr.anchor_tier == "C" + # #2661: гейт ставит anchor=None → tier-флаг сбрасывается вместе с ним. + assert pr.anchor_tier is None # Headline was NOT mutated by the suppressed anchor — stays at radius median. assert pr.median_price == radius_median_price # anchor_comps_used stays empty (anchor didn't fire). @@ -245,6 +247,87 @@ def test_low_conf_gate_suppresses_anchor() -> None: assert pr.anchor_comps_used == [] +def test_anchor_tier_reset_when_anchor_not_built() -> None: + """Якорь не построен (комплов меньше min_comps) → anchor_tier=None, а не залипшая "C". + + Третий путь к anchor=None (#2661), отдельный от Tier C гейта и low-conf гейта выше: + когда ``_compute_same_building_anchor`` возвращает None САМА (комплов меньше + ``estimate_sb_min_comps``=4), флаг раньше оставался равным ``anchor_tier_fetched`` — + дальше по коду он читается как «headline построил якорь». + """ + comps = [_anchor_comp(150_000), _anchor_comp(155_000)] # 2 < estimate_sb_min_comps=4 + radius_median_price = int(100_000 * 50.0) + pr = _call( + listings=_lots(100_000, n=5), + anchor_comps=comps, + anchor_tier_fetched="C", + ratio=None, + ) + + assert pr.anchor_tier is None + assert pr.anchor_comps_used == [] + assert pr.median_price == radius_median_price # headline остался радиусным + + +def test_sticky_anchor_tier_no_longer_mutes_imv_blend() -> None: + """Недостроенный якорь (тир "C" не подтверждён) + IMV-якорь — blend обязан сработать. + + Денежное последствие залипшего флага #2661: сделки, у которых якоря нет ни до, ни + после правки, всё равно теряли IMV-blend — залипший ``anchor_tier="C"`` глушил гейт + ``anchor_tier is None`` у блока #651. Числа те же, что в + ``test_imv_blend_raises_median_when_anchor_tier_none`` выше (radius median=5M, + IMV=7M → blend 6M), плюс недостроенный якорь, который раньше эту цепочку выключал. + """ + comps = [_anchor_comp(150_000), _anchor_comp(155_000)] + imv_anchor = { + "recommended_price": 7_000_000, + "lower_price": 6_000_000, + "higher_price": 8_000_000, + "market_count": 50, + } + pr = _call( + listings=_lots(100_000, n=5), + anchor_comps=comps, + anchor_tier_fetched="C", + imv_anchor=imv_anchor, + ratio=None, + ) + + assert pr.anchor_tier is None + assert pr.median_price == round(5_000_000 * 0.5 + 7_000_000 * 0.5) # IMV-blend сработал + assert pr.avito_imv_summary is not None + + +def test_imv_card_survives_when_headline_suppressed_and_anchor_absent() -> None: + """Карточка Avito IMV не исчезает в щели «тир добыт, якоря нет, headline подавлен». + + #2661: раньше карточку заполнял display-only блок под условием `anchor_tier is not + None`. После сброса залипшего флага открылась щель: blend не срабатывает (нужен + listings_clean и median_price > 0), старый display-only блок тоже не срабатывал (tier + уже None) — пользователь ТЕРЯЛ карточку, которую видел раньше. Гейт по tier у + display-only блока снят — отображение, не деньги (median/expected_sold не трогает). + """ + comps = [_anchor_comp(150_000), _anchor_comp(155_000)] + imv_anchor = { + "recommended_price": 7_000_000, + "lower_price": 6_000_000, + "higher_price": 8_000_000, + "market_count": 50, + } + pr = _call( + listings=[], + anchor_comps=comps, + anchor_tier_fetched="C", + imv_anchor=imv_anchor, + ratio=None, + ) + + 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 == 7_000_000 + + def test_imv_blend_raises_median_when_anchor_tier_none() -> None: """IMV blend pushes radius median up when IMV >> median × threshold.