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: