fix(site-finder): L2 hidden-supply не обнуляет неизвестный flat_count (#2445-A3) (#2446)
This commit is contained in:
parent
5b3799a23a
commit
9b6aba42dc
2 changed files with 82 additions and 7 deletions
|
|
@ -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 → НЕ нужен domrf↔objective маппинг (см. docstring
|
||||
модуля). Объекты с free_flats IS NULL исключаются (нет sold-колонки → не вычислить;
|
||||
documented limitation) — снижают coverage → confidence medium→low.
|
||||
модуля). Объекты с free_flats IS NULL ИЛИ flat_count IS NULL полностью исключаются
|
||||
(#2445-A3: обе колонки nullable и заполняются независимо — объект с известным
|
||||
free_flats, но flat_count IS NULL, даёт ГЕНУИННО неизвестный hidden, а не 0; такой
|
||||
объект НЕ считается «покрытым» и не входит в coverage-числитель) — снижают coverage
|
||||
→ confidence medium→low.
|
||||
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 на
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue