fix(forecasting): _price_overlap zero-width discontinuity (#1224) #1277

Merged
bot-backend merged 1 commit from fix/price-overlap-zero-width-degenerate into main 2026-06-13 08:10:47 +00:00
2 changed files with 54 additions and 10 deletions

View file

@ -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))
# Канонические ключи квартирографии (комнатность) — общее пространство, в котором

View file

@ -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: