docs(ptica): три комментария, утверждавших не то, что делает код (#2464 кластер H)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 2m4s
CI / backend-tests (pull_request) Successful in 17m16s

Ложное утверждение в комментарии живёт дольше кода и распространяется на соседние
решения. Сегодня такие фразы трижды вводили в заблуждение меня самого, поэтому
правлю их отдельной пачкой — по каждому пункту решено, КАКАЯ сторона неверна.

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
This commit is contained in:
bot-backend 2026-08-19 21:50:57 +05:00
parent 5a7d558a5c
commit 988c4c83c9
4 changed files with 71 additions and 8 deletions

View file

@ -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) high3 / medium2 / 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`), а не покрытие маппинга
domrfobjective. Продьюсера для второго нет и не было (#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, см. комментарий у констант): короткий
ряд связь ratesales / тренды не установлены.
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

View file

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

View file

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

View file

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