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
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:
parent
2878a88c67
commit
828e32e9ab
4 changed files with 135 additions and 58 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -150,5 +150,6 @@
|
|||
"sharpness": {
|
||||
"median_rel_width": 0.743,
|
||||
"n": 269
|
||||
}
|
||||
},
|
||||
"unrecorded_lookup_calls": 19
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue