Merge pull request 'fix(forecasting): honest scenarios collapse when β rate-sensitivity gate fails (audit #1871 P1)' (#1873) from audit/1871-p1-scenarios-collapse into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-backend (push) Successful in 2m14s
Deploy / deploy (push) Successful in 1m36s
Deploy / build-frontend (push) Successful in 3m57s
Deploy / build-worker (push) Successful in 4m10s

This commit is contained in:
bot-backend 2026-06-22 14:17:32 +00:00
commit fd107aeadb
15 changed files with 832 additions and 65 deletions

View file

@ -516,20 +516,56 @@ def _build_product_tz_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
def _build_scenarios_sheet(ws: Worksheet, report: dict[str, Any]) -> None:
"""Лист «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
"""Лист «Сценарии»: conservative/base/aggressive (таблица). Graceful.
§22 audit-issue #1871 P1 (collapse): если `scenarios_collapsed=True` (failed
β-gate в §9.6 §9.4 coefficient=1.0 три сценария идентичны), выводим ОДНУ
строку «Базовый» + плашку-caveat с причиной collapse НЕ три одинаковых
столбца (иначе таблица читается как баг). Поля `scenarios_collapsed`/
`scenarios_collapse_reason` гарантированы в схеме (report.ReportScenarios).
"""
scenarios = _as_dict(report.get("scenarios"))
by_scenario = _as_dict(scenarios.get("by_scenario"))
collapsed = bool(scenarios.get("scenarios_collapsed"))
collapse_reason = scenarios.get("scenarios_collapse_reason")
row = _write_title(ws, 1, "Сценарии")
row = _write_kv(ws, row, "Резюме", scenarios.get("summary"))
row += 1
# ── Таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
if collapsed:
# Плашка-caveat: причина collapse (italic, янтарный — зеркало _ADVISORY_FONT).
caveat_cell = ws.cell(row=row, column=1, value="Сценарная дифференциация недоступна")
caveat_cell.font = Font(bold=True, color="B45309")
row += 1
if isinstance(collapse_reason, str) and collapse_reason:
reason_cell = ws.cell(row=row, column=1, value=collapse_reason)
reason_cell.font = _ADVISORY_FONT
reason_cell.alignment = _WRAP_TOP
row += 1
row += 1
# ── ОДНА строка «Базовый» вместо трёх идентичных (collapse) ──
row = _write_title(ws, row, "Базовый сценарий")
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
base_payload = _as_dict(by_scenario.get("base"))
table_rows: list[list[Any]] = (
[["base", _scenario_deficit_cell(base_payload), base_payload.get("advisory")]]
if base_payload
else []
)
_write_table(ws, row, headers, table_rows)
_set_widths(ws, [_LABEL_COL_WIDTH, _VALUE_COL_WIDTH, _VALUE_COL_WIDTH])
return
# ── Штатная таблица сценариев (имя + сводный дефицит 12 мес + advisory) ──
# _scenario_deficit_cell: при fallback на нестандартный горизонт ячейка несёт
# «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590).
row = _write_title(ws, row, "Сводка по сценариям")
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
table_rows: list[list[Any]] = []
table_rows = []
for name, payload in by_scenario.items():
data = _as_dict(payload)
table_rows.append([name, _scenario_deficit_cell(data), data.get("advisory")])

View file

@ -143,6 +143,19 @@ td.empty, .no-data { text-align: center; color: #9ca3af; font-style: italic; pad
margin-top: 18pt; padding-top: 8pt;
border-top: 1px solid #e6e8ec; font-size: 8pt; color: #9ca3af;
}
/* §22 audit-issue #1871 P1: collapse-плашка вместо таблицы трёх сценариев. */
.scenarios-collapse {
margin: 0 0 10pt 0; padding: 12pt 14pt;
background: #1f2937; color: #f9fafb;
border-radius: 4pt;
}
.scenarios-collapse-title {
font-size: 11pt; font-weight: 700; margin-bottom: 6pt; color: #fef3c7;
}
.scenarios-collapse-body {
font-size: 9.5pt; line-height: 1.4; color: #e5e7eb;
}
"""
@ -523,10 +536,52 @@ def _scenario_deficit_cell(payload: dict[str, Any]) -> Any:
def _build_scenarios(report: dict[str, Any]) -> str:
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful."""
"""Блок «Сценарии»: conservative/base/aggressive (таблица). Graceful.
§22 audit-issue #1871 P1 (collapse-плашка): если `scenarios_collapsed=True`
(failed β-gate в §9.6 §9.4 coefficient=1.0 три сценария идентичны),
рисуем тёмную плашку-объяснение ВМЕСТО таблицы трёх одинаковых столбцов.
Сохраняем deficit_index базы как информационный фон (без сравнения с
conservative/aggressive они идентичны). Поля `scenarios_collapsed`/
`scenarios_collapse_reason` гарантированы в схеме (см. report.ReportScenarios).
"""
scenarios = _as_dict(report.get("scenarios"))
by_scenario = _as_dict(scenarios.get("by_scenario"))
collapsed = bool(scenarios.get("scenarios_collapsed"))
collapse_reason = scenarios.get("scenarios_collapse_reason")
if collapsed:
base_payload = _as_dict(by_scenario.get("base"))
base_deficit = _scenario_deficit_cell(base_payload) if base_payload else None
base_horizon = _scenario_deficit_horizon(base_payload) if base_payload else None
info_label = "Базовый сценарий"
if base_horizon is not None:
info_label += f" (индекс дефицита, гор. {base_horizon} мес)"
info_row = (
f'<table class="kv"><tr>'
f'<td class="k">{html.escape(info_label)}</td>'
f'<td class="v">{_esc(base_deficit)}</td>'
f"</tr></table>"
if base_deficit is not None
else ""
)
reason_text = (
collapse_reason
if isinstance(collapse_reason, str) and collapse_reason
else "Сценарная дифференциация недоступна на текущих данных."
)
return f"""
<div class="section" id="scenarios">
<h2>{html.escape(_TITLE_SCENARIOS)}</h2>
<div class="scenarios-collapse">
<div class="scenarios-collapse-title">Сценарная дифференциация недоступна</div>
<div class="scenarios-collapse-body">{html.escape(reason_text)}</div>
</div>
{info_row}
</div>
"""
rows: list[list[Any]] = []
for name, payload in by_scenario.items():
data = _as_dict(payload)

View file

@ -353,12 +353,22 @@ def _build_site_finder_report_impl(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
)
scenarios = _safe_call(
# §22 audit-issue #1871 P1: compute_scenarios теперь возвращает кортеж
# `(scenarios, collapsed, collapse_reason)` — collapse-флаг идёт в payload отчёта,
# чтобы фронт показал ПОЧЕМУ три сценария идентичны (failed β-gate в §9.6).
# _safe_call оборачивает любой сбой → None → штатно деградируем (collapse=False).
scenarios_result = _safe_call(
"scenarios",
lambda: compute_scenarios(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list
),
)
if scenarios_result is None:
scenarios = None
scenarios_collapsed = False
scenarios_collapse_reason: str | None = None
else:
scenarios, scenarios_collapsed, scenarios_collapse_reason = scenarios_result
product_scores = _safe_call(
"score_card",
lambda: compute_score_card(
@ -400,6 +410,8 @@ def _build_site_finder_report_impl(
forecasts=forecasts,
future_supply=future_supply,
scenarios=scenarios,
scenarios_collapsed=scenarios_collapsed,
scenarios_collapse_reason=scenarios_collapse_reason,
recommendation_overlay=recommendation_overlay,
product_scores=product_scores,
special_indices=special_indices,

View file

@ -192,15 +192,29 @@ class ReportScenarios:
`by_scenario` карта {scenario: ScenarioForecast.as_dict()} (или иной сводный
dict на сценарий). Держит advisory-сценарии #984 как плоские JSON-safe dict'ы.
Пустая карта секция без сценариев (graceful). advisory наследуется (cap medium).
COLLAPSE (§22 audit-issue #1871 P1): когда §9.6 rate_sensitivity gate не
пройден, §9.4 demand_normalization возвращает coefficient=1.0 для всех трёх
конвертов conservative/aggressive идентичны base. Это by-design, но без явного
сигнала frontend/экспортёрам таблица «три одинаковых столбца» читается как баг.
Поля `scenarios_collapsed`/`scenarios_collapse_reason` пробрасывают вердикт
`compute_scenarios` (#984): True+RU-причина → плашка-объяснение вместо таблицы;
False+None штатная таблица сценариев. Дефолты False/None обратная
совместимость для частичных отчётов и прежних путей сборки. as_dict() отдаёт
оба ключа ВСЕГДА (стабильная схема для фронта).
"""
by_scenario: dict[str, Any] = field(default_factory=dict) # {scenario: сводка/as_dict}
summary: str | None = None # короткий RU-текст про разброс сценариев
scenarios_collapsed: bool = False # §22 P1: True ⇒ три сценария идентичны (failed β-gate)
scenarios_collapse_reason: str | None = None # RU-объяснение причины collapse (или None)
def as_dict(self) -> dict[str, Any]:
return {
"by_scenario": dict(self.by_scenario),
"summary": self.summary,
"scenarios_collapsed": self.scenarios_collapsed,
"scenarios_collapse_reason": self.scenarios_collapse_reason,
}

View file

@ -611,12 +611,24 @@ def _product_tz_summary(obj_class: str | None, usp: list[dict[str, Any]]) -> str
return "Продукт: " + ", ".join(parts) + "."
def _build_scenarios(scenarios: Sequence[dict[str, Any]] | None) -> ReportScenarios:
def _build_scenarios(
scenarios: Sequence[dict[str, Any]] | None,
*,
collapsed: bool = False,
collapse_reason: str | None = None,
) -> ReportScenarios:
"""§13.5 scenarios — карта {scenario: сводка} из трёх ScenarioForecast #984. PURE.
`scenarios` список из трёх по `as_dict()` (conservative/base/aggressive). Свора-
чиваем в `by_scenario` (карта по имени сценария) стабильно для экспортёров/чата.
None/пусто пустая секция (graceful).
§22 audit-issue #1871 P1: `collapsed`/`collapse_reason` — вердикт
`compute_scenarios` (когда §9.6 β-gate fail §9.4 coefficient=1.0 три сценария
идентичны base). Прокидываем как есть в `ReportScenarios`; экспортёры/фронт сами
решат рисовать плашку-объяснение вместо таблицы. Защитная нормализация: пустые
`scenarios` флаги сбрасываем в дефолт (нет данных нет вердикта «схлопнулось»,
`compute_scenarios` сам уже это гарантирует, но belt-and-suspenders).
"""
if not scenarios:
return ReportScenarios()
@ -630,7 +642,12 @@ def _build_scenarios(scenarios: Sequence[dict[str, Any]] | None) -> ReportScenar
if not by_scenario:
return ReportScenarios()
summary = _scenarios_summary(by_scenario)
return ReportScenarios(by_scenario=by_scenario, summary=summary)
return ReportScenarios(
by_scenario=by_scenario,
summary=summary,
scenarios_collapsed=collapsed,
scenarios_collapse_reason=collapse_reason,
)
def _scenarios_summary(by_scenario: dict[str, Any]) -> str | None:
@ -855,6 +872,8 @@ def assemble_report(
forecasts: Sequence[Any] | None = None,
future_supply: Any = None,
scenarios: Sequence[Any] | None = None,
scenarios_collapsed: bool = False,
scenarios_collapse_reason: str | None = None,
recommendation_overlay: Any = None,
product_scores: Any = None,
special_indices: Any = None,
@ -897,6 +916,11 @@ def assemble_report(
forecasts: per-горизонт DemandSupplyForecast #952 (или as_dict-список / None).
future_supply: §9.3 FutureSupplyPressure (или as_dict / None).
scenarios: три ScenarioForecast #984 (или as_dict-список / None).
scenarios_collapsed: §22 audit-issue #1871 P1 — `compute_scenarios` поймал
идентичность conservative/aggressive (failed β-gate в §9.6); фронт/
экспортёры рисуют плашку-объяснение вместо таблицы. Дефолт False.
scenarios_collapse_reason: RU-объяснение collapse (если есть) кладётся в
ReportScenarios.scenarios_collapse_reason. Дефолт None.
recommendation_overlay: §10 overlay #983 (или as_dict / None).
product_scores: §14.2 ProductScoreCard #985 (или as_dict / None).
special_indices: §25 SpecialIndices #986 (или as_dict / None).
@ -930,7 +954,11 @@ def assemble_report(
forecasts_l, future_supply_d, future_competitors, scenarios_summary
)
product_tz = _build_product_tz(overlay_d)
scenarios_section = _build_scenarios(scenarios_l)
scenarios_section = _build_scenarios(
scenarios_l,
collapsed=scenarios_collapsed,
collapse_reason=scenarios_collapse_reason,
)
scoring = _build_scoring(product_scores_d, special_indices_d)
confidence = _build_confidence(
analyze=analyze,

View file

@ -41,9 +41,10 @@ Graceful-on-thin-data (дух #952): нет макро / нет базовой
from __future__ import annotations
import logging
import math
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Literal
from typing import Any, Final, Literal
from sqlalchemy.orm import Session
@ -87,6 +88,27 @@ _HORIZON_WIDEN_PER_YEAR: float = 0.5
# бывает — кламп снизу при агрессивном (вниз) сдвиге на дальнем горизонте.
_MIN_RATE_PCT: float = 0.0
# §22 audit-issue #1871 P1: RU-объяснение, почему сценарии conservative/aggressive
# идентичны base. Срабатывает, когда §9.6 rate_sensitivity gate (n≥30 ∧ R²≥0.1 ∧
# slope<0) НЕ пройден на текущих данных ЕКБ — §9.4 demand_normalization возвращает
# coefficient=1.0 на любой rate_path → сценарии схлопываются. Это by-design (защита
# от шумной β), но фронт обязан показать ПОЧЕМУ три колонки совпадают, иначе таблица
# читается как баг. Текст уходит в отчёт через ReportScenarios.scenarios_collapse_reason
# и далее в PDF/Excel-плашку вместо таблицы трёх одинаковых столбцов.
_COLLAPSE_REASON_LOW_BETA: Final[str] = (
"β rate-sensitivity не прошёл gate (n≥30 ∧ R²≥0.1 ∧ slope<0) на текущих "
"данных ЕКБ — сценарная дифференциация conservative/aggressive восстановится "
"после расширения окна по ключевой ставке."
)
# Допуски сравнения metric'ов сценариев при детекции collapse (math.isclose). Берём
# rel_tol=1e-9 (~машинная точность float — после прогона #952 с одинаковым coefficient
# любые расхождения = только численный шум) + abs_tol=1e-6 (страхует около нуля, где
# rel_tol вырождается). Шире 1e-6 нельзя — рискуем затереть подлинные slight
# расхождения, когда β только-только прошёл gate.
_COLLAPSE_REL_TOL: Final[float] = 1e-9
_COLLAPSE_ABS_TOL: Final[float] = 1e-6
@dataclass(frozen=True)
class ScenarioForecast:
@ -207,6 +229,80 @@ def build_rate_envelopes(
return {"conservative": conservative, "base": base, "aggressive": aggressive}
# ──────────────────────────────────────────────────────────────────────────────
# Collapse-детектор (§22 audit-issue #1871 P1). PURE, без БД, юнит-тестируется.
# ──────────────────────────────────────────────────────────────────────────────
def _metrics_equal(a: float | None, b: float | None) -> bool:
"""Сравнить две метрики (projected_demand_units / deficit_index) на «равенство». PURE.
Оба None True (одинаково «нет данных»). Один None, другой число False (это
подлинное расхождение нельзя считать схлопнутым). Оба числа math.isclose с
_COLLAPSE_REL_TOL / _COLLAPSE_ABS_TOL (после §9.4 coefficient=1.0 любые расхождения
= численный шум; шире рискуем замаскировать настоящие slight расхождения).
"""
if a is None and b is None:
return True
if a is None or b is None:
return False
return math.isclose(a, b, rel_tol=_COLLAPSE_REL_TOL, abs_tol=_COLLAPSE_ABS_TOL)
def _detect_collapsed(out: list[ScenarioForecast]) -> bool:
"""§22 audit-issue #1871 P1: схлопнулись ли conservative/aggressive в base? PURE.
«Схлопнулось» = на КАЖДОМ пересекающемся горизонте `projected_demand_units` И
`deficit_index` conservative совпадают с aggressive (с допуском `_metrics_equal`).
Это маркер «§9.4 demand_normalization вернул coefficient=1.0» что бывает, когда
§9.6 rate_sensitivity gate не пройден и β-ом нельзя двигать спрос. Тогда три
сценария идентичны base, и фронт обязан показать ПОЧЕМУ.
Защитная семантика:
Любой из сценариев отсутствует False (без полной пары вердикта нет).
Пустой `forecasts` у одного из сценариев False (нет данных нет вердикта
«схлопнулось»; иначе пустой отчёт ложно помечался бы как collapsed).
Хоть один горизонт даёт расхождение по demand ИЛИ deficit False
(защита от ЧАСТИЧНОГО collapse: если хотя бы один metric разошёлся, β
уже работает).
Только когда ОБА metric'а совпадают на ВСЕХ пересекающихся горизонтах → True.
NB: base сравнивать не обязательно §9.4 при failed gate возвращает 1.0 для
ВСЕХ rate_path, так что conservative base aggressive автоматически.
Проверяем только пару conservative/aggressive (крайние конверты самый сильный
разнос ставки, поэтому самое чувствительное место для β).
Args:
out: три ScenarioForecast (conservative/base/aggressive) выход
`compute_scenarios`.
Returns:
True, если crash β-gate схлопнул сценарии; False иначе.
"""
by_name = {s.scenario: s for s in out}
conservative = by_name.get("conservative")
aggressive = by_name.get("aggressive")
if conservative is None or aggressive is None:
return False
if not conservative.forecasts or not aggressive.forecasts:
return False
cons_by_h = {f.horizon_months: f for f in conservative.forecasts}
aggr_by_h = {f.horizon_months: f for f in aggressive.forecasts}
shared = set(cons_by_h.keys()) & set(aggr_by_h.keys())
if not shared:
return False
for h in shared:
cons_f = cons_by_h[h]
aggr_f = aggr_by_h[h]
if not _metrics_equal(cons_f.projected_demand_units, aggr_f.projected_demand_units):
return False
if not _metrics_equal(cons_f.deficit_index, aggr_f.deficit_index):
return False
return True
# ──────────────────────────────────────────────────────────────────────────────
# DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
# ──────────────────────────────────────────────────────────────────────────────
@ -219,7 +315,7 @@ def compute_scenarios(
district: str | None,
cad_num: str,
horizons: Sequence[int] = _DEFAULT_HORIZONS,
) -> list[ScenarioForecast]:
) -> tuple[list[ScenarioForecast], bool, str | None]:
"""§11 три макро-сценария: прогон #952 движка под тремя конвертами ставки.
ADVISORY (каждый сценарий наследует advisory-статус #952, см. module docstring) —
@ -240,6 +336,14 @@ def compute_scenarios(
с None-полями, но ТРИ сценария возвращаются всегда. НИКОГДА не crash. advisory
всегда True (наследует #952 cap ≤ 'medium').
COLLAPSE-ДЕТЕКЦИЯ (§22 audit-issue #1871 P1): когда §9.6 rate_sensitivity gate
не пройден на текущих данных ЕКБ, §9.4 demand_normalization возвращает
coefficient=1.0 для ЛЮБОГО rate_path conservative/aggressive prognosis
идентичны base. Это by-design, но в payload отчёта нужен флаг, чтобы фронт/
экспортёры показали ПОЧЕМУ три колонки совпадают (иначе таблица читается как баг).
Возвращаем кортеж `(scenarios, collapsed, collapse_reason)`, где
`collapse_reason = _COLLAPSE_REASON_LOW_BETA` ПРИ collapsed=True, иначе None.
Args:
db: SQLAlchemy sync Session.
spec: целевой сегмент рынка (любой subset осей) общий для всех сценариев.
@ -248,7 +352,11 @@ def compute_scenarios(
horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS).
Returns:
Список из ТРЁХ ScenarioForecast (conservative, base, aggressive) всегда.
Кортеж `(scenarios, collapsed, collapse_reason)`:
scenarios список из ТРЁХ ScenarioForecast (conservative/base/aggressive);
collapsed True, если §22 audit-issue #1871 P1 collapse-детектор поймал
идентичность conservative/aggressive (failed β-gate);
collapse_reason `_COLLAPSE_REASON_LOW_BETA` при collapsed=True; None иначе.
"""
horizon_list = list(horizons)
@ -280,15 +388,20 @@ def compute_scenarios(
)
)
collapsed = _detect_collapsed(out)
collapse_reason = _COLLAPSE_REASON_LOW_BETA if collapsed else None
logger.info(
"scenarios: district=%s cad_num=%s base_rate=%s horizons=%s scenarios=%d (ADVISORY)",
"scenarios: district=%s cad_num=%s base_rate=%s horizons=%s scenarios=%d "
"collapsed=%s (ADVISORY)",
district,
cad_num,
_round_or_none(base_rate, 2),
horizon_list,
len(out),
collapsed,
)
return out
return out, collapsed, collapse_reason
def _first_non_none(rate_path: dict[int, float | None]) -> float | None:

View file

@ -132,7 +132,9 @@ def _patched_layers() -> dict[str, Any]:
"compute_all_layers": MagicMock(return_value=[supply_row]),
"compute_future_supply_pressure": MagicMock(return_value="FSP"),
"compute_demand_supply_forecast": MagicMock(return_value=["F6", "F12"]),
"compute_scenarios": MagicMock(return_value=["SC"]),
# #1871 P1: compute_scenarios теперь возвращает кортеж
# `(scenarios, collapsed, collapse_reason)`. Sentinel: scenarios=["SC"], не схлопнуто.
"compute_scenarios": MagicMock(return_value=(["SC"], False, None)),
"compute_score_card": MagicMock(return_value="PSC"),
"compute_special_indices": MagicMock(return_value="SI"),
"build_forecast_overlay": MagicMock(return_value="OVL"),

View file

@ -28,12 +28,15 @@ 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,
@ -214,12 +217,26 @@ class TestRoundOrNone:
# ── dataclass as_dict ─────────────────────────────────────────────────────────
def _forecast_stub(*, horizon: int, deficit_index: float | None = 0.3) -> MagicMock:
"""Одиночный DemandSupplyForecast-стаб (нужны horizon_months + 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.as_dict.return_value = {"horizon_months": horizon, "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
@ -279,6 +296,18 @@ def _forecast_side_effect() -> Any:
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": "Академический",
@ -456,3 +485,294 @@ class TestComputeScenariosBaseRate:
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

View file

@ -1032,6 +1032,21 @@
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
/* #1871 P1 when backend collapses 3 scenarios into one (failed β
rate-sensitivity gate demand_normalization coefficient=1.0), the
ScenarioCards grid renders ONE «Базовый» card spanning the full row
plus this caveat note. Token names follow .pticaRoot[data-theme="dark"]
scope (no light --fg-on-dark-muted / --border-soft / --bg-card-alt). */
.scenarioCollapsedNote {
grid-column: 1 / -1;
color: var(--text-soft);
font-size: 11px;
line-height: 1.45;
padding: 10px 14px;
border: var(--line) solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-2);
}
.scenarioCard {
border-top: 2px solid var(--accent-cyan);
}

View file

@ -114,7 +114,16 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
const presentScenarios = SCENARIO_ORDER.filter(
(k) => (byScenario[k]?.forecasts.length ?? 0) > 0,
);
const hasScenarios = presentScenarios.length > 0;
// #1871 P1 — when backend collapses 3 scenarios into one (failed §9.6 β
// rate-sensitivity gate), conservative/base/aggressive deficit_index lines
// are identical and just stack on top of each other. Render the base line
// only — the caveat above (Section6Forecast) explains why. Band / rate-line
// / markLine remain untouched (still derived from the base / fallback row).
const collapsed = report.scenarios.scenarios_collapsed === true;
const effectiveScenarios: ScenarioKey[] = collapsed
? presentScenarios.filter((k) => k === "base")
: presentScenarios;
const hasScenarios = effectiveScenarios.length > 0;
const hasHorizons = fm.forecasts_by_horizon.length > 0;
// Target horizon: user's selector wins, else future_supply, else 12.
@ -128,7 +137,7 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
// ── Deficit series ──────────────────────────────────────────────────────
// Per-scenario lines when available, else a single fallback line.
const deficitSeries: Record<string, unknown>[] = hasScenarios
? presentScenarios.map((key) => ({
? effectiveScenarios.map((key) => ({
name: SCENARIO_RU[key],
type: "line",
yAxisIndex: 0,
@ -164,7 +173,7 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
const rateSource: DemandSupplyForecast[] =
byScenario.base?.forecasts ??
(hasScenarios
? (byScenario[presentScenarios[0]]?.forecasts ?? [])
? (byScenario[effectiveScenarios[0]]?.forecasts ?? [])
: fm.forecasts_by_horizon);
const rateData = rateByHorizon(rateSource, horizons);
const hasRate = rateData.some((v) => v != null);
@ -292,7 +301,10 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
fm,
byScenario,
hasScenarios,
presentScenarios,
effectiveScenarios,
// `collapsed` intentionally omitted: it's already encoded in
// `effectiveScenarios` (collapse filters the array down to ["base"]), and
// exhaustive-deps flags it as an unnecessary dep when included separately.
targetHorizon,
]);

View file

@ -93,6 +93,68 @@ export function ScenariosBlock({
);
}
// #1871 P1 — when backend `compute_scenarios` collapses three scenarios into
// a single line (failed §9.6 β rate-sensitivity gate → demand_normalization
// returns coefficient=1.0 across the envelope) we render ONE «Базовый» card
// + a dark headline-bar with the reason from backend instead of three
// visually-identical copies. Caveat text is the source-of-truth backend
// constant `_COLLAPSE_REASON_LOW_BETA`, NEVER hardcoded in TSX.
if (scenarios.scenarios_collapsed === true) {
const sc = scenarios.by_scenario.base;
const di = deficitForScenario(
"base",
scenarios,
scenariosSummary,
targetHorizon,
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div
style={{
borderRadius: 12,
background: "var(--bg-headline)",
color: "var(--fg-on-dark)",
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<div style={{ fontSize: 14, fontWeight: 600 }}>
Сценарная дифференциация недоступна
</div>
{scenarios.scenarios_collapse_reason && (
<div
style={{
fontSize: 12,
lineHeight: 1.5,
color: "var(--fg-on-dark-muted)",
}}
>
{scenarios.scenarios_collapse_reason}
</div>
)}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(220px, 480px)",
gap: 12,
}}
>
<ScenarioCard
name={SCENARIO_RU.base}
hint={SCENARIO_HINT.base}
deficitIndex={di.value}
deficitHorizon={di.horizon}
ratePath={sc?.rate_path}
targetHorizon={targetHorizon}
/>
</div>
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div

View file

@ -355,6 +355,18 @@ function ForecastReady({
)}
{/* Траектория индекса дефицита (chart-then-table, выше 6.1) */}
{report.scenarios.scenarios_collapsed &&
report.scenarios.scenarios_collapse_reason && (
<p
style={{
fontSize: 12,
color: "var(--fg-secondary)",
margin: "0 0 8px 0",
}}
>
{report.scenarios.scenarios_collapse_reason}
</p>
)}
<ForecastChart report={report} selectedHorizon={selectedHorizon} />
{/* 6.1 Прогноз по горизонтам */}

View file

@ -5,10 +5,22 @@
* report.scenarios.by_scenario. Each card shows the deficit-index (big, signed,
* colour by sign) at the target horizon, the demand/supply projection, and the
* months-of-inventory (MOI). Mirrors the §22 light ScenariosBlock field mapping.
*
* #1871 P1 when backend `compute_scenarios` collapses three scenarios into
* one (failed §9.6 β rate-sensitivity gate demand_normalization returns
* coefficient=1.0 across the envelope), we render ONE «Базовый» card + a
* caveat note instead of three visually-identical copies. Caveat text is the
* source-of-truth backend constant `_COLLAPSE_REASON_LOW_BETA`, NEVER hardcoded.
*/
import styles from "@/app/site-finder/analysis/[cad]/ptica/ptica.module.css";
import type { ReportScenarios, ScenariosSummary } from "@/types/forecast";
import type {
DemandSupplyForecast,
ReportScenarios,
ScenarioForecast,
ScenarioKey,
ScenariosSummary,
} from "@/types/forecast";
import {
DEFICIT_BALANCE_EPS,
SCENARIO_ACCENT,
@ -43,34 +55,27 @@ const ACCENT_CLASS = {
cons: styles.scAccentCons,
} as const;
export function ScenarioCards({
scenarios,
scenariosSummary,
targetHorizon,
}: Props) {
const present = SCENARIO_ORDER.filter(
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
);
if (present.length === 0) {
return (
<div className={`${styles.panel} ${styles.emptyPanel}`}>
Сценарный прогноз недоступен.
</div>
);
interface ScenarioCardData {
value: number | null;
horizon: number | null;
}
return (
<section className={styles.scenarioGrid}>
{present.map((key) => {
const sc = scenarios.by_scenario[key];
const di = deficitForScenario(
key,
scenarios,
scenariosSummary,
function ScenarioBaseCard({
scenarioKey,
sc,
di,
fc,
targetHorizon,
);
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
}: {
scenarioKey: ScenarioKey;
sc: ScenarioForecast | undefined;
di: ScenarioCardData;
fc: DemandSupplyForecast | null;
targetHorizon: number;
}) {
// Suppress unused-when-collapsed warning: `sc` is part of the contract so
// future card surfaces (rate-path) can read it without re-plumbing props.
void sc;
const labelHorizon = di.horizon ?? targetHorizon;
const value = di.value;
const tone = value != null ? deficitTone(value) : "neutral";
@ -83,10 +88,9 @@ export function ScenarioCards({
return (
<div
key={key}
className={`${styles.card} ${styles.scenarioCard} ${ACCENT_CLASS[SCENARIO_ACCENT[key]]}`}
className={`${styles.card} ${styles.scenarioCard} ${ACCENT_CLASS[SCENARIO_ACCENT[scenarioKey]]}`}
>
<div className={styles.scTag}>{SCENARIO_RU[key]}</div>
<div className={styles.scTag}>{SCENARIO_RU[scenarioKey]}</div>
<div className={`${styles.scDeficit} ${DEFICIT_TONE_CLASS[tone]}`}>
{value != null ? fmtSignedDeficit(value) : "—"}
</div>
@ -114,9 +118,79 @@ export function ScenarioCards({
{fc ? fmtNum(fc.months_of_inventory, 1) : "—"}
</span>
</div>
<div className={styles.scNote}>{SCENARIO_HINT[key]}</div>
<div className={styles.scNote}>{SCENARIO_HINT[scenarioKey]}</div>
</div>
);
}
export function ScenarioCards({
scenarios,
scenariosSummary,
targetHorizon,
}: Props) {
const present = SCENARIO_ORDER.filter(
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
);
if (present.length === 0) {
return (
<div className={`${styles.panel} ${styles.emptyPanel}`}>
Сценарный прогноз недоступен.
</div>
);
}
// #1871 P1 — collapse: ONE «Базовый» card + caveat from backend. Caveat
// string comes from `scenarios.scenarios_collapse_reason` only — never
// hardcoded here (backend `_COLLAPSE_REASON_LOW_BETA` is source of truth).
if (scenarios.scenarios_collapsed === true) {
const sc = scenarios.by_scenario.base;
const di = deficitForScenario(
"base",
scenarios,
scenariosSummary,
targetHorizon,
);
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
return (
<section className={styles.scenarioGrid}>
<ScenarioBaseCard
scenarioKey="base"
sc={sc}
di={di}
fc={fc}
targetHorizon={targetHorizon}
/>
{scenarios.scenarios_collapse_reason && (
<div className={styles.scenarioCollapsedNote}>
{scenarios.scenarios_collapse_reason}
</div>
)}
</section>
);
}
return (
<section className={styles.scenarioGrid}>
{present.map((key) => {
const sc = scenarios.by_scenario[key];
const di = deficitForScenario(
key,
scenarios,
scenariosSummary,
targetHorizon,
);
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
return (
<ScenarioBaseCard
key={key}
scenarioKey={key}
sc={sc}
di={di}
fc={fc}
targetHorizon={targetHorizon}
/>
);
})}
</section>
);

View file

@ -46,6 +46,12 @@ export function ScenarioCompareTable({
scenariosSummary,
targetHorizon,
}: Props) {
// #1871 P1 — when backend collapses 3 scenarios into one, the compare-table
// row-per-scenario layout is meaningless (three identical rows). The
// ScenarioCards block above already shows the single «Базовый» card + the
// backend-sourced caveat — render nothing here.
if (scenarios.scenarios_collapsed === true) return null;
const present = SCENARIO_ORDER.filter(
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
);

View file

@ -150,6 +150,12 @@ export interface ScenarioForecast {
export interface ReportScenarios {
summary: string | null;
by_scenario: Partial<Record<ScenarioKey, ScenarioForecast>>;
/** #1871 P1 true когда demand_normalization gate отбил β rate-sensitivity и
* conservative/base/aggressive дают идентичный projected_demand_units на всех
* горизонтах. UI рендерит ОДНУ карточку «Базовый» + caveat вместо трёх копий. */
scenarios_collapsed?: boolean;
/** Полное русское предложение-причина (готовое к рендеру). null когда не схлопнуто. */
scenarios_collapse_reason?: string | null;
}
// ── Confidence ───────────────────────────────────────────────────────────────