velocity_alerts: prior_n использует COUNT(*) вместо COUNT(realised) — NULL-строки засчитываются в гейт значимости z-score #1581

Closed
opened 2026-06-16 17:08:11 +00:00 by bot-backend · 0 comments
Collaborator

Severity: medium · Категория: incorrect_aggregate_null_handling · Файл: backend/app/services/analytics/velocity_alerts.py:164-210

Файл /Users/anton/Птица/gendesign/backend/app/services/analytics/velocity_alerts.py:189 — в CTE windows: prior_n = COUNT() FILTER (WHERE rn > :recent_window). COUNT() считает ВСЕ строки, тогда как соседние агрегаты в той же CTE — prior_mean = AVG(realised) FILTER (...) (line 187) и prior_std = STDDEV_SAMP(realised) FILTER (...) (line 188) — по стандартной семантике SQL пропускают NULL. Колонка realised объявлена nullable (data/sql/51_schema_kn_extras.sql:11 — 'realised INT', без NOT NULL/DEFAULT), а скрапер пишет сырое r.get("realised") (backend/app/services/scrapers/domrf_kn.py:1256), т.е. None, когда DomRF API не отдаёт поле. Гейт в scored (line 207): w.prior_n >= :min_prior_obs (=3).

Импакт: объект с >=3 prior-строк, но 1-2 непустыми realised, проходит гейт prior_n>=3, при том что mean/sample-stddev считаются всего по 1-2 точкам. Гейт prior_std > 0 (lines 199/209) отсеивает случай STDDEV_SAMP по 1 значению (=NULL), но по 2 значениям stddev остаётся вырожденно-шумной → завышенный/ненадёжный z-score. Это ослабляет двойной гейт значимости, который по acceptance issue #17 должен держать false-positive rate <=10% на низкообъёмных ЖК. Реальная серьёзность зависит от доли NULL в realised на проде (если NULL редки — эффект мал), поэтому severity medium, не high. Интеграционный тест test_velocity_alerts.py использует только полностью заполненный realised и этот путь не покрывает.

Фикс: заменить COUNT(*) FILTER (WHERE rn > :recent_window) на COUNT(realised) FILTER (WHERE rn > :recent_window) — тогда гейт сертифицирует ровно те непустые точки, по которым считаются mean/stddev. Альтернатива: добавить g.realised IS NOT NULL в CTE series (line 165), чтобы NULL не попадали в окна вовсе.

Почему баг: An object can clear the prior_n >= 3 gate with only 1–2 actual non-null realised values feeding the mean and sample stddev. STDDEV_SAMP over 1 value is NULL (drops the object) but over 2 it is a noisy near-degenerate estimate, producing an inflated/unreliable z-score. The dual gate exists specifically to hold the false-positive rate to the issue's ≤10% acceptance on noisy low-volume ЖК; counting NULL rows toward prior_n defeats that guarantee — the stat-significance gate is computed over fewer points than it certifies. The matching integration test (test_velocity_alerts.py) only uses fully-populated realised, so it never exercises this path.

Как чинить: Filter NULLs at the source: add AND g.realised IS NOT NULL to the series WHERE clause (lines 168-171), so prior_n, prior_mean, prior_std, n_months and the recent window are all computed over the same non-null set. Alternatively change prior_n to COUNT(realised) FILTER (WHERE rn > :recent_window) and likewise tie n_months to non-null counts.


deep-audit backend v2 (после PR #1543) · verify+harden, confidence 0.78 · unit B15

**Severity:** medium · **Категория:** incorrect_aggregate_null_handling · **Файл:** `backend/app/services/analytics/velocity_alerts.py:164-210` Файл /Users/anton/Птица/gendesign/backend/app/services/analytics/velocity_alerts.py:189 — в CTE windows: prior_n = COUNT(*) FILTER (WHERE rn > :recent_window). COUNT(*) считает ВСЕ строки, тогда как соседние агрегаты в той же CTE — prior_mean = AVG(realised) FILTER (...) (line 187) и prior_std = STDDEV_SAMP(realised) FILTER (...) (line 188) — по стандартной семантике SQL пропускают NULL. Колонка realised объявлена nullable (data/sql/51_schema_kn_extras.sql:11 — 'realised INT', без NOT NULL/DEFAULT), а скрапер пишет сырое r.get("realised") (backend/app/services/scrapers/domrf_kn.py:1256), т.е. None, когда DomRF API не отдаёт поле. Гейт в scored (line 207): w.prior_n >= :min_prior_obs (=3). Импакт: объект с >=3 prior-строк, но 1-2 непустыми realised, проходит гейт prior_n>=3, при том что mean/sample-stddev считаются всего по 1-2 точкам. Гейт prior_std > 0 (lines 199/209) отсеивает случай STDDEV_SAMP по 1 значению (=NULL), но по 2 значениям stddev остаётся вырожденно-шумной → завышенный/ненадёжный z-score. Это ослабляет двойной гейт значимости, который по acceptance issue #17 должен держать false-positive rate <=10% на низкообъёмных ЖК. Реальная серьёзность зависит от доли NULL в realised на проде (если NULL редки — эффект мал), поэтому severity medium, не high. Интеграционный тест test_velocity_alerts.py использует только полностью заполненный realised и этот путь не покрывает. Фикс: заменить COUNT(*) FILTER (WHERE rn > :recent_window) на COUNT(realised) FILTER (WHERE rn > :recent_window) — тогда гейт сертифицирует ровно те непустые точки, по которым считаются mean/stddev. Альтернатива: добавить g.realised IS NOT NULL в CTE series (line 165), чтобы NULL не попадали в окна вовсе. **Почему баг:** An object can clear the `prior_n >= 3` gate with only 1–2 actual non-null realised values feeding the mean and sample stddev. STDDEV_SAMP over 1 value is NULL (drops the object) but over 2 it is a noisy near-degenerate estimate, producing an inflated/unreliable z-score. The dual gate exists specifically to hold the false-positive rate to the issue's ≤10% acceptance on noisy low-volume ЖК; counting NULL rows toward `prior_n` defeats that guarantee — the stat-significance gate is computed over fewer points than it certifies. The matching integration test (test_velocity_alerts.py) only uses fully-populated `realised`, so it never exercises this path. **Как чинить:** Filter NULLs at the source: add `AND g.realised IS NOT NULL` to the `series` WHERE clause (lines 168-171), so `prior_n`, `prior_mean`, `prior_std`, `n_months` and the recent window are all computed over the same non-null set. Alternatively change `prior_n` to `COUNT(realised) FILTER (WHERE rn > :recent_window)` and likewise tie `n_months` to non-null counts. --- <sub>deep-audit backend v2 (после PR #1543) · verify+harden, confidence 0.78 · unit B15</sub>
bot-backend added the
week ревью 1
label 2026-06-16 17:08:11 +00:00
Sign in to join this conversation.
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#1581
No description provided.