Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
e7c79c9646 docs(ptica): шесть мест, где документация расходилась с кодом (#2464)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 1m53s
CI / backend-tests (pull_request) Successful in 17m21s
Каждое проверено против кода или прод-данных, а не переписано по впечатлению.

1. macro_coefficient:99 — «СУММА backed-весов = 0.45». С #946 inflation стал
   backed-каналом с весом 0.08: 0.18+0.12+0.10+0.05+0.08 = 0.53. Сумму не
   обновили.

2. macro_series:305 и 3. sales_series:496 — оба обещали пустой результат «при
   months_back < 0». Код клампит через max(0, months_back), поэтому сетка всегда
   содержит текущий месяц. Проверено прогоном: months_back=-5 → 1 месяц.
   Документировалось поведение, которого нет.

4. analytics_queries._velocity_baseline — «objective_corpus_room_month.district
   matches domrf_kn_objects.district_name». Неверно, и соседний _elasticity_coef
   описывает ту же колонку правильно (МИКРО-вокабуляр). Замер прода:

     district (микро)      Академический, ВИЗ, Юго-Западный, Уктус, Втузгородок…
     district_name (админ) Академический, Чкаловский, Верх-Исетский, Ленинский…

   Из 8 админ-имён в микро-колонке встречаются 4, и с меньшим объёмом (Ленинский
   55 точек против 621 у Академического; Чкаловский и Верх-Исетский — ноль).
   Вызывающий передаёт админ-имя. Резолв admin→micros тут НЕ делаю — это
   отдельная задача; docstring лишь перестаёт утверждать обратное.

5. nspd_denorm.denorm_dump — «Caller отвечает за commit/close», при том что
   функция сама вызывает db.commit() на 373. Вызывающий, понадеявшийся обернуть
   это в свою транзакцию, получил бы уже зафиксированные строки.

6. nspd_client.search_by_quarter — смета «6/11/22 запроса, ~3.6с/~6.6с/~13с».
   Фактически три из пяти core-слоёв и ВСЕ zouit/risk идут grid-walk'ом по 49
   запросов: 150/395/934 запроса, ~90с/~237с/~560с. Занижение в 25-42 раза, и
   это не безобидно: по такой оценке слои включают не задумываясь, а объём
   запросов здесь — прямой фактор WAF-риска (ср. #2956, где НСПД сейчас отдаёт
   403 на IP VPS).

Два из шести чисел проверяемы автоматически, и на них поставлен гейт: сумма
backed-весов сверяется с константами, смета запросов — с _GRID_WALK_LAYERS.
Мутационно проверен: вернуть 0.45 → красный, изменить вес канала не тронув
комментарий → красный, вернуть 6/11/22 → красный, контроль → 3 passed rc=0.
Плюс контроль на сам гейт: если _GRID_WALK_LAYERS опустеет, расчёт совпал бы с
любой мелкой цифрой тавтологически.

Прогоны: tests/services — 3116 passed rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 15:02:17 +05:00
7 changed files with 151 additions and 17 deletions

View file

@ -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"]`, то есть АДМИН-имя: для половины районов
выборка пустая, для остальных заметно урезанная. Резолв adminmicros
(как в `_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.
"""

View file

@ -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 не попадают.

View file

@ -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()

View file

@ -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.

View file

@ -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'.

View file

@ -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).

View 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 — пересчитать смету"