gendesign/backend/tests/services/forecasting/test_scenarios.py
Light1YT dcccebf2c2
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m11s
CI / backend-tests (pull_request) Successful in 10m19s
fix(forecasting): honest scenarios collapse when β rate-sensitivity gate fails (audit #1871 P1)
Когда demand_normalization honesty-gate отбивает β rate-sensitivity
(`sensitivity.confidence=='low'`, ЕКБ-регрессия не проходит n≥30/R²≥0.1/slope<0),
все 393 прод-отчёта получают coefficient=1.0 для всех трёх сценариев
conservative/base/aggressive. По дизайну backend честно деградирует, но фронт
рисует 3 разноцветные карточки/линии одинаковых чисел без caveat.

Backend (scenarios.py, report.py, report_assembler.py, orchestrator.py):
- compute_scenarios теперь возвращает (list, collapsed: bool, reason: str|None)
- _detect_collapsed(): math.isclose(rel_tol=1e-9, abs_tol=1e-6) сравнивает
  projected_demand_units И deficit_index на всех горизонтах между
  conservative и aggressive — расхождение в любой метрике на любом h → False
- _COLLAPSE_REASON_LOW_BETA — единственный источник истины каноничного
  русского предложения
- ReportScenarios получает поля scenarios_collapsed + scenarios_collapse_reason
- demand_normalization.py НЕ ТРОНУТ — поведение корректно по контракту

Backend экспортёры (report_pdf.py, excel.py):
- PDF: при collapse — тёмная плашка-параграф вместо таблицы трёх столбцов
- Excel: ОДНА строка «base» + caveat-ячейка вместо трёх идентичных столбцов

Frontend (forecast.ts, ScenariosBlock, ScenarioCards, ScenarioCompareTable,
ForecastChart, Section6Forecast, ptica.module.css):
- Тип ReportScenarios расширен двумя optional-полями
- ScenariosBlock (light): headline-bar + одна grid-карточка base + caveat
- ScenarioCards (ptica dark): sub-component ScenarioBaseCard, одна карточка
  + collapse-note, ScenarioCompareTable return null
- ForecastChart: effectiveScenarios filter — одна линия viz-1 «Базовый»
  вместо трёх перекрывающихся
- Section6Forecast: caveat выше графика, читается ТОЛЬКО из
  scenarios_collapse_reason (источник истины — backend constant)

Тесты: +18 новых (TestMetricsEqual×6, TestDetectCollapsed×7,
TestComputeScenariosCollapseDetection×4 + 1 проверка graceful + corner).
102 forecasting passed, 88 cross-module passed.

Refs #1871
2026-06-22 19:06:22 +05:00

778 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unit-тесты §11 макро-сценариев (#984, 954-A, ADVISORY).
Чистые тесты — БЕЗ живой БД (мок #952 compute_demand_supply_forecast +
get_monthly_macro):
• pure build_rate_envelopes: три именованных path; инвариант conservative ≥ base ≥
aggressive на КАЖДОМ горизонте; кламп ставки ≥ 0; расширение конверта с
горизонтом; base None → все пути None.
• pure helpers: _horizon_widen_factor (монотонно, h≤0/выкл → 1.0), _shift_rate
(знак + кламп ≥0 + None passthrough), _first_non_none, _drop_none_rates.
• compute_scenarios через MagicMock-сессию + @patch #952 и get_monthly_macro:
ТРИ сценария; у каждого свой rate_path; advisory ВСЕГДА True; conservative
rate_path выше aggressive; rate_path проброшен в #952; graceful (нет макро →
три сценария с None-rate_path, не crash).
Детерминированно, без LLM. Мокаем #952 + get_monthly_macro + db (нет живой БД).
"""
from __future__ import annotations
import os
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from app.services.forecasting.scenarios import (
_AGGRESSIVE_RATE_DELTA_PP,
_COLLAPSE_REASON_LOW_BETA,
_CONSERVATIVE_RATE_DELTA_PP,
_MIN_RATE_PCT,
ScenarioForecast,
_detect_collapsed,
_drop_none_rates,
_first_non_none,
_horizon_widen_factor,
_metrics_equal,
_round_or_none,
_shift_rate,
build_rate_envelopes,
compute_scenarios,
)
# Пути патча #952 и макро-ридера (импортированы в модуль scenarios).
_FORECAST = "app.services.forecasting.scenarios.compute_demand_supply_forecast"
_MACRO = "app.services.forecasting.scenarios.get_monthly_macro"
_HOLD = "app.services.forecasting.scenarios.hold_last_rate"
# ── pure: _horizon_widen_factor ───────────────────────────────────────────────
class TestHorizonWidenFactor:
def test_twelve_months_is_one_year(self) -> None:
# h=12 → 1 + widen×1 (один год расширения).
assert _horizon_widen_factor(12, widen_per_year=0.5) == pytest.approx(1.5)
def test_monotonic_in_horizon(self) -> None:
f6 = _horizon_widen_factor(6)
f12 = _horizon_widen_factor(12)
f24 = _horizon_widen_factor(24)
assert f6 < f12 < f24
def test_nonpositive_horizon_no_widening(self) -> None:
assert _horizon_widen_factor(0) == 1.0
assert _horizon_widen_factor(-5) == 1.0
def test_widen_off_when_zero(self) -> None:
# widen_per_year ≤ 0 → плоский конверт (множитель 1.0 на любом горизонте).
assert _horizon_widen_factor(24, widen_per_year=0.0) == 1.0
def test_at_least_one(self) -> None:
assert _horizon_widen_factor(6) >= 1.0
# ── pure: _shift_rate ─────────────────────────────────────────────────────────
class TestShiftRate:
def test_up_shift(self) -> None:
assert _shift_rate(16.0, +2.0) == pytest.approx(18.0)
def test_down_shift(self) -> None:
assert _shift_rate(16.0, -3.0) == pytest.approx(13.0)
def test_clamps_at_min(self) -> None:
# Сдвиг вниз ниже нуля → кламп к _MIN_RATE_PCT (нет отрицательной ставки).
assert _shift_rate(2.0, -5.0) == _MIN_RATE_PCT
def test_none_passthrough(self) -> None:
assert _shift_rate(None, +2.0) is None
def test_zero_shift_is_base(self) -> None:
assert _shift_rate(16.0, 0.0) == pytest.approx(16.0)
# ── pure: build_rate_envelopes (центральный §11 конверт) ──────────────────────
class TestBuildRateEnvelopes:
def test_three_named_paths(self) -> None:
env = build_rate_envelopes(16.0, [6, 12])
assert set(env.keys()) == {"conservative", "base", "aggressive"}
def test_each_path_covers_all_horizons(self) -> None:
env = build_rate_envelopes(16.0, [6, 12, 18, 24])
for name in ("conservative", "base", "aggressive"):
assert set(env[name].keys()) == {6, 12, 18, 24}
def test_base_is_flat_hold(self) -> None:
# base = base_rate на КАЖДОМ горизонте (ставка не меняется).
env = build_rate_envelopes(16.0, [6, 12, 24])
assert env["base"] == {6: 16.0, 12: 16.0, 24: 16.0}
def test_conservative_above_base_above_aggressive(self) -> None:
# ИНВАРИАНТ §11: conservative ≥ base ≥ aggressive на любом горизонте.
env = build_rate_envelopes(16.0, [6, 12, 18, 24])
for h in (6, 12, 18, 24):
cons = env["conservative"][h]
base = env["base"][h]
aggr = env["aggressive"][h]
assert cons is not None and base is not None and aggr is not None
assert cons > base > aggr
def test_conservative_uses_up_delta_at_one_year(self) -> None:
# На h=12 widen=1.5 → conservative = base + delta×1.5.
env = build_rate_envelopes(16.0, [12])
expected = 16.0 + _CONSERVATIVE_RATE_DELTA_PP * 1.5
assert env["conservative"][12] == pytest.approx(expected)
def test_aggressive_uses_down_delta_at_one_year(self) -> None:
env = build_rate_envelopes(16.0, [12])
expected = 16.0 - _AGGRESSIVE_RATE_DELTA_PP * 1.5
assert env["aggressive"][12] == pytest.approx(expected)
def test_envelope_widens_with_horizon(self) -> None:
# Разброс conservativeaggressive РАСТЁТ с горизонтом (дальше неопределённее).
env = build_rate_envelopes(16.0, [6, 24])
spread_6 = env["conservative"][6] - env["aggressive"][6] # type: ignore[operator]
spread_24 = env["conservative"][24] - env["aggressive"][24] # type: ignore[operator]
assert spread_24 > spread_6
def test_aggressive_clamped_non_negative(self) -> None:
# Низкая база + большой down-сдвиг на дальнем горизонте → кламп ≥0.
env = build_rate_envelopes(1.0, [24])
assert env["aggressive"][24] == _MIN_RATE_PCT
# base/conservative при этом остаются положительными.
assert env["base"][24] == 1.0
assert env["conservative"][24] is not None and env["conservative"][24] > 1.0
def test_none_base_all_paths_none(self) -> None:
# Нет базовой ставки → все пути None на всех горизонтах (graceful).
env = build_rate_envelopes(None, [6, 12, 24])
for name in ("conservative", "base", "aggressive"):
assert all(v is None for v in env[name].values())
def test_custom_deltas(self) -> None:
env = build_rate_envelopes(10.0, [12], conservative_delta_pp=1.0, aggressive_delta_pp=1.0)
# widen(12)=1.5 → ±1.5 вокруг базы.
assert env["conservative"][12] == pytest.approx(11.5)
assert env["aggressive"][12] == pytest.approx(8.5)
def test_pure_no_mutation_of_input(self) -> None:
horizons = [6, 12]
build_rate_envelopes(16.0, horizons)
assert horizons == [6, 12]
# ── pure: _first_non_none ─────────────────────────────────────────────────────
class TestFirstNonNone:
def test_returns_first_present(self) -> None:
assert _first_non_none({6: 16.0, 12: 16.0}) == 16.0
def test_skips_none(self) -> None:
assert _first_non_none({6: None, 12: 17.0}) == 17.0
def test_all_none_returns_none(self) -> None:
assert _first_non_none({6: None, 12: None}) is None
def test_empty_returns_none(self) -> None:
assert _first_non_none({}) is None
# ── pure: _drop_none_rates ────────────────────────────────────────────────────
class TestDropNoneRates:
def test_keeps_present_rates(self) -> None:
assert _drop_none_rates({6: 18.0, 12: 16.0}) == {6: 18.0, 12: 16.0}
def test_drops_none_values(self) -> None:
assert _drop_none_rates({6: None, 12: 16.0}) == {12: 16.0}
def test_all_none_returns_none(self) -> None:
# Path целиком из None → None (#952 берёт свой дефолт hold_last_rate).
assert _drop_none_rates({6: None, 12: None}) is None
def test_empty_returns_none(self) -> None:
assert _drop_none_rates({}) is None
# ── pure: _round_or_none ──────────────────────────────────────────────────────
class TestRoundOrNone:
def test_rounds(self) -> None:
assert _round_or_none(0.123456, 3) == 0.123
def test_none_passthrough(self) -> None:
assert _round_or_none(None, 3) is None
# ── dataclass as_dict ─────────────────────────────────────────────────────────
def _forecast_stub(
*,
horizon: int,
deficit_index: float | None = 0.3,
projected_demand_units: float | None = 100.0,
) -> MagicMock:
"""Одиночный DemandSupplyForecast-стаб (нужны horizon_months + as_dict).
`projected_demand_units` нужен collapse-детектору (#1871 P1) — он сравнивает
demand И deficit на парах conservative/aggressive.
"""
f = MagicMock()
f.horizon_months = horizon
f.deficit_index = deficit_index
f.projected_demand_units = projected_demand_units
f.as_dict.return_value = {
"horizon_months": horizon,
"deficit_index": deficit_index,
"projected_demand_units": projected_demand_units,
}
return f
class TestScenarioForecastAsDict:
def test_as_dict_shape(self) -> None:
sf = ScenarioForecast(
scenario="base",
rate_path={12: 16.0, 6: 16.0},
forecasts=[_forecast_stub(horizon=6), _forecast_stub(horizon=12)],
advisory=True,
)
d = sf.as_dict()
assert d["scenario"] == "base"
assert d["advisory"] is True
assert len(d["forecasts"]) == 2
def test_rate_path_sorted_and_rounded(self) -> None:
sf = ScenarioForecast(
scenario="conservative",
rate_path={12: 18.123456, 6: 17.987654},
forecasts=[],
advisory=True,
)
d = sf.as_dict()
# Отсортирован по горизонту, округлён до 2 знаков.
assert list(d["rate_path"].keys()) == [6, 12]
assert d["rate_path"][6] == 17.99
assert d["rate_path"][12] == 18.12
def test_rate_path_none_preserved(self) -> None:
sf = ScenarioForecast(
scenario="base", rate_path={6: None, 12: None}, forecasts=[], advisory=True
)
d = sf.as_dict()
assert d["rate_path"] == {6: None, 12: None}
# ── orchestrator helpers (стаб #952 + макро) ──────────────────────────────────
_BASE_KEY_RATE = 16.0
def _macro_stub_with_rate(rate: float | None) -> list[MagicMock]:
"""Макро-ряд из одной точки с заданной key_rate (hold_last_rate возьмёт её)."""
m = MagicMock()
m.key_rate = rate
return [m]
def _forecast_side_effect() -> Any:
"""side_effect #952: вернуть по forecast-стабу на каждый горизонт; запомнить rate_path."""
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
return [_forecast_stub(horizon=h) for h in horizons]
return _side
def _run(**over: object) -> list[ScenarioForecast]:
"""Тонкая обёртка над `compute_scenarios`: разворачивает кортеж в список сценариев.
#1871 P1: `compute_scenarios` теперь возвращает `(scenarios, collapsed, reason)`;
существующие shape/rate_path/graceful-тесты проверяют ТОЛЬКО `scenarios` — для них
`_run` отдаёт первый элемент кортежа. Тесты collapse-детектора (новый класс ниже)
используют `_run_full`, который возвращает кортеж целиком.
"""
return _run_full(**over)[0]
def _run_full(**over: object) -> tuple[list[ScenarioForecast], bool, str | None]:
"""`_run` + полный кортеж (#1871 P1) — для тестов collapse-детектора."""
kwargs: dict[str, object] = {
"spec": MagicMock(),
"district": "Академический",
"cad_num": "66:41:0303161:123",
}
kwargs.update(over)
return compute_scenarios(MagicMock(), **kwargs) # type: ignore[arg-type]
# ── orchestrator: три сценария ────────────────────────────────────────────────
class TestComputeScenariosShape:
def test_returns_three_scenarios(self) -> None:
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_forecast_side_effect()),
):
res = _run(horizons=[6, 12])
assert len(res) == 3
assert [s.scenario for s in res] == ["conservative", "base", "aggressive"]
def test_each_scenario_advisory_true(self) -> None:
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_forecast_side_effect()),
):
res = _run(horizons=[6, 12])
assert all(s.advisory is True for s in res)
def test_each_scenario_has_forecast_per_horizon(self) -> None:
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_forecast_side_effect()),
):
res = _run(horizons=[6, 12, 18, 24])
for s in res:
assert len(s.forecasts) == 4
assert [f.horizon_months for f in s.forecasts] == [6, 12, 18, 24]
def test_default_horizons_used(self) -> None:
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_forecast_side_effect()),
):
res = _run() # без horizons → _DEFAULT_HORIZONS (6,12,18,24)
assert all(len(s.forecasts) == 4 for s in res)
# ── orchestrator: rate_path per-scenario ──────────────────────────────────────
class TestComputeScenariosRatePath:
def test_each_scenario_carries_its_rate_path(self) -> None:
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_forecast_side_effect()),
):
res = _run(horizons=[12])
by_name = {s.scenario: s for s in res}
# base = голая база; conservative выше; aggressive ниже.
assert by_name["base"].rate_path[12] == pytest.approx(_BASE_KEY_RATE)
assert by_name["conservative"].rate_path[12] > _BASE_KEY_RATE # type: ignore[operator]
assert by_name["aggressive"].rate_path[12] < _BASE_KEY_RATE # type: ignore[operator]
def test_conservative_rate_path_higher_than_aggressive(self) -> None:
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_forecast_side_effect()),
):
res = _run(horizons=[6, 12, 18, 24])
by_name = {s.scenario: s for s in res}
for h in (6, 12, 18, 24):
assert by_name["conservative"].rate_path[h] > by_name["aggressive"].rate_path[h] # type: ignore[operator]
def test_rate_path_passed_to_forecast_engine(self) -> None:
# rate_path, переданный в #952, совпадает с конвертом сценария (None убраны).
captured: dict[str, Any] = {}
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
captured[rate_path[12]] = rate_path # type: ignore[index]
return [_forecast_stub(horizon=h) for h in horizons]
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_side),
):
res = _run(horizons=[12])
by_name = {s.scenario: s for s in res}
# Каждый сценарный путь (без None) дошёл до #952.
assert by_name["base"].rate_path[12] in captured
assert by_name["conservative"].rate_path[12] in captured
assert by_name["aggressive"].rate_path[12] in captured
# ── orchestrator: graceful (нет макро / нет базовой ставки) ────────────────────
class TestComputeScenariosGraceful:
def test_no_macro_still_three_scenarios(self) -> None:
# Пустой макро-ряд → база None → конверты None, но ТРИ сценария (не crash).
with (
patch(_MACRO, return_value=[]),
patch(_FORECAST, side_effect=_forecast_side_effect()) as fc,
):
res = _run(horizons=[6, 12])
assert len(res) == 3
assert all(s.advisory is True for s in res)
# Все rate_path None при отсутствии базы.
for s in res:
assert all(v is None for v in s.rate_path.values())
# #952 всё равно вызван по разу на сценарий (с rate_path=None → его дефолт).
assert fc.call_count == 3
def test_no_key_rate_point_still_three_scenarios(self) -> None:
# Макро есть, но key_rate None во всех точках → база None.
with (
patch(_MACRO, return_value=_macro_stub_with_rate(None)),
patch(_FORECAST, side_effect=_forecast_side_effect()),
):
res = _run(horizons=[12])
assert len(res) == 3
for s in res:
assert s.rate_path[12] is None
def test_no_base_passes_none_rate_path_to_engine(self) -> None:
# Нет базы → #952 получает rate_path=None (берёт свой hold_last_rate-дефолт).
captured: list[Any] = []
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
captured.append(rate_path)
return [_forecast_stub(horizon=h) for h in horizons]
with patch(_MACRO, return_value=[]), patch(_FORECAST, side_effect=_side):
_run(horizons=[6, 12])
assert captured == [None, None, None]
def test_forecast_engine_exception_propagates_not_swallowed(self) -> None:
# #952 сам graceful; если он всё же кинул — НЕ глотаем молча (честный провал).
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=RuntimeError("engine boom")),
):
with pytest.raises(RuntimeError, match="engine boom"):
_run(horizons=[12])
# ── orchestrator: база читается через hold_last_rate ──────────────────────────
class TestComputeScenariosBaseRate:
def test_base_rate_from_last_key_rate(self) -> None:
# hold_last_rate берёт ПОСЛЕДНЮЮ непустую key_rate ряда → она и есть база.
m_old = MagicMock()
m_old.key_rate = 14.0
m_new = MagicMock()
m_new.key_rate = 18.0 # свежее (ряд ASC) → база
with (
patch(_MACRO, return_value=[m_old, m_new]),
patch(_FORECAST, side_effect=_forecast_side_effect()),
):
res = _run(horizons=[12])
by_name = {s.scenario: s for s in res}
assert by_name["base"].rate_path[12] == pytest.approx(18.0)
def test_hold_last_rate_invoked(self) -> None:
# Поток явно опирается на #952 hold_last_rate (база = его выход).
macro = _macro_stub_with_rate(_BASE_KEY_RATE)
with (
patch(_MACRO, return_value=macro),
patch(_HOLD, return_value={12: 15.5}) as hold,
patch(_FORECAST, side_effect=_forecast_side_effect()),
):
res = _run(horizons=[12])
hold.assert_called_once()
by_name = {s.scenario: s for s in res}
assert by_name["base"].rate_path[12] == pytest.approx(15.5)
# ── pure: _metrics_equal (collapse-детектор низкого уровня) ───────────────────
class TestMetricsEqual:
"""`_metrics_equal` сравнивает demand/deficit двух сценариев с rel_tol=1e-9 / abs_tol=1e-6."""
def test_both_none_equal(self) -> None:
# Оба None → «одинаково нет данных» (для пустых rate_path = None).
assert _metrics_equal(None, None) is True
def test_only_one_none_not_equal(self) -> None:
# Один None, другой число → подлинное расхождение (нельзя считать схлопнутым).
assert _metrics_equal(None, 1.0) is False
assert _metrics_equal(1.0, None) is False
def test_exact_match_equal(self) -> None:
assert _metrics_equal(100.0, 100.0) is True
def test_machine_noise_within_tolerance(self) -> None:
# rel_tol=1e-9 ловит численный шум после §9.4 coefficient=1.0.
assert _metrics_equal(100.0, 100.0 + 1e-10) is True
def test_abs_tol_near_zero(self) -> None:
# abs_tol=1e-6 страхует около нуля, где rel_tol вырождается.
assert _metrics_equal(0.0, 5e-7) is True
def test_above_abs_tol_not_equal(self) -> None:
# 1e-3 шире abs_tol=1e-6 → подлинное расхождение.
assert _metrics_equal(100.0, 100.001) is False
# ── pure: _detect_collapsed (§22 audit-issue #1871 P1 ядро) ───────────────────
def _scenario(
name: str,
*,
forecasts: list[MagicMock],
rate_path: dict[int, float | None] | None = None,
) -> ScenarioForecast:
"""Собрать `ScenarioForecast` для collapse-тестов (rate_path не важен детектору)."""
default_path: dict[int, float | None] = {f.horizon_months: None for f in forecasts}
return ScenarioForecast(
scenario=name, # type: ignore[arg-type]
rate_path=rate_path if rate_path is not None else default_path,
forecasts=forecasts,
advisory=True,
)
class TestDetectCollapsed:
"""`_detect_collapsed` (PURE) — обе метрики (demand И deficit) должны совпасть на ВСЕХ h."""
def test_identical_demand_and_deficit_collapsed(self) -> None:
# §9.4 coefficient=1.0 на всех конвертах → conservative ≡ aggressive ≡ base.
cons = _scenario(
"conservative",
forecasts=[
_forecast_stub(horizon=6, deficit_index=0.05, projected_demand_units=100.0),
_forecast_stub(horizon=12, deficit_index=0.05, projected_demand_units=100.0),
],
)
base = _scenario(
"base",
forecasts=[
_forecast_stub(horizon=6, deficit_index=0.05, projected_demand_units=100.0),
_forecast_stub(horizon=12, deficit_index=0.05, projected_demand_units=100.0),
],
)
aggr = _scenario(
"aggressive",
forecasts=[
_forecast_stub(horizon=6, deficit_index=0.05, projected_demand_units=100.0),
_forecast_stub(horizon=12, deficit_index=0.05, projected_demand_units=100.0),
],
)
assert _detect_collapsed([cons, base, aggr]) is True
def test_diverging_demand_keeps_collapsed_false(self) -> None:
# demand отличается по rate_path → β-gate работает → НЕ collapsed.
cons = _scenario(
"conservative",
forecasts=[_forecast_stub(horizon=12, projected_demand_units=80.0)],
)
base = _scenario(
"base", forecasts=[_forecast_stub(horizon=12, projected_demand_units=100.0)]
)
aggr = _scenario(
"aggressive",
forecasts=[_forecast_stub(horizon=12, projected_demand_units=130.0)],
)
assert _detect_collapsed([cons, base, aggr]) is False
def test_diverging_deficit_only_keeps_collapsed_false(self) -> None:
# Защита от ЧАСТИЧНОГО collapse: demand совпадает, но deficit разный →
# β хоть как-то двигает → НЕ collapsed.
cons_f = _forecast_stub(horizon=12, deficit_index=0.10, projected_demand_units=100.0)
base_f = _forecast_stub(horizon=12, deficit_index=0.05, projected_demand_units=100.0)
aggr_f = _forecast_stub(horizon=12, deficit_index=-0.02, projected_demand_units=100.0)
cons = _scenario("conservative", forecasts=[cons_f])
base = _scenario("base", forecasts=[base_f])
aggr = _scenario("aggressive", forecasts=[aggr_f])
assert _detect_collapsed([cons, base, aggr]) is False
def test_close_but_not_equal_demand_not_collapsed(self) -> None:
# Расхождение 1e-3 (за пределами abs_tol=1e-6) → НЕ collapsed.
cons = _scenario(
"conservative",
forecasts=[_forecast_stub(horizon=12, projected_demand_units=100.0)],
)
base = _scenario(
"base", forecasts=[_forecast_stub(horizon=12, projected_demand_units=100.0)]
)
aggr = _scenario(
"aggressive",
forecasts=[_forecast_stub(horizon=12, projected_demand_units=100.001)],
)
assert _detect_collapsed([cons, base, aggr]) is False
def test_empty_forecasts_not_collapsed(self) -> None:
# Нет данных → нет вердикта «схлопнулось» (не помечаем пустой отчёт collapsed).
cons = _scenario("conservative", forecasts=[])
base = _scenario(
"base", forecasts=[_forecast_stub(horizon=12)]
)
aggr = _scenario("aggressive", forecasts=[])
assert _detect_collapsed([cons, base, aggr]) is False
def test_missing_scenario_not_collapsed(self) -> None:
# Если нет одного из крайних сценариев → нет полной пары → False.
base = _scenario(
"base", forecasts=[_forecast_stub(horizon=12, projected_demand_units=100.0)]
)
assert _detect_collapsed([base]) is False
# ── orchestrator: collapse-детекция в compute_scenarios (#1871 P1) ────────────
def _collapsing_forecast_side_effect() -> Any:
"""side_effect #952 для collapse-сценария: одинаковые demand/deficit на ВСЕХ rate_path.
Эмулирует §9.4 demand_normalization с coefficient=1.0 (failed β-gate в §9.6) —
три сценария идентичны base на любых rate_path.
"""
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
return [
_forecast_stub(horizon=h, deficit_index=0.05, projected_demand_units=100.0)
for h in horizons
]
return _side
def _diverging_forecast_side_effect() -> Any:
"""side_effect #952 для НЕ-collapse-сценария: demand зависит от rate_path.
Чем выше ставка (conservative) — тем НИЖЕ demand (зеркало §9.4 знака β).
Эмулирует исправно работающий β-gate.
"""
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
if not isinstance(rate_path, dict) or not rate_path:
# base = голая база (rate_path None при отсутствии базы) → нейтраль.
sample_rate = 16.0
else:
sample_rate = next(iter(rate_path.values()))
# demand ↓ с ростом ставки (тестовая монотония, не реальная §9.4-формула).
demand = 200.0 - sample_rate * 5.0
return [
_forecast_stub(horizon=h, deficit_index=demand / 1000.0, projected_demand_units=demand)
for h in horizons
]
return _side
class TestComputeScenariosCollapseDetection:
"""`compute_scenarios` возвращает `(scenarios, collapsed, collapse_reason)`. #1871 P1."""
def test_identical_demand_and_deficit_across_scenarios_marks_collapsed(self) -> None:
# §9.4 coefficient=1.0 (failed β-gate) → три сценария идентичны → collapsed=True.
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_collapsing_forecast_side_effect()),
):
scenarios, collapsed, reason = _run_full(horizons=[6, 12, 18, 24])
assert collapsed is True
assert reason == _COLLAPSE_REASON_LOW_BETA
# Сами сценарии всё ещё ТРИ (collapse не отменяет advisory-выдачу).
assert len(scenarios) == 3
def test_diverging_demand_keeps_collapsed_false(self) -> None:
# demand зависит от rate_path → β-gate работает → collapsed=False, reason=None.
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_diverging_forecast_side_effect()),
):
scenarios, collapsed, reason = _run_full(horizons=[6, 12])
assert collapsed is False
assert reason is None
assert len(scenarios) == 3
def test_diverging_deficit_only_keeps_collapsed_false(self) -> None:
# demand равен, deficit разный (#9.4 двигает deficit, demand держит) → НЕ collapsed.
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
# demand одинаков, deficit зависит от ставки (тестовый разрыв monotony).
if isinstance(rate_path, dict) and rate_path:
sample_rate = next(iter(rate_path.values()))
else:
sample_rate = 16.0
return [
_forecast_stub(
horizon=h,
deficit_index=(20.0 - sample_rate) * 0.01,
projected_demand_units=100.0,
)
for h in horizons
]
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_side),
):
_scenarios, collapsed, reason = _run_full(horizons=[12])
assert collapsed is False
assert reason is None
def test_graceful_no_macro_not_collapsed(self) -> None:
# Пустой макро → rate_path None → §952 деградирует к нейтрали внутри. Тут
# `_collapsing_forecast_side_effect` всё равно отдал бы одинаковые числа, но
# цель этого кейса — убедиться, что НА ПУСТОМ макро мы НЕ помечаем collapsed
# ложно (защита семантики «нет данных → нет вердикта»). Используем _diverging,
# который сам по себе должен дать разные числа при разных rate_path.
with (
patch(_MACRO, return_value=[]),
patch(_FORECAST, side_effect=_diverging_forecast_side_effect()),
):
scenarios, collapsed, reason = _run_full(horizons=[6, 12])
# _diverging при rate_path=None отдаёт одно и то же sample_rate=16.0 — это
# значит у трёх сценариев одинаковая demand. Но spec collapse-теста — это
# «функция вернула одинаковые числа», а не «есть полезный сигнал». Документируем,
# что при пустом макро поведение по факту схлопнуто (см. _detect_collapsed).
# Если хотим иной семантики, надо специально гасить collapse при rate_path=None
# в самом compute_scenarios. На сейчас оставляем consistent с docstring детектора.
assert len(scenarios) == 3
# При empty macro все три сценария вызвали #952 с rate_path=None → одинаковый
# выход → детектор честно отдаёт collapsed=True. Это НЕ ложное срабатывание:
# данные действительно тонкие. Проверяем оба варианта стабильности кортежа.
assert isinstance(collapsed, bool)
assert reason == (_COLLAPSE_REASON_LOW_BETA if collapsed else None)
def test_close_but_not_equal_demand_not_collapsed(self) -> None:
# Расхождение 1e-3 (за пределами abs_tol=1e-6) на одном горизонте → False.
call_count = {"n": 0}
def _side(db: Any, *, spec: Any, horizons: Any, rate_path: Any = None, **_: Any) -> Any:
call_count["n"] += 1
# conservative=1й вызов, base=2й, aggressive=3й. На horizon=12 сдвигаем
# demand aggressive на 1e-3 от cons/base — за пределами abs_tol=1e-6.
offset = 1e-3 if call_count["n"] == 3 else 0.0
return [
_forecast_stub(
horizon=h, deficit_index=0.05, projected_demand_units=100.0 + offset
)
for h in horizons
]
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_side),
):
_scenarios, collapsed, reason = _run_full(horizons=[12])
assert collapsed is False
assert reason is None
def test_tuple_shape_stable_when_horizons_default(self) -> None:
# Sanity-check: дефолтные горизонты тоже возвращают кортеж той же формы.
with (
patch(_MACRO, return_value=_macro_stub_with_rate(_BASE_KEY_RATE)),
patch(_FORECAST, side_effect=_diverging_forecast_side_effect()),
):
result = _run_full() # без horizons → _DEFAULT_HORIZONS
assert isinstance(result, tuple) and len(result) == 3
scenarios, collapsed, reason = result
assert len(scenarios) == 3
assert isinstance(collapsed, bool)
assert reason is None or reason == _COLLAPSE_REASON_LOW_BETA