From 582f384a5fb7c6b019b8ce36ae7102ad7ce5bd27 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sat, 13 Jun 2026 12:54:14 +0500 Subject: [PATCH] =?UTF-8?q?fix(forecasting):=20=5Fprice=5Foverlap=20zero-w?= =?UTF-8?q?idth=20discontinuity=20at=20=C2=A725.3=20(#1224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Degenerate price band (own_min==own_max или c_lo==c_hi, оба разрешены CHECK 148 и Pydantic) внутри другой вилки возвращали 0.0 вместо 1.0: фильтр w>0 выкидывал нулевую ширину → 0/positive=0.0. Это рвало докстринг 'полное накрытие узкого = 1.0' и давало разрыв: [148k,152k]→1.0 vs [150k,150k]→0.0, занижая среднее каннибализации. Patch: вырожденные ширины обрабатываются ДО нормирования. lo<=hi → точка внутри другой вилки → 1.0, вне → 0.0. +inf-обе-премиум ветка перенесена в начало (избежать inf-inf=nan). +7 новых тестов в TestPriceOverlap. 220 special_indices тестов зелёные. Closes #1224 --- .../services/forecasting/special_indices.py | 30 ++++++++++------ .../forecasting/test_special_indices.py | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/backend/app/services/forecasting/special_indices.py b/backend/app/services/forecasting/special_indices.py index 251657a4..79a14142 100644 --- a/backend/app/services/forecasting/special_indices.py +++ b/backend/app/services/forecasting/special_indices.py @@ -442,9 +442,15 @@ def _price_overlap( Пересечение интервалов [c_lo,c_hi] и [o_lo,o_hi]; нормируем на УЖЕ узкую вилку (min ширины) → полное накрытие более узкого диапазона = 1.0 (стороны полностью - конкурируют в этой ценовой зоне). Любая граница / вилка кандидата неизвестна или - обе вилки нулевой ширины → None (ось НЕДОСТУПНА — НЕ фабрикуем 0). Открытая правая - граница (премиум, +inf) поддержана: ширина считается от конечной нижней. PURE. + конкурируют в этой ценовой зоне). Любая граница / вилка кандидата неизвестна → + None (ось НЕДОСТУПНА — НЕ фабрикуем 0). Открытая правая граница (премиум, +inf) + поддержана: ширина считается от конечной нижней. + + Вырожденная вилка (point: own_min==own_max или c_lo==c_hi) обрабатывается ДО + нормирования: точка ВНУТРИ другой вилки → 1.0 (полное накрытие узкого = вырожденная + вилка нулевой ширины полностью «попадает» в широкую — предельный случай 1.0), + точка ВНЕ → 0.0. Обе вилки — точки: совпадают → 1.0, различны → 0.0. Это устраняет + разрыв [148k,152k]→1.0 vs [150k,150k]→0.0 (см. #1224). PURE. """ if candidate_band is None or own_lo is None or own_hi is None: return None @@ -452,16 +458,20 @@ def _price_overlap( o_lo, o_hi = (own_lo, own_hi) if own_lo <= own_hi else (own_hi, own_lo) lo = max(c_lo, o_lo) hi = min(c_hi, o_hi) - overlap = max(0.0, hi - lo) - if not math.isfinite(overlap): - # Обе вилки уходят в +inf (оба премиум) — считаем полным пересечением. + # Обе вилки уходят в +inf (оба премиум) — считаем полным пересечением. + # Проверяется ДО арифметики ширин (inf - inf = nan). + if math.isinf(c_hi) and math.isinf(o_hi): return 1.0 c_width = c_hi - c_lo o_width = o_hi - o_lo - widths = [w for w in (c_width, o_width) if math.isfinite(w) and w > 0] - if not widths: - return None - return _clamp01(overlap / min(widths)) + # Вырожденный случай (≥1 вилка — точка): фильтр w>0 ниже выкинул бы нулевую ширину + # и дал бы 0/positive=0.0 даже когда точка ЛЕЖИТ внутри другой вилки. Это разрыв + # ([148k,152k]→1.0 vs [150k,150k]→0.0) и противоречит докстрингу «накрытие узкого = + # 1.0». Обрабатываем геометрией включения: точка внутри [lo,hi] → 1.0, вне → 0.0. + if c_width == 0 or o_width == 0: + return 1.0 if lo <= hi else 0.0 + overlap = max(0.0, hi - lo) + return _clamp01(overlap / min(c_width, o_width)) # Канонические ключи квартирографии (комнатность) — общее пространство, в котором diff --git a/backend/tests/services/forecasting/test_special_indices.py b/backend/tests/services/forecasting/test_special_indices.py index 146ee422..872d66c1 100644 --- a/backend/tests/services/forecasting/test_special_indices.py +++ b/backend/tests/services/forecasting/test_special_indices.py @@ -366,6 +366,40 @@ class TestPriceOverlap: assert _price_overlap((120_000.0, 160_000.0), None, 200_000.0) is None assert _price_overlap((120_000.0, 160_000.0), 100_000.0, None) is None + def test_own_point_inside_candidate_is_one(self) -> None: + # #1224: own_min==own_max (нулевая ширина — допустимо CHECK миграции 148 и + # Pydantic own_project.py:75) ВНУТРИ вилки кандидата → 1.0, а не 0.0 + # (которое выдавал старый фильтр w>0). Полное накрытие узкого = 1.0 по + # документированной семантике; разрыв [148k,152k]→1.0 vs [150k,150k]→0.0 + # устранён. + assert _price_overlap((120_000.0, 160_000.0), 150_000.0, 150_000.0) == 1.0 + + def test_own_point_on_candidate_boundary_is_one(self) -> None: + # точка на границе вилки кандидата (lo<=hi включительно) — внутри → 1.0. + assert _price_overlap((120_000.0, 160_000.0), 120_000.0, 120_000.0) == 1.0 + assert _price_overlap((120_000.0, 160_000.0), 160_000.0, 160_000.0) == 1.0 + + def test_own_point_outside_candidate_is_zero(self) -> None: + # точка ВНЕ вилки кандидата → 0.0 (нет ценовой конкуренции). + assert _price_overlap((120_000.0, 160_000.0), 200_000.0, 200_000.0) == 0.0 + assert _price_overlap((120_000.0, 160_000.0), 100_000.0, 100_000.0) == 0.0 + + def test_candidate_point_inside_own_is_one(self) -> None: + # симметричный случай: вырожденная вилка кандидата (c_lo==c_hi) ВНУТРИ + # вилки нашего проекта → 1.0. + assert _price_overlap((150_000.0, 150_000.0), 120_000.0, 160_000.0) == 1.0 + + def test_candidate_point_outside_own_is_zero(self) -> None: + assert _price_overlap((200_000.0, 200_000.0), 120_000.0, 160_000.0) == 0.0 + + def test_both_points_equal_is_one(self) -> None: + # обе стороны — одна и та же точка → полное накрытие = 1.0. + assert _price_overlap((150_000.0, 150_000.0), 150_000.0, 150_000.0) == 1.0 + + def test_both_points_different_is_zero(self) -> None: + # обе стороны — точки, но РАЗНЫЕ → 0.0. + assert _price_overlap((150_000.0, 150_000.0), 200_000.0, 200_000.0) == 0.0 + class TestUnitMixSimilarity: def test_identical_mix_is_one(self) -> None: