fix(site-finder): L2 hidden-supply не обнуляет неизвестный flat_count (#2445-A3) #2446

Merged
bot-backend merged 1 commit from fix/supply-layers-l2-flat-count-null into main 2026-07-05 18:54:15 +00:00
2 changed files with 82 additions and 7 deletions

View file

@ -46,6 +46,9 @@ Graceful-on-thin-data (КРИТИЧНО, зеркало market_metrics): пус
м.108-audit). Поэтому при free_flats IS NULL hidden НЕ вычислим (flat_count в одиночку
бессмыслен). Такие объекты ИСКЛЮЧАЮТСЯ из L2 (preferred path из ТЗ чище, чем
выдуманный sold-proxy) coverage снижается confidence падает с medium до low.
Тот же exclusion И при flat_count IS NULL (#2445-A3: заполняется независимо от
free_flats, м.124) иначе GREATEST(0, COALESCE(flat_count,0) free_flats) молча
давал 0 «скрытых» для объекта с генуинно неизвестным flat_count, раздувая coverage.
L2 district-resolution: используем domrf_kn_objects.district_name НАПРЯМУЮ (м.57
spatial-join по lat/long в полигоны ekb_districts_geom). Это самый прямой путь
БЕЗ complexes/complex_sources join (которые сами строятся из этой же колонки, м.73).
@ -244,6 +247,21 @@ _L1_OPEN_SQL = text(
# documented). clamp ≥ 0 через GREATEST. Считаем coverage (всего объектов района vs с
# free_flats) для confidence. NO join на objective_lots / complexes — намеренно.
#
# #2445-A3 (audit): flat_count ТОЖЕ nullable и заполняется НЕЗАВИСИМО от free_flats
# (м.124 — "NULL = не заполнено"). Раньше фильтр гейтил ТОЛЬКО free_flats IS NOT NULL:
# при flat_count IS NULL (частично забэкфилленный снапшот) GREATEST(0, COALESCE(flat_count,
# 0) - free_flats) = GREATEST(0, -free_flats) = 0 — объект молча давал 0 hidden units,
# ХОТЯ его истинный hidden НЕИЗВЕСТЕН (не 0), И при этом всё равно попадал в
# n_with_free_flats → раздувал coverage-деноминатор (denominator inflation). Зеркалит баг,
# которого уже избегает pure-хелпер _hidden_units() (возвращает None при flat_count IS
# NULL) — но тот хелпер не вызывается из этого SQL-пути (только из тестов), поэтому баг
# жил в проде. Fix: гейтим ОБА (COUNT-деноминатор и SUM) на
# flat_count IS NOT NULL AND free_flats IS NOT NULL — объект с любым из двух NULL
# полностью исключается (как из числителя coverage, так и из hidden_units), а не
# засчитывается как "0 скрытых". Сиблинг-конвенция: 60_v_zk_rosreestr_velocity.sql /
# 64_v_zk_rosreestr_velocity.sql уже гейтят `o.flat_count IS NOT NULL AND o.flat_count > 0`
# на этой же таблице.
#
# Граница L2/L3 (#1355): L2 захватывает ТОЛЬКО объекты с ready_dt ≤ горизонту (или NULL —
# объект уже должен был выйти). ready_dt > горизонту → это L3 (future), НЕ L2.
# Полу-открытые интервалы (half-open): L2 = [now, now+12mo], L3 = (now+12mo, ∞).
@ -268,10 +286,14 @@ _L2_HIDDEN_SQL = text(
SELECT
district_name,
COUNT(*) AS n_objects_total,
COUNT(*) FILTER (WHERE free_flats IS NOT NULL) AS n_with_free_flats,
COUNT(*) FILTER (
WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
) AS n_with_free_flats,
COALESCE(SUM(
GREATEST(0, COALESCE(flat_count, 0) - free_flats)
) FILTER (WHERE free_flats IS NOT NULL), 0) AS hidden_units
GREATEST(0, flat_count - free_flats)
) FILTER (
WHERE free_flats IS NOT NULL AND flat_count IS NOT NULL
), 0) AS hidden_units
FROM latest
WHERE ready_dt IS NULL
OR ready_dt <= CURRENT_DATE + CAST(:near_horizon AS interval)
@ -397,8 +419,11 @@ def compute_layer2_hidden(
Per dom.рф-объект hidden = max(0, flat_count free_flats); Σ к району. ОБЕ колонки
на одной строке domrf_kn_objects НЕ нужен domrfobjective маппинг (см. docstring
модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки не вычислить;
documented limitation) снижают coverage confidence mediumlow.
модуля). Объекты с free_flats IS NULL ИЛИ flat_count IS NULL полностью исключаются
(#2445-A3: обе колонки nullable и заполняются независимо — объект с известным
free_flats, но flat_count IS NULL, даёт ГЕНУИННО неизвестный hidden, а не 0; такой
объект НЕ считается «покрытым» и не входит в coverage-числитель) снижают coverage
confidence mediumlow.
source='domrf_declared'. obj_class=None (district-агрегат класс-agnostic).
units_estimate всегда 0 (clamp). Пустые данные []. Не бросает.
@ -413,11 +438,16 @@ def compute_layer2_hidden(
out: list[SupplyLayerRow] = []
for r in rows:
n_total = int(r["n_objects_total"] or 0)
# n_with_free_flats (SQL) = объекты, где ОБЕ free_flats и flat_count известны
# (#2445-A3) — это единственные объекты, для которых hidden вообще вычислим.
# Имя колонки оставлено как есть (ripple в SQL/тестах/logger); семантика теперь
# "покрыто и вычислимо", не только "free_flats заполнен".
n_with = int(r["n_with_free_flats"] or 0)
# hidden_units приходит из SQL уже клампленным (GREATEST(0, ...)); страхуемся.
hidden = max(0, int(r["hidden_units"] or 0))
if n_with <= 0:
# Нет ни одного объекта с free_flats → нечего объявлять скрытым (а не «0»).
# Нет ни одного объекта с ВЫЧИСЛИМЫМ hidden (оба flat_count и free_flats
# известны) → нечего объявлять скрытым (а не «0»).
continue
confidence = _l2_confidence(n_total, n_with)
out.append(
@ -434,7 +464,7 @@ def compute_layer2_hidden(
confidence=confidence,
method=(
f"domrf_kn_objects: Σ max(0, flat_count free_flats); "
f"coverage {n_with}/{n_total} objects with free_flats"
f"coverage {n_with}/{n_total} objects with flat_count & free_flats known"
),
snapshot_date=snapshot_date,
)

View file

@ -379,6 +379,51 @@ class TestLayer2Hidden:
sql = _executed_sql(db)
assert "free_flats IS NOT NULL" in sql
def test_sql_excludes_null_flat_count_from_both_filters(self) -> None:
# #2445-A3 (audit fix): flat_count IS NULL — заполняется НЕЗАВИСИМО от free_flats
# (м.124 "NULL = не заполнено"). Раньше GREATEST(0, COALESCE(flat_count,0) -
# free_flats) молча давало 0 hidden для объекта с генуинно неизвестным
# flat_count, И этот объект всё равно засчитывался в n_with_free_flats (гейт был
# только free_flats IS NOT NULL) → раздутый coverage-деноминатор. Fix гейтит ОБЕ
# агрегатные колонки (COUNT-денoминатор и SUM) на flat_count IS NOT NULL тоже —
# зеркалит _hidden_units() (возвращает None при flat_count IS None) и sibling
# convention 60_v_zk_rosreestr_velocity.sql / 64_v_zk_rosreestr_velocity.sql
# ("o.flat_count IS NOT NULL AND o.flat_count > 0" на этой же таблице).
db = _mock_db([])
compute_layer2_hidden(db, district="X")
sql = _executed_sql(db)
norm = " ".join(sql.split())
# Оба FILTER-предложения (n_with_free_flats COUNT и hidden_units SUM) должны
# гейтить flat_count IS NOT NULL наравне с free_flats IS NOT NULL.
assert (
norm.count("flat_count IS NOT NULL") == 2
), f"expected flat_count IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
assert (
norm.count("free_flats IS NOT NULL") == 2
), f"expected free_flats IS NOT NULL guard on both COUNT and SUM filters:\n{sql}"
def test_row_with_null_flat_count_and_positive_free_flats_excluded_end_to_end(
self,
) -> None:
# End-to-end (mocked-row) mirror of test_none_flat_count_returns_none, but at the
# SQL-aggregate boundary: simulate what the CORRECTED SQL would return for a
# district containing exactly one object with free_flats=5 but flat_count IS NULL
# (partially-backfilled snapshot). That object must be excluded from BOTH
# n_with_free_flats (coverage numerator) and hidden_units (SUM) by the SQL itself
# — so the row the Python layer receives already reflects n_with=0, hidden=0 for
# a district containing only that one ambiguous object.
rows = [
{
"district_name": "X",
"n_objects_total": 1, # the one flat_count-NULL object still counts here
"n_with_free_flats": 0, # excluded: flat_count IS NULL despite free_flats=5
"hidden_units": 0, # excluded from SUM too — NOT GREATEST(0, -5) miscounted
}
]
db = _mock_db(rows)
# n_with <= 0 → whole row skipped (no fabricated "0 hidden" district row).
assert compute_layer2_hidden(db, district="X") == []
def test_sql_dedups_latest_snapshot(self) -> None:
# КРИТИЧНО: domrf_kn_objects = UNIQUE(obj_id, snapshot_date), накапливает снапшоты.
# Без DISTINCT ON (obj_id) + snapshot_date DESC наивный SUM умножил бы hidden на