From 988c4c83c9e9da795fa4060ff5b5a9243f29288d Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 19 Aug 2026 21:50:57 +0500 Subject: [PATCH] =?UTF-8?q?docs(ptica):=20=D1=82=D1=80=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D1=8F,?= =?UTF-8?q?=20=D1=83=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B0=D0=B2=D1=88?= =?UTF-8?q?=D0=B8=D1=85=20=D0=BD=D0=B5=20=D1=82=D0=BE,=20=D1=87=D1=82?= =?UTF-8?q?=D0=BE=20=D0=B4=D0=B5=D0=BB=D0=B0=D0=B5=D1=82=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=B4=20(#2464=20=D0=BA=D0=BB=D0=B0=D1=81=D1=82=D0=B5=D1=80=20?= =?UTF-8?q?H)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ложное утверждение в комментарии живёт дольше кода и распространяется на соседние решения. Сегодня такие фразы трижды вводили в заблуждение меня самого, поэтому правлю их отдельной пачкой — по каждому пункту решено, КАКАЯ сторона неверна. 1. special_indices._timing_overlap. Докстринг обещал `exp(−|Δмес| / half_life)` и тут же — «расхождение в half_life мес → 0.5». Утверждения несовместимы: exp(−1) ≈ 0.368. Верен КОД: он считает `0.5 ** (Δ/half_life)` и даёт ровно 0.5 на половинном периоде (у самой строки есть пояснение об этом). Исправлен докстринг. Свойство закреплено тестом, а не текстом: на Δ = half_life значение равно 0.5 И НЕ равно exp(−1). Второе утверждение существенно — без него тест прошёл бы и на экспоненциальной формуле, если бы кто-то «починил» код по старому докстрингу. Фальсифицировано подстановкой: с экспонентой тест краснеет (`assert 0.132 < 1e-09`). 2. domrf_catalog: комментарий обещал «BFS по вложенному dict/list», а обход идёт через stack.pop() — это LIFO, то есть DFS. Не косметика: функция возвращает ПЕРВОЕ совпадение, а при упоре в cap (20 000 узлов) обход успевает посмотреть разные подмножества дерева. Порядок влияет и на то, какой план найдётся, и на то, найдётся ли. 3. confidence_engine ссылался на «§9.6 _CONF_HIGH_MIN_OBS=24 / _MIN_OBS=8» в четырёх местах. Значения верны, неверен АДРЕС: обе константы живут в rate_sensitivity.py, а §9.6 (forecasting/regression.py) держит свой _MIN_OBS=30 (gate-порог для claim) и _MIN_FIT_OBS=8 (можно ли вообще фитить). Совпадение цифры 8 в двух модулях и сбило ссылку. Здесь же поправлена и сама находка эпика: она утверждала «actual constant is 12», перепутав _HISTORY_MONTHS_LOW=12 с константой, на которую ссылается комментарий. Дрейф реальный, но не тот, что описан. pytest tests/services: 3077 passed, 14 skipped, rc=0 --- .../services/forecasting/confidence_engine.py | 19 +++++--- .../services/forecasting/special_indices.py | 9 +++- .../app/services/scrapers/domrf_catalog.py | 6 ++- .../test_2464h_timing_overlap_formula.py | 45 +++++++++++++++++++ 4 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 backend/tests/services/test_2464h_timing_overlap_formula.py diff --git a/backend/app/services/forecasting/confidence_engine.py b/backend/app/services/forecasting/confidence_engine.py index 3e4aaec3..02aa4d45 100644 --- a/backend/app/services/forecasting/confidence_engine.py +++ b/backend/app/services/forecasting/confidence_engine.py @@ -24,7 +24,8 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, и причина это ПРОГОВАРИВАЕТ. Честность важнее оптимистичной метки. ПОРОГИ (align с per-service gate'ами, которые читает движок): - • deal_count — зеркало market_metrics._confidence (n_lots/n_sold) + §9.6 _MIN_OBS: + • deal_count — зеркало market_metrics._confidence (n_lots/n_sold) + порог + rate_sensitivity._MIN_OBS: мало сделок → скоростные метрики статистически ненадёжны. • analog_count (ЖК-аналоги, = market_metrics.obj_count) — high≥3 / medium≥2 / 1 → low (точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример). @@ -32,7 +33,8 @@ High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, из Objective (`analyze.market_data_coverage_pct`), а не покрытие маппинга domrf↔objective. Продьюсера для второго нет и не было (#2464-H). Прод 13.08: медиана 40%, среднее 31.7%. Низкое покрытие → рынок и конкуренция оценены хуже. - • history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий + • history_months — созвучно rate_sensitivity._CONF_HIGH_MIN_OBS=24 (≥2 года) / + _MIN_OBS=8 (НЕ §9.6: там свой _MIN_OBS=30, см. комментарий у констант): короткий ряд → связь rate↔sales / тренды не установлены. • confounded — шок-окно (is_confounded_window, PR2): ряд пересекает структурный разрыв → оценки смещены (НИКОГДА не 'high'). @@ -84,7 +86,8 @@ _SERVICE_RU_DEFAULT: str = "Компонент" # deal_count: число сделок (продаж) за окно. high — длинная плотная выборка, # medium — рабочий минимум, low — статистически ненадёжно (зеркало духа -# market_metrics: n_sold>0 обязателен; §9.6 _MIN_OBS=8 — пол для регрессии). +# market_metrics: n_sold>0 обязателен; rate_sensitivity._MIN_OBS=8 — пол для оценки +# чувствительности. НЕ §9.6: у регрессии §9.6 порог свой, _MIN_OBS=30.) _DEAL_COUNT_HIGH: int = 50 _DEAL_COUNT_LOW: int = 15 @@ -101,8 +104,14 @@ _ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low _DOMRF_COVERAGE_HIGH: float = 0.6 _DOMRF_COVERAGE_LOW: float = 0.2 -# history_months: глубина ряда (мес). Зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / -# _MIN_OBS=8 (пол): короткий ряд → тренды/чувствительность не установлены. +# history_months: глубина ряда (мес). Пороги созвучны rate_sensitivity: +# _CONF_HIGH_MIN_OBS=24 (≥2 года Δln-наблюдений) и _MIN_OBS=8 (пол, ниже которого +# чувствительность не считаем). Короткий ряд → тренды/чувствительность не установлены. +# +# #2464 кластер H: раньше обе константы приписывались «§9.6». Это неверный адрес — +# §9.6 (forecasting/regression.py) держит СВОЙ _MIN_OBS=30 (gate-порог для claim) и +# _MIN_FIT_OBS=8 (можно ли вообще фитить). Совпадение цифры 8 в двух модулях и сбило +# ссылку. Значения 24/8 верны, неверна была атрибуция. _HISTORY_MONTHS_HIGH: int = 24 _HISTORY_MONTHS_LOW: int = 12 diff --git a/backend/app/services/forecasting/special_indices.py b/backend/app/services/forecasting/special_indices.py index 58142935..0035b1e2 100644 --- a/backend/app/services/forecasting/special_indices.py +++ b/backend/app/services/forecasting/special_indices.py @@ -588,8 +588,13 @@ def _timing_overlap( ) -> float | None: """Ось тайминга: временна́я близость окон запуска. PURE. - exp(−|Δмесяцев| / half_life): одновременный выход → 1.0, расхождение в half_life мес - → 0.5, дальше затухает. Чем ближе наши запуски, тем сильнее пересекаются окна продаж + 0.5 ** (|Δмесяцев| / half_life): одновременный выход → 1.0, расхождение в half_life + мес → ровно 0.5, дальше затухает. + + Формула в докстринге раньше была записана как exp(−Δ/half_life) — она даёт при + Δ=half_life не 0.5, а exp(−1) ≈ 0.368, то есть противоречила соседнему же + утверждению «→ 0.5». Верен КОД (строка ниже несёт то же пояснение); расходился + докстринг (#2464 кластер H). Чем ближе наши запуски, тем сильнее пересекаются окна продаж = выше каннибализация. Любая дата None → None (ось НЕДОСТУПНА — НЕ фабрикуем). PURE. """ if candidate_month is None or own_month is None: diff --git a/backend/app/services/scrapers/domrf_catalog.py b/backend/app/services/scrapers/domrf_catalog.py index 0896d3fc..7dc417d2 100644 --- a/backend/app/services/scrapers/domrf_catalog.py +++ b/backend/app/services/scrapers/domrf_catalog.py @@ -372,7 +372,11 @@ def _extract_plan_from_next_data(html: str) -> str | None: page_props = blob.get("props", {}).get("pageProps") root: Any = page_props if isinstance(page_props, dict) else blob - # BFS по вложенному dict/list; ключ+значение проверяем на plan-hint. + # DFS по вложенному dict/list (stack.pop() — LIFO); ключ+значение проверяем на + # plan-hint. Раньше здесь стояло «BFS» — неверно, и это не косметика: функция + # возвращает ПЕРВОЕ найденное совпадение, а при упоре в cap (20 000 узлов) обход + # успевает посмотреть разные подмножества дерева. То есть порядок влияет и на то, + # какой план найдётся, и на то, найдётся ли (#2464 кластер H). stack: list[Any] = [root] seen = 0 while stack and seen < 20_000: # cap: защита от патологически глубокого JSON diff --git a/backend/tests/services/test_2464h_timing_overlap_formula.py b/backend/tests/services/test_2464h_timing_overlap_formula.py new file mode 100644 index 00000000..460c10bd --- /dev/null +++ b/backend/tests/services/test_2464h_timing_overlap_formula.py @@ -0,0 +1,45 @@ +"""#2464 кластер H: докстринг `_timing_overlap` обещал не ту формулу. + +Было записано `exp(−|Δмесяцев| / half_life)` и тут же — «расхождение в half_life +мес → 0.5». Эти два утверждения несовместимы: `exp(−1) ≈ 0.368`, а не 0.5. + +Код при этом верен — он считает `0.5 ** (Δ/half_life)` и даёт ровно 0.5 на +половинном периоде. Расходился докстринг. + +Тест закрепляет СВОЙСТВО, а не текст комментария: на Δ = half_life значение равно +0.5, и оно НЕ равно exp(−1). Второе утверждение существенно — без него тест прошёл +бы и на экспоненциальной формуле, если бы кто-то «починил» код по старому +докстрингу. +""" + +from __future__ import annotations + +import math +from datetime import date + +from app.services.forecasting.special_indices import _timing_overlap + + +def test_half_life_gives_exactly_one_half() -> None: + same = date(2026, 1, 1) + later = date(2026, 7, 1) # ровно 6 месяцев + + value = _timing_overlap(later, same, half_life_months=6.0) + + assert value is not None + assert abs(value - 0.5) < 1e-9, "на половинном периоде значение обязано быть 0.5" + assert abs(value - math.exp(-1)) > 0.1, ( + "значение совпало с exp(−1) — значит формула экспоненциальная, " + "а докстринг обещает половинное затухание" + ) + + +def test_simultaneous_launch_is_one() -> None: + same = date(2026, 1, 1) + assert _timing_overlap(same, same, half_life_months=6.0) == 1.0 + + +def test_missing_date_returns_none_not_a_number() -> None: + """Контроль: недоступную ось не фабрикуем (обещание того же докстринга).""" + assert _timing_overlap(None, date(2026, 1, 1)) is None + assert _timing_overlap(date(2026, 1, 1), None) is None