From e7c79c964696757341ebbed9ed0309638c2e6c56 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 15:02:17 +0500 Subject: [PATCH] =?UTF-8?q?docs(ptica):=20=D1=88=D0=B5=D1=81=D1=82=D1=8C?= =?UTF-8?q?=20=D0=BC=D0=B5=D1=81=D1=82,=20=D0=B3=D0=B4=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B0=D1=81=D1=85=D0=BE=D0=B4=D0=B8=D0=BB=D0=B0?= =?UTF-8?q?=D1=81=D1=8C=20=D1=81=20=D0=BA=D0=BE=D0=B4=D0=BE=D0=BC=20(#2464?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Каждое проверено против кода или прод-данных, а не переписано по впечатлению. 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 --- backend/app/services/analytics_queries.py | 20 +++- .../services/forecasting/macro_coefficient.py | 6 +- .../app/services/forecasting/macro_series.py | 11 ++- .../app/services/forecasting/sales_series.py | 8 +- backend/app/services/scrapers/nspd_client.py | 23 +++-- backend/app/services/scrapers/nspd_denorm.py | 6 +- .../services/test_2464_docs_match_code.py | 94 +++++++++++++++++++ 7 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 backend/tests/services/test_2464_docs_match_code.py diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 7239d698..9e6ef0ab 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -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. """ diff --git a/backend/app/services/forecasting/macro_coefficient.py b/backend/app/services/forecasting/macro_coefficient.py index 4fe60ccd..b48dbd0f 100644 --- a/backend/app/services/forecasting/macro_coefficient.py +++ b/backend/app/services/forecasting/macro_coefficient.py @@ -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 не попадают. diff --git a/backend/app/services/forecasting/macro_series.py b/backend/app/services/forecasting/macro_series.py index 2143e5cc..c4f07f27 100644 --- a/backend/app/services/forecasting/macro_series.py +++ b/backend/app/services/forecasting/macro_series.py @@ -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() diff --git a/backend/app/services/forecasting/sales_series.py b/backend/app/services/forecasting/sales_series.py index c0c2630b..f48aa633 100644 --- a/backend/app/services/forecasting/sales_series.py +++ b/backend/app/services/forecasting/sales_series.py @@ -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. diff --git a/backend/app/services/scrapers/nspd_client.py b/backend/app/services/scrapers/nspd_client.py index 95a9019b..2960a50c 100644 --- a/backend/app/services/scrapers/nspd_client.py +++ b/backend/app/services/scrapers/nspd_client.py @@ -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'. diff --git a/backend/app/services/scrapers/nspd_denorm.py b/backend/app/services/scrapers/nspd_denorm.py index 58082cb7..071be5c6 100644 --- a/backend/app/services/scrapers/nspd_denorm.py +++ b/backend/app/services/scrapers/nspd_denorm.py @@ -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). diff --git a/backend/tests/services/test_2464_docs_match_code.py b/backend/tests/services/test_2464_docs_match_code.py new file mode 100644 index 00000000..99eea44c --- /dev/null +++ b/backend/tests/services/test_2464_docs_match_code.py @@ -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 — пересчитать смету" -- 2.45.3