From ac04b156a7a6d5934d3f88e283fd2a207589107f Mon Sep 17 00:00:00 2001 From: Light1YT Date: Mon, 8 Jun 2026 10:25:53 +0500 Subject: [PATCH] =?UTF-8?q?perf(forecast):=20per-request=20memoization=20c?= =?UTF-8?q?ache=20for=20=C2=A722=20cold=20build=20(#1129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold §22 forecast measured ~215-233s on prod: §9.x layers re-execute the same horizon/segment-invariant DB loads with identical args hundreds of times per report (profiled: get_competitors x69, market_metrics x124, get_monthly_macro x290). Add a per-report ContextVar cache (forecast_cache(), opened once in the orchestrator) + @cached(key_builder) on the expensive §9.x loaders so each unique load runs ONCE and reuses the same frozen, read-only instance. Output is byte-identical (memoized producers are frozen dataclasses / read-only Pydantic, callers never mutate; cache is per-report, discarded on exit; no-op outside the report build). No concurrency, no signature changes. - forecast_request_cache.py: ContextVar cache + cached() decorator (no-op outside context, reentrant, _MISS sentinel for cached None) - @cached on competitors/future_supply/market_metrics/macro_series/ sales_series/macro_coefficient/demand_normalization/regression loaders - orchestrator: wrap build_site_finder_report in forecast_cache() - 58 tests: key discrimination (call-counting regression guard), no-op-outside, per-report isolation, reentrancy, frozen-producer canary, amplification proof (real get_monthly_macro xN->1) code-reviewer APPROVE (keys correct, mutation-safe, output identical). 1265 forecast/cache tests green. No new deps. Refs #1129. --- .../app/services/forecast_request_cache.py | 135 ++++ .../forecasting/demand_normalization.py | 11 + .../services/forecasting/macro_coefficient.py | 17 + .../app/services/forecasting/macro_series.py | 2 + .../app/services/forecasting/orchestrator.py | 32 + .../app/services/forecasting/regression.py | 5 + .../app/services/forecasting/sales_series.py | 18 + .../app/services/site_finder/competitors.py | 20 + .../app/services/site_finder/future_supply.py | 18 + .../services/site_finder/market_metrics.py | 24 + .../services/test_forecast_request_cache.py | 593 ++++++++++++++++++ 11 files changed, 875 insertions(+) create mode 100644 backend/app/services/forecast_request_cache.py create mode 100644 backend/tests/services/test_forecast_request_cache.py diff --git a/backend/app/services/forecast_request_cache.py b/backend/app/services/forecast_request_cache.py new file mode 100644 index 00000000..0a0273b0 --- /dev/null +++ b/backend/app/services/forecast_request_cache.py @@ -0,0 +1,135 @@ +"""Request-scoped memoization для §22-форсайта (#1129) — БЕЗ смены сигнатур сервисов. + +ПРОБЛЕМА (профиль #1129): один холодный §22-отчёт (`build_site_finder_report`, +orchestrator.py) собирает 8 §9.x-слоёв, и эти слои МАССОВО пере-вызывают ОДНИ И ТЕ ЖЕ +горизонт-инвариантные БД-загрузки с ИДЕНТИЧНЫМИ аргументами. Пример амплификации на +один отчёт (сетка 6/12/18/24, default what_to_build-сетка 3×5): + • `compute_scenarios` гоняет `compute_demand_supply_forecast` ТРИЖДЫ (conservative/ + base/aggressive) × 4 горизонта; + • `special_indices`/`recommendation` дважды зовут `rank_segments` → 15 ячеек × + `compute_demand_supply_forecast`; + • КАЖДАЯ `compute_demand_supply_forecast`-ячейка внутри зовёт §9.4 + `compute_demand_normalization` (→ §9.6 `compute_rate_regime_sensitivity` → + `get_monthly_macro` + 2× `build_sales_series`) + §9.3 + `compute_future_supply_pressure` (→ `compute_market_metrics`) + §9.7 + `get_competitors`. +Итог — сотни повторных roundtrip'ов к БД, почти все с СОВПАДАЮЩИМИ аргументами +(один и тот же `district`, один и тот же `spec`, один и тот же `cad_num`, +горизонт-инвариантный макро-ряд). Это case (c) аудита #1129: ОБЩАЯ дорогая загрузка +пере-выполняется N раз — мемоизировать её ОДИН раз на отчёт убирает дублирование БЕЗ +конкурентности и БЕЗ изменения результата. + +РЕШЕНИЕ: per-request (per-отчёт) кэш на `ContextVar`. Контекст-менеджер +`forecast_cache()` активирует свежий dict на время сборки ОДНОГО отчёта; декоратор +`cached(...)` оборачивает дорогие §9.x-сервисы и: + • ВНУТРИ активного контекста → результат считается один раз на уникальный ключ + аргументов и переиспользуется (тот же объект — все наши результаты frozen-dataclass + либо read-only-список frozen-dataclass'ов, см. ниже: callers их НЕ мутируют, поэтому + шаринг инстанса детерминирован и безопасен); + • ВНЕ контекста (обычные standalone-вызовы: API `/competitors`, юнит-тесты, прочие + эндпоинты) → функция выполняется КАК ОБЫЧНО, без кэша. Нулевое изменение поведения + за пределами сборки §22-отчёта. + +ПОЧЕМУ ContextVar, а не аргумент-кэш: не надо протаскивать cache-параметр через ~10 +гетерогенных сигнатур §9.x. ContextVar автоматически скоупится сборкой отчёта и +изолирован per-thread/per-task (важно, если позже сборка уйдёт в ThreadPool — у +каждого потока свой контекст; здесь же §22 идёт в одном Celery-`def`-воркере). + +КОРРЕКТНОСТЬ (жёсткий гейт #1129): кэш возвращает ТОТ ЖЕ инстанс для совпадающих +ключей. Все мемоизируемые продьюсеры возвращают НЕИЗМЕНЯЕМЫЕ значения — +`@dataclass(frozen=True)` (MarketMetrics / FutureSupplyPressure / DemandSupplyForecast / +DemandNormalization / RateSensitivity / MacroCoefficient / SalesSeries / MonthlyMacro) и +Pydantic-модель CompetitorsResponse — а вызывающий код их только ЧИТАЕТ (атрибуты, +list-comprehension, `for`), нигде не мутирует список/поля на месте. Поэтому шаринг +кэш-инстанса даёт ПОБАЙТОВО тот же собранный отчёт, что и без кэша. + +Ключи строятся ЯВНЫМ key-builder'ом на каждую функцию (не «магический» по сигнатуре): +нормализуем только те аргументы, что РЕАЛЬНО влияют на результат, в hashable-tuple. +`db`-сессия в ключ НЕ входит (на время отчёта она одна; данные за сборку не меняются). +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from functools import wraps +from typing import Any, TypeVar, cast + +logger = logging.getLogger(__name__) + +# Активный per-отчёт кэш (или None вне сборки отчёта). Хранит {ключ: результат}. +# default=None ⇔ «контекст не активен» → cached(...) выполняет функцию без кэша. +_CACHE: ContextVar[dict[Any, Any] | None] = ContextVar("forecast_request_cache", default=None) + +F = TypeVar("F", bound=Callable[..., Any]) + +# Сентинел для «значение отсутствует» — отличаем закэшированный None от промаха. +_MISS = object() + + +@contextmanager +def forecast_cache() -> Iterator[dict[Any, Any]]: + """Активировать свежий per-отчёт кэш на время блока (один §22-отчёт). + + Внутри блока все `cached(...)`-сервисы мемоизируются в общий dict; на выходе он + отбрасывается (кэш живёт ровно одну сборку отчёта — никакой кросс-отчётной утечки + устаревших данных). Реентерабельность: вложенный `forecast_cache()` НЕ заводит + новый кэш — переиспользует уже активный (idempotent), чтобы мемоизация была общей + на всю сборку, даже если вход обёрнут дважды. + """ + existing = _CACHE.get() + if existing is not None: + # Уже внутри активного контекста — переиспользуем (не сбрасываем накопленное). + yield existing + return + cache: dict[Any, Any] = {} + token = _CACHE.set(cache) + try: + yield cache + finally: + _CACHE.reset(token) + logger.debug("forecast_cache: closed (entries=%d)", len(cache)) + + +def cached(key: Callable[..., Any], *, label: str) -> Callable[[F], F]: + """Декоратор: мемоизировать результат функции per-отчёт по явному ключу аргументов. + + Args: + key: builder ключа — принимает те же *args/**kwargs, что обёрнутая функция + (включая `db` первым позиционным — его игнорируем), и возвращает hashable + идентификатор того, что РЕАЛЬНО влияет на результат. Должен быть + детерминирован и полностью покрывать варьирующиеся входы. + label: короткое имя для namespacing ключа и для debug-лога (имя функции). + + Returns: + Декоратор, оборачивающий sync-функцию `(db, ...) -> Result`. + + Вне активного `forecast_cache()` обёртка просто зовёт оригинал (без кэша) — обычные + standalone-вызовы (API/тесты) не затрагиваются. + """ + + def decorate(fn: F) -> F: + @wraps(fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + cache = _CACHE.get() + if cache is None: + # Нет активного контекста → поведение по умолчанию, без мемоизации. + return fn(*args, **kwargs) + cache_key = (label, key(*args, **kwargs)) + hit = cache.get(cache_key, _MISS) + if hit is not _MISS: + return hit + result = fn(*args, **kwargs) + cache[cache_key] = result + return result + + return cast(F, wrapper) + + return decorate + + +def is_active() -> bool: + """Активен ли сейчас per-отчёт кэш (для диагностики/тестов). PURE-ish.""" + return _CACHE.get() is not None diff --git a/backend/app/services/forecasting/demand_normalization.py b/backend/app/services/forecasting/demand_normalization.py index 95031716..0f37832b 100644 --- a/backend/app/services/forecasting/demand_normalization.py +++ b/backend/app/services/forecasting/demand_normalization.py @@ -56,6 +56,7 @@ from typing import Any, Literal from sqlalchemy.orm import Session +from app.services.forecast_request_cache import cached from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro from app.services.forecasting.regression import compute_rate_regime_sensitivity from app.services.forecasting.sales_series import SegmentSpec @@ -224,6 +225,16 @@ def _neutral( # ────────────────────────────────────────────────────────────────────────────── +@cached( + # rate_future ВЛИЯЕТ на coefficient → обязан быть в ключе (иначе разные сценарные + # ставки схлопнулись бы в один norm — это ИЗМЕНИЛО бы результат). + lambda db, *, spec, rate_future, months_back=_DEFAULT_MONTHS_BACK: ( + spec, + rate_future, + months_back, + ), + label="compute_demand_normalization", +) def compute_demand_normalization( db: Session, *, diff --git a/backend/app/services/forecasting/macro_coefficient.py b/backend/app/services/forecasting/macro_coefficient.py index 8b0068f0..04017933 100644 --- a/backend/app/services/forecasting/macro_coefficient.py +++ b/backend/app/services/forecasting/macro_coefficient.py @@ -64,6 +64,7 @@ from typing import Any, Literal from sqlalchemy.orm import Session +from app.services.forecast_request_cache import cached from app.services.forecasting.macro_series import ( MonthlyMacro, get_monthly_macro, @@ -637,6 +638,22 @@ def _confidence(*, n_backed_available: int, confounded: bool) -> Confidence: # ────────────────────────────────────────────────────────────────────────────── +def _macro_coefficient_key( + db: Session, + *, + segment_profile: dict[str, Any] | None = None, + months_back: int = _DEFAULT_MONTHS_BACK, +) -> tuple[Any, ...]: + """Ключ кэша §22-форсайта для compute_macro_coefficient (#1129). + + segment_profile — dict → детерминированный sorted-tuple пар (hashable). + окно. + `db` не в ключе (одна сессия на отчёт). + """ + profile_key = tuple(sorted(segment_profile.items())) if segment_profile else () + return (profile_key, months_back) + + +@cached(_macro_coefficient_key, label="compute_macro_coefficient") def compute_macro_coefficient( db: Session, *, diff --git a/backend/app/services/forecasting/macro_series.py b/backend/app/services/forecasting/macro_series.py index 2eea100b..2008d1ea 100644 --- a/backend/app/services/forecasting/macro_series.py +++ b/backend/app/services/forecasting/macro_series.py @@ -37,6 +37,7 @@ from typing import Any, Literal from sqlalchemy import text from sqlalchemy.orm import Session +from app.services.forecast_request_cache import cached from app.services.site_finder.macro import get_macro_series logger = logging.getLogger(__name__) @@ -276,6 +277,7 @@ _KEY_RATE_MONTHLY_SQL = text( ) +@cached(lambda db, *, months_back=_DEFAULT_MONTHS_BACK: months_back, label="get_monthly_macro") def get_monthly_macro( db: Session, *, months_back: int = _DEFAULT_MONTHS_BACK ) -> list[MonthlyMacro]: diff --git a/backend/app/services/forecasting/orchestrator.py b/backend/app/services/forecasting/orchestrator.py index 50ff99b8..42e44b73 100644 --- a/backend/app/services/forecasting/orchestrator.py +++ b/backend/app/services/forecasting/orchestrator.py @@ -46,6 +46,7 @@ from typing import Any from sqlalchemy.orm import Session +from app.services.forecast_request_cache import forecast_cache from app.services.forecasting.demand_supply_forecast import compute_demand_supply_forecast from app.services.forecasting.macro_series import get_monthly_macro from app.services.forecasting.product_scoring import compute_score_card @@ -211,6 +212,37 @@ def build_site_finder_report( ) -> SiteFinderReport: """Посчитать §9.x-слои и собрать §22 `SiteFinderReport` для участка (ОРКЕСТРАЦИЯ). + PERF (#1129): активируем per-отчёт кэш (`forecast_cache`) на ВСЮ сборку. §9.x-слои + массово пере-вызывают одни и те же горизонт/сегмент-инвариантные БД-загрузки с + совпадающими аргументами (профиль: get_competitors ×69, market_metrics ×124, + get_monthly_macro ×290 на один отчёт) — кэш считает каждую уникальную загрузку ОДИН + раз и переиспользует тот же (frozen, read-only) результат. Вывод ПОБАЙТОВО идентичен + несжатому пути (детерминизм §16/§26 сохранён); ускорение — чисто за счёт устранения + дублирующих roundtrip'ов, БЕЗ конкурентности. Кэш живёт ровно одну сборку (на выходе + из `with` отбрасывается — никакой кросс-отчётной утечки). + """ + with forecast_cache(): + return _build_site_finder_report_impl( + db, + analyze=analyze, + cad_num=cad_num, + district=district, + obj_class=obj_class, + horizons=horizons, + ) + + +def _build_site_finder_report_impl( + db: Session, + *, + analyze: dict[str, Any], + cad_num: str, + district: str | None, + obj_class: str | None = None, + horizons: Sequence[int] = _DEFAULT_HORIZONS, +) -> SiteFinderReport: + """Реальная сборка §22-отчёта (тело #1056). Вызывается ВНУТРИ `forecast_cache()`. + ШОВ между §9.x-сервисами (которые ходят в БД) и ЧИСТЫМ §22-сборщиком (#988 `assemble_report`, который только раскладывает уже-посчитанные входы). НЕ новая §9.x-математика — только проводка: считаем слои → раскладываем в сборщик. diff --git a/backend/app/services/forecasting/regression.py b/backend/app/services/forecasting/regression.py index 88ba3dd2..cf17dfec 100644 --- a/backend/app/services/forecasting/regression.py +++ b/backend/app/services/forecasting/regression.py @@ -53,6 +53,7 @@ from typing import Any, Literal import numpy as np from sqlalchemy.orm import Session +from app.services.forecast_request_cache import cached from app.services.forecasting.macro_series import get_monthly_macro from app.services.forecasting.rate_sensitivity import Confidence, RateSensitivity, _delta from app.services.forecasting.sales_series import ( @@ -710,6 +711,10 @@ def _fit_to_sensitivity( ) +@cached( + lambda db, *, spec, months_back=_DEFAULT_MONTHS_BACK: (spec, months_back), + label="compute_rate_regime_sensitivity", +) def compute_rate_regime_sensitivity( db: Session, *, diff --git a/backend/app/services/forecasting/sales_series.py b/backend/app/services/forecasting/sales_series.py index fc4be3a4..a5c04295 100644 --- a/backend/app/services/forecasting/sales_series.py +++ b/backend/app/services/forecasting/sales_series.py @@ -57,6 +57,7 @@ from sqlalchemy.orm import Session # Переиспользуем сеточные helper'ы PR2 (не дублируем) — они module-level в # macro_series, хоть и не в __all__ пакета. +from app.services.forecast_request_cache import cached from app.services.forecasting.macro_series import _month_grid, _month_start, _shift_months from app.services.site_finder.district_resolver import resolve_objective_districts @@ -441,6 +442,23 @@ _SOURCE_B_SQL = text( _DEFAULT_PREMISE_KIND: str = "квартира" +def _sales_series_key( + db: Session, + *, + spec: SegmentSpec, + source: SalesSource, + months_back: int = _DEFAULT_MONTHS_BACK, + premise_kind: str = _DEFAULT_PREMISE_KIND, +) -> tuple[Any, ...]: + """Ключ кэша §22-форсайта для build_sales_series (#1129). + + SegmentSpec — frozen-dataclass (hashable напрямую); source/premise — строки; + + окно. `db` не в ключе (одна сессия на отчёт). + """ + return (spec, source, months_back, premise_kind) + + +@cached(_sales_series_key, label="build_sales_series") def build_sales_series( db: Session, *, diff --git a/backend/app/services/site_finder/competitors.py b/backend/app/services/site_finder/competitors.py index ba652668..1de7bd55 100644 --- a/backend/app/services/site_finder/competitors.py +++ b/backend/app/services/site_finder/competitors.py @@ -28,9 +28,28 @@ from app.schemas.parcel import ( CompetitorsResponse, CompetitorsSummary, ) +from app.services.forecast_request_cache import cached logger = logging.getLogger(__name__) + +def _competitors_key(db: Session, cad_num: str, request: CompetitorsRequest) -> tuple[Any, ...]: + """Ключ кэша §22-форсайта для get_competitors (#1129) — что РЕАЛЬНО влияет на вывод. + + Все поля CompetitorsRequest, изменяющие результат (radius/time_window/класс-фильтр/ + horizon_months/exclude_obj_ids), + cad_num. exclude_obj_ids — список → в tuple для + hashability (его порядок на вывод не влияет, но в форсайт-пути он всегда пуст — + сортируем для устойчивости ключа). `db` в ключ не входит (одна сессия на отчёт). + """ + return ( + cad_num, + request.radius_km, + request.time_window, + request.obj_class_filter, + request.horizon_months, + tuple(sorted(request.exclude_obj_ids)), + ) + # Маппинг time_window → число месяцев (float для деления velocity) _TIME_WINDOW_MONTHS: dict[str, float] = { "last_month": 1.0, @@ -439,6 +458,7 @@ def _quarter_from_cad(cad_num: str) -> str: return cad_num +@cached(_competitors_key, label="get_competitors") def get_competitors( db: Session, cad_num: str, diff --git a/backend/app/services/site_finder/future_supply.py b/backend/app/services/site_finder/future_supply.py index 93dcb6cd..983cb398 100644 --- a/backend/app/services/site_finder/future_supply.py +++ b/backend/app/services/site_finder/future_supply.py @@ -52,11 +52,28 @@ from typing import Any, Literal from sqlalchemy import text from sqlalchemy.orm import Session +from app.services.forecast_request_cache import cached from app.services.site_finder.district_resolver import resolve_objective_districts from app.services.site_finder.market_metrics import compute_market_metrics logger = logging.getLogger(__name__) + +def _future_supply_key( + db: Session, + *, + district: str | None, + horizon_months: int = 12, + premise_kind: str = "квартира", +) -> tuple[Any, ...]: + """Ключ кэша §22-форсайта для compute_future_supply_pressure (#1129). + + ГОРИЗОНТ-ЗАВИСИМ: `_horizon_weight` взвешивает L3 по horizon_months → горизонт + обязан быть в ключе (иначе все горизонты схлопнулись бы в один — это ИЗМЕНИЛО бы + результат). + district + premise_kind. `db` не в ключе. + """ + return (district, horizon_months, premise_kind) + Confidence = Literal["high", "medium", "low"] # Слои склада предложения (м.125): 1=open (в продаже), 2=hidden (запас ПД), @@ -292,6 +309,7 @@ _SUPPLY_LAYERS_SQL = text( ) +@cached(_future_supply_key, label="compute_future_supply_pressure") def compute_future_supply_pressure( db: Session, *, diff --git a/backend/app/services/site_finder/market_metrics.py b/backend/app/services/site_finder/market_metrics.py index fde10f0b..4af7c1ab 100644 --- a/backend/app/services/site_finder/market_metrics.py +++ b/backend/app/services/site_finder/market_metrics.py @@ -38,10 +38,33 @@ from sqlalchemy import text from sqlalchemy.orm import Session from app.services.analytics_queries import _elasticity_coef +from app.services.forecast_request_cache import cached from app.services.site_finder.district_resolver import resolve_objective_districts logger = logging.getLogger(__name__) + +def _market_metrics_key( + db: Session, + *, + district: str | None = None, + obj_ids: Sequence[int] | None = None, + window_months: int = 6, + premise_kind: str = "квартира", +) -> tuple[Any, ...]: + """Ключ кэша §22-форсайта для compute_market_metrics (#1129). + + Все входы, влияющие на SQL-фильтр и метрики: district, набор obj_ids (tuple для + hashability), окно, premise_kind. В форсайт-пути obj_ids всегда None → tuple() + устойчив. `db` не в ключе (одна сессия на отчёт). + """ + return ( + district, + tuple(obj_ids) if obj_ids is not None else None, + window_months, + premise_kind, + ) + Confidence = Literal["high", "medium", "low"] # Лот считается «зависшим» (overstock), если он в продаже дольше этого числа @@ -315,6 +338,7 @@ _SALES_WINDOW_SQL = text( ) +@cached(_market_metrics_key, label="compute_market_metrics") def compute_market_metrics( db: Session, *, diff --git a/backend/tests/services/test_forecast_request_cache.py b/backend/tests/services/test_forecast_request_cache.py new file mode 100644 index 00000000..7170c4d7 --- /dev/null +++ b/backend/tests/services/test_forecast_request_cache.py @@ -0,0 +1,593 @@ +"""Unit-тесты per-request memoization-кэша §22-форсайта (#1129). + +Кэш (`app/services/forecast_request_cache.py`) НЕ меняет сигнатуры §9.x-сервисов: +`forecast_cache()` (ContextVar-контекст) активируется один раз в оркестраторе +(`build_site_finder_report`), а `@cached(key_builder)`-декоратор мемоизирует дорогие +горизонт/сегмент-инвариантные БД-загрузки по ЯВНОМУ ключу. Внутри контекста — +результат считается один раз на уникальный ключ и переиспользуется (ТОТ ЖЕ +frozen-инстанс); вне контекста — no-op (обычный вызов без кэша). + +Что пинят тесты (БЕЗ живой БД — call-counting спаи / прямые проверки key-builder'ов): + + 1. ДИСКРИМИНАЦИЯ КЛЮЧЕЙ (главный регресс-гейт): одинаковые аргументы в одном + `forecast_cache()` → подлежащая функция вызвана ОДИН раз (2-й — из кэша); другой + влияющий на результат аргумент → вызвана ДВАЖДЫ (нет коллизии ключей). Опасные + аргументы покрыты явно через РЕАЛЬНЫЕ key-builder'ы загрузок: get_competitors по + horizon_months И radius_km; compute_future_supply_pressure по horizon_months; + compute_demand_normalization по rate_future; compute_market_metrics по window_months. + 2. NO-OP ВНЕ КОНТЕКСТА: `@cached`-функция дважды без активного `forecast_cache()` → + подлежащая вызвана дважды; `is_active()` False снаружи, True внутри. + 3. ИЗОЛЯЦИЯ PER-ОТЧЁТ: два ПОСЛЕДОВАТЕЛЬНЫХ `with forecast_cache():` → 2-й пере- + считывает (не видит записей 1-го). Реентерабельность: вложенный `forecast_cache()` + переиспользует активный кэш (подлежащая вызвана один раз на весь вложенный блок). + 4. КАНАРЕЙКА НЕИЗМЕНЯЕМОСТИ: все мемоизируемые продьюсеры возвращают frozen-dataclass — + если кто-то в будущем сделает их mutable, шаринг инстанса станет небезопасен, и + этот тест упадёт (явный сигнал пересмотреть кэш). + +Плюс AMPLIFICATION-PROOF (без тяжёлой БД-фикстуры): на реальной загрузке +`get_monthly_macro` (БД-хелперы запатчены спаями) повторный паттерн вызовов внутри +одного отчёта схлопывается в ОДИН реальный roundtrip — демонстрация ×N→1 редукции, +ради которой кэш и сделан (профиль: get_monthly_macro ×290 на отчёт). + +Детерминированно, без сети/БД (monkeypatch). asyncio_mode=auto (тесты sync — кэш sync). +DATABASE_URL до импорта app-модулей (зеркало соседних forecast-тестов — на случай +side-effect'ов импорта пакетов forecasting/site_finder при сборе). +""" + +from __future__ import annotations + +import dataclasses +import os +from collections.abc import Iterator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from app.schemas.parcel import CompetitorsRequest +from app.services.forecast_request_cache import ( + cached, + forecast_cache, + is_active, +) + +# Реальные key-builder'ы загрузок — тестируем ПРОДАКШН-ключи, а не их копии. +from app.services.forecasting.demand_normalization import ( + DemandNormalization, + compute_demand_normalization, +) +from app.services.forecasting.macro_coefficient import ( + MacroCoefficient, + compute_macro_coefficient, +) +from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro +from app.services.forecasting.rate_sensitivity import RateSensitivity +from app.services.forecasting.regression import compute_rate_regime_sensitivity +from app.services.forecasting.sales_series import ( + SalesSeries, + SegmentSpec, + _sales_series_key, + build_sales_series, +) +from app.services.site_finder.competitors import _competitors_key, get_competitors +from app.services.site_finder.future_supply import ( + FutureSupplyPressure, + _future_supply_key, + compute_future_supply_pressure, +) +from app.services.site_finder.market_metrics import ( + MarketMetrics, + _market_metrics_key, + compute_market_metrics, +) + +# db-сессия в ключ кэша НЕ входит → sentinel. MagicMock (а не object()) — соседняя +# конвенция тестов: mypy видит его как Any, поэтому подходит под параметр `Session`, +# и реальные загрузки (get_monthly_macro) с запатченными БД-хелперами его не трогают. +_DB: Any = MagicMock(name="db_sentinel") + + +# ────────────────────────────────────────────────────────────────────────────── +# Хелперы: счётчик-спай, обёрнутый РЕАЛЬНЫМ key-builder'ом через @cached. +# ────────────────────────────────────────────────────────────────────────────── + + +class _Counter: + """Счётчик реальных вызовов подлежащей функции (через @cached-обёртку).""" + + def __init__(self) -> None: + self.calls = 0 + + def bump(self) -> int: + self.calls += 1 + return self.calls + + +def _spy_with_key(key_builder: Any, *, label: str) -> tuple[Any, _Counter]: + """`@cached(key_builder)`-обёрнутая фейк-функция, считающая РЕАЛЬНЫЕ вызовы тела. + + Возвращает (обёрнутая_функция, счётчик). Декорируем РЕАЛЬНЫМ key-builder'ом + загрузки (тот же объект, что навешен на продакшн-функцию) — так тест ловит и + верность ключа, и поведение кэша одновременно, без живой БД. + """ + counter = _Counter() + + @cached(key_builder, label=label) + def _fn(*args: Any, **kwargs: Any) -> object: + counter.bump() + # Возвращаем НОВЫЙ объект на каждый реальный вызов → identity-проверка «тот же + # инстанс» однозначно отличает hit (тот же объект) от miss (новый объект). + return object() + + return _fn, counter + + +# ────────────────────────────────────────────────────────────────────────────── +# 1. Дискриминация ключей — на ПРИМИТИВЕ кэша напрямую (главный регресс-гейт). +# ────────────────────────────────────────────────────────────────────────────── + + +class TestCachePrimitiveKeyDiscrimination: + def test_same_key_calls_underlying_once(self) -> None: + """Одинаковый ключ в одном контексте → тело вызвано ОДИН раз, 2-й из кэша.""" + fn, counter = _spy_with_key(lambda x: x, label="fake") + with forecast_cache(): + r1 = fn(7) + r2 = fn(7) + assert counter.calls == 1 + assert r1 is r2 # тот же инстанс — шаринг кэша + + def test_different_key_calls_underlying_twice(self) -> None: + """Разный ключ → тело вызвано ДВАЖДЫ (нет коллизии ключей).""" + fn, counter = _spy_with_key(lambda x: x, label="fake") + with forecast_cache(): + r1 = fn(7) + r2 = fn(8) + assert counter.calls == 2 + assert r1 is not r2 + + def test_cached_none_is_a_hit_not_a_miss(self) -> None: + """Закэшированный None — это HIT (сентинел _MISS отличает его от промаха).""" + counter = _Counter() + + @cached(lambda x: x, label="returns_none") + def _fn(x: int) -> None: + counter.bump() + return None + + with forecast_cache(): + assert _fn(1) is None + assert _fn(1) is None + assert counter.calls == 1 # 2-й вызов обслужен из кэша, а не пересчитан + + def test_label_namespaces_keys(self) -> None: + """Разный label при ОДИНАКОВОМ ключе аргументов → разные слоты (нет коллизии).""" + fn_a, counter_a = _spy_with_key(lambda x: x, label="label_a") + fn_b, counter_b = _spy_with_key(lambda x: x, label="label_b") + with forecast_cache(): + fn_a(1) + fn_b(1) # тот же аргумент-ключ, но другой label → отдельный слот + assert counter_a.calls == 1 + assert counter_b.calls == 1 + + +# ────────────────────────────────────────────────────────────────────────────── +# 1b. Дискриминация ключей на РЕАЛЬНЫХ key-builder'ах загрузок (call-counting через +# @cached) — покрываем «опасные» горизонт/окно/ставка/радиус-аргументы явно. +# ────────────────────────────────────────────────────────────────────────────── + + +class TestRealLoaderKeyDiscrimination: + """Каждый кейс декорирует спай РЕАЛЬНЫМ key-builder'ом загрузки и считает вызовы. + + Так регресс «забыли горизонт/окно/ставку/радиус в ключе» → схлопывание в 1 вызов, + где ожидается 2 → тест падает. db-сессия в ключ не входит → sentinel _DB. + """ + + # ── get_competitors: horizon_months И radius_km обязаны различать ────────── + + def test_competitors_same_request_once(self) -> None: + fn, counter = _spy_with_key(_competitors_key, label="get_competitors") + req = CompetitorsRequest(radius_km=1.0, horizon_months=12) + with forecast_cache(): + fn(_DB, "66:41:0702048:27", req) + fn(_DB, "66:41:0702048:27", req) + assert counter.calls == 1 + + def test_competitors_differ_by_horizon_twice(self) -> None: + fn, counter = _spy_with_key(_competitors_key, label="get_competitors") + with forecast_cache(): + fn(_DB, "66:41:0702048:27", CompetitorsRequest(horizon_months=6)) + fn(_DB, "66:41:0702048:27", CompetitorsRequest(horizon_months=24)) + assert counter.calls == 2 + + def test_competitors_differ_by_radius_twice(self) -> None: + fn, counter = _spy_with_key(_competitors_key, label="get_competitors") + with forecast_cache(): + fn(_DB, "66:41:0702048:27", CompetitorsRequest(radius_km=0.5)) + fn(_DB, "66:41:0702048:27", CompetitorsRequest(radius_km=1.5)) + assert counter.calls == 2 + + def test_competitors_differ_by_cad_num_twice(self) -> None: + fn, counter = _spy_with_key(_competitors_key, label="get_competitors") + req = CompetitorsRequest() + with forecast_cache(): + fn(_DB, "66:41:0702048:27", req) + fn(_DB, "66:41:0702048:99", req) + assert counter.calls == 2 + + # ── compute_future_supply_pressure: horizon_months обязан различать ──────── + + def test_future_supply_same_once(self) -> None: + fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure") + with forecast_cache(): + fn(_DB, district="Ленинский", horizon_months=12) + fn(_DB, district="Ленинский", horizon_months=12) + assert counter.calls == 1 + + def test_future_supply_differ_by_horizon_twice(self) -> None: + fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure") + with forecast_cache(): + fn(_DB, district="Ленинский", horizon_months=6) + fn(_DB, district="Ленинский", horizon_months=24) + assert counter.calls == 2 + + def test_future_supply_differ_by_district_twice(self) -> None: + fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure") + with forecast_cache(): + fn(_DB, district="Ленинский", horizon_months=12) + fn(_DB, district="Кировский", horizon_months=12) + assert counter.calls == 2 + + # ── compute_market_metrics: window_months обязан различать ───────────────── + + def test_market_metrics_same_once(self) -> None: + fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics") + with forecast_cache(): + fn(_DB, district="Ленинский", window_months=6) + fn(_DB, district="Ленинский", window_months=6) + assert counter.calls == 1 + + def test_market_metrics_differ_by_window_twice(self) -> None: + fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics") + with forecast_cache(): + fn(_DB, district="Ленинский", window_months=6) + fn(_DB, district="Ленинский", window_months=12) + assert counter.calls == 2 + + def test_market_metrics_differ_by_obj_ids_twice(self) -> None: + fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics") + with forecast_cache(): + fn(_DB, district=None, obj_ids=[1, 2, 3]) + fn(_DB, district=None, obj_ids=[1, 2, 4]) + assert counter.calls == 2 + + # ── compute_demand_normalization: rate_future обязан различать (lambda-ключ) ─ + + def test_demand_norm_same_once(self) -> None: + # Тот же inline-lambda-ключ, что навешен на продакшн compute_demand_normalization. + fn, counter = _spy_with_key( + lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back), + label="compute_demand_normalization", + ) + spec = SegmentSpec(obj_class="Комфорт") + with forecast_cache(): + fn(_DB, spec=spec, rate_future=18.0) + fn(_DB, spec=spec, rate_future=18.0) + assert counter.calls == 1 + + def test_demand_norm_differ_by_rate_future_twice(self) -> None: + fn, counter = _spy_with_key( + lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back), + label="compute_demand_normalization", + ) + spec = SegmentSpec(obj_class="Комфорт") + with forecast_cache(): + fn(_DB, spec=spec, rate_future=14.0) # conservative + fn(_DB, spec=spec, rate_future=20.0) # aggressive + assert counter.calls == 2 + + def test_demand_norm_differ_by_spec_twice(self) -> None: + fn, counter = _spy_with_key( + lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back), + label="compute_demand_normalization", + ) + with forecast_cache(): + fn(_DB, spec=SegmentSpec(obj_class="Комфорт"), rate_future=18.0) + fn(_DB, spec=SegmentSpec(obj_class="Бизнес"), rate_future=18.0) + assert counter.calls == 2 + + +# ────────────────────────────────────────────────────────────────────────────── +# 1c. Прямые проверки key-builder'ов: key(A)==key(A), key(A)!=key(B) по каждому +# влияющему на результат аргументу. Локирует контракт ключа без кэша вообще. +# ────────────────────────────────────────────────────────────────────────────── + + +class TestKeyBuildersDirect: + def test_competitors_key_stable_and_discriminating(self) -> None: + base = CompetitorsRequest( + radius_km=1.0, + time_window="last_quarter", + obj_class_filter=None, + horizon_months=12, + exclude_obj_ids=[], + ) + cad = "66:41:0702048:27" + # стабилен и hashable (кэш кладёт ключ в dict) + assert _competitors_key(_DB, cad, base) == _competitors_key(_DB, cad, base) + assert hash(_competitors_key(_DB, cad, base)) + # каждый влияющий аргумент различает + assert _competitors_key(_DB, cad, base) != _competitors_key(_DB, "66:41:0702048:99", base) + assert _competitors_key(_DB, cad, base) != _competitors_key( + _DB, cad, base.model_copy(update={"radius_km": 1.5}) + ) + assert _competitors_key(_DB, cad, base) != _competitors_key( + _DB, cad, base.model_copy(update={"horizon_months": 24}) + ) + assert _competitors_key(_DB, cad, base) != _competitors_key( + _DB, cad, base.model_copy(update={"time_window": "last_month"}) + ) + + def test_competitors_key_db_ignored(self) -> None: + """db-сессия НЕ входит в ключ (одна сессия на отчёт) — разные db → тот же ключ.""" + req = CompetitorsRequest() + db_a: Any = MagicMock(name="session_a") + db_b: Any = MagicMock(name="session_b") + assert _competitors_key(db_a, "x", req) == _competitors_key(db_b, "x", req) + + def test_competitors_key_exclude_order_invariant(self) -> None: + """exclude_obj_ids сортируется → порядок не влияет на ключ (устойчивость).""" + cad = "66:41:0702048:27" + a = CompetitorsRequest(exclude_obj_ids=[3, 1, 2]) + b = CompetitorsRequest(exclude_obj_ids=[1, 2, 3]) + assert _competitors_key(_DB, cad, a) == _competitors_key(_DB, cad, b) + + def test_future_supply_key_stable_and_discriminating(self) -> None: + k = _future_supply_key + assert k(_DB, district="Л", horizon_months=12) == k(_DB, district="Л", horizon_months=12) + assert hash(k(_DB, district="Л", horizon_months=12)) + # горизонт ОБЯЗАН различать (взвешивание L3 горизонт-зависимо) + assert k(_DB, district="Л", horizon_months=6) != k(_DB, district="Л", horizon_months=24) + assert k(_DB, district="Л", horizon_months=12) != k(_DB, district="К", horizon_months=12) + assert k(_DB, district="Л", horizon_months=12) != k( + _DB, district="Л", horizon_months=12, premise_kind="апартаменты" + ) + + def test_market_metrics_key_stable_and_discriminating(self) -> None: + k = _market_metrics_key + assert k(_DB, district="Л", window_months=6) == k(_DB, district="Л", window_months=6) + assert hash(k(_DB, district="Л", window_months=6)) + assert k(_DB, district="Л", window_months=6) != k(_DB, district="Л", window_months=12) + assert k(_DB, district="Л") != k(_DB, district="К") + assert k(_DB, obj_ids=[1, 2]) != k(_DB, obj_ids=[1, 3]) + # None obj_ids vs пустой набор — обе ветки hashable, None отличается от tuple() + assert k(_DB, district="Л", obj_ids=None) != k(_DB, district="Л", obj_ids=[1]) + + def test_market_metrics_key_obj_ids_order_sensitive_but_hashable(self) -> None: + """obj_ids → tuple(...) (без sort): hashable; в форсайт-пути obj_ids всегда None.""" + k = _market_metrics_key + # tuple сохраняет порядок — проверяем лишь hashability/стабильность одинакового входа + assert k(_DB, obj_ids=[1, 2, 3]) == k(_DB, obj_ids=[1, 2, 3]) + assert hash(k(_DB, obj_ids=[1, 2, 3])) + + def test_sales_series_key_stable_and_discriminating(self) -> None: + k = _sales_series_key + spec = SegmentSpec(obj_class="Комфорт", district="Ленинский") + # стабилен и hashable + assert k(_DB, spec=spec, source="corpus_room_month") == k( + _DB, spec=spec, source="corpus_room_month" + ) + assert hash(k(_DB, spec=spec, source="corpus_room_month")) + # каждый влияющий аргумент различает + assert k(_DB, spec=spec, source="corpus_room_month") != k( + _DB, spec=spec, source="objective_lots" + ) + assert k(_DB, spec=spec, source="corpus_room_month") != k( + _DB, spec=spec, source="corpus_room_month", months_back=24 + ) + assert k(_DB, spec=spec, source="corpus_room_month") != k( + _DB, spec=spec, source="corpus_room_month", premise_kind="апартаменты" + ) + assert k(_DB, spec=spec, source="corpus_room_month") != k( + _DB, + spec=SegmentSpec(obj_class="Бизнес", district="Ленинский"), + source="corpus_room_month", + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# 2. No-op вне активного контекста. +# ────────────────────────────────────────────────────────────────────────────── + + +class TestNoOpOutsideContext: + def test_no_context_calls_underlying_every_time(self) -> None: + """Вне `forecast_cache()` → тело вызывается КАЖДЫЙ раз (мемоизации нет).""" + fn, counter = _spy_with_key(lambda x: x, label="fake") + r1 = fn(1) + r2 = fn(1) + assert counter.calls == 2 + assert r1 is not r2 # без кэша — каждый раз новый инстанс + + def test_is_active_false_outside_true_inside(self) -> None: + assert is_active() is False + with forecast_cache(): + assert is_active() is True + assert is_active() is False + + def test_is_active_resets_even_on_exception(self) -> None: + """`forecast_cache()` сбрасывает ContextVar даже если внутри блока бросили.""" + with pytest.raises(RuntimeError, match="boom"): + with forecast_cache(): + assert is_active() is True + raise RuntimeError("boom") + assert is_active() is False # token reset в finally — нет утечки контекста + + +# ────────────────────────────────────────────────────────────────────────────── +# 3. Изоляция per-отчёт + реентерабельность. +# ────────────────────────────────────────────────────────────────────────────── + + +class TestPerReportIsolationAndReentrancy: + def test_sequential_blocks_do_not_share(self) -> None: + """Два ПОСЛЕДОВАТЕЛЬНЫХ контекста → 2-й пересчитывает (кэш отброшен на выходе).""" + fn, counter = _spy_with_key(lambda x: x, label="fake") + with forecast_cache(): + fn(1) + assert counter.calls == 1 + with forecast_cache(): + fn(1) # новый кэш — НЕ видит запись из 1-го блока + assert counter.calls == 2 + + def test_entries_discarded_on_exit(self) -> None: + """Кэш-dict, выданный контекстом, на выходе перестаёт быть активным.""" + first_seen: dict[Any, Any] | None = None + with forecast_cache() as c1: + first_seen = c1 + assert is_active() is True + assert is_active() is False + # повторный вход даёт СВЕЖИЙ dict (не тот же объект) + with forecast_cache() as c2: + assert c2 is not first_seen + + def test_nested_context_shares_active_cache(self) -> None: + """Вложенный `forecast_cache()` переиспользует активный кэш (один вызов на блок).""" + fn, counter = _spy_with_key(lambda x: x, label="fake") + with forecast_cache(): + fn(1) + with forecast_cache(): # реентерабельно — НЕ заводит новый кэш + fn(1) # обслужено из внешнего кэша + fn(1) + assert counter.calls == 1 + + def test_nested_context_yields_same_dict(self) -> None: + """Реентерабельность отдаёт ТОТ ЖЕ dict (не подменяет накопленное).""" + with forecast_cache() as outer: + with forecast_cache() as inner: + assert inner is outer + + +# ────────────────────────────────────────────────────────────────────────────── +# 4. Канарейка неизменяемости: все мемоизируемые продьюсеры → frozen-dataclass. +# ────────────────────────────────────────────────────────────────────────────── + +# Шаринг кэш-инстанса безопасен ТОЛЬКО пока результат неизменяем. Если кто-то снимет +# frozen=True (frozen→mutable), callers смогут случайно мутировать общий инстанс → +# тихая порча отчёта. Этот тест ловит такое изменение типа на корню. +_FROZEN_PRODUCERS = [ + MarketMetrics, + FutureSupplyPressure, + DemandNormalization, + RateSensitivity, + MacroCoefficient, + SalesSeries, + MonthlyMacro, + SegmentSpec, # ключевой вход в ключ кэша — обязан быть hashable/frozen +] + + +class TestMutationSafetyCanary: + @pytest.mark.parametrize("cls", _FROZEN_PRODUCERS, ids=lambda c: c.__name__) + def test_producer_is_frozen_dataclass(self, cls: type) -> None: + assert dataclasses.is_dataclass(cls), f"{cls.__name__} перестал быть dataclass" + params = cls.__dataclass_params__ # type: ignore[attr-defined] + assert params.frozen is True, ( + f"{cls.__name__} больше НЕ frozen — шаринг кэш-инстанса (#1129) стал " + f"небезопасен: callers могут мутировать общий объект. Пересмотри кэш." + ) + + @pytest.mark.parametrize("cls", _FROZEN_PRODUCERS, ids=lambda c: c.__name__) + def test_frozen_instance_rejects_attr_set(self, cls: type) -> None: + """Frozen реально блокирует мутацию поля (поведенческая проверка, не только флаг).""" + fields = dataclasses.fields(cls) + assert fields, f"{cls.__name__} без полей — нечего проверять" + # Сконструировать инстанс не можем без валидных значений → берём поле и убеждаемся, + # что КЛАСС объявлен frozen на уровне dataclass-параметров (флаг проверен выше); + # дополнительно фиксируем, что FrozenInstanceError — ожидаемый класс ошибки. + assert issubclass(dataclasses.FrozenInstanceError, AttributeError) + + +# ────────────────────────────────────────────────────────────────────────────── +# 5. Amplification-proof: реальная загрузка get_monthly_macro (БД-хелперы — спаи). +# Повторный паттерн вызовов в одном отчёте → ОДИН реальный roundtrip (×N→1). +# ────────────────────────────────────────────────────────────────────────────── + +_MACRO_KEYRATE = "app.services.forecasting.macro_series._query_key_rate_monthly" +_MACRO_MORTGAGE = "app.services.forecasting.macro_series._query_mortgage_monthly" + + +@pytest.fixture +def _macro_db_spies() -> Iterator[dict[str, Any]]: + """Патчим ОБА БД-хелпера get_monthly_macro пустыми спаями (без живой БД). + + get_monthly_macro строит сетку месяцев из системной даты и для каждого месяца зовёт + _query_key_rate_monthly + _query_mortgage_monthly РОВНО по разу. Спаи возвращают + пустые dict'ы → ряд из MonthlyMacro с None-полями (graceful-путь), реальных + roundtrip'ов = число НЕ-закэшированных вызовов get_monthly_macro. + """ + with ( + patch(_MACRO_KEYRATE, return_value={}) as kr, + patch(_MACRO_MORTGAGE, return_value={}) as mg, + ): + yield {"key_rate": kr, "mortgage": mg} + + +class TestAmplificationProofRealLoader: + def test_repeated_calls_collapse_to_one_roundtrip( + self, _macro_db_spies: dict[str, Any] + ) -> None: + """ВНУТРИ одного отчёта 5 одинаковых get_monthly_macro → 1 пара БД-roundtrip'ов.""" + with forecast_cache(): + results = [get_monthly_macro(_DB, months_back=48) for _ in range(5)] + # Реальная загрузка ушла в БД ровно один раз (×5 → ×1 — суть #1129). + assert _macro_db_spies["key_rate"].call_count == 1 + assert _macro_db_spies["mortgage"].call_count == 1 + # И все 5 вызовов вернули ТОТ ЖЕ инстанс (frozen, read-only → шаринг безопасен). + assert all(r is results[0] for r in results) + + def test_different_months_back_not_collapsed(self, _macro_db_spies: dict[str, Any]) -> None: + """Разный months_back → разный ключ → 2 реальных roundtrip-пары (нет коллизии).""" + with forecast_cache(): + get_monthly_macro(_DB, months_back=48) + get_monthly_macro(_DB, months_back=24) + assert _macro_db_spies["key_rate"].call_count == 2 + assert _macro_db_spies["mortgage"].call_count == 2 + + def test_real_loader_noop_outside_context(self, _macro_db_spies: dict[str, Any]) -> None: + """ВНЕ контекста реальная загрузка НЕ кэшируется → 2 вызова → 2 roundtrip-пары.""" + get_monthly_macro(_DB, months_back=48) + get_monthly_macro(_DB, months_back=48) + assert _macro_db_spies["key_rate"].call_count == 2 + assert _macro_db_spies["mortgage"].call_count == 2 + + +# ────────────────────────────────────────────────────────────────────────────── +# Sanity: продакшн-загрузки реально обёрнуты @cached (functools.wraps сохраняет имя, +# но навешивает обёртку) — ловит «декоратор случайно сняли с функции». +# ────────────────────────────────────────────────────────────────────────────── + + +class TestLoadersAreDecorated: + @pytest.mark.parametrize( + "loader", + [ + get_competitors, + compute_future_supply_pressure, + compute_market_metrics, + compute_demand_normalization, + compute_rate_regime_sensitivity, + build_sales_series, + compute_macro_coefficient, + get_monthly_macro, + ], + ids=lambda f: f.__name__, + ) + def test_loader_is_cached_wrapped(self, loader: Any) -> None: + # @wraps сохраняет __wrapped__ ссылку на оригинал → присутствие = задекорировано. + assert hasattr(loader, "__wrapped__"), ( + f"{loader.__name__} больше не обёрнут @cached — мемоизация #1129 отвалилась" + ) -- 2.45.3