fix(tradein/estimate): фильтр свежести в якоре дома и знаменателе коэффициента выкупа (#2656) #2661

Merged
bot-backend merged 7 commits from fix/2656-anchor-ratio-freshness into main 2026-08-26 13:31:53 +00:00
Collaborator

Что было

Фильтр свежести (scraped_at > NOW() - LISTINGS_FRESH_DAYS дней) стоял ТОЛЬКО в главном радиусном пути эстиматора (_COMMON_WHERE + inline-копия Tier W). Четыре другие выборки, которые двигают деньги, читали is_active без свежести:

место как двигает деньги
estimator._fetch_anchor_comps Tier A (:1885) якорь замещает headline: median_ppm2 = new_ppm2, median_price = point, n_analogs = anchor["n"]
estimator._fetch_anchor_comps Tier C (:1977) то же замещение
asking_to_sold_ratio.ask_side (:157) знаменатель коэффициента выкупа
asking_to_sold_ratio.ask_global (:217) он же, global-фолбэк

is_active свежесть не заменяет: он означает РАЗНОЕ у разных источников (TTL деактивации 30 дней у cian/yandex-вторички, NULL-сегмент не деактивируется никогда), а scraped_at — одно и то же. Прод 2026-08: 21 132 из 37 497 «активных» строк протухли по 14-дневной мерке самого эстиматора и при этом были полностью годны для якоря.

Побочный дефект, вскрытый замером: anchor_tier оставался "C", когда _compute_same_building_anchor вернула None, и этот залипший флаг молча глушил IMV-blend (if anchor_tier is None and …, estimator.py:2770), quarter-index (#764 Guard-1a), radius-floor от ДКП-коридора и corridor-clamp-exempt Tier A.

Что стало

  • Тот же предикат AND scraped_at > NOW() - (:fresh_days || ' days')::interval во всех четырёх местах.
  • Окно живёт в одном месте — app.core.config.LISTINGS_FRESH_DAYS. Держать его в estimator.py нельзя: тот сам импортирует area_bucket из app.tasks.asking_to_sold_ratio, обратный импорт дал бы цикл. Оба модуля читают константу из app.core.config.
  • anchor_tier сбрасывается явно у источника (if anchor is None: anchor_tier = None) — для всех трёх причин «якоря нет»: None из _compute_same_building_anchor, гейт Tier C #1795, low-conf гейт #audit-1 (раньше сбрасывал только последний). Не как побочный эффект фильтра.
  • Display-only карточка Avito IMV больше не гейтится по anchor_tier — после сброса флага открылась щель (тир добыт, якоря нет, headline подавлен → карточку не заполнял ни blend, ни display-блок). Отображение, не деньги.

Числа последствий

⚠️ ЧИСЛА В ЭТОМ РАЗДЕЛЕ — ЗАМЕР 05.08. ПЕРЕЗАМЕР 12.08 (той же оснасткой, на смерженной с main ветке) даёт ДРУГОЙ ответ: итог по сумме выкупа −2.25%+0.09%, якорная половина −3.63%−1.07%, знаменатель +1.25%+0.71%, сброс anchor_tier +0.113%+0.46%. Теряют якорь 57 из 1052 (5.4%), а не 139 из 1014 (13.7%); роста отказов нет вовсе. Причина — состав данных: вторичка перестала протухать (avito/domklik 0% протухших), а протухшая часть знаменателя стала дешевле свежей. Разбор с таблицами, проверка оснастки и сверка направления по реальным ДКП — в комментарии ниже. Раздел ниже оставлен как есть — это история решения, а не текущее число.

Прогон всех 1040 записей trade_in_estimates через настоящую _price_from_inputs (замер в #2656) + перезамер против полной ветки (обе половины фильтра + сброс anchor_tier вместе):

значение
теряют якорь 139 из 1014 (13.7%), все Tier C; Tier A не теряет ни одной
куда уезжают радиусный путь 127, ДКП-фолбэк 6, insufficient_data 6
рост отказов 79 → 85 (+0.59% оценок)
фильтр якоря сумма выкупа −3.63%
фильтр знаменателя +1.25%
сброс anchor_tier +0.113%
итого (полная ветка) −2.25%
двигаются больше чем на 10% 111 оценок (10.9%), худший случай −40.6% (без сброса был −48.6%)

Сброс anchor_tier тянет цену вверх и смягчает худший случай. Он меняет цену у 99 оценок (9.76%): 82 — через квартальный индекс (двусторонний, до +26% и до −30%), 17 — через IMV-blend (всегда вверх). Кламп коридора и пол по ДКП не срабатывают ни разу.

У сохранивших якорь сдвиг симметричный: 241 вверх, 227 вниз, медиана 0.00%. Медианный размер пула 19 → 13.

Потеря якоря шире потери порога — механизма два

  1. Гейты confidence. У 106 из 139 пул после фильтра всё ещё ≥4, но тоньше пул → выше штраф в FSD → confidence low → подавление.
  2. Переворот include_primary (#1774). Решение «пускать ли новостроечные перепродажи» теперь принимается на уже прореженном наборе. Дом, где протухание сильнее ударило по вторичке, может перевернуться из «пускать» в «не пускать» и потерять заодно свежие первичные комплы. Пример: 3 свежих вторички + 5 протухших вторичек + 4 свежих новостройки — до правки пул 12 и якорь есть; после правки вторичек 3 против 4 первичек, гард #1186 включается, пул 3, якорь потерян при семи живых комплах. Внутри измеренных чисел, направление консервативное.

estimate_sb_min_comps НЕ трогаю (остаётся 4). Замер: повышать нельзя (хуже по всем осям), понижение до 3 — единственная реальная компенсация, но оно обнуляет гейт тонкого пула estimate_sb_gate_min_n=3 (условен на n < 3) — отдельное решение под бэктест, отдельный PR.

Почему обе половины одним PR

Порознь они дадут два заметных скачка цены вместо одного меньшего: якорная половина −3.63%, компенсирующая половина знаменателя +1.25%.

Ловушка деплоя — нужен ручной пересчёт сразу после

asking_to_sold_ratios пересчитывается ночным заданием (06:00-07:00 UTC). После деплоя одну ночь действует только якорная половина — то есть полные −3.63% без компенсирующего +1.25%. Либо дёрнуть пересчёт вручную сразу после деплоя (UPDATE scrape_schedules SET next_run_at = now() … в tradein-scraper, подхват ≤60 с), либо сознательно прожить эту ночь.

Тесты

Новый tests/test_freshness_filter_2656.py (8 тестов):

  1. предикат присутствует во всех четырёх починенных местах + в _COMMON_WHERE и его inline-копии Tier W (самое вероятное место следующего расхождения того же сорта);
  2. окно берётся из одной константы (оба модуля импортируют её из app.core.config, локальных копий нет, литерала '14 days' в SQL нет);
  3. :fresh_days забинден в обоих тирах якоря;
  4. протухший комп не попадает в пул якоря, свежий попадает;
  5. пул из 3 свежих + 5 протухших теряет якорь (tier is None);
  6. anchor_tier сбрасывается, когда якорь не построен;
  7. залипший anchor_tier больше не глушит IMV-blend (цена реально двигается);
  8. карточка Avito IMV не исчезает в щели «тир добыт, якоря нет, headline подавлен».

Фальсификация: git stash push на файлах реализации → все 7 исходных краснеют; возврат старого условия display-блока валит №8; снятие предиката из Tier W валит №1. Полный прогон: 3356 passed, 9 skipped.

Честно про два места, которые НЕ являются защитой:

  • test_migration_080_derivation_is_subset_of_refresh_sql — добавлен нормализатор нового предиката; он проходит и без фикса (совместимость, не гард).
  • test_estimator_price_spine::test_tier_c_corridor_gate_suppresses_anchor — раньше закреплял залипший "C" как ожидаемое поведение; переписан на is None.

Frozen backtest gate

Сброс anchor_tier открывает quarter-index-гейт Guard-1a на 19 из 277 сделок фикстуры, а фикстура захвачена ДО правки и ответов на эти lookup'ы не содержит (был жёсткий RuntimeError: control flow diverged from capture). Сделано:

  • _make_call_stub получил on_exhausted — quarter-index-lookup'ы отвечают «промах» (None / {}); ratio_resolver остаётся строгим намеренно (лишний вызов там = молча другой коэффициент выкупа);
  • число таких промахов считается и выносится в метрику unrecorded_lookup_calls, которая попадает в baseline целым числом (сравнивается ТОЧНО). Иначе ослабление выключило бы канарейку «реплей разошёлся с захватом» навсегда и для всех будущих PR. Сейчас в baseline закоммичено 19: любой будущий PR, открывающий НОВЫЙ незаписанный путь, снова валит гейт громко, а перезахват фикстуры доведёт число до нуля (проверено: правка baseline 19→18 гейт валит).

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

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

Что гейт по-прежнему НЕ мерит: для этих 19 сделок квартальный индекс в реплее не применяется вовсе (в проде применился бы), а именно он даёт 82 из 99 изменений цены от сброса. Источник истины по эффекту сброса — прод-замер выше, не гейт.

Test plan

  • прод-верификация после деплоя: asking_to_sold_ratios пересчитан вручную (см. ловушку выше), ratio в бакетах 44-62 и 62-85 ушёл вверх (+2.43% / +4.58% по замеру), глобальный — вниз на 0.49%
  • частота quarter_index: applied в логах выросла примерно вдвое (112 → ~197 на выборке 1014) — если не выросла, сброс anchor_tier до прода не доехал
  • смоук оценки на адресе с тонким якорным пулом — цена ниже, объяснение перестроилось на радиусный путь / ДКП
  • n_listings в asking_to_sold_ratios упал (NULL-сегмент в знаменателе: 674 → ~20 строк)

НЕ мержить без решения владельца. По состоянию на 12.08 цена решения почти нулевая: сумма выкупа +0.09%, типовая оценка −0.23%, но 66 оценок (6.3%) двигаются больше чем на 10% в обе стороны. Сверка с медианами зарегистрированных ДКП по тому же дому: там, где правка шевелит цену, новое число ближе к сделкам (медиана ошибки 22.90% → 19.56% на 147 подвижках ≥5%). Это по-прежнему вопрос политики, а не кода — но аргумент «правка стоит клиенту 2.25%» больше не действует.

Refs #2656

## Что было Фильтр свежести (`scraped_at > NOW() - LISTINGS_FRESH_DAYS дней`) стоял ТОЛЬКО в главном радиусном пути эстиматора (`_COMMON_WHERE` + inline-копия Tier W). Четыре другие выборки, которые двигают деньги, читали `is_active` без свежести: | место | как двигает деньги | |---|---| | `estimator._fetch_anchor_comps` Tier A (:1885) | якорь **замещает** headline: `median_ppm2 = new_ppm2`, `median_price = point`, `n_analogs = anchor["n"]` | | `estimator._fetch_anchor_comps` Tier C (:1977) | то же замещение | | `asking_to_sold_ratio.ask_side` (:157) | знаменатель коэффициента выкупа | | `asking_to_sold_ratio.ask_global` (:217) | он же, global-фолбэк | `is_active` свежесть не заменяет: он означает РАЗНОЕ у разных источников (TTL деактивации 30 дней у cian/yandex-вторички, NULL-сегмент не деактивируется никогда), а `scraped_at` — одно и то же. Прод 2026-08: **21 132 из 37 497** «активных» строк протухли по 14-дневной мерке самого эстиматора и при этом были полностью годны для якоря. Побочный дефект, вскрытый замером: `anchor_tier` оставался `"C"`, когда `_compute_same_building_anchor` вернула `None`, и этот залипший флаг молча глушил IMV-blend (`if anchor_tier is None and …`, `estimator.py:2770`), quarter-index (#764 Guard-1a), radius-floor от ДКП-коридора и corridor-clamp-exempt Tier A. ## Что стало - Тот же предикат `AND scraped_at > NOW() - (:fresh_days || ' days')::interval` во всех четырёх местах. - Окно живёт в **одном** месте — `app.core.config.LISTINGS_FRESH_DAYS`. Держать его в `estimator.py` нельзя: тот сам импортирует `area_bucket` из `app.tasks.asking_to_sold_ratio`, обратный импорт дал бы цикл. Оба модуля читают константу из `app.core.config`. - `anchor_tier` сбрасывается **явно у источника** (`if anchor is None: anchor_tier = None`) — для всех трёх причин «якоря нет»: `None` из `_compute_same_building_anchor`, гейт Tier C #1795, low-conf гейт #audit-1 (раньше сбрасывал только последний). Не как побочный эффект фильтра. - Display-only карточка Avito IMV больше не гейтится по `anchor_tier` — после сброса флага открылась щель (тир добыт, якоря нет, headline подавлен → карточку не заполнял ни blend, ни display-блок). Отображение, не деньги. ## Числа последствий > ⚠️ **ЧИСЛА В ЭТОМ РАЗДЕЛЕ — ЗАМЕР 05.08. ПЕРЕЗАМЕР 12.08** (той же оснасткой, на смерженной с main ветке) даёт ДРУГОЙ ответ: итог по сумме выкупа `−2.25%` → **`+0.09%`**, якорная половина `−3.63%` → `−1.07%`, знаменатель `+1.25%` → `+0.71%`, сброс `anchor_tier` `+0.113%` → `+0.46%`. Теряют якорь **57 из 1052 (5.4%)**, а не 139 из 1014 (13.7%); роста отказов нет вовсе. Причина — состав данных: вторичка перестала протухать (avito/domklik 0% протухших), а протухшая часть знаменателя стала дешевле свежей. Разбор с таблицами, проверка оснастки и сверка направления по реальным ДКП — [в комментарии ниже](https://git.gendsgn.ru/lekss361/gendesign/pulls/2661#issuecomment-25282). Раздел ниже оставлен как есть — это история решения, а не текущее число. Прогон всех 1040 записей `trade_in_estimates` через настоящую `_price_from_inputs` (замер в #2656) + **перезамер против полной ветки** (обе половины фильтра + сброс `anchor_tier` вместе): | | значение | |---|---| | теряют якорь | **139 из 1014 (13.7%)**, все Tier C; Tier A не теряет ни одной | | куда уезжают | радиусный путь 127, ДКП-фолбэк 6, `insufficient_data` 6 | | рост отказов | **79 → 85** (+0.59% оценок) | | фильтр якоря | сумма выкупа **−3.63%** | | фильтр знаменателя | **+1.25%** | | сброс `anchor_tier` | **+0.113%** | | **итого (полная ветка)** | **−2.25%** | | двигаются больше чем на 10% | 111 оценок (10.9%), худший случай **−40.6%** (без сброса был −48.6%) | Сброс `anchor_tier` тянет цену **вверх** и смягчает худший случай. Он меняет цену у **99 оценок (9.76%)**: 82 — через квартальный индекс (двусторонний, до +26% и до −30%), 17 — через IMV-blend (всегда вверх). Кламп коридора и пол по ДКП не срабатывают ни разу. У сохранивших якорь сдвиг симметричный: 241 вверх, 227 вниз, медиана 0.00%. Медианный размер пула 19 → 13. ### Потеря якоря шире потери порога — механизма два 1. **Гейты confidence.** У 106 из 139 пул после фильтра всё ещё ≥4, но тоньше пул → выше штраф в FSD → confidence `low` → подавление. 2. **Переворот `include_primary` (#1774).** Решение «пускать ли новостроечные перепродажи» теперь принимается на **уже прореженном** наборе. Дом, где протухание сильнее ударило по вторичке, может перевернуться из «пускать» в «не пускать» и потерять заодно свежие первичные комплы. Пример: 3 свежих вторички + 5 протухших вторичек + 4 свежих новостройки — до правки пул 12 и якорь есть; после правки вторичек 3 против 4 первичек, гард #1186 включается, пул 3, якорь потерян при семи живых комплах. Внутри измеренных чисел, направление консервативное. `estimate_sb_min_comps` НЕ трогаю (остаётся 4). Замер: повышать нельзя (хуже по всем осям), понижение до 3 — единственная реальная компенсация, но оно обнуляет гейт тонкого пула `estimate_sb_gate_min_n=3` (условен на `n < 3`) — отдельное решение под бэктест, отдельный PR. ## Почему обе половины одним PR Порознь они дадут **два заметных скачка цены вместо одного меньшего**: якорная половина −3.63%, компенсирующая половина знаменателя +1.25%. ## Ловушка деплоя — нужен ручной пересчёт сразу после `asking_to_sold_ratios` пересчитывается **ночным заданием (06:00-07:00 UTC)**. После деплоя одну ночь действует только якорная половина — то есть **полные −3.63% без компенсирующего +1.25%**. Либо дёрнуть пересчёт вручную сразу после деплоя (`UPDATE scrape_schedules SET next_run_at = now() …` в `tradein-scraper`, подхват ≤60 с), либо сознательно прожить эту ночь. ## Тесты Новый `tests/test_freshness_filter_2656.py` (8 тестов): 1. предикат присутствует во всех четырёх починенных местах + в `_COMMON_WHERE` и его **inline-копии Tier W** (самое вероятное место следующего расхождения того же сорта); 2. окно берётся из одной константы (оба модуля импортируют её из `app.core.config`, локальных копий нет, литерала `'14 days'` в SQL нет); 3. `:fresh_days` забинден в обоих тирах якоря; 4. протухший комп не попадает в пул якоря, свежий попадает; 5. пул из 3 свежих + 5 протухших теряет якорь (`tier is None`); 6. `anchor_tier` сбрасывается, когда якорь не построен; 7. залипший `anchor_tier` больше не глушит IMV-blend (цена реально двигается); 8. карточка Avito IMV не исчезает в щели «тир добыт, якоря нет, headline подавлен». **Фальсификация:** `git stash push` на файлах реализации → все 7 исходных краснеют; возврат старого условия display-блока валит №8; снятие предиката из Tier W валит №1. Полный прогон: `3356 passed, 9 skipped`. Честно про два места, которые НЕ являются защитой: - `test_migration_080_derivation_is_subset_of_refresh_sql` — добавлен нормализатор нового предиката; он проходит и без фикса (совместимость, не гард). - `test_estimator_price_spine::test_tier_c_corridor_gate_suppresses_anchor` — раньше **закреплял** залипший `"C"` как ожидаемое поведение; переписан на `is None`. ## Frozen backtest gate Сброс `anchor_tier` открывает quarter-index-гейт `Guard-1a` на **19 из 277** сделок фикстуры, а фикстура захвачена ДО правки и ответов на эти lookup'ы не содержит (был жёсткий `RuntimeError: control flow diverged from capture`). Сделано: - `_make_call_stub` получил `on_exhausted` — quarter-index-lookup'ы отвечают «промах» (`None` / `{}`); `ratio_resolver` остаётся строгим намеренно (лишний вызов там = молча другой коэффициент выкупа); - число таких промахов считается и выносится в метрику **`unrecorded_lookup_calls`**, которая попадает в baseline целым числом (сравнивается ТОЧНО). Иначе ослабление выключило бы канарейку «реплей разошёлся с захватом» навсегда и для всех будущих PR. Сейчас в baseline закоммичено `19`: любой будущий PR, открывающий НОВЫЙ незаписанный путь, снова валит гейт громко, а перезахват фикстуры доведёт число до нуля (проверено: правка baseline 19→18 гейт валит). Перезахват фикстуры (предпочтительный путь) **не сделан осознанно**: он требует живой прод-БД `tradein` и тянет НОВУЮ выборку сделок — то есть не «дозаписывает» 19 ответов, а меняет саму систему отсчёта регресс-гейта по причинам, не связанным с этим PR. Попутно найдено и починено: документированная регенерация baseline (`--from-fixture --update-baseline`) с #2173 писала baseline, который гейт **не мог совпасть НИКОГДА** — тест пиннит `estimate_dedup_analogs_enabled=False` (фикстура — OFF-захват), а CLI нет. Пин переехал внутрь `replay_fixture`, оба пути согласованы. Регенерированный baseline отличается **ровно одной строкой** (новый ключ), ни одна метрика не сдвинулась. Что гейт по-прежнему НЕ мерит: для этих 19 сделок квартальный индекс в реплее не применяется вовсе (в проде применился бы), а именно он даёт 82 из 99 изменений цены от сброса. Источник истины по эффекту сброса — прод-замер выше, не гейт. ## Test plan - [ ] прод-верификация после деплоя: `asking_to_sold_ratios` пересчитан вручную (см. ловушку выше), `ratio` в бакетах 44-62 и 62-85 ушёл вверх (+2.43% / +4.58% по замеру), глобальный — вниз на 0.49% - [ ] частота `quarter_index: applied` в логах выросла примерно вдвое (112 → ~197 на выборке 1014) — если не выросла, сброс `anchor_tier` до прода не доехал - [ ] смоук оценки на адресе с тонким якорным пулом — цена ниже, объяснение перестроилось на радиусный путь / ДКП - [ ] `n_listings` в `asking_to_sold_ratios` упал (NULL-сегмент в знаменателе: 674 → ~20 строк) **НЕ мержить без решения владельца.** По состоянию на 12.08 цена решения почти нулевая: сумма выкупа **+0.09%**, типовая оценка **−0.23%**, но 66 оценок (6.3%) двигаются больше чем на 10% в обе стороны. Сверка с медианами зарегистрированных ДКП по тому же дому: там, где правка шевелит цену, новое число ближе к сделкам (медиана ошибки 22.90% → 19.56% на 147 подвижках ≥5%). Это по-прежнему вопрос политики, а не кода — но аргумент «правка стоит клиенту 2.25%» больше не действует. Refs #2656
bot-backend added 1 commit 2026-08-05 16:29:40 +00:00
fix(tradein/estimate): фильтр свежести в якоре дома и знаменателе коэффициента выкупа (#2656)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / 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 2m45s
2878a88c67
Цена местами строилась на объявлениях, которых никто не видел месяц. Главный
радиусный путь эстиматора нёс `scraped_at > NOW() - LISTINGS_FRESH_DAYS дней`
(_COMMON_WHERE), а четыре денежные выборки — нет:

- `_fetch_anchor_comps` Tier A и Tier C: якорь ЗАМЕЩАЕТ headline
  (median_ppm2/median_price/n_analogs), т.е. правит цену напрямую;
- `asking_to_sold_ratio` ask_side и ask_global: знаменатель коэффициента,
  на который умножается expected_sold_price.

`is_active` свежесть не заменяет — он означает разное у разных источников
(TTL деактивации 30 дней, NULL-сегмент не деактивируется никогда), а
`scraped_at` одно и то же. Прод: 21 132 из 37 497 активных строк протухли по
14-дневной мерке самого эстиматора и были полностью годны для якоря.

Окно вынесено в app.core.config.LISTINGS_FRESH_DAYS (одно место на всех):
держать его в estimator.py нельзя — тот сам импортирует area_bucket из
app.tasks.asking_to_sold_ratio, обратный импорт дал бы цикл.

Второй половиной — залипший anchor_tier: он оставался "C"/"A", когда якорь не
был построен, и молча глушил IMV-blend, quarter-index (#764 Guard-1a),
radius-floor и corridor-clamp-exempt Tier A. Теперь сбрасывается явно, у
источника, для всех трёх причин (None из _compute_same_building_anchor, гейт
Tier C #1795, low-conf гейт #audit-1).

Обе половины одним PR намеренно: порознь они дадут два заметных скачка цены
вместо одного меньшего (якорная половина −3.63%, знаменатель +1.25%,
вместе −2.42% от суммы выкупа на 1040 реальных оценках).

Тесты: tests/test_freshness_filter_2656.py — предикат во всех четырёх местах,
единственность константы, бинд :fresh_days, протухший комп не в пуле якоря,
сброс anchor_tier и разглушённый IMV-blend. Все 7 краснеют без фикса
(проверено git stash).
Author
Collaborator

Глубокое ревью: дефектов в коде не найдено, но есть дыра в покрытии замера — и она моя.

Что проверено и претензий нет

Предикат побайтово совпадает во всех четырёх новых местах и в двух существующих: та же колонка (scraped_at, а не last_seen_at), то же построение интервала, то же окно. Ловушка psycopg v3 здесь не срабатывает — каст ложится на скобочное выражение, :fresh_days связывается штатно и забинден в обоих тирах якоря и в параметрах пересчёта. Это важнее, чем кажется: у якоря стоит except Exception, который глотает ошибку в rows = [], то есть несвязанный параметр молча удалил бы якорь вместо пятисотки.

Переезд константы чистый: значение не изменилось, второго определения не осталось, аргумент про цикл импортов верен. Сброс anchor_tier полный — прослежены все присваивания, после него anchor_tier is None строго эквивалентно anchor is None. Ключ расчёта коэффициента совпадает с ключом применения (повтора истории #2620 нет). Индекс listings_scraped_idx уже существует и теперь становится применим — план не деградирует.

Прод-числа воспроизведены независимыми запросами: премия протухших в якорном сегменте +7.3-7.6% (я говорил +7.7%), глобальный коэффициент −0.51% (я говорил −0.49%), бакеты 44-62 и 62-85 сходятся. Плюс проверено то, чего я не проверял: строк с scraped_at IS NULL ноль (скрытого доп-исключения нет), и порог n_listings >= 30 выдерживают все пять бакетов — ни один не проваливается в global-фолбэк.

Дыра: замер описывает не то состояние кода, которое смержится

Замер на 1040 оценках шёл против деплойнутого кода, равного main, — то есть с залипшим anchor_tier на месте. Сам залипший тир был находкой того замера, а не его условием. Значит −2.42% относится к «main + два SQL-фильтра», а не к этой ветке.

Сброс тира открывает сразу четырёх потребителей цены, и они тянут в разные стороны:

место потребитель направление
:2799 IMV-blend в любую
:2897 квартальный индекс, Guard-1a #764 в любую
:3041 пол по ДКП-коридору вверх
:3361:5466 кламп коридора, Tier A освобождён вниз

Суммарный знак чтением кода не выводится. По фикстуре бэктеста тир менялся у 20 из 277 сделок (7.2%) — если на проде похоже, это ~7% оценок с новой мутацией цены поверх измеренных −2.42%.

И замороженный бэктест это увидеть не может: ослабление стаба сделано ровно в том месте (quarter-index), где эффект бы и проявился. Его «метрики не сдвинулись» — молчание, а не зелёный свет.

Перезамер против полной ветки уже запущен. До него мержить не надо: получится решение по цене, принятое по числу от другого кода.

Ослабленный стаб: намерение законное, эффект — маскировка

Асимметрия (строгий ratio_resolver, мягкий quarter-index) показывает, что автор понимал компромисс. Но перестала проверяться «канарейка расхождения с захватом» — навсегда и для всех будущих PR, а не только для 19 известных сделок. Будущая правка, которая ошибочно откроет Guard-1a, получит «индекса нет» вместо исключения; а поскольку это no-op, метрики не сдвинутся и гейт останется зелёным на настоящей регрессии.

В соседнем тесте того же файла есть прецедент: когда дедуп сменил последовательность вызовов, чинили пином флага, чтобы реплей следовал захвату, а не ослаблением стаба. Здесь флага нет, поэтому вариантов два: перезахватить фикстуру с прода (снимает обе проблемы разом) либо считать число исчерпанных вызовов и вынести его в метрики (~6 строк) — тогда 19 становится закоммиченной константой, и любой будущий PR, открывающий новые незаписанные пути, падает громко.

Мелочи

  • include_primary (#1774) теперь решается на уже прореженном наборе строк: дом, где протухание сильнее ударило по вторичке, может перевернуться в «не пускать первичку» и потерять заодно свежие новостроечные перепродажи. Это внутри измеренных −3.63%, направление консервативное — но это второй механизм за «потеря якоря шире потери порога», а в теле PR назван только первый.
  • Карточка Avito IMV может исчезнуть при нулевом headline: старый поток заполнял её через :2878, новый — через :2799, и есть достижимая щель, где не срабатывает ни один. Только отображение, на цену не влияет.
  • Новый тест не стережёт inline-копию Tier W, хотя её собственный комментарий требует держать пути синхронными — а это самое вероятное место следующего расхождения.

Самооценка автора по тестам оказалась точной и местами строже, чем заслуживает: заглушка вместо Postgres покрывает больше, чем он ей приписал (краснеет и без предиката, и при несвязанном параметре).

Обратимость

Откат кода возвращает якорную половину сразу, а знаменатель — только после ручного пинка расписания пересчёта или одной ночи. Асимметрия та же, что и в прямую сторону. Сохранённые оценки не переписываются, миграции нет.

Глубокое ревью: **дефектов в коде не найдено, но есть дыра в покрытии замера — и она моя.** ## Что проверено и претензий нет Предикат побайтово совпадает во всех четырёх новых местах и в двух существующих: та же колонка (`scraped_at`, а не `last_seen_at`), то же построение интервала, то же окно. Ловушка psycopg v3 здесь не срабатывает — каст ложится на скобочное выражение, `:fresh_days` связывается штатно и забинден в обоих тирах якоря и в параметрах пересчёта. Это важнее, чем кажется: у якоря стоит `except Exception`, который глотает ошибку в `rows = []`, то есть несвязанный параметр молча удалил бы якорь вместо пятисотки. Переезд константы чистый: значение не изменилось, второго определения не осталось, аргумент про цикл импортов верен. Сброс `anchor_tier` полный — прослежены все присваивания, после него `anchor_tier is None` строго эквивалентно `anchor is None`. Ключ расчёта коэффициента совпадает с ключом применения (повтора истории #2620 нет). Индекс `listings_scraped_idx` уже существует и теперь становится применим — план не деградирует. Прод-числа воспроизведены независимыми запросами: премия протухших в якорном сегменте +7.3-7.6% (я говорил +7.7%), глобальный коэффициент −0.51% (я говорил −0.49%), бакеты 44-62 и 62-85 сходятся. Плюс проверено то, чего я не проверял: строк с `scraped_at IS NULL` ноль (скрытого доп-исключения нет), и порог `n_listings >= 30` выдерживают все пять бакетов — ни один не проваливается в global-фолбэк. ## Дыра: замер описывает не то состояние кода, которое смержится Замер на 1040 оценках шёл против деплойнутого кода, равного `main`, — то есть **с залипшим `anchor_tier` на месте**. Сам залипший тир был *находкой* того замера, а не его условием. Значит **−2.42% относится к «main + два SQL-фильтра», а не к этой ветке.** Сброс тира открывает сразу четырёх потребителей цены, и они тянут в разные стороны: | место | потребитель | направление | |---|---|---| | `:2799` | IMV-blend | в любую | | `:2897` | квартальный индекс, Guard-1a #764 | в любую | | `:3041` | пол по ДКП-коридору | вверх | | `:3361`→`:5466` | кламп коридора, Tier A освобождён | вниз | Суммарный знак чтением кода не выводится. По фикстуре бэктеста тир менялся у 20 из 277 сделок (7.2%) — если на проде похоже, это ~7% оценок с новой мутацией цены **поверх** измеренных −2.42%. И замороженный бэктест это увидеть не может: ослабление стаба сделано ровно в том месте (quarter-index), где эффект бы и проявился. Его «метрики не сдвинулись» — молчание, а не зелёный свет. **Перезамер против полной ветки уже запущен.** До него мержить не надо: получится решение по цене, принятое по числу от другого кода. ## Ослабленный стаб: намерение законное, эффект — маскировка Асимметрия (строгий `ratio_resolver`, мягкий quarter-index) показывает, что автор понимал компромисс. Но перестала проверяться «канарейка расхождения с захватом» — **навсегда и для всех будущих PR**, а не только для 19 известных сделок. Будущая правка, которая ошибочно откроет Guard-1a, получит «индекса нет» вместо исключения; а поскольку это no-op, метрики не сдвинутся и гейт останется зелёным на настоящей регрессии. В соседнем тесте того же файла есть прецедент: когда дедуп сменил последовательность вызовов, чинили **пином флага**, чтобы реплей следовал захвату, а не ослаблением стаба. Здесь флага нет, поэтому вариантов два: перезахватить фикстуру с прода (снимает обе проблемы разом) либо считать число исчерпанных вызовов и вынести его в метрики (~6 строк) — тогда `19` становится закоммиченной константой, и любой будущий PR, открывающий новые незаписанные пути, падает громко. ## Мелочи - `include_primary` (#1774) теперь решается на **уже прореженном** наборе строк: дом, где протухание сильнее ударило по вторичке, может перевернуться в «не пускать первичку» и потерять заодно свежие новостроечные перепродажи. Это внутри измеренных −3.63%, направление консервативное — но это **второй** механизм за «потеря якоря шире потери порога», а в теле PR назван только первый. - Карточка Avito IMV может исчезнуть при нулевом headline: старый поток заполнял её через `:2878`, новый — через `:2799`, и есть достижимая щель, где не срабатывает ни один. Только отображение, на цену не влияет. - Новый тест не стережёт inline-копию Tier W, хотя её собственный комментарий требует держать пути синхронными — а это самое вероятное место следующего расхождения. Самооценка автора по тестам оказалась точной и местами строже, чем заслуживает: заглушка вместо Postgres покрывает больше, чем он ей приписал (краснеет и без предиката, и при несвязанном параметре). ## Обратимость Откат кода возвращает якорную половину сразу, а знаменатель — только после ручного пинка расписания пересчёта или одной ночи. Асимметрия та же, что и в прямую сторону. Сохранённые оценки не переписываются, миграции нет.
Author
Collaborator

Перезамер против полной ветки сделан. Дыра была реальная, и число меняется — в лучшую сторону.

Оснастка

estimator.py коммита 2878a88c загружен отдельным модулем через importlib — деплойнутый код не тронут, обе версии _price_from_inputs живут в одном процессе. Пул якоря для новых состояний берётся настоящей веткой _fetch_anchor_comps (фильтр в самом SQL), а не питоновской репликацией. Все три состояния прогнаны одним скриптом на одном снимке данных, 1014 оценок, 0 ошибок:

код пул якоря таблица коэффициентов
v1 main main живая прод-таблица
v2 main ветка пересчитана с фильтром
v3 ветка (+ сброс) ветка пересчитана с фильтром

_price_from_inputs ветки отличается от main только сбросом — значит переход v2→v3 изолирует его точно.

Число для решения

headline выкуп якорь отказы confidence low
v1 — сегодня 12.5574 млрд 9.8839 млрд 685 79 215
v2 — только фильтры −2.99% −2.36% 546 85 235
v3 — полная ветка −2.86% −2.25% 546 85 236

Нести владельцу надо −2.25%, а не −2.42%. Сброс отыгрывает назад +0.113% — он тянет вверх, а не вниз.

Худший случай тоже смягчился: −48.6% в v2 → −40.6% в v3. Это та самая оценка, что теряет якорь и падает на радиус; сброс включает ей квартальный индекс, и падение выходит менее резким.

Отказы: 79 → 85, сброс на них не влияет вовсе.

Чистый вклад сброса и его механизм

Флаг меняется у 190 оценок (18.7%), цену меняет у 99 (9.76%) — ревью по фикстуре предсказывало 7.2%, порядок совпал. Вверх 73, вниз 26. Медиана нулевая, но хвост толстый: у 46 оценок сдвиг больше 10%. Остальные 91 — только метаданные (ярлык тира в API, карточка IMV, confidence).

Атрибуция по маркерам в логах, полная — необъяснённых изменений ноль:

механизм сработал у p50 max min вверх/вниз >10%
квартальный индекс (Guard-1a #764) 82 +2.31% +26.27% −30.20% 56 / 26 29
IMV-blend 17 +15.65% +15.65% +12.84% 17 / 0 17
кламп коридора 0
пол по ДКП 0

Опасение ревью про «вниз через кламп коридора у залипшего "A"» не подтвердилось: кандидатов всего 3, ни у одного кламп не включился. Пол по ДКП не срабатывает ни разу ни в одном из трёх состояний — на этих данных его условие не выполняется ни у одной оценки (маркер живой, проверено на клампе коридора: 39/42/42 срабатывания).

Квартальный индекс — главный канал и единственный двусторонний: он множитель, поэтому у 26 оценок тянет вниз, вплоть до −30.2%.

Чего замер честно не покрывает

Канал IMV-blend занижен. imv_eval передан как отсутствующий (живой Avito требует запроса к площадке), blend питается либо им, либо house-level якорем из БД. Из 190 сменивших флаг якорь в БД есть только у 37 — у них blend и сработал. Остальные 145 в проде могли бы включить blend через живой imv_eval.

То есть 17 — нижняя граница канала, верхняя ≤162. Направление от этого не меняется (blend в наблюдаемых случаях всегда вверх, то есть сброс смягчает падение), но +0.113% надо читать как нижнюю оценку отыгрыша, а не как точное число. Точнее без похода на Авито не измерить.

Вывод

Сброс anchor_tier — правильная часть PR, и мержить его надо вместе с фильтрами: он чинит настоящий баг и по деньгам работает в противофазе.

Но это не бесплатный довесок к однострочнику: у 46 оценок он сам по себе двигает цену больше чем на 10%, у 26 — вниз, и всё через квартальный индекс, который на этих оценках до сих пор просто не включался. И именно этот канал заглушен в бэктесте — то есть «метрики не сдвинулись» про сброс не говорит ничего. Ревью право.

Что делаю до мержа: расстабить квартальный индекс в фикстуре, чтобы главный канал изменений перестал быть слепым пятном, плюс мелочи ревью.

Прод-верификация после деплоя — вешать на частоту quarter_index: applied в логах: она должна вырасти примерно вдвое (112 → 197 на выборке из 1014). Не выросла — значит сброс до прода не доехал.

Перезамер против **полной ветки** сделан. Дыра была реальная, и число меняется — в лучшую сторону. ## Оснастка `estimator.py` коммита `2878a88c` загружен отдельным модулем через `importlib` — деплойнутый код не тронут, обе версии `_price_from_inputs` живут в одном процессе. Пул якоря для новых состояний берётся **настоящей веткой** `_fetch_anchor_comps` (фильтр в самом SQL), а не питоновской репликацией. Все три состояния прогнаны одним скриптом на одном снимке данных, 1014 оценок, 0 ошибок: | | код | пул якоря | таблица коэффициентов | |---|---|---|---| | v1 | main | main | живая прод-таблица | | v2 | main | ветка | пересчитана с фильтром | | v3 | **ветка** (+ сброс) | ветка | пересчитана с фильтром | `_price_from_inputs` ветки отличается от main **только** сбросом — значит переход v2→v3 изолирует его точно. ## Число для решения | | headline | выкуп | якорь | отказы | confidence low | |---|---|---|---|---|---| | v1 — сегодня | 12.5574 млрд | 9.8839 млрд | 685 | 79 | 215 | | v2 — только фильтры | −2.99% | **−2.36%** | 546 | 85 | 235 | | v3 — **полная ветка** | −2.86% | **−2.25%** | 546 | 85 | 236 | **Нести владельцу надо −2.25%, а не −2.42%.** Сброс отыгрывает назад +0.113% — он тянет **вверх**, а не вниз. Худший случай тоже смягчился: −48.6% в v2 → **−40.6%** в v3. Это та самая оценка, что теряет якорь и падает на радиус; сброс включает ей квартальный индекс, и падение выходит менее резким. Отказы: 79 → 85, сброс на них не влияет вовсе. ## Чистый вклад сброса и его механизм Флаг меняется у **190 оценок (18.7%)**, цену меняет у **99 (9.76%)** — ревью по фикстуре предсказывало 7.2%, порядок совпал. Вверх 73, вниз 26. Медиана нулевая, но хвост толстый: у **46 оценок сдвиг больше 10%**. Остальные 91 — только метаданные (ярлык тира в API, карточка IMV, confidence). Атрибуция по маркерам в логах, **полная — необъяснённых изменений ноль**: | механизм | сработал у | p50 | max | min | вверх/вниз | >10% | |---|---|---|---|---|---|---| | **квартальный индекс** (Guard-1a #764) | **82** | +2.31% | +26.27% | **−30.20%** | 56 / 26 | 29 | | **IMV-blend** | **17** | +15.65% | +15.65% | +12.84% | **17 / 0** | 17 | | кламп коридора | 0 | — | — | — | — | — | | пол по ДКП | 0 | — | — | — | — | — | Опасение ревью про «вниз через кламп коридора у залипшего `"A"`» **не подтвердилось**: кандидатов всего 3, ни у одного кламп не включился. Пол по ДКП не срабатывает ни разу ни в одном из трёх состояний — на этих данных его условие не выполняется ни у одной оценки (маркер живой, проверено на клампе коридора: 39/42/42 срабатывания). Квартальный индекс — главный канал и **единственный двусторонний**: он множитель, поэтому у 26 оценок тянет вниз, вплоть до −30.2%. ## Чего замер честно не покрывает **Канал IMV-blend занижен.** `imv_eval` передан как отсутствующий (живой Avito требует запроса к площадке), blend питается либо им, либо house-level якорем из БД. Из 190 сменивших флаг якорь в БД есть только у 37 — у них blend и сработал. Остальные **145** в проде могли бы включить blend через живой `imv_eval`. То есть **17 — нижняя граница канала, верхняя ≤162**. Направление от этого не меняется (blend в наблюдаемых случаях всегда вверх, то есть сброс смягчает падение), но **+0.113% надо читать как нижнюю оценку отыгрыша**, а не как точное число. Точнее без похода на Авито не измерить. ## Вывод Сброс `anchor_tier` — правильная часть PR, и мержить его надо **вместе** с фильтрами: он чинит настоящий баг и по деньгам работает в противофазе. Но это не бесплатный довесок к однострочнику: у 46 оценок он сам по себе двигает цену больше чем на 10%, у 26 — вниз, и всё через квартальный индекс, который на этих оценках до сих пор просто не включался. **И именно этот канал заглушен в бэктесте** — то есть «метрики не сдвинулись» про сброс не говорит ничего. Ревью право. Что делаю до мержа: расстабить квартальный индекс в фикстуре, чтобы главный канал изменений перестал быть слепым пятном, плюс мелочи ревью. **Прод-верификация после деплоя** — вешать на частоту `quarter_index: applied` в логах: она должна вырасти примерно вдвое (112 → 197 на выборке из 1014). Не выросла — значит сброс до прода не доехал.
Light1YT added 1 commit 2026-08-05 17:06:59 +00:00
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
828e32e9ab
По итогам глубокого ревью 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-блока валит новый тест карточки.
Author
Collaborator

Все замечания ревью закрыты вторым коммитом. CI зелёный (7/7), 3356 тестов, PR готов к решению.

Что сделано

Канарейка бэктеста восстановлена — счётчиком, а не перезахватом. Перезахват фикстуры отвергнут по существу: capture-режим тянет новую выборку сделок, то есть не дозаписывает 19 недостающих ответов к тем же 277, а меняет систему отсчёта регресс-гейта по причинам, к этому PR отношения не имеющим (два месяца новых объявлений и сделок). Вместо этого число исчерпанных вызовов вынесено в метрику unrecorded_lookup_calls, в baseline закоммичено 19. Сравнение целых точное, поэтому любой будущий PR, открывающий новые незаписанные пути, упадёт громко, а перезахват фикстуры доведёт число до нуля. Фальсификация: правка baseline 19 → 18 роняет гейт.

Карточка Avito IMV больше не исчезает — гейт по тиру снят, остался только «карточка ещё не заполнена». Плюс тест ровно на ту щель: три объявления, headline подавлен, карточка обязана выжить. Возврат старого условия делает тест красным.

Inline-копия Tier W добавлена в набор проверяемых мест — снятие предиката оттуда теперь роняет тест. Это было самое вероятное место следующего расхождения, того же сорта, что и починенное этим PR.

Тело PR обновлено: −2.25% вместо −2.42%, вклад сброса с разбивкой по механизмам, худший случай −40.6%, второй механизм потери якоря (переворот include_primary #1774), и прод-верификация по частоте quarter_index: applied.

Побочная находка: регенерация baseline сломана с #2173

Документированная команда --from-fixture --update-baseline писала baseline, который тест не совпал бы никогда. Причина: гейт-тест монкипатчит estimate_dedup_analogs_enabled=False (фикстура — OFF-захват), а CLI этого не делает. Первый прогон по документированной команде выдал уехавшие метрики (mape 13.18 → 13.1, n 269 → 266).

Пин переехал внутрь replay_fixture под try/finally, оба пути согласованы. После этого регенерация дала ровно одну новую строку в diff и ни одной сдвинувшейся метрики — что заодно служит подтверждением, что правки в реплее цену не двигают.

Это стоит знать отдельно от решения по цене: починка инструмента лежит в этом же PR. Если правка цены будет отклонена, её надо вынуть отдельно, иначе регенерация baseline останется сломанной для всех.

Что осталось честно непокрытым

Гейт по-прежнему не мерит главный канал правки: для тех 19 сделок квартальный индекс в реплее не применяется, а он даёт 82 из 99 изменений цены от сброса. Счётчик это фиксирует и заставит вернуться к вопросу, но перезахват не заменяет. Источник истины по эффекту сброса — прод-замер, а не гейт, и так к нему и надо относиться.

Отдельного взгляда стоит пин estimate_dedup_analogs_enabled внутри replay_fixture: он мутирует глобальные настройки на время реплея (с восстановлением в finally). Это чужая инфраструктура, тронута ради корректной регенерации.

Все замечания ревью закрыты вторым коммитом. CI зелёный (7/7), 3356 тестов, PR готов к решению. ## Что сделано **Канарейка бэктеста восстановлена — счётчиком, а не перезахватом.** Перезахват фикстуры отвергнут по существу: capture-режим тянет **новую выборку сделок**, то есть не дозаписывает 19 недостающих ответов к тем же 277, а меняет систему отсчёта регресс-гейта по причинам, к этому PR отношения не имеющим (два месяца новых объявлений и сделок). Вместо этого число исчерпанных вызовов вынесено в метрику `unrecorded_lookup_calls`, в baseline закоммичено `19`. Сравнение целых точное, поэтому любой будущий PR, открывающий **новые** незаписанные пути, упадёт громко, а перезахват фикстуры доведёт число до нуля. Фальсификация: правка baseline `19 → 18` роняет гейт. **Карточка Avito IMV больше не исчезает** — гейт по тиру снят, остался только «карточка ещё не заполнена». Плюс тест ровно на ту щель: три объявления, headline подавлен, карточка обязана выжить. Возврат старого условия делает тест красным. **Inline-копия Tier W** добавлена в набор проверяемых мест — снятие предиката оттуда теперь роняет тест. Это было самое вероятное место следующего расхождения, того же сорта, что и починенное этим PR. **Тело PR обновлено**: −2.25% вместо −2.42%, вклад сброса с разбивкой по механизмам, худший случай −40.6%, второй механизм потери якоря (переворот `include_primary` #1774), и прод-верификация по частоте `quarter_index: applied`. ## Побочная находка: регенерация baseline сломана с #2173 Документированная команда `--from-fixture --update-baseline` **писала baseline, который тест не совпал бы никогда**. Причина: гейт-тест монкипатчит `estimate_dedup_analogs_enabled=False` (фикстура — OFF-захват), а CLI этого не делает. Первый прогон по документированной команде выдал уехавшие метрики (`mape 13.18 → 13.1`, `n 269 → 266`). Пин переехал внутрь `replay_fixture` под `try/finally`, оба пути согласованы. После этого регенерация дала **ровно одну новую строку** в diff и ни одной сдвинувшейся метрики — что заодно служит подтверждением, что правки в реплее цену не двигают. **Это стоит знать отдельно от решения по цене**: починка инструмента лежит в этом же PR. Если правка цены будет отклонена, её надо вынуть отдельно, иначе регенерация baseline останется сломанной для всех. ## Что осталось честно непокрытым Гейт **по-прежнему не мерит главный канал правки**: для тех 19 сделок квартальный индекс в реплее не применяется, а он даёт 82 из 99 изменений цены от сброса. Счётчик это фиксирует и заставит вернуться к вопросу, но перезахват не заменяет. **Источник истины по эффекту сброса — прод-замер, а не гейт**, и так к нему и надо относиться. Отдельного взгляда стоит пин `estimate_dedup_analogs_enabled` внутри `replay_fixture`: он мутирует глобальные настройки на время реплея (с восстановлением в `finally`). Это чужая инфраструктура, тронута ради корректной регенерации.
Light1YT added 1 commit 2026-08-12 12:50:56 +00:00
Merge remote-tracking branch 'origin/main' into fix/2656-anchor-ratio-freshness
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
CI Trade-In / browser-tests (pull_request) Has been skipped
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 4m6s
3f87873d7b
Ветка отстала от main на 151 коммит. Текстовых конфликтов нет, семантический — один.

`test_imv_card_survives_when_headline_suppressed_and_anchor_absent` добывал нулевой
headline тонкой выборкой (n=3 < HEADLINE_LISTINGS_MIN_N) — гейт достаточности его
обнулял. #oblast-F (#2823, смержен 2026-08-09) это поведение СНЯЛ: тонкая выборка
больше не обнуляет headline, только помечает низкую надёжность. Тест падал на
собственной предпосылке, а не на щели, которую стережёт. Нулевой headline берётся
отсутствием аналогов (n=0) — единственное оставшееся нулевое состояние; сама щель
(«тир добыт, якоря нет, headline нулевой → карточка IMV не должна исчезнуть») от
этого не изменилась. Фальсификация: возврат старого условия display-блока
(`anchor_tier is not None and ...`) снова роняет тест.

Прогон полного набора на смерженном дереве: 4248 passed, 18 skipped.
Author
Collaborator

Перезамер на 12.08 (замер в теле PR — от 05.08, с тех пор в main 151 коммит). Число изменилось качественно: эффекта по деньгам больше практически нет.

Ветка обновлена мержем main (см. коммит выше) — CI зелёный, 4248 passed / 18 skipped.

Оснастка — та же, что 05.08

Деплойнутый estimator.py (md5 сверен с origin/main) и estimator.py смерженной ветки живут в одном процессе (ветка загружена через importlib отдельным модулем), обоим скармливаются одни живые данные. Пул якоря для новых состояний берёт настоящая _fetch_anchor_comps ветки, таблица коэффициентов — настоящий _REDERIVE_SQL ветки, выполненный как SELECT (шапка INSERT срезана, в прод ничего не пишется). 1052 оценки из 1080 (28 без координат), 0 ошибок.

Добавлены два арма, которых 05.08 не было, — они изолируют половины:

код пул якоря таблица коэффициентов
v1 main main живая прод-таблица
vA main ветка живая прод-таблица
vB main main пересчёт с фильтром
v2 main ветка пересчёт с фильтром
v3 ветка ветка пересчёт с фильтром

Проверка оснастки: прогон v1 против СОХРАНЁННЫХ прод-оценок, созданных за последние сутки, — медиана |Δ headline| = 0.04%, 44% совпадают точно. На оценках старше расхождение растёт ровно так, как и должно от дрейфа данных (7 суток — 1.04%, 30 суток — 11.8%).

Число для решения

05.08 (тело PR) 12.08
фильтр якоря (vA) −3.63% −1.07%
фильтр знаменателя (vB) +1.25% +0.71%
сброс anchor_tier (v2→v3) +0.113% +0.46%
итого сумма выкупа −2.25% +0.09%
теряют якорь 139 из 1014 (13.7%) 57 из 1052 (5.4%), и 11 приобретают
рост отказов 79 → 85 нет: 15 → 15
двигаются больше чем на 10% 111 (10.9%) 66 (6.3%), худший −100%, лучший +54.4%
медиана подвижки 0.00% −0.23%

Устойчивость: сумма без 1% хвостов с каждой стороны — −0.12%, усечённое среднее −0.18%, p10 = −4.5%, p90 = +3.4%. То есть по сумме — ноль, по отдельным оценкам подвижки остались большие и двусторонние.

«Нет цены выкупа» 33 → 33: ровно одна оценка теряет её и ровно одна приобретает.

Почему число уехало — состав данных, а не код

1. Якорная половина сдулась, потому что вторичка перестала протухать.

источник × сегмент активных протухших доля
avito / вторичка 7629 0 0.0%
domklik / вторичка 752 0 0.0%
cian / вторичка 7452 1534 20.6%
yandex / вторичка 4202 908 21.6%
cian+yandex / новостройки 21912 16797 76.7%

Протухшая масса (19 989 строк из 45 030) на 84% новостройки, а их гард #1186 в якорь почти не пускает. Премия протухших над свежими в якорном сегменте: было +7.7%, стало +3.3%. Пул якоря всё ещё режется у 765 оценок из 923 (медиана −20%), но до потери самого якоря это доходит вчетверо реже.

2. Половина знаменателя частично перевернулась. Протухшая часть знаменателя теперь — cian/yandex вторичка (2428 строк, медианы 128 906 и 137 179 ₽/м²), она дешевле свежей популяции (147 137). Дорогой NULL-сегмент (654 строки по 162–169 тыс.) её больше не перевешивает. Итог: фильтр поднимает ask_median 147 137 → 148 648 и опускает глобальный коэффициент 0.8444 → 0.8354.

бакет live с фильтром Δ (05.08)
−1 глобальный 0.8444 0.8354 −1.06% −0.49%
0 (<30 м²) 0.7613 0.7550 −0.82%
1 (30–44) 0.8256 0.8153 −1.25%
2 (44–62) 0.8746 0.8725 −0.23% +2.43%
3 (62–85) 0.9207 0.9333 +1.38% +4.58%
4 (85+) 0.8382 0.8510 +1.52%

По сумме знаменатель всё ещё тянет вверх (+0.71%) — деньги сидят в бакетах 62–85 и 85+. Но по типовой оценке он теперь тянет вниз (медиана −0.23%) и работает в ОДНУ сторону с якорной половиной, а не в противофазе. Формулировку «обе половины надо мержить вместе, иначе два скачка вместо одного меньшего» это не отменяет, но её обоснование стало слабее.

Живая прод-таблица и пересчёт main-овским SQL прямо сейчас расходятся на 0.02% — ночной пересчёт работает, «ловушка деплоя» из тела PR в силе.

3. Сброс anchor_tier стал вдвое весомее: +0.46%. Флаг меняется у 150 оценок (14.3%), цену меняет у 64 (6.1%). Атрибуция по маркерам в логах полная, необъяснённых ноль: 47 через квартальный индекс, 17 через IMV-blend, кламп коридора и пол по ДКП — 0 раз, как и 05.08. Вверх 46 / вниз 18, медиана +1.90%, размах от −23.0% до +25.7%.

4. Последствие «уедут в insufficient_data» исчезло вместе с гейтом. #oblast-F (#2823, смержен 09.08) снял обнуление headline на тонкой выборке. Потеря якоря больше никого не роняет в отказ: 15 оценок без headline до правки и 15 после.

Направление: новое число ближе к реальным сделкам

914 оценок сверены с медианой зарегистрированных ДКП по тому же дому (≤150 м, 12 мес., с матчем по комнатности; при тонкой выборке — ≤600 м):

v1 (сегодня) v3 (ветка)
медиана |ошибки| vs ДКП 22.81% 21.92%
новое ближе / дальше 484 / 430
из 147, что двигаются ≥5% 22.90% 19.56% (ближе 91 / дальше 56)

То есть там, где правка реально шевелит цену, она шевелит её к ценам сделок, и заметно. Оговорка честная: ДКП — не полностью независимый эталон (сделки кормят коридор и квартальный индекс внутри самого эстиматора) и это заявленная цена договора, а не обязательно уплаченная.

Постановка НЕ устарела

Проверено по git show origin/main: оба SQL якоря (estimator.py Tier A / Tier C) и обе CTE коэффициента (asking_to_sold_ratio.py ask_side / ask_global) на сегодняшнем main по-прежнему читают is_active без фильтра свежести. Соседние правки (#2797 TTL, #2771 координаты, #2818 адреса) починили ПОСТАВЩИКА данных, а не потребителей. Дефект в силе; изменилась только его цена.

Что стоит решить заодно

#2823 добавил второе окно свежести: LISTINGS_FRESH_DAYS_RELAXED = 60 — радиусный путь расширяет окно 14 → 60 дней, когда выборка тоньше HEADLINE_LISTINGS_MIN_N (сработало у 19 оценок из 1052; всего хоть одна релаксация — у 219). Якорь после этого PR остаётся жёстко на 14. Тезис тела PR «окно живёт в ОДНОМ месте» относится к базовому окну и остаётся верным, но вопрос политики открыт: в тонком рынке мы теперь предпочитаем 60-дневный радиусный пул 14-дневному якорю по тому же дому. Отдельным решением, не этим PR.


Мержить не буду — решение за владельцем. Технически правка не изменилась и по-прежнему бесспорна: цена местами строится на объявлениях, которых никто не видел месяц. Но аргумент «это стоит −2.25% клиенту» больше не действует: по сумме сегодня +0.09%, по типовой оценке −0.23%. Цена решения упала почти до нуля, а сверка с ДКП говорит, что оставшиеся подвижки идут в правильную сторону.

Перезамер на **12.08** (замер в теле PR — от 05.08, с тех пор в main 151 коммит). **Число изменилось качественно: эффекта по деньгам больше практически нет.** Ветка обновлена мержем main (см. коммит выше) — CI зелёный, 4248 passed / 18 skipped. ## Оснастка — та же, что 05.08 Деплойнутый `estimator.py` (md5 сверен с `origin/main`) и `estimator.py` смерженной ветки живут в **одном процессе** (ветка загружена через `importlib` отдельным модулем), обоим скармливаются **одни живые данные**. Пул якоря для новых состояний берёт настоящая `_fetch_anchor_comps` ветки, таблица коэффициентов — настоящий `_REDERIVE_SQL` ветки, выполненный как SELECT (шапка INSERT срезана, в прод ничего не пишется). 1052 оценки из 1080 (28 без координат), 0 ошибок. Добавлены два арма, которых 05.08 не было, — они изолируют половины: | | код | пул якоря | таблица коэффициентов | |---|---|---|---| | v1 | main | main | живая прод-таблица | | **vA** | main | **ветка** | живая прод-таблица | | **vB** | main | main | **пересчёт с фильтром** | | v2 | main | ветка | пересчёт с фильтром | | v3 | **ветка** | ветка | пересчёт с фильтром | **Проверка оснастки:** прогон v1 против СОХРАНЁННЫХ прод-оценок, созданных за последние сутки, — медиана \|Δ headline\| = **0.04%**, 44% совпадают точно. На оценках старше расхождение растёт ровно так, как и должно от дрейфа данных (7 суток — 1.04%, 30 суток — 11.8%). ## Число для решения | | 05.08 (тело PR) | **12.08** | |---|---|---| | фильтр якоря (vA) | −3.63% | **−1.07%** | | фильтр знаменателя (vB) | +1.25% | **+0.71%** | | сброс `anchor_tier` (v2→v3) | +0.113% | **+0.46%** | | **итого сумма выкупа** | **−2.25%** | **+0.09%** | | теряют якорь | 139 из 1014 (13.7%) | **57 из 1052 (5.4%)**, и 11 **приобретают** | | рост отказов | 79 → 85 | **нет: 15 → 15** | | двигаются больше чем на 10% | 111 (10.9%) | **66 (6.3%)**, худший −100%, лучший +54.4% | | медиана подвижки | 0.00% | −0.23% | Устойчивость: сумма без 1% хвостов с каждой стороны — **−0.12%**, усечённое среднее −0.18%, p10 = −4.5%, p90 = +3.4%. То есть по сумме — ноль, по отдельным оценкам подвижки остались большие и двусторонние. «Нет цены выкупа» 33 → 33: ровно одна оценка теряет её и ровно одна приобретает. ## Почему число уехало — состав данных, а не код **1. Якорная половина сдулась, потому что вторичка перестала протухать.** | источник × сегмент | активных | протухших | доля | |---|---|---|---| | avito / вторичка | 7629 | **0** | 0.0% | | domklik / вторичка | 752 | **0** | 0.0% | | cian / вторичка | 7452 | 1534 | 20.6% | | yandex / вторичка | 4202 | 908 | 21.6% | | cian+yandex / новостройки | 21912 | 16797 | 76.7% | Протухшая масса (19 989 строк из 45 030) на **84% новостройки**, а их гард #1186 в якорь почти не пускает. Премия протухших над свежими в якорном сегменте: было +7.7%, стало **+3.3%**. Пул якоря всё ещё режется у 765 оценок из 923 (медиана −20%), но до потери самого якоря это доходит вчетверо реже. **2. Половина знаменателя частично перевернулась.** Протухшая часть знаменателя теперь — cian/yandex **вторичка** (2428 строк, медианы 128 906 и 137 179 ₽/м²), она **дешевле** свежей популяции (147 137). Дорогой NULL-сегмент (654 строки по 162–169 тыс.) её больше не перевешивает. Итог: фильтр **поднимает** `ask_median` 147 137 → 148 648 и **опускает** глобальный коэффициент 0.8444 → 0.8354. | бакет | live | с фильтром | Δ | (05.08) | |---|---|---|---|---| | −1 глобальный | 0.8444 | 0.8354 | **−1.06%** | −0.49% | | 0 (<30 м²) | 0.7613 | 0.7550 | −0.82% | — | | 1 (30–44) | 0.8256 | 0.8153 | −1.25% | — | | 2 (44–62) | 0.8746 | 0.8725 | **−0.23%** | **+2.43%** | | 3 (62–85) | 0.9207 | 0.9333 | +1.38% | +4.58% | | 4 (85+) | 0.8382 | 0.8510 | +1.52% | — | По сумме знаменатель всё ещё тянет **вверх** (+0.71%) — деньги сидят в бакетах 62–85 и 85+. Но по **типовой** оценке он теперь тянет **вниз** (медиана −0.23%) и работает в ОДНУ сторону с якорной половиной, а не в противофазе. Формулировку «обе половины надо мержить вместе, иначе два скачка вместо одного меньшего» это не отменяет, но её обоснование стало слабее. Живая прод-таблица и пересчёт main-овским SQL прямо сейчас расходятся на 0.02% — ночной пересчёт работает, «ловушка деплоя» из тела PR в силе. **3. Сброс `anchor_tier` стал вдвое весомее: +0.46%.** Флаг меняется у 150 оценок (14.3%), цену меняет у 64 (6.1%). Атрибуция по маркерам в логах **полная, необъяснённых ноль**: **47 через квартальный индекс**, **17 через IMV-blend**, кламп коридора и пол по ДКП — **0 раз**, как и 05.08. Вверх 46 / вниз 18, медиана +1.90%, размах от −23.0% до +25.7%. **4. Последствие «уедут в `insufficient_data`» исчезло вместе с гейтом.** #oblast-F (#2823, смержен 09.08) снял обнуление headline на тонкой выборке. Потеря якоря больше никого не роняет в отказ: 15 оценок без headline до правки и 15 после. ## Направление: новое число ближе к реальным сделкам 914 оценок сверены с медианой зарегистрированных ДКП по **тому же дому** (≤150 м, 12 мес., с матчем по комнатности; при тонкой выборке — ≤600 м): | | v1 (сегодня) | v3 (ветка) | |---|---|---| | медиана \|ошибки\| vs ДКП | 22.81% | **21.92%** | | новое ближе / дальше | — | **484 / 430** | | **из 147, что двигаются ≥5%** | 22.90% | **19.56%** (ближе 91 / дальше 56) | То есть там, где правка реально шевелит цену, она шевелит её **к** ценам сделок, и заметно. Оговорка честная: ДКП — не полностью независимый эталон (сделки кормят коридор и квартальный индекс внутри самого эстиматора) и это заявленная цена договора, а не обязательно уплаченная. ## Постановка НЕ устарела Проверено по `git show origin/main`: оба SQL якоря (`estimator.py` Tier A / Tier C) и обе CTE коэффициента (`asking_to_sold_ratio.py` `ask_side` / `ask_global`) на сегодняшнем main **по-прежнему читают `is_active` без фильтра свежести**. Соседние правки (#2797 TTL, #2771 координаты, #2818 адреса) починили ПОСТАВЩИКА данных, а не потребителей. Дефект в силе; изменилась только его цена. ## Что стоит решить заодно #2823 добавил **второе** окно свежести: `LISTINGS_FRESH_DAYS_RELAXED = 60` — радиусный путь расширяет окно 14 → 60 дней, когда выборка тоньше `HEADLINE_LISTINGS_MIN_N` (сработало у 19 оценок из 1052; всего хоть одна релаксация — у 219). Якорь после этого PR остаётся жёстко на 14. Тезис тела PR «окно живёт в ОДНОМ месте» относится к базовому окну и остаётся верным, но вопрос политики открыт: в тонком рынке мы теперь предпочитаем 60-дневный радиусный пул 14-дневному якорю по тому же дому. Отдельным решением, не этим PR. --- **Мержить не буду — решение за владельцем.** Технически правка не изменилась и по-прежнему бесспорна: цена местами строится на объявлениях, которых никто не видел месяц. Но **аргумент «это стоит −2.25% клиенту» больше не действует**: по сумме сегодня +0.09%, по типовой оценке −0.23%. Цена решения упала почти до нуля, а сверка с ДКП говорит, что оставшиеся подвижки идут в правильную сторону.
Light1YT added 1 commit 2026-08-12 13:06:33 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (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 3m57s
bbed743773
Light1YT added 1 commit 2026-08-13 07:37:46 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
Some checks failed
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Failing after 4m21s
d7017ebfee
Author
Collaborator

Обновил ветку до сегодняшнего main — и это было не формальностью

Зелёный CI на этом PR считался 12.08 в 13:06, а main с тех пор ушёл на 17 коммитов, включая три правки того же самого якоря: #2816 (приведение якоря к базису ремонта), #2839 (квартал цели), #2852. Проверки тестировали голову ветки, а не результат слияния — то есть про сегодняшнее состояние не говорили ничего.

Влил origin/main (слияние текстово чистое, git merge-tree) и прогнал.

Что показал бэктест-гейт

Он упал. Но не там, где можно было ждать: расхождение единственное во всём наборе метрик.

unrecorded_lookup_calls      19  →  0

Сравнивал не первое несовпадение, а весь набор (_assert_match останавливается на первом — своим скриптом обошёл всё дерево). Все денежные метрики совпали: общий MAPE, покрытие, sharpness, калибровка по бакетам, по комнатам, по сегментам, headline-spread — побайтно.

Что это значит. На фикстуре из 277 сделок правка не двигает деньги вообще. Отличается только диагностический счётчик: ветка не делает 19 «неучтённых lookup-вызовов», которые делает main. Поведенческая разница реальная, но на этой выборке она денежно нейтральна.

Это независимое подтверждение вашего же перезамера (+0.09% по сумме): другой инструмент, другая выборка, тот же вывод.

Оговорка, которая обязана быть

Фикстура зовёт _price_from_inputs напрямую, минуя estimate_quality — это выяснилось при разборе #2839. Значит часть путей она не проходит, и «метрики не сдвинулись» здесь означает «не сдвинулись на покрытых путях», а не «не сдвинулись нигде». Ваши ±100% на отдельных оценках фикстурой не воспроизводятся и не опровергаются.

Что осталось сделать

Эталон бэктеста надо обновить на unrecorded_lookup_calls: 0 — но не механически. Сначала стоит понять, почему счётчик обнулился: если эти 19 вызовов исчезли потому, что фильтр свежести отсёк входные данные, это ожидаемо; если потому, что какой-то путь перестал исполняться — это отдельный факт, который стоит назвать до обновления эталона.

Ветка обновлена и запушена (d7017ebf), CI пересчитается. Локально: 1019 passed по тестам оценщика/якоря/коэффициента, падает только гейт и только на этом счётчике.

Не мержу. Ваше «решение за владельцем» остаётся в силе, но его цена теперь измерена дважды и независимо: по сумме ноль, по направлению — к ценам сделок.

## Обновил ветку до сегодняшнего main — и это было не формальностью Зелёный CI на этом PR считался **12.08 в 13:06**, а main с тех пор ушёл на **17 коммитов**, включая три правки того же самого якоря: #2816 (приведение якоря к базису ремонта), #2839 (квартал цели), #2852. Проверки тестировали голову ветки, а не результат слияния — то есть про сегодняшнее состояние не говорили ничего. Влил `origin/main` (слияние текстово чистое, `git merge-tree`) и прогнал. ## Что показал бэктест-гейт Он **упал**. Но не там, где можно было ждать: расхождение единственное во всём наборе метрик. ``` unrecorded_lookup_calls 19 → 0 ``` Сравнивал не первое несовпадение, а **весь** набор (`_assert_match` останавливается на первом — своим скриптом обошёл всё дерево). Все денежные метрики совпали: общий MAPE, покрытие, sharpness, калибровка по бакетам, по комнатам, по сегментам, headline-spread — **побайтно**. **Что это значит.** На фикстуре из 277 сделок правка не двигает деньги вообще. Отличается только диагностический счётчик: ветка не делает 19 «неучтённых lookup-вызовов», которые делает main. Поведенческая разница реальная, но на этой выборке она денежно нейтральна. Это независимое подтверждение вашего же перезамера (+0.09% по сумме): другой инструмент, другая выборка, тот же вывод. ## Оговорка, которая обязана быть Фикстура зовёт `_price_from_inputs` **напрямую**, минуя `estimate_quality` — это выяснилось при разборе #2839. Значит часть путей она не проходит, и «метрики не сдвинулись» здесь означает «не сдвинулись на покрытых путях», а не «не сдвинулись нигде». Ваши ±100% на отдельных оценках фикстурой не воспроизводятся и не опровергаются. ## Что осталось сделать Эталон бэктеста надо обновить на `unrecorded_lookup_calls: 0` — но **не механически**. Сначала стоит понять, почему счётчик обнулился: если эти 19 вызовов исчезли потому, что фильтр свежести отсёк входные данные, это ожидаемо; если потому, что какой-то путь перестал исполняться — это отдельный факт, который стоит назвать до обновления эталона. Ветка обновлена и запушена (`d7017ebf`), CI пересчитается. Локально: 1019 passed по тестам оценщика/якоря/коэффициента, падает только гейт и только на этом счётчике. **Не мержу.** Ваше «решение за владельцем» остаётся в силе, но его цена теперь измерена дважды и независимо: по сумме ноль, по направлению — к ценам сделок.
Author
Collaborator

Почему счётчик обнулился — разобрался, эталон обновлять можно

Выше я предостерёг от механического обновления эталона, пока не понятно, почему unrecorded_lookup_calls ушёл 19 → 0. Разобрался; ответ снимает возражение.

Что означает счётчик

scripts/backtest_estimator.py:1494-1506: заглушка квартального индекса отвечает «промах» на вызовы, которых нет в захваченной фикстуре, и считает их. Смысл — канарейка «реплей разошёлся с захватом»:

Иначе ослабление выключило бы канарейку навсегда и для всех будущих PR: с закоммиченным числом новое расхождение всё так же валит гейт.

То есть 19 — это не метрика качества, а мера расхождения реплея с прод-захватом, намеренно вбитая в эталон точным числом, чтобы любое её изменение роняло гейт. Гейт сработал ровно по назначению.

Моя гипотеза была неверной

Я предположил, что счётчик обнулила вчерашняя #2839 (снятие квартального фолбэка). Проверил на чистом origin/main: гейт зелёный, 2 passed — значит на main счётчик по-прежнему 19, и #2839 тут ни при чём.

Обнуляет его сам фильтр свежести: те 19 оценок, что в реплее просили незахваченный квартальный индекс, с фильтром до этого блока просто не доходят — часть из 57, теряющих якорь.

Что это значит для решения

Направление правильное. Комментарий в оснастке прямо говорит, что 19 — это занижение эффекта правки: «для этих 19 сделок квартальный индекс в реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает эффект правки». Ноль означает, что реплей и захват сошлись, а не разошлись.

Ничего не маскируется: все денежные метрики совпали побайтно, я сверил весь набор, а не первое несовпадение.

Эталон обновлять можно — на unrecorded_lookup_calls: 0, с пометкой почему. Это не ослабление канарейки: следующее расхождение так же уронит гейт, просто отсчёт пойдёт от нуля.

Сам обновлять не стал: правка принадлежит этому PR, а решение о его мерже — за владельцем. Если решение будет «мержим», обновление эталона входит в тот же коммит.

## Почему счётчик обнулился — разобрался, эталон обновлять можно Выше я предостерёг от механического обновления эталона, пока не понятно, почему `unrecorded_lookup_calls` ушёл 19 → 0. Разобрался; ответ снимает возражение. ### Что означает счётчик `scripts/backtest_estimator.py:1494-1506`: заглушка квартального индекса отвечает «промах» на вызовы, **которых нет в захваченной фикстуре**, и считает их. Смысл — канарейка «реплей разошёлся с захватом»: > Иначе ослабление выключило бы канарейку навсегда и для всех будущих PR: с закоммиченным числом новое расхождение всё так же валит гейт. То есть 19 — это не метрика качества, а **мера расхождения реплея с прод-захватом**, намеренно вбитая в эталон точным числом, чтобы любое её изменение роняло гейт. Гейт сработал ровно по назначению. ### Моя гипотеза была неверной Я предположил, что счётчик обнулила вчерашняя #2839 (снятие квартального фолбэка). **Проверил на чистом `origin/main`: гейт зелёный, 2 passed** — значит на main счётчик по-прежнему 19, и #2839 тут ни при чём. Обнуляет его **сам фильтр свежести**: те 19 оценок, что в реплее просили незахваченный квартальный индекс, с фильтром до этого блока просто не доходят — часть из 57, теряющих якорь. ### Что это значит для решения Направление правильное. Комментарий в оснастке прямо говорит, что 19 — это **занижение** эффекта правки: «для этих 19 сделок квартальный индекс в реплее НЕ применяется вовсе (в проде — применился бы), т.е. гейт занижает эффект правки». Ноль означает, что реплей и захват сошлись, а не разошлись. Ничего не маскируется: все денежные метрики совпали побайтно, я сверил весь набор, а не первое несовпадение. **Эталон обновлять можно** — на `unrecorded_lookup_calls: 0`, с пометкой почему. Это не ослабление канарейки: следующее расхождение так же уронит гейт, просто отсчёт пойдёт от нуля. Сам обновлять не стал: правка принадлежит этому PR, а решение о его мерже — за владельцем. Если решение будет «мержим», обновление эталона входит в тот же коммит.
Owner

Разбит надвое по решению владельца 16.08.2026

Безопасная половина выделена в #2920 и смержена: сброс залипавшего anchor_tier, восстановление канарейки бэктеста и починка штатной регенерации эталона. Здесь остаётся только спорная часть — фильтры свежести.

Что остаётся в этом PR

  • фильтр scraped_at > NOW() - :fresh_days в SQL якоря дома, оба тира (_fetch_anchor_comps);
  • тот же фильтр в двух CTE знаменателя коэффициента выкупа (asking_to_sold_ratio._REDERIVE_SQL);
  • вынос окна LISTINGS_FRESH_DAYS в конфиг.

Перезамер на текущем коде — почему решение отложено

Прежние числа (сумма выкупа +0,09%, типовая оценка −0,23%, 66 из 1052 оценок двигаются больше 10%) устарели: main переписал оценщик. Замер повторён на слитом с main состоянии, 996 парных оценок.

Живой путь оценки — эффекта почти нет: медиана изменения 0,000%, ни одна оценка не двигается больше чем на 10%, худший случай −1,67%. Причина выяснилась по ходу: миграции 264–266 от 15.08 и джобы деактивации уже сняли ту самую популяцию протухших строк, которую фильтр отсекал на лету — было 674 строки, осталось 34. То есть на estimate-уровне правка сегодня в основном дублирует уже сделанную чистку данных.

А вот знаменатель коэффициента выкупа живёт отдельно — он пересчитывается ночной джобой, и там эффект реальный:

бакет сейчас с фильтром Δ
общий 0,8484 0,8373 −1,31%
студии 0,7587 0,7549 −0,50%
однокомнатные 0,8267 0,8145 −1,48%
44–62 м² 0,8806 0,8714 −1,04%
62–85 м² 0,9311 0,9256 −0,59%
85+ м² 0,8397 0,8397 0%

Все бакеты идут вниз — то есть предложение клиенту системно уменьшится примерно на процент после первого же ночного пересчёта.

Расхождение, которое стоит отдельно поправить

Комментарий в asking_to_sold_ratio.py:33-34 ссылается на августовский замер, где бакеты 44–62 и 62–85 двигались вверх (+2,43% и +4,58%). Сегодня те же бакеты идут вниз. Состав данных с тех пор изменился, а комментарий остался и вводит следующего читателя в заблуждение.

Про красный CI

Гейт бэктеста падал на unrecorded_lookup_calls: 0 != 19, и это списывали на фильтр свежести. Оказалось иначе — виновата была третья правка (сброс anchor_tier), она меняла число обращений к квартальному индексу. Эта причина уехала в #2920 вместе с перегенерированным эталоном, так что после слияния с main гейт здесь должен вести себя иначе — проверять заново.

Решение по этому PR — за владельцем: согласиться на системную просадку выкупа около процента ради того, чтобы цена не строилась на объявлениях месячной давности, либо считать вопрос закрытым чисткой данных и PR закрыть.

## Разбит надвое по решению владельца 16.08.2026 Безопасная половина выделена в #2920 и **смержена**: сброс залипавшего `anchor_tier`, восстановление канарейки бэктеста и починка штатной регенерации эталона. Здесь остаётся только спорная часть — фильтры свежести. ## Что остаётся в этом PR - фильтр `scraped_at > NOW() - :fresh_days` в SQL якоря дома, оба тира (`_fetch_anchor_comps`); - тот же фильтр в двух CTE знаменателя коэффициента выкупа (`asking_to_sold_ratio._REDERIVE_SQL`); - вынос окна `LISTINGS_FRESH_DAYS` в конфиг. ## Перезамер на текущем коде — почему решение отложено Прежние числа (сумма выкупа +0,09%, типовая оценка −0,23%, 66 из 1052 оценок двигаются больше 10%) устарели: main переписал оценщик. Замер повторён на слитом с main состоянии, 996 парных оценок. **Живой путь оценки — эффекта почти нет:** медиана изменения **0,000%**, ни одна оценка не двигается больше чем на 10%, худший случай −1,67%. Причина выяснилась по ходу: миграции 264–266 от 15.08 и джобы деактивации уже сняли ту самую популяцию протухших строк, которую фильтр отсекал на лету — было 674 строки, осталось 34. То есть на estimate-уровне правка сегодня в основном дублирует уже сделанную чистку данных. **А вот знаменатель коэффициента выкупа живёт отдельно** — он пересчитывается ночной джобой, и там эффект реальный: | бакет | сейчас | с фильтром | Δ | |---|---|---|---| | общий | 0,8484 | 0,8373 | **−1,31%** | | студии | 0,7587 | 0,7549 | −0,50% | | однокомнатные | 0,8267 | 0,8145 | −1,48% | | 44–62 м² | 0,8806 | 0,8714 | −1,04% | | 62–85 м² | 0,9311 | 0,9256 | −0,59% | | 85+ м² | 0,8397 | 0,8397 | 0% | Все бакеты идут **вниз** — то есть предложение клиенту системно уменьшится примерно на процент после первого же ночного пересчёта. ## Расхождение, которое стоит отдельно поправить Комментарий в `asking_to_sold_ratio.py:33-34` ссылается на августовский замер, где бакеты 44–62 и 62–85 двигались **вверх** (+2,43% и +4,58%). Сегодня те же бакеты идут вниз. Состав данных с тех пор изменился, а комментарий остался и вводит следующего читателя в заблуждение. ## Про красный CI Гейт бэктеста падал на `unrecorded_lookup_calls: 0 != 19`, и это списывали на фильтр свежести. Оказалось иначе — виновата была третья правка (сброс `anchor_tier`), она меняла число обращений к квартальному индексу. Эта причина уехала в #2920 вместе с перегенерированным эталоном, так что после слияния с main гейт здесь должен вести себя иначе — проверять заново. **Решение по этому PR — за владельцем:** согласиться на системную просадку выкупа около процента ради того, чтобы цена не строилась на объявлениях месячной давности, либо считать вопрос закрытым чисткой данных и PR закрыть.
Owner

Смотрел, можно ли снять конфликт, чтобы PR был готов к твоему решению. Снимать не стал — и вот почему это правильнее, чем «привести в mergeable».

Дрейф с точки ветвления

Ветка отстала от main на 280 коммитов. По файлам, которые она правит:

Файл Коммитов в main Строк +/−
app/services/estimator.py 9 +336 / −60
app/core/config.py 4 +65 / −0
tests/fixtures/backtest_baseline.json 1 +2 / −1
app/tasks/asking_to_sold_ratio.py 0

Точка ветвления — d6c000ed (05.08).

Почему это не «просто конфликт»

Ценность этого PR — не в диффе, а в замерах: −3.63 % на якорной половине, +1.25 % на знаменателе, итог −2.25 %, потом перезамер 12.08 → +0.09 %. Решение мержить или нет принимается по этим числам.

И сам PR уже показал, насколько они хрупкие: перезамер той же оснасткой через неделю перевернул вывод (−2.25 % → +0.09 %) — не из-за правок кода, а потому что изменился состав данных («вторичка перестала протухать, avito/domklik 0 % протухших»).

С тех пор прошло ещё 19 дней, а estimator.py — файл, чьё поведение и мерили — прибавил 336 строк за 9 коммитов. Числа в шапке описывают систему, которой больше нет.

Если я сейчас разрешу конфликт, PR станет зелёным и будет выглядеть готовым к решению — с цифрами, на которые уже нельзя опереться. Это ровно тот класс, который я всю ночь ловил в другом месте: артефакт выглядит исправным, а вывод из него делать нельзя.

Что нужно, чтобы решение снова стало осмысленным

  1. Влить main в ветку — конфликт в estimator.py придётся разбирать руками, механически он не сойдётся при +336 строках.
  2. Перезамерить той же оснасткой (scripts/backtest_estimator.py на всех записях trade_in_estimates) — получить актуальные проценты вместо августовских.
  3. Проверить frozen backtest gate. backtest_baseline.json на main тоже сдвинулся (1 коммит), а PR пиннит в нём unrecorded_lookup_calls: 19. Возможно, потребуется перезахват — а он, по твоему же описанию в шапке, меняет саму систему отсчёта и требует живой прод-БД.

Пункт 1 я могу сделать, но только вместе с пунктом 2 — иначе получится зелёный PR с недействительными обоснованиями. Пункт 2 — это прогон против боевых данных оценщика, и запускать его без твоего слова я не буду.

Предложение

Скажи, делать ли перезамер — и я пройду все три пункта разом, с новыми числами в шапке. Либо, если политика цены за это время определилась в другую сторону, PR проще закрыть: он висит с 05.08, и держать его дальше «на всякий случай» дороже, чем переписать заново от актуального main, когда решение появится.

Сам по себе он от времени не улучшается — только устаревает.

Смотрел, можно ли снять конфликт, чтобы PR был готов к твоему решению. **Снимать не стал — и вот почему это правильнее, чем «привести в mergeable».** ## Дрейф с точки ветвления Ветка отстала от `main` на **280 коммитов**. По файлам, которые она правит: | Файл | Коммитов в main | Строк +/− | |---|---|---| | `app/services/estimator.py` | **9** | **+336 / −60** | | `app/core/config.py` | 4 | +65 / −0 | | `tests/fixtures/backtest_baseline.json` | 1 | +2 / −1 | | `app/tasks/asking_to_sold_ratio.py` | 0 | — | Точка ветвления — `d6c000ed` (05.08). ## Почему это не «просто конфликт» Ценность этого PR — не в диффе, а **в замерах**: −3.63 % на якорной половине, +1.25 % на знаменателе, итог −2.25 %, потом перезамер 12.08 → **+0.09 %**. Решение мержить или нет принимается по этим числам. И сам PR уже показал, насколько они хрупкие: **перезамер той же оснасткой через неделю перевернул вывод** (−2.25 % → +0.09 %) — не из-за правок кода, а потому что изменился состав данных («вторичка перестала протухать, avito/domklik 0 % протухших»). С тех пор прошло ещё **19 дней**, а `estimator.py` — файл, чьё поведение и мерили — прибавил **336 строк за 9 коммитов**. Числа в шапке описывают систему, которой больше нет. Если я сейчас разрешу конфликт, PR станет зелёным и **будет выглядеть готовым к решению** — с цифрами, на которые уже нельзя опереться. Это ровно тот класс, который я всю ночь ловил в другом месте: артефакт выглядит исправным, а вывод из него делать нельзя. ## Что нужно, чтобы решение снова стало осмысленным 1. **Влить main в ветку** — конфликт в `estimator.py` придётся разбирать руками, механически он не сойдётся при +336 строках. 2. **Перезамерить той же оснасткой** (`scripts/backtest_estimator.py` на всех записях `trade_in_estimates`) — получить актуальные проценты вместо августовских. 3. **Проверить frozen backtest gate.** `backtest_baseline.json` на main тоже сдвинулся (1 коммит), а PR пиннит в нём `unrecorded_lookup_calls: 19`. Возможно, потребуется перезахват — а он, по твоему же описанию в шапке, меняет саму систему отсчёта и требует живой прод-БД. Пункт 1 я могу сделать, но **только вместе с пунктом 2** — иначе получится зелёный PR с недействительными обоснованиями. Пункт 2 — это прогон против боевых данных оценщика, и запускать его без твоего слова я не буду. ## Предложение Скажи, делать ли перезамер — и я пройду все три пункта разом, с новыми числами в шапке. Либо, если политика цены за это время определилась в другую сторону, PR проще закрыть: он висит с 05.08, и держать его дальше «на всякий случай» дороже, чем переписать заново от актуального main, когда решение появится. Сам по себе он от времени не улучшается — только устаревает.
Light1YT added 2 commits 2026-08-26 13:26:00 +00:00
test(2656): краснота на main — по значению, не ImportError
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
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 4m39s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
7a5997b059
getattr вместо прямого импорта LISTINGS_FRESH_DAYS из config: на origin/main
константы там ещё нет, и тест умирал ImportError'ом на сборке модуля —
«возможности нет» вместо «значение неверно». Теперь на main: 5 красных
ассертами (предикат отсутствует/окно None/протухшие комплы в пуле), 3 зелёных
(сброс anchor_tier уже влит отдельно).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Collaborator

Ревью + актуализация 26.08 (ветка обновлена мержем main, конфликты разрешены)

PR висел с 05.08 и разъехался с main. Что сделано:

Разрешение конфликтов. Всё, что из этого PR уже влито на main отдельно (сброс залипшего anchor_tier, backtest-машинерия on_exhausted/unrecorded_lookup_calls, перезахваченная фикстура с unrecorded=0), — взято в main-версии. Остаток PR против main теперь ровно ядро: константа в app.core.config + предикат свежести в 4 местах (+ тесты). Diff 5 файлов, +414/−7.

Краснота теста переведена на «по значению»: прямой импорт LISTINGS_FRESH_DAYS из config умирал на main ImportError'ом (константы там ещё нет) — теперь getattr, и на main-коде 5 тестов падают ассертами (предикат отсутствует / окно None / протухшие комплы в пуле), 3 зелёных (сброс anchor_tier уже влит). С фиксом — 8/8.

Два адверсариальных вопроса к дифу — сняты прод-замером (26.08, Poincare):

  1. scraped_at IS NULL молча выпадет из якоря? — Активных строк с NULL scraped_at ноль. Не случай.
  2. Асимметрия с #oblast-F: радиусный путь области ходит с ослабленным окном 60д, а якорь получит жёсткие 14д — не убьёт ли якоря в областных городах? — Пулы свежих-14д здоровы: Н.Тагил 1032, К-Уральский 721, Первоуральск 670, В.Пышма 619, Серов 341. Теряемый хвост (34–198 строк на город) — именно те строки, которых «никто не видел от 2 недель до 2 месяцев»; до PR якорь их брал вообще без окна. Асимметрия консервативна в верную сторону: при нехватке свежих комплов якорь уступает радиусному пути (60д, с релаксацией-лейблом), а не тащит месячные объявления в headline без пометки. Отдельно: NULL-city хвост only-60d — 5 119 строк, ровно масса, ради которой фильтр и ставится.

Прогоны: свой тест 8/8, соседи (test_asking_to_sold_ratio, test_estimator_price_spine) и весь backtest-гейт (test_backtest_regression_gate реально исполнялся, не скипнут) — 125 passed суммарно.

Приёмка после деплоя: следующий ночной asking_to_sold_ratio_refreshn_listings NULL-сегмента в ask-строках схлопывается с ~674 до ~20 (замер из описания PR); по якорю — SELECT числа домов Tier A на протухших строках → 0.

## Ревью + актуализация 26.08 (ветка обновлена мержем main, конфликты разрешены) PR висел с 05.08 и разъехался с main. Что сделано: **Разрешение конфликтов.** Всё, что из этого PR уже влито на main отдельно (сброс залипшего `anchor_tier`, backtest-машинерия `on_exhausted`/`unrecorded_lookup_calls`, перезахваченная фикстура с `unrecorded=0`), — взято в main-версии. Остаток PR против main теперь ровно ядро: константа в `app.core.config` + предикат свежести в 4 местах (+ тесты). Diff 5 файлов, +414/−7. **Краснота теста переведена на «по значению»**: прямой импорт `LISTINGS_FRESH_DAYS` из config умирал на main ImportError'ом (константы там ещё нет) — теперь `getattr`, и на main-коде 5 тестов падают ассертами (предикат отсутствует / окно None / протухшие комплы в пуле), 3 зелёных (сброс anchor_tier уже влит). С фиксом — 8/8. **Два адверсариальных вопроса к дифу — сняты прод-замером (26.08, Poincare):** 1. *`scraped_at IS NULL` молча выпадет из якоря?* — Активных строк с NULL `scraped_at` **ноль**. Не случай. 2. *Асимметрия с #oblast-F: радиусный путь области ходит с ослабленным окном 60д, а якорь получит жёсткие 14д — не убьёт ли якоря в областных городах?* — Пулы свежих-14д здоровы: Н.Тагил 1032, К-Уральский 721, Первоуральск 670, В.Пышма 619, Серов 341. Теряемый хвост (34–198 строк на город) — именно те строки, которых «никто не видел от 2 недель до 2 месяцев»; до PR якорь их брал вообще без окна. Асимметрия консервативна в верную сторону: при нехватке свежих комплов якорь уступает радиусному пути (60д, с релаксацией-лейблом), а не тащит месячные объявления в headline без пометки. Отдельно: NULL-city хвост only-60d — 5 119 строк, ровно масса, ради которой фильтр и ставится. **Прогоны**: свой тест 8/8, соседи (`test_asking_to_sold_ratio`, `test_estimator_price_spine`) и весь backtest-гейт (`test_backtest_regression_gate` реально исполнялся, не скипнут) — 125 passed суммарно. Приёмка после деплоя: следующий ночной `asking_to_sold_ratio_refresh` — `n_listings` NULL-сегмента в ask-строках схлопывается с ~674 до ~20 (замер из описания PR); по якорю — `SELECT` числа домов Tier A на протухших строках → 0.
bot-backend merged commit 036608f1bc into main 2026-08-26 13:31:53 +00:00
Author
Collaborator

Смержен и на проде (26.08, деплой 036608f1 зелёный, код в контейнере проверен)

  • предикат :fresh_days в контейнере: estimator — 17 вхождений (включая оба якорных тира), asking_to_sold_ratio — 3 (было 0);
  • срез эффекта по якорю: 465 домов из 7 708 с активными лотами держат ТОЛЬКО протухшие (>14д) строки — якорь на них больше не построится, оценка уходит в радиусный путь вместо замещения headline невидимыми месяц объявлениями.

Приёмка с датой: после ближайшего ночного asking_to_sold_ratio_refresh (сегодня ~00:40 UTC) — n_listings NULL-сегмента в ask-строках схлопывается с ~674 до ~20 (замер из описания PR); проверить завтра запросом по asking_to_sold_ratios.

## Смержен и на проде (26.08, деплой 036608f1 зелёный, код в контейнере проверен) - предикат `:fresh_days` в контейнере: estimator — 17 вхождений (включая оба якорных тира), asking_to_sold_ratio — 3 (было 0); - срез эффекта по якорю: **465 домов из 7 708** с активными лотами держат ТОЛЬКО протухшие (>14д) строки — якорь на них больше не построится, оценка уходит в радиусный путь вместо замещения headline невидимыми месяц объявлениями. **Приёмка с датой**: после ближайшего ночного `asking_to_sold_ratio_refresh` (сегодня ~00:40 UTC) — `n_listings` NULL-сегмента в ask-строках схлопывается с ~674 до ~20 (замер из описания PR); проверить завтра запросом по `asking_to_sold_ratios`.
Author
Collaborator

Приёмка по знаменателю прошла (27.08, первый ночной refresh с фиксом)

asking_to_sold_ratio_refresh отработал 27.08 06:12 (уже на коде с фильтром, деплой был 26.08 15:35). Прямого диффа со вчерашними строками нет (refresh делает DELETE), поэтому фальсификация тем же трактом — ask-популяция с фильтром и без на живой базе:

с фильтром свежести (14д): 27 684
без фильтра:               48 787   ← ~21k протухших строк больше не в знаменателе

Сохранённые строки за 06:12: global n_listings = 17 625 (доп. сужение city-скоупом и ppm²-полосой), пер-бакеты 1 731–4 907. Если бы refresh шёл без фильтра, n_listings был бы масштаба 48k — числа однозначно различают эры.

Обе приёмки PR закрыты (якорь — вчера, знаменатель — сегодня).

## Приёмка по знаменателю прошла (27.08, первый ночной refresh с фиксом) `asking_to_sold_ratio_refresh` отработал 27.08 06:12 (уже на коде с фильтром, деплой был 26.08 15:35). Прямого диффа со вчерашними строками нет (refresh делает DELETE), поэтому фальсификация тем же трактом — ask-популяция с фильтром и без на живой базе: ``` с фильтром свежести (14д): 27 684 без фильтра: 48 787 ← ~21k протухших строк больше не в знаменателе ``` Сохранённые строки за 06:12: global n_listings = 17 625 (доп. сужение city-скоупом и ppm²-полосой), пер-бакеты 1 731–4 907. Если бы refresh шёл без фильтра, n_listings был бы масштаба 48k — числа однозначно различают эры. Обе приёмки PR закрыты (якорь — вчера, знаменатель — сегодня).
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
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#2661
No description provided.