docs(ptica): шесть мест, где документация расходилась с кодом (#2464) #2968
7 changed files with 151 additions and 17 deletions
|
|
@ -1436,9 +1436,27 @@ def _velocity_baseline(
|
|||
|
||||
Migrated from domrf_kn_sale_graph (stale since 2026-01) to
|
||||
objective_corpus_room_month (updated weekly via Objective API).
|
||||
objective_corpus_room_month.district matches domrf_kn_objects.district_name.
|
||||
class filter uses 'class' column (Комфорт/Бизнес/Стандарт).
|
||||
|
||||
ВНИМАНИЕ ПРО СЛОВАРЬ РАЙОНОВ. Прежняя редакция утверждала, что
|
||||
`objective_corpus_room_month.district` совпадает с
|
||||
`domrf_kn_objects.district_name`. Это неверно, и docstring `_elasticity_coef`
|
||||
ниже описывает ту же колонку правильно: там МИКРО-вокабуляр ЕКБ.
|
||||
|
||||
Замер прода 20.08.2026:
|
||||
district (микро) Академический, ВИЗ, Юго-Западный, Уктус, Втузгородок,
|
||||
Широкая Речка, Центр, Эльмаш, …
|
||||
district_name (админ) Академический, Чкаловский, Верх-Исетский, Ленинский,
|
||||
Орджоникидзевский, Кировский, …
|
||||
|
||||
Пересечение частичное: из 8 админ-имён в микро-колонке встречаются 4, и с
|
||||
сильно меньшим объёмом (Ленинский 55 точек против 621 у Академического;
|
||||
Чкаловский и Верх-Исетский — ноль). Вызывающий передаёт сюда
|
||||
`district_row["district_name"]`, то есть АДМИН-имя: для половины районов
|
||||
выборка пустая, для остальных — заметно урезанная. Резолв admin→micros
|
||||
(как в `_elasticity_coef`, #1211) здесь НЕ сделан — это отдельная задача,
|
||||
docstring лишь перестаёт утверждать обратное (#2464).
|
||||
|
||||
Returns dict {realised_per_month_median, realised_per_month_avg,
|
||||
objects_count, observations}. All-None means no data → caller falls back.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -96,10 +96,12 @@ _MACRO_COEF_NEUTRAL: float = 1.0
|
|||
# режима (зеркалит дух лагов §9.6, где полугодовой лаг ловит ипотечный эффект).
|
||||
_TREND_WINDOW_MONTHS: int = 6
|
||||
|
||||
# ── Named-константы: веса sub-factors (СУММА backed-весов = 0.45) ──────────────
|
||||
# ── Named-константы: веса sub-factors (СУММА backed-весов = 0.53) ──────────────
|
||||
# Веса — экспертная оценка вклада каждого канала в макрорежим спроса (НЕ фит).
|
||||
# Заданы в ИСХОДНОМ (полном) наборе из 8 каналов; renorm делит на сумму ДОСТУПНЫХ.
|
||||
# Backed-каналы (rate/mortgage_rate/issuance/overdue) несут основную массу: ставка и
|
||||
# Backed-каналы (rate/mortgage_rate/issuance/overdue/inflation) несут основную массу:
|
||||
# 0.18+0.12+0.10+0.05+0.08 = 0.53. Прежде здесь стояло 0.45 — цифра до #946, где
|
||||
# inflation стал backed-каналом с весом 0.08; сумму тогда не обновили (#2464). Ставка и
|
||||
# стоимость/доступность ипотеки — доминирующий драйвер первичного спроса в РФ.
|
||||
# Degraded-каналы (gov/income/confidence) имеют НЕнулевые веса в схеме (резерв
|
||||
# под будущие ряды), но СЕЙЧАС всегда None → в renorm не попадают.
|
||||
|
|
|
|||
|
|
@ -301,16 +301,19 @@ def get_monthly_macro(
|
|||
ЛЮБЫХ данных всё равно присутствует (все поля None для него — кроме carry key_rate).
|
||||
|
||||
Graceful: при сбое БД или пустой таблице key_rate сетка месяцев всё равно
|
||||
возвращается, но с None-полями (НЕ crash). Пустой список [] — только если
|
||||
сама сетка пуста (months_back < 0).
|
||||
возвращается, но с None-полями (НЕ crash).
|
||||
|
||||
Пустой список [] недостижим: months_back клампится через max(0, ...), поэтому
|
||||
даже при отрицательном вводе сетка содержит текущий месяц. Прежняя редакция
|
||||
обещала [] «при months_back < 0» — это описывало поведение, которого нет (#2464).
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
months_back: глубина ряда в месяцах (по умолчанию _DEFAULT_MONTHS_BACK).
|
||||
|
||||
Returns:
|
||||
Список MonthlyMacro по возрастанию month (по непрерывной сетке);
|
||||
[] только при пустой сетке (months_back < 0).
|
||||
Список MonthlyMacro по возрастанию month (по непрерывной сетке).
|
||||
Пустым не бывает: см. про клампинг выше.
|
||||
"""
|
||||
# month-bucketing в локальной tz сервера (single-region, как и весь codebase)
|
||||
today = date.today()
|
||||
|
|
|
|||
|
|
@ -479,8 +479,12 @@ def build_sales_series(
|
|||
bias на старых месяцах — каведат в module docstring).
|
||||
|
||||
Graceful: при сбое БД / пустых данных возвращается ряд по сетке с units=0,
|
||||
area/price=None, confidence='low' (НЕ crash). Пустой ряд (months=[]) — только
|
||||
если сетка пуста (months_back < 0).
|
||||
area/price=None, confidence='low' (НЕ crash).
|
||||
|
||||
Пустой ряд (months=[]) недостижим: months_back клампится через max(0, ...),
|
||||
поэтому даже при отрицательном вводе сетка содержит текущий месяц. Прежняя
|
||||
редакция обещала пустой ряд «при months_back < 0» — это описывало поведение,
|
||||
которого нет (#2464).
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy sync Session.
|
||||
|
|
|
|||
|
|
@ -876,15 +876,24 @@ class NSPDClient:
|
|||
Шаги:
|
||||
1. `search_by_cad(quarter_cad, thematic_id=2)` — получить полигон квартала
|
||||
2. Compute bbox в EPSG:3857 из quarter geometry (или None если NSPD пуст)
|
||||
3. Для каждого core layer → `get_features_in_bbox(layer_id, bbox)`
|
||||
4. Если include_zouit — то же для 5 ЗОУИТ layers
|
||||
5. Если include_risks — то же для 11 risk layers
|
||||
3. Core layers: parcels/buildings — legacy `get_features_in_bbox`
|
||||
(1 запрос); territorial_zones/red_lines/engineering_structures —
|
||||
`get_features_in_bbox_grid` при grid_n=7, то есть 49 запросов КАЖДЫЙ
|
||||
(см. _GRID_WALK_LAYERS и docstring get_features_in_bbox_grid)
|
||||
4. Если include_zouit — 5 ЗОУИТ layers, все через grid-walk
|
||||
5. Если include_risks — 11 risk layers, все через grid-walk
|
||||
|
||||
Стоимость HTTP:
|
||||
- core only: 1 (search) + 5 (core layers) = 6 запросов
|
||||
- +zouit: +5 = 11 запросов
|
||||
- +risks: +11 = 22 запроса
|
||||
При rate_ms=600 один dump = ~3.6с (core) / ~6.6с (+zouit) / ~13с (всё).
|
||||
- core only: 1 (search) + 2*1 (legacy) + 3*49 (grid) = 150 запросов
|
||||
- +zouit: +5*49 = 395 запросов
|
||||
- +risks: +11*49 = 934 запроса
|
||||
При rate_ms=600 один dump = ~90с (core) / ~237с (+zouit) / ~560с (всё).
|
||||
|
||||
Прежняя редакция обещала 6/11/22 запроса и ~3.6с/~6.6с/~13с — цифры для
|
||||
мира, где все слои идут legacy-путём. Занижение в 25-42 раза, и это не
|
||||
безобидно: по такой оценке слои включают не задумываясь, а объём запросов
|
||||
здесь — прямой фактор WAF-риска (#2464; ср. #2956, где НСПД сейчас отдаёт
|
||||
403 на IP VPS).
|
||||
|
||||
Args:
|
||||
quarter_cad: 3-сегментный cad-номер квартала, e.g. '66:41:0204016'.
|
||||
|
|
|
|||
|
|
@ -324,7 +324,11 @@ def denorm_dump(
|
|||
одной строки не откатывает весь batch.
|
||||
|
||||
Args:
|
||||
db: SQLAlchemy Session. Caller отвечает за commit/close после вызова.
|
||||
db: SQLAlchemy Session. Функция САМА делает commit в конце (см. ниже);
|
||||
на вызывающем остаётся только close. Прежняя редакция обещала
|
||||
обратное — «caller отвечает за commit/close», — и вызывающий,
|
||||
понадеявшийся обернуть это в свою транзакцию, получил бы уже
|
||||
зафиксированные строки (#2464).
|
||||
quarter_cad: 3-сегментный кадастровый квартал.
|
||||
features: плоский list из features_json JSONB (уже декодированный Python list).
|
||||
|
||||
|
|
|
|||
94
backend/tests/services/test_2464_docs_match_code.py
Normal file
94
backend/tests/services/test_2464_docs_match_code.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Числа в документации сверяются с кодом, а не живут отдельно (#2464).
|
||||
|
||||
Оба расхождения ниже — реальные, найденные 20.08.2026, и оба были незаметны: цифра в
|
||||
комментарии не проверяется ничем, а расходится тихо при первой же правке констант.
|
||||
|
||||
• macro_coefficient: комментарий обещал сумму backed-весов 0.45. С #946 inflation
|
||||
стал backed-каналом с весом 0.08, сумма стала 0.53 — комментарий не обновили.
|
||||
|
||||
• nspd_client.search_by_quarter: docstring обещал 6/11/22 запроса и ~3.6с/~6.6с/~13с.
|
||||
Фактически три из пяти core-слоёв и ВСЕ zouit/risk идут grid-walk'ом по 49 запросов:
|
||||
150/395/934 запроса, ~90с/~237с/~560с. Занижение в 25-42 раза — а объём запросов
|
||||
здесь прямой фактор WAF-риска.
|
||||
|
||||
Гейт сверяет то, что НАПИСАНО, с тем, что ВЫЧИСЛЯЕТСЯ из констант. Красное здесь
|
||||
означает «текст разошёлся с кодом», а не «текст непривычно отформатирован».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_backed_weight_sum_in_comment_matches_constants() -> None:
|
||||
"""Сумма из комментария обязана совпасть с суммой backed-констант."""
|
||||
from app.services.forecasting import macro_coefficient as mc
|
||||
|
||||
src = Path(mc.__file__).read_text()
|
||||
m = re.search(r"СУММА backed-весов = ([0-9.]+)", src)
|
||||
assert m is not None, "в файле пропала строка «СУММА backed-весов = …» — гейт ослеп"
|
||||
documented = float(m.group(1))
|
||||
|
||||
actual = round(
|
||||
mc._W_RATE + mc._W_MORTG_RATE + mc._W_ISSUANCE + mc._W_OVERDUE + mc._W_INFLATION, 4
|
||||
)
|
||||
assert documented == actual, (
|
||||
f"в комментарии сумма backed-весов {documented}, по константам {actual} — "
|
||||
"текст разошёлся с кодом"
|
||||
)
|
||||
|
||||
|
||||
def _documented_request_counts(src: str) -> tuple[int, int, int]:
|
||||
core = re.search(r"core only:.*?= (\d+) запрос", src)
|
||||
zouit = re.search(r"\+zouit:\s*\+\d+\*\d+\s*= (\d+) запрос", src)
|
||||
risks = re.search(r"\+risks:\s*\+\d+\*\d+\s*= (\d+) запрос", src)
|
||||
assert core and zouit and risks, "в docstring пропала смета HTTP-запросов — гейт ослеп"
|
||||
return int(core.group(1)), int(zouit.group(1)), int(risks.group(1))
|
||||
|
||||
|
||||
def test_request_cost_in_docstring_matches_layer_dispatch() -> None:
|
||||
"""Смета запросов обязана следовать из _GRID_WALK_LAYERS, а не из памяти автора."""
|
||||
from app.services.scrapers import nspd_client as nc
|
||||
|
||||
src = Path(nc.__file__).read_text()
|
||||
doc_core, doc_zouit, doc_risks = _documented_request_counts(src)
|
||||
|
||||
grid = set(nc._GRID_WALK_LAYERS)
|
||||
core_layers = [
|
||||
"parcels",
|
||||
"buildings",
|
||||
"territorial_zones",
|
||||
"red_lines",
|
||||
"engineering_structures",
|
||||
]
|
||||
grid_n = 7
|
||||
per_grid = grid_n * grid_n
|
||||
|
||||
core_grid = sum(1 for c in core_layers if c in grid)
|
||||
core_legacy = len(core_layers) - core_grid
|
||||
n_zouit = len(nc.NSPDClient.QUARTER_ZOUIT_LAYERS)
|
||||
n_risks = len(nc.NSPDClient.QUARTER_RISK_LAYERS)
|
||||
|
||||
exp_core = 1 + core_legacy + core_grid * per_grid
|
||||
exp_zouit = exp_core + n_zouit * per_grid
|
||||
exp_risks = exp_zouit + n_risks * per_grid
|
||||
|
||||
assert (doc_core, doc_zouit, doc_risks) == (exp_core, exp_zouit, exp_risks), (
|
||||
f"docstring обещает {doc_core}/{doc_zouit}/{doc_risks} запросов, "
|
||||
f"по диспетчеризации слоёв выходит {exp_core}/{exp_zouit}/{exp_risks}"
|
||||
)
|
||||
|
||||
|
||||
def test_grid_walk_membership_is_not_empty() -> None:
|
||||
"""Контроль на сам гейт: если _GRID_WALK_LAYERS опустеет, расчёт станет
|
||||
тавтологически совпадать с любой мелкой цифрой в docstring."""
|
||||
from app.services.scrapers import nspd_client as nc
|
||||
|
||||
grid = set(nc._GRID_WALK_LAYERS)
|
||||
assert len(grid) >= 10, f"grid-walk слоёв всего {len(grid)} — смета считается не по тому"
|
||||
assert "territorial_zones" in grid, "core-слой выпал из grid-walk — пересчитать смету"
|
||||
Loading…
Add table
Reference in a new issue