fix(tradein/estimate): вернуть канарейку бэктеста и карточку IMV после сброса anchor_tier (#2656)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
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 2m43s

По итогам глубокого ревью PR #2661.

1. Ослабление replay-стаба выключало канарейку «реплей разошёлся с захватом»
   навсегда и для всех будущих PR, а не только для 19 сделок этой правки.
   Теперь число неотвеченных lookup-вызовов считается и выносится в метрику
   unrecorded_lookup_calls — baseline сравнивает целые ТОЧНО, поэтому 19 стало
   закоммиченной константой: новый незаписанный путь снова валит гейт громко,
   а перезахват фикстуры доведёт число до нуля.

   Перезахват фикстуры (лучший путь по ревью) не сделан осознанно: он требует
   живой прод-БД tradein и тянет НОВУЮ выборку сделок, то есть не «дозаписывает»
   19 ответов, а меняет саму систему отсчёта регресс-гейта по причинам, не
   связанным с этим PR.

   Попутно найдено и починено: документированная регенерация baseline
   (--from-fixture --update-baseline) с #2173 писала baseline, который гейт не
   мог совпасть НИКОГДА — тест пиннит estimate_dedup_analogs_enabled=False, а
   CLI нет. Пин переехал внутрь replay_fixture, теперь оба пути согласованы.
   Регенерированный baseline отличается ровно одной строкой (новый ключ), ни
   одна метрика не сдвинулась.

2. Сброс anchor_tier открывал щель в отображении: комплы якоря добыты, якорь не
   построен, headline подавлен → не срабатывал ни blend, ни display-only блок, и
   пользователь терял карточку Avito IMV. Гейт по tier снят, защита от двойного
   заполнения (`avito_imv_summary is None`) остаётся.

3. Тест мест теперь стережёт и inline-копию _COMMON_WHERE в Tier W — самое
   вероятное место следующего расхождения того же сорта.

Фальсификация: правка baseline 19→18 валит гейт; снятие предиката из Tier W
валит тест мест; возврат старого условия display-блока валит новый тест карточки.
This commit is contained in:
bot-backend 2026-08-05 22:06:49 +05:00
parent 2878a88c67
commit 828e32e9ab
4 changed files with 135 additions and 58 deletions

View file

@ -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(

View file

@ -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

View file

@ -150,5 +150,6 @@
"sharpness": {
"median_rel_width": 0.743,
"n": 269
}
},
"unrecorded_lookup_calls": 19
}

View file

@ -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