diff --git a/backend/app/services/forecasting/special_indices.py b/backend/app/services/forecasting/special_indices.py index d5c048f4..bab0b273 100644 --- a/backend/app/services/forecasting/special_indices.py +++ b/backend/app/services/forecasting/special_indices.py @@ -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, diff --git a/backend/tests/services/forecasting/test_special_indices.py b/backend/tests/services/forecasting/test_special_indices.py index 872d66c1..6bade42b 100644 --- a/backend/tests/services/forecasting/test_special_indices.py +++ b/backend/tests/services/forecasting/test_special_indices.py @@ -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