Разворот предыдущего коммита ветки (a780e3e6) на корень: вместо подстановки
`area_bucket(area)` в предикат `d.rooms = ...` предикат УДАЛЁН во всех трёх местах.
ПОЧЕМУ НЕ БАКЕТ. `deals.rooms` — синтетика из площади (321 559 из 321 560 сделок
удовлетворяют `rooms == area_bucket(area_m2)`, max(rooms)=4), значит `d.rooms = X`
тождественно `d.area_m2 ∈ [граница_X, граница_X+1)`. Это ВТОРОЙ, ступенчатый фильтр
по площади поверх полосы `area_m2 BETWEEN :area_min AND :area_max`, стоящей строкой
ниже. Прод-замер по 1179 реальным запросам (trade_in_estimates, 2026-09-12) — какая
доля полосы ±15% переживает предикат:
d.rooms = комнаты клиента медиана 77.8%, у 180 запросов полоса вырезана ЦЕЛИКОМ
(пересечение пусто ⇒ коридора нет никогда)
d.rooms = area_bucket(area) медиана 90.0%, пустых нет, НО у 902 из 1179 полоса
всё ещё усечена: 44.0 м² → сохраняется 50% полосы,
62.0 м² → 50%, 82.6 м² → 59.7%. Величину усечения
задаёт не модель, а случайное положение метража
относительно границ 30/44/62/85.
без предиката 100% по построению
Т.е. бакет-ключ чинит катастрофический случай (пустое пересечение) и оставляет
произвольное усечение у 76.5% запросов. Полоса ±15% уже выражает «похожие по
площади сделки» — второго фильтра по тому же признаку быть не должно.
ЗАМЕР ЭФФЕКТА НА ЦЕНУ (1179 запросов, все три пути влияния коридора на headline:
cap/floor, sufficiency-гейт #oblast-E, deals-headline-fallback; листинговая сторона
берётся из сохранённой оценки, коридор пересчитан на сегодняшнем снимке deals для
всех вариантов, поэтому сравнение apples-to-apples; реплика сверена с ПРОДОВЫМ SQL
на 58 оценках × 3 варианта — 174/174 совпадений):
коридор доступен n>=3: 769 → 850 (бакет, +86/−5) → 874 (без ключа, +105/−0)
n>=10: 567 → 623 (бакет, +66/−10) → 691 (без ключа, +126/−2)
сдвиг headline vs текущий прод бакет без ключа
клиентов сдвинулось 28 120
медиана сдвига +1.0% −1.6%
p10 / p90 −30.6% / +6.1% −8.6% / +4.3%
сдвиг > ±10% 10 (все вниз) 10 (7 вниз, 3 вверх)
сдвиг > ±25% 4 2
по путям (медиана сдвига): бакет без ключа
cap/floor, радиусная медиана +3.9% (p10 −36.3%) −1.7% (p10 −5.9%)
cap/floor, якорь Tier C −10.1% (5 сдвигов, 4 из них >10% вниз) −0.8%
sufficiency-гейт −0.4% +0.3%
deals-fallback +3.5% (p10 −20.8%) −0.1%
якорь Tier A 0 (коридор не влияет: cap exempt, floor требует
anchor_tier is None)
Вариант без ключа даёт больше покрытия (+105/−0 против +86/−5), сдвиг с медианой
около нуля и БЕЗ кластера сильных падений, тогда как бакет-ключ несёт кластер
Tier C с медианой −10.1%. Худший случай (−41.8%, Малышева 84, 1к/54 м²: премиальный
лот прижимается cap'ом к коридору улицы) ОБЩИЙ для обоих вариантов — он появляется
от самого факта наличия коридора, а не от выбора ключа.
УТОЧНЕНИЕ ФАКТА ИЗ a780e3e6: «у 818 клиентов выборка не меняется» — неверно, их
793. Скрипт классифицировал через `min(max(rooms,0),4) == area_bucket`, из-за чего
27 клиентов с 5-6 комнатами попали в «совпадающие», хотя у них выборка меняется с
пустой на непустую. (Практического прироста они всё равно не получают: их метраж
158-456 м² в основном вне окна импорта `area BETWEEN 18 AND 200`.)
ЯКОРЬ ПРОТИВ МОЛЧАЛИВОГО ВОЗВРАТА. Ни один тест не краснел, если импортёр начнёт
писать настоящую комнатность. tests/test_3256_deals_rooms_key.py теперь ПАРСИТ CASE
из deploy/import-rosreestr.sh и сверяет его границы с `area_bucket()` (поточечно, на
границах и между ними); у самого CASE стоит комментарий-якорь «поменяешь на реальную
комнатность — вернись в #3256».
Каверза (e) харнеса: формулировка «бакеты 0-2 чисты» УБРАНА как неверная. Замер по
тому же пулу, который видит `_fetch_analogs` (свежесть 14 дней, вторичка, регион 66):
совпадение rooms == area_bucket — бакет 0: 69.8%, 1: 63.5%, 2: 60.1%, 3: 54.6%,
4: 30.9%. В бакетах 0-3 модальная комнатность совпадает с бакетом, в бакете 4 — нет
(мода 3, 54.5% пула). Добавлена перекрёстная ссылка: каверзы (d) и (e) СКЛАДЫВАЮТСЯ
(неправильное МЕСТО + неправильный СЕГМЕНТ), а не спорят.
Логи витрины `/street-deals` называли `rooms=%d` комнатностью клиента, хотя фильтра
по ней в запросе уже нет — теперь печатают фактический ключ (полосу площади), а
комнатность помечена как контекст запроса.
НЕ входит в этот PR (заводится отдельно): TVF `street_sales_vs_listings`
(data/sql/211_*.sql:89,113) — там асимметричный ключ (`d.rooms` синтетика,
`l.rooms` настоящая), копипастой не чинится; каверза (e) для
app/tasks/landing_showcase_deals.py:415/426.
Refs #3256
`deals.rooms` — не комнатность, а синтетика из площади: import-rosreestr.sh пишет
тот же CASE 30/44/62/85, что `asking_to_sold_ratio.area_bucket`. Прод-замер
2026-09-11: 321 559 из 321 560 сделок удовлетворяют rooms == area_bucket(area_m2),
max(rooms) = 4. Значит предикат `deals.rooms = <РЕАЛЬНЫЕ комнаты клиента>` — это
переодетый фильтр по площади, который противоречит area-полосе ±15% рядом с ним,
как только комнатность клиента нетипична для метража, и НИКОГДА не совпадает у
клиентов с 5+ комнатами.
Замер по 1177 реальным запросам (trade_in_estimates): ключ расходился с
area-бакетом у 359 (30.5%); коридор ДКП пуст у 46.2% из них против 8.2% у
совпадающих. По крупному жилью (≥85 м²): «3 комнаты» — 83.5% пустых коридоров,
«5 комнат» и «6 комнат» — 100%, «4 комнаты» — 5%. Т.е. блок «реальные сделки»
и клампы коридора (cap headline + radius-floor) молча выключались ровно у
крупных лотов.
Прогон тех же 1177 запросов через `_fetch_dkp_corridor` с обоими ключами:
непустых коридоров 809 → 895, пригодных для клампа (n≥10) 567 → 623 (+66, −10),
у 818 клиентов с совпадающей комнатностью выборка не меняется вовсе. Из 66
восстановленных коридоров 7 (5 из них ≥85 м²) обрезали бы headline вниз на
медианных −10.1% — то есть сейчас часть крупных лотов оценивается выше, чем
поддерживают реальные ДКП на той же улице.
Правка — одно и то же во всех четырёх местах, где сделки фильтруются под
клиента: `_fetch_dkp_corridor` (street + city-wide widen), `_fetch_deals`
(радиус) и витрина `/street-deals`.
Бэктест этим НЕ измеряется и в докстринг харнеса добавлена причина (каверза (e)):
у всех 5500 сделок обеих прод-фикстур rooms == area_bucket, т.е. харнес кормит
спайн синтетическим ключом и поэтому по построению не видит расхождения, которое
в проде есть у 30.5% запросов.
Refs #3256
После импорта 212 937 московских ДКП (region_code=77) region_stats
refresh'а (p1 для tier region_fallback) считался бы по пулу 66+77 и
поднял бы floor ~250 малым городам области. Ключ bands становится
(region_code, city): миграция 298 (колонка DEFAULT 66, смена PK через
DO-guard, re-seed per-region, ЕКБ-исключение только для 66, doc_type=ДКП),
тот же SQL в refresh-джобе, estimator/backtest читают bands по паре.
Для 66 набор строк байт-идентичен прежнему (ревью двумя линзами).
ORDER BY id DESC берёт последние ВСТАВЛЕННЫЕ строки, а Росреестр грузится
пачками по домам: соседние id это один дом и одна улица. Замер на проде
31.08.2026 по Екатеринбургу, выборка 200:
последние 200 по id : 38 разных улиц, до 22 сделок с ОДНОЙ улицы
случайные 200 : 129 разных улиц, максимум 6 с одной
На этой выборке стоит витрина лэндинга: 20 строк с ЧЕТЫРЁХ улиц, 16 из них с
двух. Владелец заметил это как «почти все сделки из одного района».
ЧТО ЗАМЕР ПОКАЗАЛ, а что нет. Пять прогонов по 200 сделок:
recent MAPE 15.18% покрытие 88.75%
scattered mera MAPE 14.96% покрытие 83.13%
scattered alpha MAPE 15.97% покрытие 88.34%
scattered bravo MAPE 13.69% покрытие 83.85%
scattered charlie MAPE 14.36% покрытие 86.23%
Заголовочная точность УСТОЯЛА — опубликованные 14,5% лежат внутри разброса
представительной выборки. Кластеризация её не раздувала.
А покрытие коридором опубликовано как 88% — это верх диапазона: при пересборке
выборки величина гуляет 83-88 при медиане около 86. Публикуется лучший прогон
из пяти как единственный. Правка самих чисел лэндинга — отдельным заходом.
УМОЛЧАНИЕ НЕ ТРОНУТО. Докстринг _sample_sql обещает дефолтному пути побайтово
тот же SQL ради замороженного регресс-гейта; смена умолчания молча обнулила бы
сравнимость всей истории замеров. Режим выбирается флагом --spread.
Порядок по md5(id||seed), а не random(): нужен ВОСПРОИЗВОДИМЫЙ порядок, иначе
два прогона отличаются и из-за правки, и из-за состава выборки, и разделить
вклады нечем.
Тест держит обе стороны и проверен фальсификацией: сделать вразброс умолчанием
— падает identity-проверка (сравнение текстов её бы пропустило), заменить md5
на random() — падает проверка воспроизводимости.
Витрина показывала fact_rub = price_per_m2 * area_m2, хотя deals.price_rub
лежит в той же строке и не использовалась. price_per_m2 в базе integer,
поэтому под подписью «Цена ДКП» ехала реконструкция: 4 799 995 вместо
4 800 000, 3 649 995 вместо 3 650 000 (прод, сделки 5777343 и др.).
Теперь price_rub едет из выборки (DealSample.price_rub) и показывается как
есть; err_pct считается от той же величины. Строка без price_rub НЕ
показывается — подставлять реконструкцию в одну строку из двадцати значило бы
спрятать тот же дефект (на проде price_rub заполнен у 33 555 из 33 555 сделок
выборки витрины).
Вторая находка аудита (listing_date якобы «когда увидели МЫ», экспозиция
занижена втрое) НЕ ПОДТВЕРДИЛАСЬ. listing_date пишут cian (added_ts), yandex
(creationDate) и avito (дата карточки выдачи) — это дата публикации у
источника. Там, где заполнены и listing_date, и publish_date, они совпадают:
yandex 10 761 из 10 903, avito 474 из 569, медиана разницы 0 дней. 75 дней у
аудитора — эффект другой ВЫБОРКИ: publish_date есть у 15 058 активных строк
(yandex + Домклик, оба старые), listing_date — у 25 982 (плюс cian с медианой
17 дней и 87% avito с медианой 19).
Настоящий дефект рядом: по одному listing_date Домклик выпадал целиком (0 из
3061 активной строки), метрика считалась по 83.6% активных объявлений, и
подпись об этом молчала. COALESCE(listing_date, publish_date) → охват 95.2%
(29 568 из 31 068), медиана та же — 26 дней; охват теперь назван в note.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Флаг 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% по бакетам).