МЕРА: настройки оценщика, которые никогда не менялись, стали кодом — 4 кластера флагов #1970 #3556

Merged
bot-backend merged 9 commits from fix/estimator-flag-collapse into main 2026-09-17 09:22:29 +00:00
Collaborator

Четыре подзадачи #1970 одной веткой, по коммиту на issue. Смысл у всех один: поля estimate_* в Settings, которые формально переопределялись через ENV, но ни разу не переопределялись, становятся кодом. Булевы выключатели схлопнуты к боевому True, числовые пороги перенесены константами модуля с прежними значениями.

Порядок мержа с #3554 (обязательно прочитать до мержа)

Ветка удаляет поля Settings и параметр radius_floor_factor у тестового хелпера. git merge-tree сливает её с #3552 и #3554 без текстовых конфликтов. CI в Forgejo гоняет голову ветки, а не результат слияния, поэтому смысловой конфликт при мерже не всплывёт: PR, влитый вторым, молча сделает main красным.

  • #3552 (fix/estimator-analog-quality) — уже согласован. После ревью автор #3552 убрал monkeypatch.setattr(settings, "estimate_dedup_analogs_enabled", True) (коммит 1a9a612a). Проверка: пробное слияние f48a510e (эта ветка) + 3992d754 (#3552), тесты 2087, 3234, гейт, 1970 и 2265 дают 164 passed, rc=0. Порядок любой.
  • #3554 (fix/corridor-advisory-tier-a) — конфликт есть. На пробном слиянии f48a510e + 8bcb025c без правок: rc=1, 4 failed, 61 passed:
    FAILED tests/test_3466_corridor_tier_a.py::test_same_corridor_clamps_radius_path
    FAILED tests/test_3466_corridor_tier_a.py::test_tier_a_price_does_not_depend_on_corridor
    FAILED tests/test_estimator_radius_floor.py::test_floor_applied_at_exactly_clamp_min_n
    FAILED tests/test_estimator_radius_floor.py::test_floor_not_applied_below_clamp_min_n
    E   AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack'
    E   AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_min_n'
    
    За AttributeError прячется второй отказ. Если подставить порог руками, оба теста radius-floor падают с TypeError: _run_estimate() got an unexpected keyword argument 'radius_floor_factor'. Патч адаптации выложен комментарием в #3554. Он переводит тесты на estimator.CORRIDOR_CLAMP_SLACK и app.core.config.CORRIDOR_CLAMP_MIN_N, убирает radius_floor_factor=0.8 и переименовывает три комментария во фронте. С патчем: те же 4 файла дают 65 passed, rc=0. Весь сьют на тройном слиянии (эта ветка + #3552 + #3554 + патч): 6318 passed, 42 skipped, rc=0.
    Правило: кто вливается вторым из пары #3556/#3554, тот сначала мержит origin/main к себе, накладывает патч и перепрогоняет сьют. Если #3554 вольют раньше, это сделаю в этой ветке.

С чем сверялся

До удаления каждого поля проверил его фактическое значение на проде 17.09.2026: settings.<поле> внутри tradein-backend, tradein-scraper и tradein-tgbot на poincare. Во всех трёх контейнерах значения совпали с дефолтами из кода. printenv не нашёл ни одной переменной ESTIMATE_* из этих кластеров (есть только ESTIMATE_EXTERNAL_SOURCES_BACKGROUND). Файла /app/.env в контейнерах нет. Схлопывал к этим значениям.

Для проверки мало гейта по агрегированным метрикам, поэтому добавил реплей замороженной фикстуры по каждой сделке: 1600 предсказаний, из них 615 на радиусном пути (median, expected_sold, диапазон, tier, confidence). После каждого из четырёх коммитов результат cmp-идентичен main. Реплей детерминирован: два прогона на main дали один и тот же файл.

Какие механизмы фикстура реально задевает (строки лога в реплее, main и эта ветка совпадают):

  • якорь собран 985 раз;
  • MAD-clip якоря сработал 693 раза;
  • low-conf гейт 11 раз;
  • IMV-blend 3 раза;
  • radius-floor 7 раз;
  • corridor clamp 1 раз;
  • кросс-source дедуп 2319 раз.

Ни разу не сработали квартальный индекс, гейт Tier C и кламп ratio > 1.0. Для этих механизмов побитовое совпадение гейта ничего не доказывает, их держат юнит-тесты и новый тест значений (см. ниже).

#2378 — дедуп аналогов, disclosure широкого коридора, ручная оценка

Было. Флаг estimate_dedup_analogs_enabled остался, потому что гейт бэктеста пинил его в False. Тесты test_same_building_anchor и test_estimator_n_analogs_priced выключали дедуп, чтобы изолировать другой инвариант. Четыре числовых порога (wide_corridor_threshold и три manual_review_*) жили в Settings. В Settings также лежали осиротевшие комментарии к флагам, которые #2475 уже удалил.

Почему можно убрать. С #3251/#3254 фикстура захвачена с estimate_dedup_analogs_enabled=True, а реплей брал значение из неё. Пин в гейте был мёртвым.

Сделано.

  • Флаг удалён: _dedup_cross_source работает всегда.
  • Из replay_fixture убраны пин флага и try/finally, из гейта убран monkeypatch.
  • Пороги стали константами: WIDE_CORRIDOR_THRESHOLD=1.2, MANUAL_REVIEW_HIGH_VALUE_RUB=20_000_000, MANUAL_REVIEW_WIDE_RANGE_RATIO=1.9, MANUAL_REVIEW_ELITE_PPM2=250_000.
  • _manual_review больше не принимает settings.
  • OFF-тест дедупа удалён. Двум тестам, где аналоги выключали дедуп ради изоляции, выданы разные площади: теперь это разные физлоты, а не кросс-пост.

Grep из приёмки estimate_(wide_corridor|confidence_floor_no_analogs|manual_review|radius_dedup|dedup_analogs) по config.py/estimator.py даёт 0 совпадений.

Оговорка: старую фикстуру, захваченную с дедупом OFF, реплей больше не воспроизведёт. Она упадёт на «control flow diverged from capture», и её надо перезахватить. В репозитории лежит только свежая фикстура.

#2379 — квартальный индекс (#764)

Было. Переключатель снят ещё в #2475. Шесть числовых полей остались в Settings: min_n_deals, match_skip_ratio, max_for_small_n, small_n_threshold, factor_min, factor_max.

Сделано. Поля перенесены константами QUARTER_INDEX_MIN_N_DEALS=10, QUARTER_MATCH_SKIP_RATIO=0.6, QUARTER_INDEX_MAX_FOR_SMALL_N=2.0, QUARTER_INDEX_SMALL_N_THRESHOLD=50, QUARTER_INDEX_FACTOR_MIN=0.6, QUARTER_INDEX_FACTOR_MAX=1.8. Комментарии #764/#859 переехали вместе с ними. Бэктест читает m.QUARTER_INDEX_MIN_N_DEALS.

Про риск из issue («проверить pure-radius путь отдельно»). Реплей побитово совпадает по всем 615 радиусным сделкам. Но квартальный индекс в нём не применяется ни разу: у сделок фикстуры нет своего кадастра цели. Мутационная проверка по одной константе за раз до ревью показала:

  • small_n_threshold, factor_min и factor_max ловят поведенческие тесты test_estimator_quarter_index.py;
  • min_n_deals, match_skip_ratio и max_for_small_n не ловил ни один тест.

Поэтому добавлен tests/test_1970_estimator_constants.py. В нём боевые значения, снятые с прода, и проверка, что поле не вернулось в Settings. Этот тест держит только число в константе, а не то, что код его читает (см. «Правки по ревью»: оба гарда можно было снять целиком). Теперь min_n_deals, match_skip_ratio и max_for_small_n ловят поведенческие тесты test_estimator_quarter_index.py, в том числе на месте вызова.

#2380#1795: corridor-clamp, radius-floor, expected_sold ≤ asking, outlier small-n

Было. Флаги corridor_clamp_enabled и radius_floor_enabled сняты в #2475. Остались булев estimate_expected_sold_le_asking с двумя ветками и пять числовых полей.

Сделано.

  • Флаг удалён. Оба клампа безусловные: if effective_ratio > 1.0 и повторный кламп после хедоники.
  • В estimator.py перенесены CORRIDOR_CLAMP_SLACK=0.40, RADIUS_FLOOR_FACTOR=0.8, OUTLIER_SMALL_N_THRESHOLD=15, OUTLIER_TUKEY_K_SMALL=1.0.
  • CORRIDOR_CLAMP_MIN_N=10 лежит в app.core.config рядом с LISTINGS_FRESH_DAYS. Этот же порог читает DkpCorridor.advisory_only (#3452), а схема не должна импортировать estimator. Порог по-прежнему один.
  • Убрана мёртвая проверка tukey_k_small < 1.5: это сравнение константы с константой.
  • Сегментный множитель #2255 не тронут, порядок операций прежний.
  • Тесты: OFF-тесты клампа удалены. Тест потолка хедоники раньше выключал кламп, теперь берёт ratio=0.70: при нём 0.70 × 1.30 = 0.91 < 1, и кламп не срабатывает. Мёртвый параметр radius_floor_factor в test_estimator_radius_floor убран: все вызовы и так передавали дефолт 0.8.
  • Три комментария фронта (rosreestr.ts, types/trade-in.ts, content.ts) по-прежнему называют старые имена полей. Правка из первой версии откачена, чтобы не пересобирать фронт ради комментариев (см. «Правки по ревью», п.7).

Бизнес и элит отдельно. Реплей по сделкам побитово тот же во всех сегментах по sold ₽/м²: эконом 884, комфорт 538, бизнес 169, элит 5, премиум 4.

#2381 — якорь «тот же дом» и IMV-blend

Было. Пять булевых флагов сняты в #2475 («No numeric/dict tuning constants touched»). Оставались 14 числовых полей.

Сделано. Поля перенесены константами: IMV_BLEND_WEIGHT=0.5, IMV_BLEND_THRESHOLD=1.15, SB_MIN_COMPS=4, SB_AREA_SIGMA=0.18, SB_ROOMS_MATCH_BOOST=1.6, SB_FLOOR_SIGMA=0.25, SB_GUARDRAIL_TOL=0.05, SB_MAD_K=3.5, SB_MAD_K_SMALL_N=2.5, SB_SMALL_N_THRESHOLD=10, ANCHOR_TIER_C_CORRIDOR_MULT=1.5, FSD_K=1.65, SB_GATE_MIN_N=3, SB_GATE_MAX_FSD=0.20. Убрана мёртвая проверка tier_c_mult > 0. Тесты подменяют SB_MIN_COMPS на модуле, а не поле settings. git grep -E 'estimate_(imv_blend|sb_|fsd_k|anchor_tier_c)' -- tradein-mvp/backend/app даёт 0 совпадений.

Мутационная проверка по одной константе:

  • imv_blend_weight/threshold, sb_min_comps, sb_area_sigma, sb_mad_k, sb_mad_k_small_n, sb_small_n_threshold и sb_gate_min_n ловят гейт или поведенческие тесты;
  • sb_rooms_match_boost и sb_floor_sigma: после ревью их ловит test_estimate_anchor_gets_prod_weights, в том числе подмену на месте вызова;
  • anchor_tier_c_corridor_mult при сдвиге вверх (3.0) ловит test_estimator_price_spine::test_tier_c_corridor_gate_suppresses_anchor, при сдвиге вниз (1.2) не ловит никто, кроме теста значений. В первой версии тела было «не ловит никто», это было неверно;
  • sb_guardrail_tol (0.10), fsd_k (1.5) и sb_gate_max_fsd (0.30) поведенческие тесты не ловят, проверено заново 17.09: 319 passed, rc=0 без теста значений. Тест значений держит только число, подмену на месте использования он не заметит.

Это долг покрытия, существовавший и до правки. Перед калибровкой этих порогов на них нужны поведенческие тесты.

По условию issue перед мержем обязателен APPROVE от deep-code-reviewer. Его нет, поэтому #2381 этот PR не закрывает (Refs, а не Closes). Код и тесты по #2381 готовы. Issue закрывается после APPROVE вручную, или строка закрытия для #2381 возвращается в тело, если APPROVE придёт до мержа.

Тесты

  • Весь сьют tradein-mvp/backend на голове f48a510e (после правок по ревью и merge origin/main): DATABASE_URL=... uv run python -m pytest tests/ -q -p no:cacheprovider6280 passed, 42 skipped, rc=0 (rc снят у самого pytest). До ревью было 6274: +6 новых тестов и параметров. На main до правки было 6225 passed, 42 skipped, rc=0.
  • test_backtest_regression_gate.py: 2 passed. Реплей по сделкам cmp-идентичен main после каждого коммита.
  • uv run ruff check app tests scripts: rc=0. ruff format --check на 23 изменённых .py: 23 files already formatted, rc=0.
  • Фронт: после ревью правок фронта в PR нет, tradein-mvp/frontend совпадает с main.

Фальсификация (красные прогоны)

Каждый раз копировал исходник в scratchpad, ломал его, прогонял тесты, восстанавливал и сверял diff -q.

#2378. _dedup_cross_source схлопнут к OFF-ветке (return lots), WIDE_CORRIDOR_THRESHOLD=1.5, MANUAL_REVIEW_HIGH_VALUE_RUB=25_000_000:

pytest rc=1
FAILED tests/test_backtest_regression_gate.py::test_backtest_regression_gate
FAILED tests/test_estimator_dedup_cross_source_2087.py::test_dedup_default_is_on
... (ещё 13 тестов дедупа)
FAILED tests/test_estimator_manual_review.py::test_high_value_boundary_inclusive
FAILED tests/test_estimator_split_corridor_1871.py::test_threshold_default_1_2
FAILED tests/test_estimator_anchor_dedup_gap_2265.py::test_anchor_path_n_analogs_matches_shown_count
19 failed, 97 passed
E  RuntimeError: ratio_resolver: replay made call #1 but fixture recorded only 0 — control flow diverged from capture
E  AssertionError: assert 3 == 1

#2379. QUARTER_INDEX_MIN_N_DEALS=5, QUARTER_MATCH_SKIP_RATIO=0.85, поле estimate_quarter_index_factor_max возвращено в Settings:

pytest rc=1
FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[QUARTER_INDEX_MIN_N_DEALS]
FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[QUARTER_MATCH_SKIP_RATIO]
FAILED tests/test_1970_estimator_constants.py::test_settings_field_is_gone[QUARTER_INDEX_FACTOR_MAX]
E  AssertionError: assert 5 == 10

#2380. Кламп expected_sold схлопнут к OFF-ветке (if False: в обоих местах):

pytest rc=1
FAILED tests/test_estimator_expected_sold_clamp.py::test_expected_sold_clamped_to_headline_when_ratio_above_1
FAILED tests/test_estimator_hedonic.py::test_le_asking_invariant_holds_under_hedonic
E  AssertionError: expected_sold 15525000 > asking 13500000

Затем значения: CORRIDOR_CLAMP_MIN_N=8, CORRIDOR_CLAMP_SLACK=0.30, RADIUS_FLOOR_FACTOR=0.7:

pytest rc=1
FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[CORRIDOR_CLAMP_MIN_N]
FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[CORRIDOR_CLAMP_SLACK]
FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[RADIUS_FLOOR_FACTOR]
FAILED tests/test_backtest_regression_gate.py::test_backtest_regression_gate

#2381. FSD_K=1.5, SB_MIN_COMPS=3:

pytest rc=1
FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[FSD_K]
FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[SB_MIN_COMPS]
FAILED tests/test_backtest_regression_gate.py::test_backtest_regression_gate
FAILED tests/test_same_building_anchor.py::test_755_min_comps_3_anchor_does_not_fire
E  AssertionError: assert 566248 == 210000

Деплой

  • Меняется только tradein-mvp/backend/**, пересобирается образ backend. Три комментария во фронте откачены: ради них пересобирался и пересоздавался tradein-frontend.
  • Пересоздаются tradein-backend, tradein-tgbot и tradein-scraper. tradein-frontend не трогается.
  • Деплой сам ждёт слива scrape_runs.status='running' до 5 минут, а потом всё равно пересоздаёт scraper. Перед мержем проверить, что running-прогонов нет.
  • Миграций нет. Контракт API не меняется.

Приёмка на проде (после деплоя, до 18.09.2026)

Счёт строк в логах признаком не годится. Контейнеры на poincare перезапущены 17.09 в 08:36 UTC. С тех пор в docker logs tradein-backend --since 24h 0 строк sb_anchor, imv_blend, corridor clamp, quarter_index, corridor_advisory_zone и 0 POST /api/v1/trade-in/estimate (проверено 17.09 в 09:00 UTC). Сравнение «до и после» дало бы 0 против 0 при любом исходе.

  1. Код доехал (основной признак). Проверка в каждом из трёх контейнеров (tradein-backend, tradein-scraper, tradein-tgbot):
    docker exec <c> sh -c 'cd /app && python -c "from app.core.config import Settings; from app.services import estimator as e; print(e.SB_MIN_COMPS, e.QUARTER_INDEX_FACTOR_MAX, e.CORRIDOR_CLAMP_SLACK, e.WIDE_CORRIDOR_THRESHOLD, [f for f in Settings.model_fields if f.startswith((\"estimate_sb_\",\"estimate_imv_blend\",\"estimate_dedup_analogs\",\"estimate_expected_sold_le\",\"estimate_corridor_clamp\",\"estimate_manual_review\"))])"'
    
    Ожидается 4 1.8 0.4 1.2 []. На старом коде команда падает с AttributeError: module 'app.services.estimator' has no attribute 'SB_MIN_COMPS'. Проверено на origin/main 17.09, на голове ветки вывод 4 1.8 0.4 1.2 []: маркер различает оба состояния. Рядом нужна проверка образа: docker inspect -f '{{.Image}}' у всех трёх контейнеров должен совпасть с digest gendesign-tradein-backend:<sha мержа>.
  2. Поведение не сдвинулось (прямое сравнение). Одна и та же оценка по фиксированному адресу (квартира ЕКБ с якорем того же дома) делается сразу до деплоя и сразу после. median_price_rub, expected_sold_price и range_low/high_rub должны совпасть. Между запусками не должен закончиться ни один scrape_runs, иначе меняется набор аналогов. Если числа разошлись, сначала сравнить списки analogs в двух ответах. Оценка пишет строку в trade_in_estimates, решение о ней за тем, кто деплоит.
  3. Офлайн-эквивалент п.2 уже есть: реплей фикстуры по 1600 сделкам cmp-идентичен main после каждого коммита.

Правки по ревью

Каждую находку перед правкой проверял сам. Все подтвердились, опровергнутых нет.

1. Смысловой конфликт с #3552 и #3554 (блокирующая). Подтверждено. Пробные слияния голов через git merge-tree --write-tree + commit-tree в отдельных worktree. Для #3552 (голова 08216e4c, до его правки): rc=1, 2 failed, 92 passed, AttributeError: Settings(...) has no attribute 'estimate_dedup_analogs_enabled' в test_estimator_dedup_cross_source_2087.py:963/:981. Для #3554: rc=1, текст выше в «Порядок мержа». С тех пор #3552 исправился сам (1a9a612a), а #3554 добавил ещё один такой вызов (test_floor_applied_at_exactly_clamp_min_n). Сделано: патч для #3554 проверен на тройном слиянии (6318 passed, rc=0), выложен комментарием в #3554. Правило порядка записано в начале тела.

2. Закрытие #2381 без APPROVE deep-code-reviewer. Подтверждено. Через API у PR нет ни одного ревью. Закрывающая строка для #2381 заменена на Refs.

3. Тест значений защищает число, а не использование. Подтверждено, и дыра оказалась шире. Повторил M4 (rooms_boost=1.0 на месте вызова) и M5 (min_n_deals=5 на обоих местах вызова): 345 passed, rc=0. Дальше выяснилось, что Guard-2 и bimodal-гард квартального индекса можно снять целиком (if False:), и test_estimator_quarter_index.py остаётся зелёным (40 passed, rc=0). Оба их теста были пустыми. В Guard-2 заглушка отдавала аналогам индекс цели, получался factor=1.0. В bimodal-тесте у аналогов не было кадастра, и поправку гасил Guard-5 (#2583) при любом состоянии гарда. Это путь #2379 с риском MEDIUM. Сделано (коммиты bc7fe9b0, 228c5f58):

  • у обоих тестов аналоги теперь с известным уровнем 1.0;
  • добавлены границы: доля ровно 0.6 поправку даёт, индекс ровно 2.0 даёт, 2.01 нет;
  • заглушки lookup'ов пишут min_n_deals, тест требует [10, 10];
  • обёртка _compute_same_building_anchor требует боевые sigma/rooms_boost/floor_sigma/min_comps/mad_k.

Фальсификация: каждый раз копия исходника в scratchpad, правка, прогон 17 файлов PR + test_estimator_quarter_index.py + test_backtest_fixture_roundtrip.py, восстановление, diff -q без различий.

M4  rooms_boost=1.0 на месте вызова          → rc=1  FAILED test_same_building_anchor.py::test_estimate_anchor_gets_prod_weights
    E  {'rooms_boost': 1.0} != {'rooms_boost': 1.6}
M4b floor_sigma=0.5 на месте вызова          → rc=1  FAILED ...::test_estimate_anchor_gets_prod_weights
M5  min_n_deals=5 на обоих местах вызова     → rc=1  FAILED test_estimator_quarter_index.py::test_quarter_lookups_filter_by_ten_deals
    E  assert [5, 5] == [10, 10]
M5' min_n_deals=5 только у батч-lookup'а     → rc=1  E  assert [10, 5] == [10, 10]
Guard-2 снят (if False:)                     → rc=1  FAILED ...::test_guard2_skip_when_majority_analogs_in_target_quarter
    E  AssertionError: assert 9000000 == 6000000
Guard-2 `>` → `>=`                           → rc=1  FAILED ...::test_guard2_ratio_exactly_0_6_still_corrects
    E  AssertionError: assert 6000000 == 9000000
bimodal снят                                 → rc=1  FAILED ...::test_bimodal_guard_skips_high_index_small_n[3.5-1.0], [2.01-1.0]
    E  AssertionError: assert 10800000 == 6000000
bimodal `>` → `>=`                           → rc=1  FAILED ...::test_bimodal_guard_skips_high_index_small_n[2.0-1.8]
    E  AssertionError: assert 6000000 == 10800000

Без test_1970_estimator_constants.py поведенческие тесты ловят и сдвиг самих констант. QUARTER_INDEX_MIN_N_DEALS=5, QUARTER_MATCH_SKIP_RATIO=0.85 и 0.5, QUARTER_INDEX_MAX_FOR_SMALL_N=4.0 и 1.9, SB_ROOMS_MATCH_BOOST=1.0, SB_FLOOR_SIGMA=0.5: rc=1 в каждом случае.

4. Неточность про anchor_tier_c_corridor_mult. Подтверждено. M9 (3.0): rc=1, test_tier_c_corridor_gate_suppresses_anchor. M10 (1.2): падает только тест значений. Формулировка в #2381 исправлена.

5. Приёмка слабо фальсифицируема. Подтверждено. 17.09 в 09:00 UTC в логах с рестарта в 08:36 было 0 строк по всем пяти маркерам и 0 POST-оценок. Раздел приёмки переписан: основной признак — маркер импорта плюс digest образа, дальше прямое сравнение одной оценки.

6. settings_at_capture без калибровочных констант. Подтверждено. _write_fixture писал только estimate_*. Теперь туда же пишутся числовые константы модуля, 63 штуки, CORRIDOR_CLAMP_MIN_N в том числе. replay_fixture снимок не читает, гейт не меняется. Коммит 91a075bc, тест test_write_fixture_records_engine_constants. Фальсификация: фильтр констант заменён на if False → rc=1, {'CORRIDOR_CLAMP_MIN_N': None} != {'CORRIDOR_CLAMP_MIN_N': 10}.

7. tradein-frontend пересобирается ради трёх комментариев. Подтверждено. Фильтр deploy-tradein.yml frontend: tradein-mvp/frontend/**. Правки откачены (коммит 2cfc2eda), фронт PR совпадает с main. Переименования ушли в патч для #3554: там фронт пересобирается в любом случае.

Прочее. Ветка уже была запушена, поэтому вместо rebase сделан git merge origin/main (f48a510e, подтянут 9cc23c66, #3567).

Closes #2378
Closes #2379
Closes #2380
Refs #2381 (закрыть после APPROVE deep-code-reviewer)

🤖 Generated with Claude Code

Четыре подзадачи #1970 одной веткой, по коммиту на issue. Смысл у всех один: поля `estimate_*` в `Settings`, которые формально переопределялись через ENV, но ни разу не переопределялись, становятся кодом. Булевы выключатели схлопнуты к боевому `True`, числовые пороги перенесены константами модуля с прежними значениями. ## Порядок мержа с #3554 (обязательно прочитать до мержа) Ветка удаляет поля `Settings` и параметр `radius_floor_factor` у тестового хелпера. `git merge-tree` сливает её с #3552 и #3554 без текстовых конфликтов. CI в Forgejo гоняет голову ветки, а не результат слияния, поэтому смысловой конфликт при мерже не всплывёт: PR, влитый вторым, молча сделает main красным. - **#3552 (`fix/estimator-analog-quality`) — уже согласован.** После ревью автор #3552 убрал `monkeypatch.setattr(settings, "estimate_dedup_analogs_enabled", True)` (коммит `1a9a612a`). Проверка: пробное слияние `f48a510e` (эта ветка) + `3992d754` (#3552), тесты 2087, 3234, гейт, 1970 и 2265 дают **164 passed, rc=0**. Порядок любой. - **#3554 (`fix/corridor-advisory-tier-a`) — конфликт есть.** На пробном слиянии `f48a510e` + `8bcb025c` без правок: **rc=1, 4 failed, 61 passed**: ``` FAILED tests/test_3466_corridor_tier_a.py::test_same_corridor_clamps_radius_path FAILED tests/test_3466_corridor_tier_a.py::test_tier_a_price_does_not_depend_on_corridor FAILED tests/test_estimator_radius_floor.py::test_floor_applied_at_exactly_clamp_min_n FAILED tests/test_estimator_radius_floor.py::test_floor_not_applied_below_clamp_min_n E AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_slack' E AttributeError: 'Settings' object has no attribute 'estimate_corridor_clamp_min_n' ``` За AttributeError прячется второй отказ. Если подставить порог руками, оба теста radius-floor падают с `TypeError: _run_estimate() got an unexpected keyword argument 'radius_floor_factor'`. Патч адаптации выложен комментарием в #3554. Он переводит тесты на `estimator.CORRIDOR_CLAMP_SLACK` и `app.core.config.CORRIDOR_CLAMP_MIN_N`, убирает `radius_floor_factor=0.8` и переименовывает три комментария во фронте. С патчем: те же 4 файла дают **65 passed, rc=0**. Весь сьют на тройном слиянии (эта ветка + #3552 + #3554 + патч): **6318 passed, 42 skipped, rc=0**. **Правило:** кто вливается вторым из пары #3556/#3554, тот сначала мержит `origin/main` к себе, накладывает патч и перепрогоняет сьют. Если #3554 вольют раньше, это сделаю в этой ветке. ## С чем сверялся До удаления каждого поля проверил его **фактическое** значение на проде 17.09.2026: `settings.<поле>` внутри `tradein-backend`, `tradein-scraper` и `tradein-tgbot` на poincare. Во всех трёх контейнерах значения совпали с дефолтами из кода. `printenv` не нашёл ни одной переменной `ESTIMATE_*` из этих кластеров (есть только `ESTIMATE_EXTERNAL_SOURCES_BACKGROUND`). Файла `/app/.env` в контейнерах нет. Схлопывал к этим значениям. Для проверки мало гейта по агрегированным метрикам, поэтому добавил реплей замороженной фикстуры **по каждой сделке**: 1600 предсказаний, из них 615 на радиусном пути (median, expected_sold, диапазон, tier, confidence). После каждого из четырёх коммитов результат `cmp`-идентичен main. Реплей детерминирован: два прогона на main дали один и тот же файл. Какие механизмы фикстура реально задевает (строки лога в реплее, main и эта ветка совпадают): - якорь собран 985 раз; - MAD-clip якоря сработал 693 раза; - low-conf гейт 11 раз; - IMV-blend 3 раза; - radius-floor 7 раз; - corridor clamp 1 раз; - кросс-source дедуп 2319 раз. **Ни разу** не сработали квартальный индекс, гейт Tier C и кламп `ratio > 1.0`. Для этих механизмов побитовое совпадение гейта ничего не доказывает, их держат юнит-тесты и новый тест значений (см. ниже). ## #2378 — дедуп аналогов, disclosure широкого коридора, ручная оценка **Было.** Флаг `estimate_dedup_analogs_enabled` остался, потому что гейт бэктеста пинил его в `False`. Тесты `test_same_building_anchor` и `test_estimator_n_analogs_priced` выключали дедуп, чтобы изолировать другой инвариант. Четыре числовых порога (`wide_corridor_threshold` и три `manual_review_*`) жили в `Settings`. В `Settings` также лежали осиротевшие комментарии к флагам, которые #2475 уже удалил. **Почему можно убрать.** С #3251/#3254 фикстура захвачена с `estimate_dedup_analogs_enabled=True`, а реплей брал значение из неё. Пин в гейте был мёртвым. **Сделано.** - Флаг удалён: `_dedup_cross_source` работает всегда. - Из `replay_fixture` убраны пин флага и try/finally, из гейта убран monkeypatch. - Пороги стали константами: `WIDE_CORRIDOR_THRESHOLD=1.2`, `MANUAL_REVIEW_HIGH_VALUE_RUB=20_000_000`, `MANUAL_REVIEW_WIDE_RANGE_RATIO=1.9`, `MANUAL_REVIEW_ELITE_PPM2=250_000`. - `_manual_review` больше не принимает `settings`. - OFF-тест дедупа удалён. Двум тестам, где аналоги выключали дедуп ради изоляции, выданы разные площади: теперь это разные физлоты, а не кросс-пост. Grep из приёмки `estimate_(wide_corridor|confidence_floor_no_analogs|manual_review|radius_dedup|dedup_analogs)` по `config.py`/`estimator.py` даёт 0 совпадений. Оговорка: старую фикстуру, захваченную с дедупом OFF, реплей больше не воспроизведёт. Она упадёт на «control flow diverged from capture», и её надо перезахватить. В репозитории лежит только свежая фикстура. ## #2379 — квартальный индекс (#764) **Было.** Переключатель снят ещё в #2475. Шесть числовых полей остались в `Settings`: `min_n_deals`, `match_skip_ratio`, `max_for_small_n`, `small_n_threshold`, `factor_min`, `factor_max`. **Сделано.** Поля перенесены константами `QUARTER_INDEX_MIN_N_DEALS=10`, `QUARTER_MATCH_SKIP_RATIO=0.6`, `QUARTER_INDEX_MAX_FOR_SMALL_N=2.0`, `QUARTER_INDEX_SMALL_N_THRESHOLD=50`, `QUARTER_INDEX_FACTOR_MIN=0.6`, `QUARTER_INDEX_FACTOR_MAX=1.8`. Комментарии #764/#859 переехали вместе с ними. Бэктест читает `m.QUARTER_INDEX_MIN_N_DEALS`. **Про риск из issue («проверить pure-radius путь отдельно»).** Реплей побитово совпадает по всем 615 радиусным сделкам. Но квартальный индекс в нём **не применяется ни разу**: у сделок фикстуры нет своего кадастра цели. Мутационная проверка по одной константе за раз до ревью показала: - `small_n_threshold`, `factor_min` и `factor_max` ловят поведенческие тесты `test_estimator_quarter_index.py`; - `min_n_deals`, `match_skip_ratio` и `max_for_small_n` не ловил ни один тест. Поэтому добавлен `tests/test_1970_estimator_constants.py`. В нём боевые значения, снятые с прода, и проверка, что поле не вернулось в `Settings`. Этот тест держит только **число в константе**, а не то, что код его читает (см. «Правки по ревью»: оба гарда можно было снять целиком). Теперь `min_n_deals`, `match_skip_ratio` и `max_for_small_n` ловят поведенческие тесты `test_estimator_quarter_index.py`, в том числе на месте вызова. ## #2380 — #1795: corridor-clamp, radius-floor, expected_sold ≤ asking, outlier small-n **Было.** Флаги `corridor_clamp_enabled` и `radius_floor_enabled` сняты в #2475. Остались булев `estimate_expected_sold_le_asking` с двумя ветками и пять числовых полей. **Сделано.** - Флаг удалён. Оба клампа безусловные: `if effective_ratio > 1.0` и повторный кламп после хедоники. - В `estimator.py` перенесены `CORRIDOR_CLAMP_SLACK=0.40`, `RADIUS_FLOOR_FACTOR=0.8`, `OUTLIER_SMALL_N_THRESHOLD=15`, `OUTLIER_TUKEY_K_SMALL=1.0`. - `CORRIDOR_CLAMP_MIN_N=10` лежит в `app.core.config` рядом с `LISTINGS_FRESH_DAYS`. Этот же порог читает `DkpCorridor.advisory_only` (#3452), а схема не должна импортировать estimator. Порог по-прежнему один. - Убрана мёртвая проверка `tukey_k_small < 1.5`: это сравнение константы с константой. - Сегментный множитель #2255 не тронут, порядок операций прежний. - Тесты: OFF-тесты клампа удалены. Тест потолка хедоники раньше выключал кламп, теперь берёт `ratio=0.70`: при нём `0.70 × 1.30 = 0.91 < 1`, и кламп не срабатывает. Мёртвый параметр `radius_floor_factor` в `test_estimator_radius_floor` убран: все вызовы и так передавали дефолт 0.8. - Три комментария фронта (`rosreestr.ts`, `types/trade-in.ts`, `content.ts`) по-прежнему называют старые имена полей. Правка из первой версии откачена, чтобы не пересобирать фронт ради комментариев (см. «Правки по ревью», п.7). **Бизнес и элит отдельно.** Реплей по сделкам побитово тот же во всех сегментах по sold ₽/м²: эконом 884, комфорт 538, бизнес 169, элит 5, премиум 4. ## #2381 — якорь «тот же дом» и IMV-blend **Было.** Пять булевых флагов сняты в #2475 («No numeric/dict tuning constants touched»). Оставались 14 числовых полей. **Сделано.** Поля перенесены константами: `IMV_BLEND_WEIGHT=0.5`, `IMV_BLEND_THRESHOLD=1.15`, `SB_MIN_COMPS=4`, `SB_AREA_SIGMA=0.18`, `SB_ROOMS_MATCH_BOOST=1.6`, `SB_FLOOR_SIGMA=0.25`, `SB_GUARDRAIL_TOL=0.05`, `SB_MAD_K=3.5`, `SB_MAD_K_SMALL_N=2.5`, `SB_SMALL_N_THRESHOLD=10`, `ANCHOR_TIER_C_CORRIDOR_MULT=1.5`, `FSD_K=1.65`, `SB_GATE_MIN_N=3`, `SB_GATE_MAX_FSD=0.20`. Убрана мёртвая проверка `tier_c_mult > 0`. Тесты подменяют `SB_MIN_COMPS` на модуле, а не поле `settings`. `git grep -E 'estimate_(imv_blend|sb_|fsd_k|anchor_tier_c)' -- tradein-mvp/backend/app` даёт 0 совпадений. Мутационная проверка по одной константе: - `imv_blend_weight/threshold`, `sb_min_comps`, `sb_area_sigma`, `sb_mad_k`, `sb_mad_k_small_n`, `sb_small_n_threshold` и `sb_gate_min_n` ловят гейт или поведенческие тесты; - `sb_rooms_match_boost` и `sb_floor_sigma`: после ревью их ловит `test_estimate_anchor_gets_prod_weights`, в том числе подмену на месте вызова; - `anchor_tier_c_corridor_mult` при сдвиге **вверх** (3.0) ловит `test_estimator_price_spine::test_tier_c_corridor_gate_suppresses_anchor`, при сдвиге **вниз** (1.2) не ловит никто, кроме теста значений. В первой версии тела было «не ловит никто», это было неверно; - `sb_guardrail_tol` (0.10), `fsd_k` (1.5) и `sb_gate_max_fsd` (0.30) **поведенческие тесты не ловят**, проверено заново 17.09: 319 passed, rc=0 без теста значений. Тест значений держит только число, подмену на месте использования он не заметит. Это долг покрытия, существовавший и до правки. Перед калибровкой этих порогов на них нужны поведенческие тесты. **По условию issue перед мержем обязателен APPROVE от `deep-code-reviewer`. Его нет, поэтому #2381 этот PR не закрывает** (`Refs`, а не `Closes`). Код и тесты по #2381 готовы. Issue закрывается после APPROVE вручную, или строка закрытия для #2381 возвращается в тело, если APPROVE придёт до мержа. ## Тесты - Весь сьют `tradein-mvp/backend` на голове `f48a510e` (после правок по ревью и merge `origin/main`): `DATABASE_URL=... uv run python -m pytest tests/ -q -p no:cacheprovider` → **6280 passed, 42 skipped, rc=0** (rc снят у самого pytest). До ревью было 6274: +6 новых тестов и параметров. На main до правки было 6225 passed, 42 skipped, rc=0. - `test_backtest_regression_gate.py`: 2 passed. Реплей по сделкам `cmp`-идентичен main после каждого коммита. - `uv run ruff check app tests scripts`: rc=0. `ruff format --check` на 23 изменённых .py: 23 files already formatted, rc=0. - Фронт: после ревью правок фронта в PR нет, `tradein-mvp/frontend` совпадает с main. ## Фальсификация (красные прогоны) Каждый раз копировал исходник в scratchpad, ломал его, прогонял тесты, восстанавливал и сверял `diff -q`. **#2378.** `_dedup_cross_source` схлопнут к OFF-ветке (`return lots`), `WIDE_CORRIDOR_THRESHOLD=1.5`, `MANUAL_REVIEW_HIGH_VALUE_RUB=25_000_000`: ``` pytest rc=1 FAILED tests/test_backtest_regression_gate.py::test_backtest_regression_gate FAILED tests/test_estimator_dedup_cross_source_2087.py::test_dedup_default_is_on ... (ещё 13 тестов дедупа) FAILED tests/test_estimator_manual_review.py::test_high_value_boundary_inclusive FAILED tests/test_estimator_split_corridor_1871.py::test_threshold_default_1_2 FAILED tests/test_estimator_anchor_dedup_gap_2265.py::test_anchor_path_n_analogs_matches_shown_count 19 failed, 97 passed E RuntimeError: ratio_resolver: replay made call #1 but fixture recorded only 0 — control flow diverged from capture E AssertionError: assert 3 == 1 ``` **#2379.** `QUARTER_INDEX_MIN_N_DEALS=5`, `QUARTER_MATCH_SKIP_RATIO=0.85`, поле `estimate_quarter_index_factor_max` возвращено в `Settings`: ``` pytest rc=1 FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[QUARTER_INDEX_MIN_N_DEALS] FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[QUARTER_MATCH_SKIP_RATIO] FAILED tests/test_1970_estimator_constants.py::test_settings_field_is_gone[QUARTER_INDEX_FACTOR_MAX] E AssertionError: assert 5 == 10 ``` **#2380.** Кламп expected_sold схлопнут к OFF-ветке (`if False:` в обоих местах): ``` pytest rc=1 FAILED tests/test_estimator_expected_sold_clamp.py::test_expected_sold_clamped_to_headline_when_ratio_above_1 FAILED tests/test_estimator_hedonic.py::test_le_asking_invariant_holds_under_hedonic E AssertionError: expected_sold 15525000 > asking 13500000 ``` Затем значения: `CORRIDOR_CLAMP_MIN_N=8`, `CORRIDOR_CLAMP_SLACK=0.30`, `RADIUS_FLOOR_FACTOR=0.7`: ``` pytest rc=1 FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[CORRIDOR_CLAMP_MIN_N] FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[CORRIDOR_CLAMP_SLACK] FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[RADIUS_FLOOR_FACTOR] FAILED tests/test_backtest_regression_gate.py::test_backtest_regression_gate ``` **#2381.** `FSD_K=1.5`, `SB_MIN_COMPS=3`: ``` pytest rc=1 FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[FSD_K] FAILED tests/test_1970_estimator_constants.py::test_constant_keeps_prod_value[SB_MIN_COMPS] FAILED tests/test_backtest_regression_gate.py::test_backtest_regression_gate FAILED tests/test_same_building_anchor.py::test_755_min_comps_3_anchor_does_not_fire E AssertionError: assert 566248 == 210000 ``` ## Деплой - Меняется только `tradein-mvp/backend/**`, пересобирается образ backend. Три комментария во фронте откачены: ради них пересобирался и пересоздавался `tradein-frontend`. - Пересоздаются `tradein-backend`, `tradein-tgbot` и `tradein-scraper`. `tradein-frontend` не трогается. - Деплой сам ждёт слива `scrape_runs.status='running'` до 5 минут, а потом всё равно пересоздаёт scraper. **Перед мержем проверить, что running-прогонов нет.** - Миграций нет. Контракт API не меняется. ## Приёмка на проде (после деплоя, до 18.09.2026) Счёт строк в логах признаком **не** годится. Контейнеры на poincare перезапущены 17.09 в 08:36 UTC. С тех пор в `docker logs tradein-backend --since 24h` 0 строк `sb_anchor`, `imv_blend`, `corridor clamp`, `quarter_index`, `corridor_advisory_zone` и 0 `POST /api/v1/trade-in/estimate` (проверено 17.09 в 09:00 UTC). Сравнение «до и после» дало бы 0 против 0 при любом исходе. 1. **Код доехал (основной признак).** Проверка в каждом из трёх контейнеров (`tradein-backend`, `tradein-scraper`, `tradein-tgbot`): ``` docker exec <c> sh -c 'cd /app && python -c "from app.core.config import Settings; from app.services import estimator as e; print(e.SB_MIN_COMPS, e.QUARTER_INDEX_FACTOR_MAX, e.CORRIDOR_CLAMP_SLACK, e.WIDE_CORRIDOR_THRESHOLD, [f for f in Settings.model_fields if f.startswith((\"estimate_sb_\",\"estimate_imv_blend\",\"estimate_dedup_analogs\",\"estimate_expected_sold_le\",\"estimate_corridor_clamp\",\"estimate_manual_review\"))])"' ``` Ожидается `4 1.8 0.4 1.2 []`. На старом коде команда падает с `AttributeError: module 'app.services.estimator' has no attribute 'SB_MIN_COMPS'`. Проверено на `origin/main` 17.09, на голове ветки вывод `4 1.8 0.4 1.2 []`: маркер различает оба состояния. Рядом нужна проверка образа: `docker inspect -f '{{.Image}}'` у всех трёх контейнеров должен совпасть с digest `gendesign-tradein-backend:<sha мержа>`. 2. **Поведение не сдвинулось (прямое сравнение).** Одна и та же оценка по фиксированному адресу (квартира ЕКБ с якорем того же дома) делается сразу до деплоя и сразу после. `median_price_rub`, `expected_sold_price` и `range_low/high_rub` должны совпасть. Между запусками не должен закончиться ни один `scrape_runs`, иначе меняется набор аналогов. Если числа разошлись, сначала сравнить списки `analogs` в двух ответах. Оценка пишет строку в `trade_in_estimates`, решение о ней за тем, кто деплоит. 3. Офлайн-эквивалент п.2 уже есть: реплей фикстуры по 1600 сделкам `cmp`-идентичен main после каждого коммита. ## Правки по ревью Каждую находку перед правкой проверял сам. Все подтвердились, опровергнутых нет. **1. Смысловой конфликт с #3552 и #3554 (блокирующая). Подтверждено.** Пробные слияния голов через `git merge-tree --write-tree` + `commit-tree` в отдельных worktree. Для #3552 (голова `08216e4c`, до его правки): **rc=1, 2 failed, 92 passed**, `AttributeError: Settings(...) has no attribute 'estimate_dedup_analogs_enabled'` в `test_estimator_dedup_cross_source_2087.py:963/:981`. Для #3554: **rc=1**, текст выше в «Порядок мержа». С тех пор #3552 исправился сам (`1a9a612a`), а #3554 добавил ещё один такой вызов (`test_floor_applied_at_exactly_clamp_min_n`). Сделано: патч для #3554 проверен на тройном слиянии (6318 passed, rc=0), выложен комментарием в #3554. Правило порядка записано в начале тела. **2. Закрытие #2381 без APPROVE `deep-code-reviewer`. Подтверждено.** Через API у PR нет ни одного ревью. Закрывающая строка для #2381 заменена на `Refs`. **3. Тест значений защищает число, а не использование. Подтверждено, и дыра оказалась шире.** Повторил M4 (`rooms_boost=1.0` на месте вызова) и M5 (`min_n_deals=5` на обоих местах вызова): **345 passed, rc=0**. Дальше выяснилось, что Guard-2 и bimodal-гард квартального индекса можно снять целиком (`if False:`), и `test_estimator_quarter_index.py` остаётся зелёным (40 passed, rc=0). Оба их теста были пустыми. В Guard-2 заглушка отдавала аналогам индекс цели, получался factor=1.0. В bimodal-тесте у аналогов не было кадастра, и поправку гасил Guard-5 (#2583) при любом состоянии гарда. Это путь #2379 с риском MEDIUM. Сделано (коммиты `bc7fe9b0`, `228c5f58`): - у обоих тестов аналоги теперь с известным уровнем 1.0; - добавлены границы: доля ровно 0.6 поправку даёт, индекс ровно 2.0 даёт, 2.01 нет; - заглушки lookup'ов пишут `min_n_deals`, тест требует `[10, 10]`; - обёртка `_compute_same_building_anchor` требует боевые `sigma/rooms_boost/floor_sigma/min_comps/mad_k`. Фальсификация: каждый раз копия исходника в scratchpad, правка, прогон 17 файлов PR + `test_estimator_quarter_index.py` + `test_backtest_fixture_roundtrip.py`, восстановление, `diff -q` без различий. ``` M4 rooms_boost=1.0 на месте вызова → rc=1 FAILED test_same_building_anchor.py::test_estimate_anchor_gets_prod_weights E {'rooms_boost': 1.0} != {'rooms_boost': 1.6} M4b floor_sigma=0.5 на месте вызова → rc=1 FAILED ...::test_estimate_anchor_gets_prod_weights M5 min_n_deals=5 на обоих местах вызова → rc=1 FAILED test_estimator_quarter_index.py::test_quarter_lookups_filter_by_ten_deals E assert [5, 5] == [10, 10] M5' min_n_deals=5 только у батч-lookup'а → rc=1 E assert [10, 5] == [10, 10] Guard-2 снят (if False:) → rc=1 FAILED ...::test_guard2_skip_when_majority_analogs_in_target_quarter E AssertionError: assert 9000000 == 6000000 Guard-2 `>` → `>=` → rc=1 FAILED ...::test_guard2_ratio_exactly_0_6_still_corrects E AssertionError: assert 6000000 == 9000000 bimodal снят → rc=1 FAILED ...::test_bimodal_guard_skips_high_index_small_n[3.5-1.0], [2.01-1.0] E AssertionError: assert 10800000 == 6000000 bimodal `>` → `>=` → rc=1 FAILED ...::test_bimodal_guard_skips_high_index_small_n[2.0-1.8] E AssertionError: assert 6000000 == 10800000 ``` Без `test_1970_estimator_constants.py` поведенческие тесты ловят и сдвиг самих констант. `QUARTER_INDEX_MIN_N_DEALS=5`, `QUARTER_MATCH_SKIP_RATIO=0.85` и `0.5`, `QUARTER_INDEX_MAX_FOR_SMALL_N=4.0` и `1.9`, `SB_ROOMS_MATCH_BOOST=1.0`, `SB_FLOOR_SIGMA=0.5`: rc=1 в каждом случае. **4. Неточность про `anchor_tier_c_corridor_mult`. Подтверждено.** M9 (3.0): rc=1, `test_tier_c_corridor_gate_suppresses_anchor`. M10 (1.2): падает только тест значений. Формулировка в #2381 исправлена. **5. Приёмка слабо фальсифицируема. Подтверждено.** 17.09 в 09:00 UTC в логах с рестарта в 08:36 было 0 строк по всем пяти маркерам и 0 POST-оценок. Раздел приёмки переписан: основной признак — маркер импорта плюс digest образа, дальше прямое сравнение одной оценки. **6. `settings_at_capture` без калибровочных констант. Подтверждено.** `_write_fixture` писал только `estimate_*`. Теперь туда же пишутся числовые константы модуля, 63 штуки, `CORRIDOR_CLAMP_MIN_N` в том числе. `replay_fixture` снимок не читает, гейт не меняется. Коммит `91a075bc`, тест `test_write_fixture_records_engine_constants`. Фальсификация: фильтр констант заменён на `if False` → rc=1, `{'CORRIDOR_CLAMP_MIN_N': None} != {'CORRIDOR_CLAMP_MIN_N': 10}`. **7. `tradein-frontend` пересобирается ради трёх комментариев. Подтверждено.** Фильтр `deploy-tradein.yml` `frontend: tradein-mvp/frontend/**`. Правки откачены (коммит `2cfc2eda`), фронт PR совпадает с main. Переименования ушли в патч для #3554: там фронт пересобирается в любом случае. **Прочее.** Ветка уже была запушена, поэтому вместо rebase сделан `git merge origin/main` (`f48a510e`, подтянут `9cc23c66`, #3567). Closes #2378 Closes #2379 Closes #2380 Refs #2381 (закрыть после APPROVE `deep-code-reviewer`) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bot-backend added 4 commits 2026-09-17 07:42:45 +00:00
Флаг estimate_dedup_analogs_enabled снят: кросс-source дедуп работает всегда.
На проде 17.09 флаг = True во всех трёх контейнерах (backend/scraper/tgbot),
ENV-оверрайда нет; фикстура бэктеста захвачена с True, поэтому пин флага в
реплее и monkeypatch в гейте больше не нужны.

Числовые пороги estimate_wide_corridor_threshold и три
estimate_manual_review_* перенесены в estimator.py константами модуля с
прежними значениями (1.2 / 20 000 000 / 1.9 / 250 000). _manual_review больше
не принимает settings. Осиротевшие комментарии Settings к уже снятым в #2475
флагам (#1871 P1.2, P2 radius-dedup) удалены.

Тесты: OFF-тест дедупа удалён; два теста, пинившие дедуп OFF ради изоляции,
получили разные площади у аналогов (разные физлоты). Регрессионный гейт и
реплей бэктеста по сделкам (1600, из них 615 радиусных) побитово те же.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Единственный переключатель estimate_quarter_index_enabled снят ещё в #2475.
Оставшиеся шесть числовых полей Settings (min_n_deals 10, match_skip_ratio 0.6,
max_for_small_n 2.0, small_n_threshold 50, factor_min 0.6, factor_max 1.8)
перенесены в estimator.py константами QUARTER_* с теми же значениями и
комментариями #764/#859; бэктест берёт QUARTER_INDEX_MIN_N_DEALS из модуля.
На проде 17.09 все шесть равны дефолтам, ENV-оверрайдов нет.

Замороженная фикстура квартальный индекс не применяет ни разу (0 из 1600
сделок), а поведенческие тесты не краснеют при подмене трёх порогов из шести,
поэтому добавлен tests/test_1970_estimator_constants.py: боевые значения,
снятые с прода, и проверка, что поле не вернулось в Settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Флаг estimate_expected_sold_le_asking снят: оба клампа (ratio > 1.0 и
повторный после хедоники) безусловные. На проде 17.09 флаг = True во всех
трёх контейнерах, ENV-оверрайда нет, фикстура бэктеста захвачена с True.
Флаги corridor_clamp/radius_floor enabled сняты ещё в #2475.

Числовые пороги перенесены с прежними значениями: CORRIDOR_CLAMP_SLACK 0.40,
RADIUS_FLOOR_FACTOR 0.8, OUTLIER_SMALL_N_THRESHOLD 15, OUTLIER_TUKEY_K_SMALL 1.0
— в estimator.py; CORRIDOR_CLAMP_MIN_N 10 — в app.core.config рядом с
LISTINGS_FRESH_DAYS, потому что его же читает DkpCorridor.advisory_only (#3452),
а схема не должна тянуть estimator. Мёртвая проверка `tukey_k_small < 1.5`
(константа против константы) убрана. Сегментный множитель (#2255) не тронут,
порядок операций прежний.

Тесты: OFF-тесты клампа удалены; тест потолка хедоники берёт ratio 0.70, при
котором кламп не срабатывает (0.70 × 1.30 = 0.91), вместо выключения клампа.
Реплей бэктеста по сделкам побитово тот же (бизнес 169, элит 5, премиум 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
МЕРА: якорь «тот же дом» и IMV-blend — константы движка, не настройки (#2381)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 22s
CI / changes (pull_request) Successful in 25s
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 / frontend-checks (pull_request) Successful in 2m35s
CI Trade-In / backend-tests (pull_request) Successful in 6m20s
31c31f8f82
Пять булевых флагов кластера сняты ещё в #2475. Оставшиеся 14 числовых полей
Settings перенесены в estimator.py константами с теми же значениями:
IMV_BLEND_WEIGHT 0.5, IMV_BLEND_THRESHOLD 1.15, SB_MIN_COMPS 4, SB_AREA_SIGMA
0.18, SB_ROOMS_MATCH_BOOST 1.6, SB_FLOOR_SIGMA 0.25, SB_GUARDRAIL_TOL 0.05,
SB_MAD_K 3.5, SB_MAD_K_SMALL_N 2.5, SB_SMALL_N_THRESHOLD 10,
ANCHOR_TIER_C_CORRIDOR_MULT 1.5, FSD_K 1.65, SB_GATE_MIN_N 3, SB_GATE_MAX_FSD
0.20. На проде 17.09 все 14 равны дефолтам, ENV-оверрайдов нет. Мёртвая
проверка `tier_c_mult > 0` (константа против нуля) убрана.

Тесты подменяют SB_MIN_COMPS на модуле вместо поля settings. Реплей бэктеста
по сделкам побитово тот же, срабатываний якоря 985, IMV-blend 3, low-conf
гейта 11 — как на main. Шесть порогов (rooms_boost, floor_sigma,
guardrail_tol, tier_c_mult, fsd_k, gate_max_fsd) не ловит ни один
поведенческий тест и ни гейт, их держит только тест боевых значений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Light1YT added 5 commits 2026-09-17 09:00:23 +00:00
Три правки в комментариях tradein-mvp/frontend (имена estimate_corridor_clamp_min_n
и estimate_imv_blend_weight → константы) кода не меняли, но фильтр deploy-tradein
по tradein-mvp/frontend/** пересобирал и пересоздавал tradein-frontend ради
комментариев. Комментарии переименуются вместе с ближайшей правкой фронта
(в #3554 фронт и так пересобирается — переименование приложено к нему патчем).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ревью #3556: тест значений держит число в константе, но не то, что код её читает.
Подмена min_n_deals=5 на обоих местах вызова проходила весь сьют. Хуже: Guard-2 и
bimodal-гард снимались целиком (`if False:`) при зелёном test_estimator_quarter_index —
оба теста были пустыми. Guard-2: заглушка отдавала аналогам индекс цели, factor=1.0.
Bimodal: аналоги без кадастра, поправку гасил Guard-5 (#2583) независимо от гарда.

- оба теста получили аналоги с известным уровнем 1.0 — без гарда цена сдвигается;
- границы: доля ровно 0.6 поправку даёт, индекс ровно 2.0 даёт, 2.01 — нет;
- заглушки lookup'ов пишут min_n_deals, тест требует 10 на обоих вызовах.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ревью #3556: подмена rooms_boost=1.0 на месте вызова _compute_same_building_anchor
проходила весь сьют — тест значений держит только число в константе. Новый тест
оборачивает расчёт якоря и требует боевые sigma/rooms_boost/floor_sigma/min_comps/
mad_k (прод 17.09.2026). SB_GUARDRAIL_TOL, FSD_K, SB_GATE_MAX_FSD и сдвиг
ANCHOR_TIER_C_CORRIDOR_MULT вниз по-прежнему без поведенческих тестов.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ревью #3556: settings_at_capture в _write_fixture писал только поля estimate_*
из Settings. После переноса 31 порога в константы estimator.py будущая фикстура
не знала бы, с какими порогами её захватили. Теперь в снимок попадают и
числовые константы модуля (63 штуки, CORRIDOR_CLAMP_MIN_N в том числе).
replay_fixture снимок по-прежнему не читает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into fix/estimator-flag-collapse
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 27s
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 6m36s
f48a510e94
bot-backend merged commit 0be4d91d30 into main 2026-09-17 09:22:29 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 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#3556
No description provided.