Deterministic 3-layer склад предложения (ТЗ §9.3), read-only compute returning
SupplyLayerRow dataclasses for the #950 Step5 worker to UPSERT into supply_layers
(m.125). Mirrors market_metrics.py house style (frozen dataclass, _SQL consts,
graceful-to-empty, pure helpers, CAST(:x AS type)).
- L1 open: objective_lots available lots per district x class (objective/high)
- L2 hidden: SUM max(0, flat_count - free_flats) per district, mapping-free via
domrf_kn_objects (free_flats m.124) - no domrf<->objective join (domrf_declared,
medium/low by free_flats coverage). NULL free_flats excluded (no sold column).
- L3 future: domrf multiphase (ready_dt beyond 12mo horizon, ~no listed flats)
grouped by dev_group_name (domrf_multiphase/low). graddoc_stub deferred to #956.
Latest-snapshot DISTINCT ON guards against cross-snapshot double-count
(domrf_kn_objects is UNIQUE(obj_id, snapshot_date)). Only whitelisted
source/confidence values (m.125 CHECK). 45 unit tests, ruff clean.
Round 1 (commit bcd7dc8) был broken: на 2-bucket входах surplus уходил в free
полностью без учёта capacity → free превышал cap → следующая итерация
clamp'ировала его и наоборот. Infinite oscillation в FastAPI handler.
Round 2 fix per review BLOCK (#282 comment):
- Surplus распределяется пропорционально available capacity (cap - v),
не текущему v. Free никогда не вылетит выше cap.
- free = строго < cap (не <=) — иначе деление на 0 capacity.
- Hard guard `for _ in range(N+1)` — гарантированно завершается.
- Pathological (surplus > total_capacity): возвращаем оригинальный pct_map
+ cap_skipped=True (sum=100 invariant сохранён).
- Hamilton round вынесен в _hamilton_round() helper.
Tests:
- 2-bucket cases (90/10, 70/30, 99/1) expected cap_skipped=True
- test_cap_iteration_count_bounded — все pathological завершаются < 100ms
- All 13 cases verified standalone (3 fast-path + 7 reproduced + 3 pathological)
Раньше _VELOCITY_DIVISORS делил агрегаты mv_layout_velocity (24 мес)
на 4/12 для quarter/year, не меняя реальное окно данных. Теперь
inline SQL из objective_corpus_room_month с CAST(:window_interval AS interval).
velocity_per_month = deals_window / months_in_window (1.0/3.0/12.0).
Разные time_window → разные строки из БД → разный mix/velocity/jk_count.
Closes (epic part) #271 item 1