Merge pull request 'fix(forecasting): correct _geo_weight unknown floor so far projects rank low (#1633)' (#1685) from fix/geo-weight-floor-1633 into main
Some checks are pending
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Waiting to run
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions

This commit is contained in:
lekss361 2026-06-17 17:55:17 +00:00
commit b92d3aeb65
2 changed files with 253 additions and 104 deletions

View file

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

View file

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