Feed candidate_unit_mix into _build_cannibalization (mirrors how #1169 fed
candidate_release_month from the launch window), completing §25.3 to all 4 axes:
class + price + timing + unit-mix, plus geo weight.
- candidate mix from recommend_mix "buckets[].share_pct" (same rule-based
квартирография as §22 product_tz), extracted + normalized to {bucket: share}.
- _canonical_room_bucket folds recommend_mix RU labels ("1-к 30-45", "80+ м²")
and manual own_planned_project Latin keys ("1k") into one room-count space —
without it the L1 similarity would silently be 0 (disjoint keys).
- recommend_mix is HEAVY, so it's GATED: derived only when the own-portfolio has
>=1 project with a non-empty unit_mix; get_own_portfolio fetched once in
compute_special_indices and threaded into _build_cannibalization (no double
fetch). With OWN_DEVELOPER_IDS unset (portfolio empty) → zero added cost on the
hot §22 report path.
- Graceful (recommend_mix None/empty/raises → axis excluded, None-not-0),
deterministic. Unit-mix only fires for manual-future own-projects with a mix
(domrf-current carry unit_mix=None) — expected narrowness, documented.
205 tests; ruff + mypy clean. Scope: special_indices.py + test only; no deps.
Refs #1169
Upgrade the §25.3 cannibalization index from a same-class-competitor proxy to
true own-portfolio overlap: score the recommended candidate segment against the
developer's own portfolio (get_own_portfolio, #1169 PR1) across axes —
audience/class (ordinal distance), price ₽/м² (interval overlap), unit-mix
(L1 similarity), timing (half-life decay) — geo-weighted by haversine proximity
to the parcel, aggregated by geo-weighted soft-max (the strongest nearby
cannibalizer dominates, not a diluting mean). Empty portfolio -> labelled proxy
fallback, confidence forced low, never presented as the true index.
Pure scoring fns unit-tested without DB; None-not-0 on missing axes; thin/
only-current portfolio -> low confidence + §26 note; deterministic (sorted
tie-breaks, no RNG). class+price+geo active now; unit-mix+timing plumbed via
optional params for a follow-up that wires them from the orchestrator horizon.
ruff + mypy clean; 151 special-index tests pass (964 forecasting dir, no regr).
Refs #1169