fix(forecasting): _price_overlap zero-width discontinuity at §25.3 (#1224)
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 8s
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled

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
This commit is contained in:
Light1YT 2026-06-13 12:54:14 +05:00 committed by bot-backend
parent 67d24e9bf7
commit 10df430cac
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: