fix(site_finder): make Location demand_index city-relative (#948) #1167

Merged
bot-backend merged 2 commits from fix/location-demand-relative-948 into main 2026-06-08 09:12:21 +00:00
Collaborator

Summary

location.demand_index (district-level Site-Finder index, #948 Part B) was clamp01(unit_velocity / 50) with a fixed magic constant. On a live prod refresh all 8 ЕКБ districts came back demand=1.0 — every district sells ≥50 apartments/mo, so the index saturated flat and gave zero discrimination between districts (the other 3 indices spread fine: infra 0.747–1.0, competition 0.540–0.837, future_supply 0–0.096).

Redesign demand to be city-relative, mirroring how infra_index already normalizes _district_poi_score / _city_avg_poi_score:

  • normalize_demand(velocity, *, city_reference_velocity) — pure, graceful (None stays None; reference ≤ 0 → honest 0.0, no ZeroDivisionError; clamp01).
  • refresh_locations is now two-pass: pass 1 collects each district's raw unit_velocity (one compute_market_metrics per district, no O(n²)); derive city_reference_velocity = max(non-None velocities); pass 2 normalizes demand relative to it and upserts. SAVEPOINT-per-row + single commit + counters preserved.
  • Removed _DEMAND_SATURATION_UPM. Added a city_reference_velocity log line (so the next prod refresh surfaces the real max-velocity figure).

Why relative > absolute: self-calibrating — always discriminates for any non-degenerate distribution, and never rots as the ЕКБ market grows (a fixed constant is wrong today and gets more wrong over time).

compute_location_indices is write-path-only (called solely by refresh_locations + tests; the GET API reads the stored column), so no read-path/blast-radius impact. No schema/migration change (raw_unit_velocity is an in-memory dataclass carrier only).

Test plan

  • uv run pytest tests/services/site_finder/test_locations.py -q34 passed; full site_finder/ dir → 366 passed (no regressions)
  • ruff check both files → clean; mypy locations.py → 0 errors in file
  • Discrimination regression test (test_discriminates_realistic_spread_regression_948): velocities [60,120,180,300,450] → distinct, strictly increasing, min < 0.2, max == 1.0 (the old /50 clamp gave five 1.0s)
  • Edge cases tested: all-None→None, mixed None, reference==0→honest-0.0, velocity-0→0.0, single/all-equal→1.0
  • code-reviewer: APPROVE, no critical issues
  • Post-deploy live verify (needs DB-connected session): rerun refresh_locations() on prod, confirm demand_index now spreads across the 8 districts instead of all-1.0; the city_reference_velocity log line will show the real max.

Note

Semantic shift on the persisted column: stored demand_index now means "relative to the busiest district this run" (comparable within a run), same property infra_index already has. No frontend consumes demand_index yet, so no UI copy to update at this time.

Refs #948

## Summary `location.demand_index` (district-level Site-Finder index, #948 Part B) was `clamp01(unit_velocity / 50)` with a fixed magic constant. On a **live prod refresh all 8 ЕКБ districts came back `demand=1.0`** — every district sells ≥50 apartments/mo, so the index saturated flat and gave **zero discrimination** between districts (the other 3 indices spread fine: infra 0.747–1.0, competition 0.540–0.837, future_supply 0–0.096). Redesign demand to be **city-relative**, mirroring how `infra_index` already normalizes `_district_poi_score / _city_avg_poi_score`: - `normalize_demand(velocity, *, city_reference_velocity)` — pure, graceful (None stays None; reference ≤ 0 → honest `0.0`, no ZeroDivisionError; `clamp01`). - `refresh_locations` is now **two-pass**: pass 1 collects each district's raw `unit_velocity` (one `compute_market_metrics` per district, no O(n²)); derive `city_reference_velocity = max(non-None velocities)`; pass 2 normalizes demand relative to it and upserts. SAVEPOINT-per-row + single commit + counters preserved. - Removed `_DEMAND_SATURATION_UPM`. Added a `city_reference_velocity` log line (so the next prod refresh surfaces the real max-velocity figure). **Why relative > absolute:** self-calibrating — always discriminates for any non-degenerate distribution, and never rots as the ЕКБ market grows (a fixed constant is wrong today and gets more wrong over time). `compute_location_indices` is write-path-only (called solely by `refresh_locations` + tests; the GET API reads the stored column), so no read-path/blast-radius impact. No schema/migration change (`raw_unit_velocity` is an in-memory dataclass carrier only). ## Test plan - [x] `uv run pytest tests/services/site_finder/test_locations.py -q` → **34 passed**; full `site_finder/` dir → **366 passed** (no regressions) - [x] `ruff check` both files → clean; `mypy locations.py` → 0 errors in file - [x] Discrimination regression test (`test_discriminates_realistic_spread_regression_948`): velocities `[60,120,180,300,450]` → distinct, strictly increasing, min < 0.2, max == 1.0 (the old `/50` clamp gave five 1.0s) - [x] Edge cases tested: all-None→None, mixed None, reference==0→honest-0.0, velocity-0→0.0, single/all-equal→1.0 - [x] code-reviewer: ✅ APPROVE, no critical issues - [ ] Post-deploy live verify (needs DB-connected session): rerun `refresh_locations()` on prod, confirm `demand_index` now spreads across the 8 districts instead of all-1.0; the `city_reference_velocity` log line will show the real max. ## Note Semantic shift on the persisted column: stored `demand_index` now means "relative to the busiest district this run" (comparable within a run), same property `infra_index` already has. No frontend consumes `demand_index` yet, so no UI copy to update at this time. Refs #948
bot-backend added 1 commit 2026-06-08 08:58:49 +00:00
fix(site_finder): make Location demand_index city-relative (#948)
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m22s
379af88424
demand_index used a fixed clamp01(velocity/50); on a live prod refresh
all 8 ЕКБ districts sold ≥50/mo so it saturated to 1.0 everywhere — zero
discrimination between districts. Redesign to mirror infra_index:
normalize each district's unit_velocity against the city reference (MAX
district velocity per refresh run), so demand always discriminates and
self-calibrates as the market grows (no magic constant to rot).

- normalize_demand(velocity, *, city_reference_velocity), pure + graceful
  (None stays None; reference<=0 -> honest 0.0, no ZeroDivisionError)
- refresh_locations now two-pass: collect velocities (one
  compute_market_metrics per district, no O(n^2)), derive city reference,
  normalize + upsert; SAVEPOINT-per-row and counters preserved
- remove _DEMAND_SATURATION_UPM constant; log city_reference_velocity
- tests: rewrite demand normalization + add end-to-end city-relative
  suite incl. discrimination regression guarding the all-1.0 prod bug

Refs #948
bot-backend added 1 commit 2026-06-08 09:05:06 +00:00
fix(db): sync location.demand_index comment to city-relative (#948)
All checks were successful
CI / changes (push) Successful in 7s
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m27s
CI / backend-tests (pull_request) Successful in 6m26s
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 4m52s
Deploy / build-worker (push) Successful in 13m20s
Deploy / deploy (push) Successful in 1m18s
4b2e7d9af8
The demand_index DB column comment still said "насыщающее преобразование"
(saturating) after #1167 switched it to city-relative normalization —
exactly the misleading-comment class that hid the original bug. Migration
147 updates COMMENT ON COLUMN (idempotent, comment-only, no data DDL).

Refs #948
bot-backend merged commit 4b2e7d9af8 into main 2026-06-08 09:12:21 +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#1167
No description provided.