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
Owner

Безопасная половина PR #2661, выделенная по решению владельца. Спорная половина — фильтры свежести, двигающие цену клиенту — остаётся в исходном PR и решается отдельно.

Что чинится

anchor_tier выставлялся при попытке построить якорь дома и не сбрасывался, когда якорь в итоге не построился. Причин «якоря нет» три (коридорный гейт Tier C, гейт низкой уверенности и недобор аналогов в самом расчёте), и после каждой флаг оставался висеть.

Флаг читается как «якорь есть», поэтому залипание молча выключало сразу четыре механизма: подмешивание IMV, квартальную поправку, пол по сделкам ДКП и коридорный зажим. Ни один из них не сообщал о себе — они просто не срабатывали.

Теперь сброс делается единой веткой if anchor is None: у источника, а не в каждом гейте по отдельности. Заодно снят гейт anchor_tier is not None у карточки IMV: она показывается пользователю независимо от якоря, и прятать её из-за чужого флага незачем.

Почему это безопасно — замер, а не рассуждение

Перезамер на текущем коде, 996 парных оценок: 154 сделки теряют флаг anchor_tier, и у всех 154 изменение цены ровно 0,000%. То есть чинится именно флаг, а не ценообразование — залипшее значение и раньше не строило итоговую цену, оно лишь глушило соседние механизмы.

Второе подтверждение пришло от эталона бэктеста. После штатной перегенерации (--from-fixture --update-baseline, не правкой JSON руками) все метрики совпали побитово — калибровка, ожидаемая цена сделки, покрытие диапазона, острота, заголовочное число. Единственная дельта — появился ключ unrecorded_lookup_calls: 0.

Про красный гейт, который был у #2661

Там гейт падал на unrecorded_lookup_calls: 0 != 19, и это приписывали фильтру свежести. Оказалось иначе: в комбинированном PR фильтр дополнительно сокращал пул аналогов якоря, из-за чего лишние сделки теряли якорь и уходили на ветки обращения к квартальному индексу, не захваченные в фикстуре. Здесь фильтра нет — поток управления совпадает с тем, что записан в фикстуре, и метрика честно равна нулю.

Попутно починена сама регенерация эталона: replay_fixture теперь пиннит estimate_dedup_analogs_enabled=False (раньше это стояло только в гейте, из-за чего после #2173 штатная регенерация не воспроизводила baseline), а заглушка обращений к индексу отвечает «промах» вместо исключения, если сброс флага открыл путь, которого в фикстуре не было.

Проверка

  • pytest tests/test_backtest_regression_gate.py — 2 passed
  • полный набор — 4606 passed, 25 skipped, ни одного упавшего
  • ruff check app tests — чисто

Добавлены три теста: сброс флага при непостроенном якоре; залипший флаг больше не глушит подмешивание IMV; карточка IMV выживает, когда заголовочное число подавлено, а якоря нет. Плюс обновлён существующий тест коридорного гейта — он проверял anchor_tier == "C" там, где теперь корректно None.

Что осталось в #2661

Фильтр свежести в SQL якоря дома (оба тира), фильтр свежести в двух CTE знаменателя коэффициента выкупа и вынос окна в конфиг. По перезамеру эта половина двигает деньги: знаменатель просаживается на 1,3% по всем бакетам, то есть предложение клиенту системно уменьшается. Решение по ней — отдельное.

Refs #2656

Безопасная половина PR #2661, выделенная по решению владельца. Спорная половина — фильтры свежести, двигающие цену клиенту — остаётся в исходном PR и решается отдельно. ## Что чинится `anchor_tier` выставлялся при попытке построить якорь дома и **не сбрасывался**, когда якорь в итоге не построился. Причин «якоря нет» три (коридорный гейт Tier C, гейт низкой уверенности и недобор аналогов в самом расчёте), и после каждой флаг оставался висеть. Флаг читается как «якорь есть», поэтому залипание молча выключало сразу четыре механизма: подмешивание IMV, квартальную поправку, пол по сделкам ДКП и коридорный зажим. Ни один из них не сообщал о себе — они просто не срабатывали. Теперь сброс делается единой веткой `if anchor is None:` у источника, а не в каждом гейте по отдельности. Заодно снят гейт `anchor_tier is not None` у карточки IMV: она показывается пользователю независимо от якоря, и прятать её из-за чужого флага незачем. ## Почему это безопасно — замер, а не рассуждение Перезамер на текущем коде, 996 парных оценок: **154 сделки теряют флаг `anchor_tier`, и у всех 154 изменение цены ровно 0,000%**. То есть чинится именно флаг, а не ценообразование — залипшее значение и раньше не строило итоговую цену, оно лишь глушило соседние механизмы. Второе подтверждение пришло от эталона бэктеста. После штатной перегенерации (`--from-fixture --update-baseline`, не правкой JSON руками) **все метрики совпали побитово** — калибровка, ожидаемая цена сделки, покрытие диапазона, острота, заголовочное число. Единственная дельта — появился ключ `unrecorded_lookup_calls: 0`. ## Про красный гейт, который был у #2661 Там гейт падал на `unrecorded_lookup_calls: 0 != 19`, и это приписывали фильтру свежести. Оказалось иначе: в комбинированном PR фильтр дополнительно сокращал пул аналогов якоря, из-за чего лишние сделки теряли якорь и уходили на ветки обращения к квартальному индексу, не захваченные в фикстуре. Здесь фильтра нет — поток управления совпадает с тем, что записан в фикстуре, и метрика честно равна нулю. Попутно починена сама регенерация эталона: `replay_fixture` теперь пиннит `estimate_dedup_analogs_enabled=False` (раньше это стояло только в гейте, из-за чего после #2173 штатная регенерация не воспроизводила baseline), а заглушка обращений к индексу отвечает «промах» вместо исключения, если сброс флага открыл путь, которого в фикстуре не было. ## Проверка - `pytest tests/test_backtest_regression_gate.py` — 2 passed - полный набор — **4606 passed, 25 skipped**, ни одного упавшего - `ruff check app tests` — чисто Добавлены три теста: сброс флага при непостроенном якоре; залипший флаг больше не глушит подмешивание IMV; карточка IMV выживает, когда заголовочное число подавлено, а якоря нет. Плюс обновлён существующий тест коридорного гейта — он проверял `anchor_tier == "C"` там, где теперь корректно `None`. ## Что осталось в #2661 Фильтр свежести в SQL якоря дома (оба тира), фильтр свежести в двух CTE знаменателя коэффициента выкупа и вынос окна в конфиг. По перезамеру эта половина двигает деньги: знаменатель просаживается на **1,3%** по всем бакетам, то есть предложение клиенту системно уменьшается. Решение по ней — отдельное. Refs #2656
lekss361 added 1 commit 2026-08-17 07:55:26 +00:00
fix(tradein/estimate): залипший anchor_tier молча глушил IMV-blend и quarter-index
All checks were successful
CI Trade-In / browser-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 / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m32s
42b0ebe338
Флаг anchor_tier оставался равным anchor_tier_fetched ("A"/"C"), когда якорь
фактически НЕ строился — сброс делал только low-conf гейт (#audit-1), но не
Tier C corridor-гейт (#1795) и не сама _compute_same_building_anchor, когда
она отклоняет кандидата (комплов меньше estimate_sb_min_comps). Дальше по коду
залипший флаг читается как «headline построил якорь» и молча глушит IMV/Yandex
blend (#651, гейт `anchor_tier is None`) и quarter-index correction (#764
Guard-1a) — притом что радиусный headline их не получал.

Замер: 154 из 996 сделок теряют tier-флаг этой правкой, и у всех 154 изменение
цены ровно 0.000% — чинится именно залипший ФЛАГ, не ценообразование (баланс
метрик бэктеста подтверждает: единственная дельта в baseline — новая канарейка
unrecorded_lookup_calls, все остальные метрики побитово те же).

- estimator.py: сброс `anchor_tier = None` единой веткой `if anchor is None`
  после всех трёх гейтов (Tier C / low-conf / _compute_same_building_anchor);
  display-only IMV-карточка больше не гейтится по `anchor_tier is not None`
  (иначе терялась в щели «тир добыт, якорь не построен, headline подавлен»).
- backtest_estimator.py: quarter_index_lookup/quarter_indexes_lookup в реплее
  отвечают «промах» (None/{}), если сброс флага открыл путь, которого не было
  в замороженной фикстуре, вместо падения с RuntimeError; счётчик таких промахов
  уходит в baseline как unrecorded_lookup_calls (точное целое, канарейка на
  расхождение реплея с захватом). Заодно пиннится estimate_dedup_analogs_enabled
  = False внутри replay_fixture (было только в самом гейте) — иначе штатная
  регенерация baseline (--from-fixture --update-baseline) писала baseline,
  который тест не совпадал бы никогда.
- backtest_baseline.json: перегенерирован штатным путём, unrecorded_lookup_calls=0.

Выделено из #2656/PR #2661 — фильтры свежести (scraped_at) в якоре дома и в
знаменателе коэффициента выкупа остаются в исходном PR как отдельная, более
спорная правка (двигает деньги: знаменатель просаживается на ~1.3% по бакетам).
lekss361 merged commit 215b78a348 into main 2026-08-17 08:00:34 +00:00
lekss361 deleted branch fix/2656-anchor-tier-reset 2026-08-17 08:00:34 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2920
No description provided.