fix(forecasting): correct _geo_weight unknown floor so far projects rank low (#1633) #1685
2 changed files with 253 additions and 104 deletions
|
|
@ -143,7 +143,12 @@ _GEO_WEIGHT_SCALE_KM: float = 3.0
|
|||
# НЕ исключаем проект и НЕ даём полный вес — низкий floor, чтобы он мог сигналить, но
|
||||
# не доминировал над проектом с подтверждённой близостью. None-not-0 дух (есть проект,
|
||||
# но геопривязка слабая → down-weight, а не выкидываем).
|
||||
_GEO_WEIGHT_UNKNOWN: float = 0.1
|
||||
#
|
||||
# ВАЖНО: значение НИЖЕ exp(−6.9/3)≈0.10 (вес подтверждённо дальнего ~6.9 км проекта),
|
||||
# иначе «координаты неизвестны» даёт бо́льший вес чем «подтверждённо далеко» — инверсия.
|
||||
# При scale=3 км: exp(−9/3)=exp(−3)≈0.050. Значение 0.05 ≈ «условные 9 км»: проект без
|
||||
# координат не перебивает подтверждённо дальний (>6.9 км), но сохраняет ненулевой сигнал.
|
||||
_GEO_WEIGHT_UNKNOWN: float = 0.05
|
||||
|
||||
# Минимум доступных осей пересечения для пары кандидат↔наш проект, чтобы считать оценку
|
||||
# надёжной. < этого (напр. только класс) → пара low-confidence (сигнал есть, но тонкий).
|
||||
|
|
@ -1023,9 +1028,7 @@ def _launch_window_horizon(launch_window: SpecialIndex) -> int | None:
|
|||
return horizon if isinstance(horizon, int) else None
|
||||
|
||||
|
||||
def _candidate_release_month(
|
||||
launch_window: SpecialIndex, *, as_of: date
|
||||
) -> date | None:
|
||||
def _candidate_release_month(launch_window: SpecialIndex, *, as_of: date) -> date | None:
|
||||
"""Когда рекомендованный проект реально выйдет на рынок (тайминг §25.3). PURE.
|
||||
|
||||
= as_of (дата отчёта) + горизонт окна запуска §25.1: на этот месяц #980 предсказывает
|
||||
|
|
@ -1288,9 +1291,7 @@ def _build_cannibalization_true(
|
|||
contributions: list[tuple[float, OwnProject, _PairOverlap]] = []
|
||||
for own in portfolio:
|
||||
distance_km = _own_distance_km(centroid, own)
|
||||
price_overlap = _price_overlap(
|
||||
candidate_band, own.price_min_per_m2, own.price_max_per_m2
|
||||
)
|
||||
price_overlap = _price_overlap(candidate_band, own.price_min_per_m2, own.price_max_per_m2)
|
||||
pair = _own_portfolio_overlap(
|
||||
class_overlap=_class_overlap(spec.obj_class, own.obj_class),
|
||||
price_overlap=price_overlap,
|
||||
|
|
@ -1315,7 +1316,7 @@ def _build_cannibalization_true(
|
|||
# Топ-вкладчики (сильнейшие каннибализаторы) — детерминированно по signal DESC,
|
||||
# tie-break по имени проекта (стабильно, без RNG/порядка set).
|
||||
contributions.sort(key=lambda t: (-t[0], t[1].name))
|
||||
top = contributions[: _COMPETITOR_TOP_N]
|
||||
top = contributions[:_COMPETITOR_TOP_N]
|
||||
confidence = _cap_confidence(_portfolio_confidence(portfolio))
|
||||
return SpecialIndex(
|
||||
key=KEY_CANNIBALIZATION,
|
||||
|
|
@ -1382,9 +1383,7 @@ def _axes_available_summary(pairs: Sequence[_PairOverlap]) -> dict[str, int]:
|
|||
return summary
|
||||
|
||||
|
||||
def _contribution_detail(
|
||||
signal: float, own: OwnProject, pair: _PairOverlap
|
||||
) -> dict[str, Any]:
|
||||
def _contribution_detail(signal: float, own: OwnProject, pair: _PairOverlap) -> dict[str, Any]:
|
||||
"""Explainability-карточка одного топ-каннибализатора. PURE."""
|
||||
return {
|
||||
"name": own.name,
|
||||
|
|
|
|||
|
|
@ -625,14 +625,22 @@ def _launch_window_index(horizon: int | None) -> SpecialIndex:
|
|||
"""Готовый Launch Window SpecialIndex с заданным best_horizon_months (None → unavail)."""
|
||||
if horizon is None:
|
||||
return SpecialIndex(
|
||||
key=KEY_LAUNCH_WINDOW, value=None, label=None, confidence="low",
|
||||
key=KEY_LAUNCH_WINDOW,
|
||||
value=None,
|
||||
label=None,
|
||||
confidence="low",
|
||||
detail={"reason": "deficit None на всех горизонтах"},
|
||||
method=_METHOD_UNAVAILABLE, advisory=True,
|
||||
method=_METHOD_UNAVAILABLE,
|
||||
advisory=True,
|
||||
)
|
||||
return SpecialIndex(
|
||||
key=KEY_LAUNCH_WINDOW, value=0.8, label=f"{horizon} мес", confidence="medium",
|
||||
key=KEY_LAUNCH_WINDOW,
|
||||
value=0.8,
|
||||
label=f"{horizon} мес",
|
||||
confidence="medium",
|
||||
detail={"best_horizon_months": horizon, "deficit_by_horizon": {}},
|
||||
method="deficit_peak_scan", advisory=True,
|
||||
method="deficit_peak_scan",
|
||||
advisory=True,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -679,11 +687,39 @@ class TestGeoWeight:
|
|||
|
||||
def test_unknown_distance_floor_weight(self) -> None:
|
||||
# нет координат → низкий floor (НЕ 0, НЕ 1): проект сигналит, но не доминирует.
|
||||
assert _geo_weight(None) == pytest.approx(0.1)
|
||||
# #1633: понижено с 0.1 до 0.05 — старое значение 0.1 совпадало с весом
|
||||
# подтверждённо дальнего проекта (~6.9 км), создавая инверсию.
|
||||
assert _geo_weight(None) == pytest.approx(0.05)
|
||||
|
||||
def test_negative_distance_clamped(self) -> None:
|
||||
assert _geo_weight(-5.0) == pytest.approx(1.0)
|
||||
|
||||
def test_near_project_weight_exceeds_far_project_weight(self) -> None:
|
||||
# #1633: ближний проект ВСЕГДА перебивает дальний (монотонность).
|
||||
near = _geo_weight(1.0)
|
||||
far = _geo_weight(10.0)
|
||||
assert near > far
|
||||
|
||||
def test_confirmed_far_project_weight_below_unknown(self) -> None:
|
||||
# #1633: подтверждённо дальний (>6.9 км) должен иметь вес НИЖЕ неизвестного.
|
||||
# До фикса: exp(-6.9/3)≈0.10 == _GEO_WEIGHT_UNKNOWN (0.10) — инверсия/паритет.
|
||||
# После фикса: _GEO_WEIGHT_UNKNOWN=0.05, exp(-6.9/3)≈0.10 > 0.05 (правильно).
|
||||
# А при ещё бо́льшем расстоянии (10 км, ≈0.036) — тем более выше 0.05.
|
||||
# Здесь проверяем что неизвестный вес НИЖЕ вполне подтверждённо БЛИЗКОГО (1 км),
|
||||
# и что подтверждённо ДАЛЬНИЙ (10 км) не превышает неизвестный (чтобы сигналил,
|
||||
# но скромно — оба низкие; инверсия устранена).
|
||||
import math
|
||||
|
||||
unknown = _geo_weight(None)
|
||||
confirmed_near = _geo_weight(1.0) # ≈ 0.72
|
||||
confirmed_far = _geo_weight(10.0) # ≈ 0.036
|
||||
# confirmed_near >> unknown >> confirmed_far (правильная иерархия).
|
||||
assert confirmed_near > unknown
|
||||
assert unknown > confirmed_far
|
||||
# Числовая граница: exp(-6.9/3) ≈ 0.10 — вес "just-confirmed-far";
|
||||
# unknown (0.05) строго ниже этого порога.
|
||||
assert unknown < math.exp(-6.9 / 3.0)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# §25.3 own-portfolio overlap — пара (среднее доступных осей) + агрегация (soft-max)
|
||||
|
|
@ -745,16 +781,25 @@ class TestAggregateOverlap:
|
|||
def test_takes_max_signal_not_mean(self) -> None:
|
||||
# один сильный (0.9) + два слабых (0.1) → агрегат = 0.9 (soft-max), не среднее.
|
||||
strong = _own_portfolio_overlap(
|
||||
class_overlap=0.9, price_overlap=0.9,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
||||
class_overlap=0.9,
|
||||
price_overlap=0.9,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=1.0,
|
||||
)
|
||||
weak1 = _own_portfolio_overlap(
|
||||
class_overlap=0.1, price_overlap=0.1,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
||||
class_overlap=0.1,
|
||||
price_overlap=0.1,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=1.0,
|
||||
)
|
||||
weak2 = _own_portfolio_overlap(
|
||||
class_overlap=0.1, price_overlap=0.1,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
||||
class_overlap=0.1,
|
||||
price_overlap=0.1,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=1.0,
|
||||
)
|
||||
assert _aggregate_overlap([strong, weak1, weak2]) == pytest.approx(0.9)
|
||||
|
||||
|
|
@ -762,19 +807,28 @@ class TestAggregateOverlap:
|
|||
# сильное пересечение, но далеко (geo 0.1) → signal 0.05; близкое слабое
|
||||
# (overlap 0.3, geo 1.0 → 0.3) перебивает. Агрегат = 0.3.
|
||||
distant_strong = _own_portfolio_overlap(
|
||||
class_overlap=1.0, price_overlap=1.0,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=0.1,
|
||||
class_overlap=1.0,
|
||||
price_overlap=1.0,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=0.1,
|
||||
)
|
||||
near_weak = _own_portfolio_overlap(
|
||||
class_overlap=0.3, price_overlap=0.3,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
||||
class_overlap=0.3,
|
||||
price_overlap=0.3,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=1.0,
|
||||
)
|
||||
assert _aggregate_overlap([distant_strong, near_weak]) == pytest.approx(0.3)
|
||||
|
||||
def test_all_none_signals_is_none(self) -> None:
|
||||
empty = _own_portfolio_overlap(
|
||||
class_overlap=None, price_overlap=None,
|
||||
unit_mix_overlap=None, timing_overlap=None, geo_weight=1.0,
|
||||
class_overlap=None,
|
||||
price_overlap=None,
|
||||
unit_mix_overlap=None,
|
||||
timing_overlap=None,
|
||||
geo_weight=1.0,
|
||||
)
|
||||
assert _aggregate_overlap([empty, empty]) is None
|
||||
|
||||
|
|
@ -955,9 +1009,7 @@ class TestArtificialDemandSQL:
|
|||
def test_sql_reads_mortgage_signal_columns(self, _identity_resolver: Any) -> None:
|
||||
db = _mock_db_one({"n_sold": 10, "n_mortgage": 4})
|
||||
with _identity_resolver:
|
||||
_query_artificial_demand(
|
||||
db, district=None, obj_class=None, premise_kind="квартира"
|
||||
)
|
||||
_query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
|
||||
sql = _executed_sql(db)
|
||||
# сигнал ипотеки = encumbrance_type / bank_name (реальные колонки objective_lots).
|
||||
assert "encumbrance_type" in sql
|
||||
|
|
@ -1007,9 +1059,7 @@ class TestArtificialDemandSQL:
|
|||
# #1205: district=None → has_district=False, districts=[]. EKB-wide.
|
||||
db = _mock_db_one({"n_sold": 200, "n_mortgage": 80})
|
||||
with _identity_resolver:
|
||||
_query_artificial_demand(
|
||||
db, district=None, obj_class=None, premise_kind="квартира"
|
||||
)
|
||||
_query_artificial_demand(db, district=None, obj_class=None, premise_kind="квартира")
|
||||
p = _executed_params(db)
|
||||
assert p["has_district"] is False
|
||||
assert p["districts"] == []
|
||||
|
|
@ -1527,7 +1577,9 @@ class TestComputeSpecialIndicesGraceful:
|
|||
# Центроид участка для гео-веса (lon, lat) в окрестностях ЕКБ.
|
||||
_CENTROID = (60.6000, 56.8000)
|
||||
_CAND_SPEC = SegmentSpec(
|
||||
obj_class="комфорт", room_bucket="2-к 45-60", district="Академический",
|
||||
obj_class="комфорт",
|
||||
room_bucket="2-к 45-60",
|
||||
district="Академический",
|
||||
price_bucket=PRICE_BUCKET_COMFORT,
|
||||
)
|
||||
|
||||
|
|
@ -1625,7 +1677,9 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
|||
# Launch Window резолвится (пик h=24) → candidate_release_month выведен →
|
||||
# тайминговая ось теперь СЧИТАЕТСЯ (на тот же месяц, что наш проект → 1.0).
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
release_month=_DERIVED_CANDIDATE_MONTH,
|
||||
)
|
||||
card = _timing_card([own])
|
||||
|
|
@ -1639,12 +1693,24 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
|||
# near: release_month = выведенный месяц кандидата (timing 1.0); far: на 4 года
|
||||
# позже (timing → почти 0). Прочие оси идентичны → near должен дать выше value.
|
||||
near = _timing_card(
|
||||
[_own("Близкий-во-времени", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
release_month=_DERIVED_CANDIDATE_MONTH)]
|
||||
[
|
||||
_own(
|
||||
"Близкий-во-времени",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
release_month=_DERIVED_CANDIDATE_MONTH,
|
||||
)
|
||||
]
|
||||
)
|
||||
far = _timing_card(
|
||||
[_own("Далёкий-во-времени", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 48))]
|
||||
[
|
||||
_own(
|
||||
"Далёкий-во-времени",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 48),
|
||||
)
|
||||
]
|
||||
)
|
||||
near_v = near.indices[KEY_CANNIBALIZATION].value
|
||||
far_v = far.indices[KEY_CANNIBALIZATION].value
|
||||
|
|
@ -1657,7 +1723,9 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
|||
# классу/цене/гео (не падает, не фабрикует тайминг).
|
||||
forecasts = [_forecast_stub(None, horizon=h) for h in (6, 12, 18, 24)]
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
release_month=date(2027, 1, 1), # есть дата, но кандидатной нет
|
||||
)
|
||||
db = MagicMock()
|
||||
|
|
@ -1686,10 +1754,16 @@ class TestCannibalizationTimingAxisFedFromLaunchWindow:
|
|||
def test_timing_deterministic_same_inputs_identical_as_dict(self) -> None:
|
||||
# Детерминизм (§16): одинаковые входы (фикс. дата отчёта) → идентичный as_dict.
|
||||
portfolio = [
|
||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
release_month=_DERIVED_CANDIDATE_MONTH),
|
||||
_own("Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85,
|
||||
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 6)),
|
||||
_own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], release_month=_DERIVED_CANDIDATE_MONTH
|
||||
),
|
||||
_own(
|
||||
"Наш-Б",
|
||||
obj_class="комфорт+",
|
||||
lon=60.65,
|
||||
lat=56.85,
|
||||
release_month=_add_months(_DERIVED_CANDIDATE_MONTH, 6),
|
||||
),
|
||||
]
|
||||
first = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
||||
second = _timing_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
||||
|
|
@ -1703,8 +1777,14 @@ class TestCannibalizationTrueMode:
|
|||
# наш проект на участке (distance 0 → geo 1.0), класс/цена совпадают → overlap 1.0.
|
||||
portfolio = [
|
||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
||||
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
|
||||
lon=60.9, lat=57.1),
|
||||
_own(
|
||||
"Наш-Б",
|
||||
obj_class="бизнес",
|
||||
price_min=200_000.0,
|
||||
price_max=240_000.0,
|
||||
lon=60.9,
|
||||
lat=57.1,
|
||||
),
|
||||
]
|
||||
card = _cannibalization_card(portfolio)
|
||||
can = card.indices[KEY_CANNIBALIZATION]
|
||||
|
|
@ -1717,8 +1797,14 @@ class TestCannibalizationTrueMode:
|
|||
def test_top_contributor_surfaced(self) -> None:
|
||||
portfolio = [
|
||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
||||
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
|
||||
lon=60.9, lat=57.1),
|
||||
_own(
|
||||
"Наш-Б",
|
||||
obj_class="бизнес",
|
||||
price_min=200_000.0,
|
||||
price_max=240_000.0,
|
||||
lon=60.9,
|
||||
lat=57.1,
|
||||
),
|
||||
]
|
||||
card = _cannibalization_card(portfolio)
|
||||
can = card.indices[KEY_CANNIBALIZATION]
|
||||
|
|
@ -1742,20 +1828,24 @@ class TestCannibalizationTrueMode:
|
|||
assert near_v > far_v
|
||||
|
||||
def test_no_geometry_still_computes_via_other_axes(self) -> None:
|
||||
# нет центроида → гео-вес на floor (0.1), но класс+цена дают overlap → индекс есть.
|
||||
# нет центроида → гео-вес на floor (_GEO_WEIGHT_UNKNOWN=0.05), класс+цена дают
|
||||
# overlap → индекс есть (None-not-0). #1633: floor снижен 0.1→0.05.
|
||||
card = _cannibalization_card([_own("Без-гео")], centroid=None)
|
||||
can = card.indices[KEY_CANNIBALIZATION]
|
||||
assert can.method == "own_portfolio_overlap"
|
||||
assert can.detail["has_geometry"] is False
|
||||
# overlap 1.0 × geo floor 0.1 = 0.1.
|
||||
assert can.value == pytest.approx(0.1)
|
||||
# overlap 1.0 × geo floor 0.05 = 0.05.
|
||||
assert can.value == pytest.approx(0.05)
|
||||
|
||||
def test_unit_mix_and_timing_axes_when_supplied(self) -> None:
|
||||
# caller передаёт квартирографию/тайминг кандидата → оси становятся доступны.
|
||||
db = MagicMock()
|
||||
own_proj = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5}, release_month=date(2026, 6, 1),
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
release_month=date(2026, 6, 1),
|
||||
)
|
||||
with (
|
||||
patch(f"{_MOD}.get_own_portfolio", return_value=[own_proj]),
|
||||
|
|
@ -1764,7 +1854,9 @@ class TestCannibalizationTrueMode:
|
|||
from app.services.forecasting.special_indices import _build_cannibalization
|
||||
|
||||
idx = _build_cannibalization(
|
||||
db, spec=_CAND_SPEC, cad_num="66:41:0303161:123",
|
||||
db,
|
||||
spec=_CAND_SPEC,
|
||||
cad_num="66:41:0303161:123",
|
||||
candidate_unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
candidate_release_month=date(2026, 6, 1),
|
||||
)
|
||||
|
|
@ -1777,8 +1869,16 @@ class TestCannibalizationTrueMode:
|
|||
# наш проект без класса И без цены → ни класс, ни цена не считаются;
|
||||
# тайминг/квартирография тоже None → пара без осей → индекс unavailable (НЕ 0).
|
||||
card = _cannibalization_card(
|
||||
[_own("Пустой", obj_class=None, price_min=None, price_max=None,
|
||||
lon=_CENTROID[0], lat=_CENTROID[1])]
|
||||
[
|
||||
_own(
|
||||
"Пустой",
|
||||
obj_class=None,
|
||||
price_min=None,
|
||||
price_max=None,
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
)
|
||||
]
|
||||
)
|
||||
can = card.indices[KEY_CANNIBALIZATION]
|
||||
assert can.value is None
|
||||
|
|
@ -1809,9 +1909,9 @@ class TestCannibalizationThinPortfolioConfidence:
|
|||
assert "future" in can.detail["confidence_note"]
|
||||
|
||||
def test_single_project_is_low_data_scarce(self) -> None:
|
||||
can = _cannibalization_card(
|
||||
[_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])]
|
||||
).indices[KEY_CANNIBALIZATION]
|
||||
can = _cannibalization_card([_own("Один", lon=_CENTROID[0], lat=_CENTROID[1])]).indices[
|
||||
KEY_CANNIBALIZATION
|
||||
]
|
||||
assert can.confidence == "low"
|
||||
assert "данных мало" in can.detail["confidence_note"]
|
||||
|
||||
|
|
@ -1857,8 +1957,14 @@ class TestCannibalizationDeterminism:
|
|||
def test_same_inputs_identical_output(self) -> None:
|
||||
portfolio = [
|
||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1]),
|
||||
_own("Наш-Б", obj_class="бизнес", price_min=200_000.0, price_max=240_000.0,
|
||||
lon=60.9, lat=57.1),
|
||||
_own(
|
||||
"Наш-Б",
|
||||
obj_class="бизнес",
|
||||
price_min=200_000.0,
|
||||
price_max=240_000.0,
|
||||
lon=60.9,
|
||||
lat=57.1,
|
||||
),
|
||||
_own("Наш-В", obj_class="комфорт+", lon=60.65, lat=56.85),
|
||||
]
|
||||
first = _cannibalization_card(list(portfolio)).indices[KEY_CANNIBALIZATION].as_dict()
|
||||
|
|
@ -1932,7 +2038,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
# recommend_mix отдаёт микс кандидата (RU-подписи), наш проект — manual латиница с
|
||||
# ТЕМ ЖЕ распределением по комнатности → ось квартирографии = 1.0 (через канон).
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
|
||||
)
|
||||
card = _unitmix_card(
|
||||
|
|
@ -1951,21 +2059,30 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
# Все прочие оси (класс/цена/гео/тайминг) идентичны → разница только в квартирографии.
|
||||
rec = _recommend_mix_result({"Студии 15-30": 50.0, "1-к 30-45": 50.0})
|
||||
similar = _unitmix_card(
|
||||
[_own("Похожий", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5})],
|
||||
[
|
||||
_own(
|
||||
"Похожий",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
)
|
||||
],
|
||||
recommend_return=rec,
|
||||
)
|
||||
dissimilar = _unitmix_card(
|
||||
[_own("Непохожий", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"2k": 0.5, "3k": 0.5})],
|
||||
[
|
||||
_own(
|
||||
"Непохожий", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"2k": 0.5, "3k": 0.5}
|
||||
)
|
||||
],
|
||||
recommend_return=rec,
|
||||
)
|
||||
sim_axis = similar.indices[KEY_CANNIBALIZATION].detail[
|
||||
"top_contributors"
|
||||
][0]["axes"]["unit_mix"]
|
||||
dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail[
|
||||
"top_contributors"
|
||||
][0]["axes"]["unit_mix"]
|
||||
sim_axis = similar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
|
||||
"unit_mix"
|
||||
]
|
||||
dis_axis = dissimilar.indices[KEY_CANNIBALIZATION].detail["top_contributors"][0]["axes"][
|
||||
"unit_mix"
|
||||
]
|
||||
assert sim_axis > dis_axis
|
||||
# и итоговое значение каннибализации выше при похожем миксе.
|
||||
assert (
|
||||
|
|
@ -1977,7 +2094,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
# recommend_mix вернул None → микс None → ось квартирографии исключена (None-not-0),
|
||||
# каннибализация всё равно считается по классу/цене/тайм/гео.
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
)
|
||||
card = _unitmix_card([own], recommend_return=None)
|
||||
|
|
@ -1990,7 +2109,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
def test_axis_excluded_when_recommend_empty_buckets(self) -> None:
|
||||
# recommend_mix отдал пустые buckets (тонкие данные) → микс None → ось исключена.
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
)
|
||||
card = _unitmix_card([own], recommend_return={"buckets": []})
|
||||
|
|
@ -2001,7 +2122,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
def test_axis_excluded_when_recommend_raises_no_crash(self) -> None:
|
||||
# recommend_mix БРОСИЛ → graceful None → ось исключена, карточка цела (НЕ crash).
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
)
|
||||
card = _unitmix_card([own], recommend_side_effect=RuntimeError("heavy query boom"))
|
||||
|
|
@ -2015,7 +2138,10 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
# ЧЕСТНОСТЬ (own_portfolio PR1): current-проект несёт unit_mix=None → даже при
|
||||
# валидном recommend-миксе кандидата ось не считается (нечего сравнивать).
|
||||
own = _own(
|
||||
"Текущий", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Текущий",
|
||||
source="current",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix=None,
|
||||
)
|
||||
card = _unitmix_card(
|
||||
|
|
@ -2029,7 +2155,9 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
def test_all_four_axes_active_together(self) -> None:
|
||||
# class + price + timing + unit_mix ВСЕ доступны на одном проекте → n_axes == 4.
|
||||
own = _own(
|
||||
"Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.5, "1k": 0.5},
|
||||
release_month=_DERIVED_CANDIDATE_MONTH, # совпадает с выведенным тайм. кандидата
|
||||
)
|
||||
|
|
@ -2045,28 +2173,34 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
def test_unit_mix_axis_deterministic_identical_as_dict(self) -> None:
|
||||
# Детерминизм (§16): одинаковые входы → идентичный as_dict (с активной осью).
|
||||
portfolio = [
|
||||
_own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3}),
|
||||
_own("Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85,
|
||||
unit_mix={"1k": 0.6, "2k": 0.4}),
|
||||
_own(
|
||||
"Наш-А",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 0.3, "1k": 0.4, "2k": 0.3},
|
||||
),
|
||||
_own(
|
||||
"Наш-Б", obj_class="комфорт+", lon=60.65, lat=56.85, unit_mix={"1k": 0.6, "2k": 0.4}
|
||||
),
|
||||
]
|
||||
rec = _recommend_mix_result(
|
||||
{"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0}
|
||||
rec = _recommend_mix_result({"Студии 15-30": 30.0, "1-к 30-45": 40.0, "2-к 45-60": 30.0})
|
||||
first = (
|
||||
_unitmix_card(list(portfolio), recommend_return=rec)
|
||||
.indices[KEY_CANNIBALIZATION]
|
||||
.as_dict()
|
||||
)
|
||||
second = (
|
||||
_unitmix_card(list(portfolio), recommend_return=rec)
|
||||
.indices[KEY_CANNIBALIZATION]
|
||||
.as_dict()
|
||||
)
|
||||
first = _unitmix_card(list(portfolio), recommend_return=rec).indices[
|
||||
KEY_CANNIBALIZATION
|
||||
].as_dict()
|
||||
second = _unitmix_card(list(portfolio), recommend_return=rec).indices[
|
||||
KEY_CANNIBALIZATION
|
||||
].as_dict()
|
||||
assert first == second
|
||||
# подтверждаем, что ось реально участвовала (не пустой детерминизм).
|
||||
assert first["detail"]["axes_available"]["unit_mix"] == 2
|
||||
|
||||
def test_recommend_mix_called_with_expected_args(self) -> None:
|
||||
# cost/корректность: recommend_mix зовётся с district+target_class+cad_num кандидата.
|
||||
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 1.0})
|
||||
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
|
||||
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
|
||||
db = MagicMock()
|
||||
with (
|
||||
|
|
@ -2091,8 +2225,7 @@ class TestCannibalizationUnitMixAxisFedFromRecommendMix:
|
|||
|
||||
def test_no_district_skips_recommend_mix(self) -> None:
|
||||
# нет района → recommend_mix НЕ зовётся (район обязателен) → ось исключена, без вызова.
|
||||
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 1.0})
|
||||
own = _own("Наш-А", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={"studio": 1.0})
|
||||
rec_mock = MagicMock(return_value=_recommend_mix_result({"Студии 15-30": 100.0}))
|
||||
db = MagicMock()
|
||||
with (
|
||||
|
|
@ -2155,10 +2288,22 @@ class TestCannibalizationUnitMixGate:
|
|||
# Портфель только из current/domrf (unit_mix=None) → ось квартирографии всё равно
|
||||
# исключилась бы → тяжёлый recommend_mix НЕ должен вызываться (call_count == 0).
|
||||
portfolio = [
|
||||
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix=None, release_month=_DERIVED_CANDIDATE_MONTH),
|
||||
_own("Текущий-2", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix=None, release_month=_DERIVED_CANDIDATE_MONTH),
|
||||
_own(
|
||||
"Текущий-1",
|
||||
source="current",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix=None,
|
||||
release_month=_DERIVED_CANDIDATE_MONTH,
|
||||
),
|
||||
_own(
|
||||
"Текущий-2",
|
||||
source="current",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix=None,
|
||||
release_month=_DERIVED_CANDIDATE_MONTH,
|
||||
),
|
||||
]
|
||||
card, rec_mock, _ = _gated_card(portfolio)
|
||||
# Гейт сработал: тяжёлый запрос НЕ выполнен.
|
||||
|
|
@ -2179,8 +2324,13 @@ class TestCannibalizationUnitMixGate:
|
|||
# Есть future-проект с unit_mix → ось может внести вклад → recommend_mix зовётся
|
||||
# РОВНО один раз (не O(n), не дважды), и get_own_portfolio фетчится РОВНО один раз.
|
||||
portfolio = [
|
||||
_own("Будущий-А", source="future", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={"studio": 1.0}),
|
||||
_own(
|
||||
"Будущий-А",
|
||||
source="future",
|
||||
lon=_CENTROID[0],
|
||||
lat=_CENTROID[1],
|
||||
unit_mix={"studio": 1.0},
|
||||
),
|
||||
]
|
||||
card, rec_mock, portfolio_mock = _gated_card(portfolio)
|
||||
# Тяжёлый запрос — ровно один доп. вызов на отчёт.
|
||||
|
|
@ -2197,8 +2347,7 @@ class TestCannibalizationUnitMixGate:
|
|||
# Даже когда гейт пропускает recommend_mix, get_own_portfolio всё равно фетчится
|
||||
# РОВНО один раз (портфель нужен и для гейта, и для самой каннибализации).
|
||||
portfolio = [
|
||||
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix=None),
|
||||
_own("Текущий-1", source="current", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix=None),
|
||||
]
|
||||
_, rec_mock, portfolio_mock = _gated_card(portfolio)
|
||||
assert rec_mock.call_count == 0
|
||||
|
|
@ -2207,8 +2356,9 @@ class TestCannibalizationUnitMixGate:
|
|||
def test_empty_mix_dict_does_not_trigger_recommend_mix(self) -> None:
|
||||
# Пустой dict unit_mix={} (грязь) — НЕ «есть микс» → recommend_mix не зовётся.
|
||||
portfolio = [
|
||||
_own("Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1],
|
||||
unit_mix={}),
|
||||
_own(
|
||||
"Будущий-пустой", source="future", lon=_CENTROID[0], lat=_CENTROID[1], unit_mix={}
|
||||
),
|
||||
]
|
||||
_, rec_mock, _ = _gated_card(portfolio)
|
||||
assert rec_mock.call_count == 0
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue