feat(forecasting): §25.3 cannibalization unit-mix axis (4/4), gated (#1169) #1180

Merged
bot-backend merged 1 commit from feat/cannibalization-unitmix-1169 into main 2026-06-09 11:18:00 +00:00
Collaborator

Summary

Activates the §25.3 cannibalization unit-mix axis — the 4th and final axis. §25.3 now scores a candidate vs own-portfolio on class + price + timing + unit-mix (+ geo weight).

  • candidate mix from recommend_mix buckets[].share_pct (the same rule-based квартирография as §22 product_tz), extracted + normalized to {bucket: share}.
  • _canonical_room_bucket folds two disjoint key vocabularies into one room-count space: recommend_mix RU labels ("1-к 30-45", "80+ м²") ↔ manual own_planned_project Latin keys ("1k"). Without it the L1 similarity would silently be 0 (no shared keys) — load-bearing, verified by code-review.
  • Gated (perf): recommend_mix is heavy, so it's derived only when the own-portfolio has ≥1 project with a non-empty unit_mix. get_own_portfolio fetched once in compute_special_indices, threaded into _build_cannibalization (no double-fetch). With OWN_DEVELOPER_IDS unset (empty portfolio) → zero added cost on the hot §22 report path.
  • Graceful (recommend_mix None/empty/raises → axis excluded, None-not-0), deterministic.

Test plan

  • uv run pytest tests/services/forecasting/test_special_indices.py -q205 passed (+ cross-vocab alignment, share extraction, gate call_count==0/1, single-fetch, all-4-axes, determinism)
  • ruff + mypy clean (0 new errors)
  • code-reviewer APPROVE (vocabulary alignment correct; eager-call regression fixed via the gate)

Honesty note

Unit-mix only fires for manual-future own-projects that have a mix set (domrf-current projects carry unit_mix=None) — narrow until manual portfolio data exists. Like the rest of own-portfolio cannibalization, it's dormant until OWN_DEVELOPER_IDS is configured. Axis is correct, just data-starved — documented.

Refs #1169

## Summary Activates the §25.3 cannibalization **unit-mix axis** — the 4th and final axis. §25.3 now scores a candidate vs own-portfolio on **class + price + timing + unit-mix** (+ geo weight). - **candidate mix** from `recommend_mix` `buckets[].share_pct` (the same rule-based квартирография as §22 `product_tz`), extracted + normalized to `{bucket: share}`. - **`_canonical_room_bucket`** folds two disjoint key vocabularies into one room-count space: `recommend_mix` RU labels ("1-к 30-45", "80+ м²") ↔ manual `own_planned_project` Latin keys ("1k"). Without it the L1 similarity would silently be **0** (no shared keys) — load-bearing, verified by code-review. - **Gated** (perf): `recommend_mix` is heavy, so it's derived only when the own-portfolio has ≥1 project with a non-empty `unit_mix`. `get_own_portfolio` fetched once in `compute_special_indices`, threaded into `_build_cannibalization` (no double-fetch). With `OWN_DEVELOPER_IDS` unset (empty portfolio) → **zero added cost on the hot §22 report path**. - Graceful (recommend_mix None/empty/raises → axis excluded, None-not-0), deterministic. ## Test plan - [x] `uv run pytest tests/services/forecasting/test_special_indices.py -q` → **205 passed** (+ cross-vocab alignment, share extraction, gate call_count==0/1, single-fetch, all-4-axes, determinism) - [x] ruff + mypy clean (0 new errors) - [x] code-reviewer ✅ APPROVE (vocabulary alignment correct; eager-call regression fixed via the gate) ## Honesty note Unit-mix only fires for **manual-future** own-projects that have a mix set (domrf-current projects carry `unit_mix=None`) — narrow until manual portfolio data exists. Like the rest of own-portfolio cannibalization, it's dormant until `OWN_DEVELOPER_IDS` is configured. Axis is correct, just data-starved — documented. Refs #1169
bot-backend added 1 commit 2026-06-09 11:10:10 +00:00
feat(forecasting): activate §25.3 cannibalization unit-mix axis (4/4), gated (#1169)
All checks were successful
Deploy / build-worker (push) Successful in 3m1s
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 5s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m32s
CI / backend-tests (pull_request) Successful in 6m24s
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m1s
Deploy / deploy (push) Successful in 1m25s
c9720a6212
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
bot-backend merged commit c9720a6212 into main 2026-06-09 11:18:00 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#1180
No description provided.