[medium] supply_layers: один domrf-объект может учитываться И в Layer2 (hidden) И в Layer3 (future) — двойной счёт предложения на горизонтах 18/24 мес #1355

Closed
opened 2026-06-14 18:09:02 +00:00 by bot-backend · 1 comment
Collaborator

Описание

L2 (_L2_HIDDEN_SQL) агрегирует каждый latest-snapshot объект с free_flats IS NOT NULL в hidden = Σ max(0, flat_count − free_flats). L3 (_L3_FUTURE_SQL) выбирает объекты с ready_dt > now + horizon И COALESCE(free_flats,0) <= :max_free_flats (=5) и суммирует flat_count как units_future. Фильтры пересекаются: объект с free_flats ∈ [0,5] и ready_dt за горизонтом удовлетворяет ОБОИМ слоям. Его задекларированный-но-непроданный сток попадает и в L2 (hidden), и в L3 (full flat_count). compute_all_layers конкатенирует строки в одну supply-таблицу, и слои суммируются — те же units считаются дважды.

Корень

Нет предиката взаимного исключения между L2 и L3 на волатильном измерении ready_dt. На дефолтном horizon_months=12 баг маскируется: _horizon_weight (future_supply.py:167-192) обнуляет вклад L3 для объектов с ready_dt > today + horizon. Но на расширенных горизонтах из _DEFAULT_HORIZONS = (6, 12, 18, 24) объекты с ready_dt между now+12mo и now+18/24mo получают _horizon_weight=1.0 И остаются в L2 hidden — двойной счёт становится активным.

Evidence

L2 (_L2_HIDDEN_SQL, ~218-244):

COUNT(*) FILTER (WHERE free_flats IS NOT NULL) ... GREATEST(0, COALESCE(flat_count,0) - free_flats) FILTER (WHERE free_flats IS NOT NULL)

L3 (_L3_FUTURE_SQL, ~252-294):

WHERE ready_dt > CURRENT_DATE + CAST(:horizon AS interval) AND COALESCE(free_flats, 0) <= :max_free_flats

Пример при horizon=18: flat_count=200, free_flats=3, ready_dt=15 мес → L2 даёт 197, L3 даёт 200 → 397 для 200-квартирного проекта. demand_supply_forecast.py:683-684 вызывает compute_future_supply_pressure(..., horizon_months=horizon) для каждого из 18/24 — раздувает months_of_pressure и deficit_index.

Suggested fix

Сделать слои взаимоисключающими по ready_dt. Либо исключить far-future объекты из L2: добавить в L2 WHERE AND (ready_dt IS NULL OR ready_dt <= CURRENT_DATE + interval '12 months'), либо исключить L2-hidden объекты из L3. Выбрать правило владения per §9.3 и задокументировать.

Обоснование severity (medium)

На дефолтном горизонте 12 мес баг нейтрализован _horizon_weight. Активен на стандартных 18/24-мес прогнозах (объекты ready_dt 13-24 мес), смещая supply-pressure вверх и потенциально искажая BUY/AVOID-сигналы. Не crash, не порча данных. Confidence 0.82.


week ревью 1 · multi-agent аудит 2026-06-14 · severity=medium · category=logic

Затронутые файлы:

  • /Users/anton/Птица/gendesign/backend/app/services/site_finder/supply_layers.py
## Описание L2 (`_L2_HIDDEN_SQL`) агрегирует каждый latest-snapshot объект с `free_flats IS NOT NULL` в `hidden = Σ max(0, flat_count − free_flats)`. L3 (`_L3_FUTURE_SQL`) выбирает объекты с `ready_dt > now + horizon` И `COALESCE(free_flats,0) <= :max_free_flats` (=5) и суммирует `flat_count` как `units_future`. Фильтры пересекаются: объект с `free_flats ∈ [0,5]` и `ready_dt` за горизонтом удовлетворяет ОБОИМ слоям. Его задекларированный-но-непроданный сток попадает и в L2 (hidden), и в L3 (full flat_count). `compute_all_layers` конкатенирует строки в одну supply-таблицу, и слои суммируются — те же units считаются дважды. ## Корень Нет предиката взаимного исключения между L2 и L3 на волатильном измерении `ready_dt`. На дефолтном `horizon_months=12` баг маскируется: `_horizon_weight` (future_supply.py:167-192) обнуляет вклад L3 для объектов с `ready_dt > today + horizon`. Но на расширенных горизонтах из `_DEFAULT_HORIZONS = (6, 12, 18, 24)` объекты с `ready_dt` между now+12mo и now+18/24mo получают `_horizon_weight=1.0` И остаются в L2 hidden — двойной счёт становится активным. ## Evidence L2 (`_L2_HIDDEN_SQL`, ~218-244): ```sql COUNT(*) FILTER (WHERE free_flats IS NOT NULL) ... GREATEST(0, COALESCE(flat_count,0) - free_flats) FILTER (WHERE free_flats IS NOT NULL) ``` L3 (`_L3_FUTURE_SQL`, ~252-294): ```sql WHERE ready_dt > CURRENT_DATE + CAST(:horizon AS interval) AND COALESCE(free_flats, 0) <= :max_free_flats ``` Пример при horizon=18: `flat_count=200, free_flats=3, ready_dt=15 мес` → L2 даёт 197, L3 даёт 200 → 397 для 200-квартирного проекта. `demand_supply_forecast.py:683-684` вызывает `compute_future_supply_pressure(..., horizon_months=horizon)` для каждого из 18/24 — раздувает `months_of_pressure` и `deficit_index`. ## Suggested fix Сделать слои взаимоисключающими по `ready_dt`. Либо исключить far-future объекты из L2: добавить в L2 WHERE `AND (ready_dt IS NULL OR ready_dt <= CURRENT_DATE + interval '12 months')`, либо исключить L2-hidden объекты из L3. Выбрать правило владения per §9.3 и задокументировать. ## Обоснование severity (medium) На дефолтном горизонте 12 мес баг нейтрализован `_horizon_weight`. Активен на стандартных 18/24-мес прогнозах (объекты ready_dt 13-24 мес), смещая supply-pressure вверх и потенциально искажая BUY/AVOID-сигналы. Не crash, не порча данных. Confidence 0.82. --- _week ревью 1 · multi-agent аудит 2026-06-14 · severity=**medium** · category=`logic`_ Затронутые файлы: - `/Users/anton/Птица/gendesign/backend/app/services/site_finder/supply_layers.py`
bot-backend added the
week ревью 1
label 2026-06-14 18:09:02 +00:00
bot-backend added the
scope/backend
label 2026-06-15 11:01:51 +00:00
Owner

🔍 week ревью 1 — верификация (ПОДТВЕРЖДЕНО)

Текущее состояние кода: backend/app/services/site_finder/supply_layers.py_L2_HIDDEN_SQL (:218-245) агрегирует GREATEST(0, COALESCE(flat_count,0) - free_flats) FILTER (WHERE free_flats IS NOT NULL) БЕЗ верхней границы по ready_dt. _L3_FUTURE_SQL (:252-295) выбирает WHERE ready_dt > CURRENT_DATE + CAST(:horizon AS interval) AND COALESCE(free_flats,0) <= :max_free_flats (:290-292), суммируя flat_count как units_future.

Корень: нет предиката взаимного исключения между L2 и L3 по ready_dt. Объект с free_flats ∈ [0, max_free_flats] и ready_dt за горизонтом попадает в ОБА слоя — его declared-but-unsold сток считается и как L2 hidden, и как L3 full flat_count. На дефолтном horizon_months=12 нейтрализуется horizon-weight downstream, но на расширенных горизонтах (18/24 из _DEFAULT_HORIZONS) объекты ready_dt 13-24 мес дают двойной счёт → раздувает supply-pressure / deficit_index.

Что править:

  • Выбрать правило владения per §9.3 и сделать слои взаимоисключающими по ready_dt. Самый простой вариант: добавить в _L2_HIDDEN_SQL внешний предикат AND (ready_dt IS NULL OR ready_dt <= CURRENT_DATE + interval '12 months') (L2 = «текущий/ближний» сток, L3 = far-future). Зафиксировать константу горизонта-границы в одном месте, чтобы совпадала с L3.
  • Альтернатива: исключить L2-hidden obj_id из L3 (anti-join) — но требует общего CTE, тяжелее.
  • Документировать выбранную границу в docstring обоих SQL.

Что посмотреть:

  • Код: supply_layers.py (_L2_HIDDEN_SQL, _L3_FUTURE_SQL, compute_all_layers); call site в demand_supply_forecast.py (compute_future_supply_pressure(..., horizon_months=...)) и _horizon_weight в future_supply.py — проверить что граница L2 согласована с весами. Добавить тест: объект flat_count=200 free_flats=3 ready_dt=15мес при horizon=18 не должен давать сумму > flat_count.
  • Vault: code/modules/Module_Velocity_Service.md (смежный SF demand-слой, не про supply); специфичных заметок по supply_layers не найдено.
**🔍 week ревью 1 — верификация (ПОДТВЕРЖДЕНО)** **Текущее состояние кода:** `backend/app/services/site_finder/supply_layers.py` — `_L2_HIDDEN_SQL` (`:218-245`) агрегирует `GREATEST(0, COALESCE(flat_count,0) - free_flats) FILTER (WHERE free_flats IS NOT NULL)` БЕЗ верхней границы по `ready_dt`. `_L3_FUTURE_SQL` (`:252-295`) выбирает `WHERE ready_dt > CURRENT_DATE + CAST(:horizon AS interval) AND COALESCE(free_flats,0) <= :max_free_flats` (`:290-292`), суммируя `flat_count` как `units_future`. **Корень:** нет предиката взаимного исключения между L2 и L3 по `ready_dt`. Объект с `free_flats ∈ [0, max_free_flats]` и `ready_dt` за горизонтом попадает в ОБА слоя — его declared-but-unsold сток считается и как L2 hidden, и как L3 full flat_count. На дефолтном `horizon_months=12` нейтрализуется horizon-weight downstream, но на расширенных горизонтах (18/24 из `_DEFAULT_HORIZONS`) объекты ready_dt 13-24 мес дают двойной счёт → раздувает supply-pressure / deficit_index. **Что править:** - Выбрать правило владения per §9.3 и сделать слои взаимоисключающими по `ready_dt`. Самый простой вариант: добавить в `_L2_HIDDEN_SQL` внешний предикат `AND (ready_dt IS NULL OR ready_dt <= CURRENT_DATE + interval '12 months')` (L2 = «текущий/ближний» сток, L3 = far-future). Зафиксировать константу горизонта-границы в одном месте, чтобы совпадала с L3. - Альтернатива: исключить L2-hidden obj_id из L3 (anti-join) — но требует общего CTE, тяжелее. - Документировать выбранную границу в docstring обоих SQL. **Что посмотреть:** - Код: `supply_layers.py` (`_L2_HIDDEN_SQL`, `_L3_FUTURE_SQL`, `compute_all_layers`); call site в `demand_supply_forecast.py` (`compute_future_supply_pressure(..., horizon_months=...)`) и `_horizon_weight` в `future_supply.py` — проверить что граница L2 согласована с весами. Добавить тест: объект flat_count=200 free_flats=3 ready_dt=15мес при horizon=18 не должен давать сумму > flat_count. - Vault: `code/modules/Module_Velocity_Service.md` (смежный SF demand-слой, не про supply); специфичных заметок по supply_layers не найдено.
bot-backend added the
needs-human
label 2026-06-15 16:44:00 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#1355
No description provided.