fix(tradein/estimate): залипший anchor_tier больше не глушит четыре механизма разом #2920

Merged
lekss361 merged 1 commit from fix/2656-anchor-tier-reset into main 2026-08-17 08:00:34 +00:00
4 changed files with 226 additions and 51 deletions

View file

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

View file

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

View file

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

View file

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