fix(forecasting): honest scenarios collapse when β rate-sensitivity gate fails (audit #1871 P1)
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

Когда 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
This commit is contained in:
Light1YT 2026-06-22 19:06:22 +05:00
parent 06929e94b3
commit dcccebf2c2
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: 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")) scenarios = _as_dict(report.get("scenarios"))
by_scenario = _as_dict(scenarios.get("by_scenario")) 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_title(ws, 1, "Сценарии")
row = _write_kv(ws, row, "Резюме", scenarios.get("summary")) row = _write_kv(ws, row, "Резюме", scenarios.get("summary"))
row += 1 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 на нестандартный горизонт ячейка несёт # _scenario_deficit_cell: при fallback на нестандартный горизонт ячейка несёт
# «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590). # «(гор. N мес)» — шапка «(12 мес)» не лжёт (#1590).
row = _write_title(ws, row, "Сводка по сценариям") row = _write_title(ws, row, "Сводка по сценариям")
headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"] headers = ["Сценарий", f"Индекс дефицита ({_PRIMARY_HORIZON_MONTHS} мес)", "Advisory"]
table_rows: list[list[Any]] = [] table_rows = []
for name, payload in by_scenario.items(): for name, payload in by_scenario.items():
data = _as_dict(payload) data = _as_dict(payload)
table_rows.append([name, _scenario_deficit_cell(data), data.get("advisory")]) 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; margin-top: 18pt; padding-top: 8pt;
border-top: 1px solid #e6e8ec; font-size: 8pt; color: #9ca3af; 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: 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")) scenarios = _as_dict(report.get("scenarios"))
by_scenario = _as_dict(scenarios.get("by_scenario")) 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]] = [] rows: list[list[Any]] = []
for name, payload in by_scenario.items(): for name, payload in by_scenario.items():
data = _as_dict(payload) 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 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", "scenarios",
lambda: compute_scenarios( lambda: compute_scenarios(
db, spec=spec, district=district, cad_num=cad_num, horizons=horizon_list 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( product_scores = _safe_call(
"score_card", "score_card",
lambda: compute_score_card( lambda: compute_score_card(
@ -400,6 +410,8 @@ def _build_site_finder_report_impl(
forecasts=forecasts, forecasts=forecasts,
future_supply=future_supply, future_supply=future_supply,
scenarios=scenarios, scenarios=scenarios,
scenarios_collapsed=scenarios_collapsed,
scenarios_collapse_reason=scenarios_collapse_reason,
recommendation_overlay=recommendation_overlay, recommendation_overlay=recommendation_overlay,
product_scores=product_scores, product_scores=product_scores,
special_indices=special_indices, special_indices=special_indices,

View file

@ -192,15 +192,29 @@ class ReportScenarios:
`by_scenario` карта {scenario: ScenarioForecast.as_dict()} (или иной сводный `by_scenario` карта {scenario: ScenarioForecast.as_dict()} (или иной сводный
dict на сценарий). Держит advisory-сценарии #984 как плоские JSON-safe dict'ы. dict на сценарий). Держит advisory-сценарии #984 как плоские JSON-safe dict'ы.
Пустая карта секция без сценариев (graceful). advisory наследуется (cap medium). Пустая карта секция без сценариев (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} by_scenario: dict[str, Any] = field(default_factory=dict) # {scenario: сводка/as_dict}
summary: str | None = None # короткий RU-текст про разброс сценариев 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]: def as_dict(self) -> dict[str, Any]:
return { return {
"by_scenario": dict(self.by_scenario), "by_scenario": dict(self.by_scenario),
"summary": self.summary, "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) + "." 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. """§13.5 scenarios — карта {scenario: сводка} из трёх ScenarioForecast #984. PURE.
`scenarios` список из трёх по `as_dict()` (conservative/base/aggressive). Свора- `scenarios` список из трёх по `as_dict()` (conservative/base/aggressive). Свора-
чиваем в `by_scenario` (карта по имени сценария) стабильно для экспортёров/чата. чиваем в `by_scenario` (карта по имени сценария) стабильно для экспортёров/чата.
None/пусто пустая секция (graceful). 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: if not scenarios:
return ReportScenarios() return ReportScenarios()
@ -630,7 +642,12 @@ def _build_scenarios(scenarios: Sequence[dict[str, Any]] | None) -> ReportScenar
if not by_scenario: if not by_scenario:
return ReportScenarios() return ReportScenarios()
summary = _scenarios_summary(by_scenario) 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: def _scenarios_summary(by_scenario: dict[str, Any]) -> str | None:
@ -855,6 +872,8 @@ def assemble_report(
forecasts: Sequence[Any] | None = None, forecasts: Sequence[Any] | None = None,
future_supply: Any = None, future_supply: Any = None,
scenarios: Sequence[Any] | None = None, scenarios: Sequence[Any] | None = None,
scenarios_collapsed: bool = False,
scenarios_collapse_reason: str | None = None,
recommendation_overlay: Any = None, recommendation_overlay: Any = None,
product_scores: Any = None, product_scores: Any = None,
special_indices: Any = None, special_indices: Any = None,
@ -897,6 +916,11 @@ def assemble_report(
forecasts: per-горизонт DemandSupplyForecast #952 (или as_dict-список / None). forecasts: per-горизонт DemandSupplyForecast #952 (или as_dict-список / None).
future_supply: §9.3 FutureSupplyPressure (или as_dict / None). future_supply: §9.3 FutureSupplyPressure (или as_dict / None).
scenarios: три ScenarioForecast #984 (или 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). recommendation_overlay: §10 overlay #983 (или as_dict / None).
product_scores: §14.2 ProductScoreCard #985 (или as_dict / None). product_scores: §14.2 ProductScoreCard #985 (или as_dict / None).
special_indices: §25 SpecialIndices #986 (или 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 forecasts_l, future_supply_d, future_competitors, scenarios_summary
) )
product_tz = _build_product_tz(overlay_d) 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) scoring = _build_scoring(product_scores_d, special_indices_d)
confidence = _build_confidence( confidence = _build_confidence(
analyze=analyze, analyze=analyze,

View file

@ -41,9 +41,10 @@ Graceful-on-thin-data (дух #952): нет макро / нет базовой
from __future__ import annotations from __future__ import annotations
import logging import logging
import math
from collections.abc import Sequence from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Literal from typing import Any, Final, Literal
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -87,6 +88,27 @@ _HORIZON_WIDEN_PER_YEAR: float = 0.5
# бывает — кламп снизу при агрессивном (вниз) сдвиге на дальнем горизонте. # бывает — кламп снизу при агрессивном (вниз) сдвиге на дальнем горизонте.
_MIN_RATE_PCT: float = 0.0 _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) @dataclass(frozen=True)
class ScenarioForecast: class ScenarioForecast:
@ -207,6 +229,80 @@ def build_rate_envelopes(
return {"conservative": conservative, "base": base, "aggressive": aggressive} 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-логика выше тестируется без него. # DB-оркестратор — тонкий, graceful. Pure-логика выше тестируется без него.
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
@ -219,7 +315,7 @@ def compute_scenarios(
district: str | None, district: str | None,
cad_num: str, cad_num: str,
horizons: Sequence[int] = _DEFAULT_HORIZONS, horizons: Sequence[int] = _DEFAULT_HORIZONS,
) -> list[ScenarioForecast]: ) -> tuple[list[ScenarioForecast], bool, str | None]:
"""§11 три макро-сценария: прогон #952 движка под тремя конвертами ставки. """§11 три макро-сценария: прогон #952 движка под тремя конвертами ставки.
ADVISORY (каждый сценарий наследует advisory-статус #952, см. module docstring) — ADVISORY (каждый сценарий наследует advisory-статус #952, см. module docstring) —
@ -240,6 +336,14 @@ def compute_scenarios(
с None-полями, но ТРИ сценария возвращаются всегда. НИКОГДА не crash. advisory с None-полями, но ТРИ сценария возвращаются всегда. НИКОГДА не crash. advisory
всегда True (наследует #952 cap ≤ 'medium'). всегда 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: Args:
db: SQLAlchemy sync Session. db: SQLAlchemy sync Session.
spec: целевой сегмент рынка (любой subset осей) общий для всех сценариев. spec: целевой сегмент рынка (любой subset осей) общий для всех сценариев.
@ -248,7 +352,11 @@ def compute_scenarios(
horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS). horizons: горизонты прогноза (мес; по умолчанию _DEFAULT_HORIZONS).
Returns: 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) 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( 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, district,
cad_num, cad_num,
_round_or_none(base_rate, 2), _round_or_none(base_rate, 2),
horizon_list, horizon_list,
len(out), len(out),
collapsed,
) )
return out return out, collapsed, collapse_reason
def _first_non_none(rate_path: dict[int, float | None]) -> float | None: 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_all_layers": MagicMock(return_value=[supply_row]),
"compute_future_supply_pressure": MagicMock(return_value="FSP"), "compute_future_supply_pressure": MagicMock(return_value="FSP"),
"compute_demand_supply_forecast": MagicMock(return_value=["F6", "F12"]), "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_score_card": MagicMock(return_value="PSC"),
"compute_special_indices": MagicMock(return_value="SI"), "compute_special_indices": MagicMock(return_value="SI"),
"build_forecast_overlay": MagicMock(return_value="OVL"), "build_forecast_overlay": MagicMock(return_value="OVL"),

View file

@ -28,12 +28,15 @@ import pytest
from app.services.forecasting.scenarios import ( from app.services.forecasting.scenarios import (
_AGGRESSIVE_RATE_DELTA_PP, _AGGRESSIVE_RATE_DELTA_PP,
_COLLAPSE_REASON_LOW_BETA,
_CONSERVATIVE_RATE_DELTA_PP, _CONSERVATIVE_RATE_DELTA_PP,
_MIN_RATE_PCT, _MIN_RATE_PCT,
ScenarioForecast, ScenarioForecast,
_detect_collapsed,
_drop_none_rates, _drop_none_rates,
_first_non_none, _first_non_none,
_horizon_widen_factor, _horizon_widen_factor,
_metrics_equal,
_round_or_none, _round_or_none,
_shift_rate, _shift_rate,
build_rate_envelopes, build_rate_envelopes,
@ -214,12 +217,26 @@ class TestRoundOrNone:
# ── dataclass as_dict ───────────────────────────────────────────────────────── # ── dataclass as_dict ─────────────────────────────────────────────────────────
def _forecast_stub(*, horizon: int, deficit_index: float | None = 0.3) -> MagicMock: def _forecast_stub(
"""Одиночный DemandSupplyForecast-стаб (нужны horizon_months + as_dict).""" *,
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 = MagicMock()
f.horizon_months = horizon f.horizon_months = horizon
f.deficit_index = deficit_index 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 return f
@ -279,6 +296,18 @@ def _forecast_side_effect() -> Any:
def _run(**over: object) -> list[ScenarioForecast]: 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] = { kwargs: dict[str, object] = {
"spec": MagicMock(), "spec": MagicMock(),
"district": "Академический", "district": "Академический",
@ -456,3 +485,294 @@ class TestComputeScenariosBaseRate:
hold.assert_called_once() hold.assert_called_once()
by_name = {s.scenario: s for s in res} by_name = {s.scenario: s for s in res}
assert by_name["base"].rate_path[12] == pytest.approx(15.5) 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); grid-template-columns: repeat(3, 1fr);
gap: 10px; 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 { .scenarioCard {
border-top: 2px solid var(--accent-cyan); border-top: 2px solid var(--accent-cyan);
} }

View file

@ -114,7 +114,16 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
const presentScenarios = SCENARIO_ORDER.filter( const presentScenarios = SCENARIO_ORDER.filter(
(k) => (byScenario[k]?.forecasts.length ?? 0) > 0, (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; const hasHorizons = fm.forecasts_by_horizon.length > 0;
// Target horizon: user's selector wins, else future_supply, else 12. // Target horizon: user's selector wins, else future_supply, else 12.
@ -128,7 +137,7 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
// ── Deficit series ────────────────────────────────────────────────────── // ── Deficit series ──────────────────────────────────────────────────────
// Per-scenario lines when available, else a single fallback line. // Per-scenario lines when available, else a single fallback line.
const deficitSeries: Record<string, unknown>[] = hasScenarios const deficitSeries: Record<string, unknown>[] = hasScenarios
? presentScenarios.map((key) => ({ ? effectiveScenarios.map((key) => ({
name: SCENARIO_RU[key], name: SCENARIO_RU[key],
type: "line", type: "line",
yAxisIndex: 0, yAxisIndex: 0,
@ -164,7 +173,7 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
const rateSource: DemandSupplyForecast[] = const rateSource: DemandSupplyForecast[] =
byScenario.base?.forecasts ?? byScenario.base?.forecasts ??
(hasScenarios (hasScenarios
? (byScenario[presentScenarios[0]]?.forecasts ?? []) ? (byScenario[effectiveScenarios[0]]?.forecasts ?? [])
: fm.forecasts_by_horizon); : fm.forecasts_by_horizon);
const rateData = rateByHorizon(rateSource, horizons); const rateData = rateByHorizon(rateSource, horizons);
const hasRate = rateData.some((v) => v != null); const hasRate = rateData.some((v) => v != null);
@ -292,7 +301,10 @@ export function ForecastChart({ report, selectedHorizon }: Props) {
fm, fm,
byScenario, byScenario,
hasScenarios, 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, 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 ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div <div

View file

@ -355,6 +355,18 @@ function ForecastReady({
)} )}
{/* Траектория индекса дефицита (chart-then-table, выше 6.1) */} {/* Траектория индекса дефицита (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} /> <ForecastChart report={report} selectedHorizon={selectedHorizon} />
{/* 6.1 Прогноз по горизонтам */} {/* 6.1 Прогноз по горизонтам */}

View file

@ -5,10 +5,22 @@
* report.scenarios.by_scenario. Each card shows the deficit-index (big, signed, * 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 * colour by sign) at the target horizon, the demand/supply projection, and the
* months-of-inventory (MOI). Mirrors the §22 light ScenariosBlock field mapping. * 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 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 { import {
DEFICIT_BALANCE_EPS, DEFICIT_BALANCE_EPS,
SCENARIO_ACCENT, SCENARIO_ACCENT,
@ -43,6 +55,74 @@ const ACCENT_CLASS = {
cons: styles.scAccentCons, cons: styles.scAccentCons,
} as const; } as const;
interface ScenarioCardData {
value: number | null;
horizon: number | null;
}
function ScenarioBaseCard({
scenarioKey,
sc,
di,
fc,
targetHorizon,
}: {
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";
const word =
value != null
? Math.abs(value) < DEFICIT_BALANCE_EPS
? "баланс"
: deficitWord(value)
: "нет данных";
return (
<div
className={`${styles.card} ${styles.scenarioCard} ${ACCENT_CLASS[SCENARIO_ACCENT[scenarioKey]]}`}
>
<div className={styles.scTag}>{SCENARIO_RU[scenarioKey]}</div>
<div className={`${styles.scDeficit} ${DEFICIT_TONE_CLASS[tone]}`}>
{value != null ? fmtSignedDeficit(value) : "—"}
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>
Дефицит-индекс ({labelHorizon} мес)
</span>
<span className={styles.scV}>{word}</span>
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>Прогноз спроса</span>
<span className={styles.scV}>
{fc ? `${fmtInt(fc.projected_demand_units)} кв` : "—"}
</span>
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>Прогноз предложения</span>
<span className={styles.scV}>
{fc ? `${fmtInt(fc.projected_supply_units)} кв` : "—"}
</span>
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>Месяцев запаса (MOI)</span>
<span className={styles.scV}>
{fc ? fmtNum(fc.months_of_inventory, 1) : "—"}
</span>
</div>
<div className={styles.scNote}>{SCENARIO_HINT[scenarioKey]}</div>
</div>
);
}
export function ScenarioCards({ export function ScenarioCards({
scenarios, scenarios,
scenariosSummary, scenariosSummary,
@ -60,6 +140,36 @@ export function ScenarioCards({
); );
} }
// #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 ( return (
<section className={styles.scenarioGrid}> <section className={styles.scenarioGrid}>
{present.map((key) => { {present.map((key) => {
@ -71,51 +181,15 @@ export function ScenarioCards({
targetHorizon, targetHorizon,
); );
const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null; const fc = sc ? pickHorizonForecast(sc.forecasts, targetHorizon) : null;
const labelHorizon = di.horizon ?? targetHorizon;
const value = di.value;
const tone = value != null ? deficitTone(value) : "neutral";
const word =
value != null
? Math.abs(value) < DEFICIT_BALANCE_EPS
? "баланс"
: deficitWord(value)
: "нет данных";
return ( return (
<div <ScenarioBaseCard
key={key} key={key}
className={`${styles.card} ${styles.scenarioCard} ${ACCENT_CLASS[SCENARIO_ACCENT[key]]}`} scenarioKey={key}
> sc={sc}
<div className={styles.scTag}>{SCENARIO_RU[key]}</div> di={di}
<div className={`${styles.scDeficit} ${DEFICIT_TONE_CLASS[tone]}`}> fc={fc}
{value != null ? fmtSignedDeficit(value) : "—"} targetHorizon={targetHorizon}
</div> />
<div className={styles.scKvRow}>
<span className={styles.scK}>
Дефицит-индекс ({labelHorizon} мес)
</span>
<span className={styles.scV}>{word}</span>
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>Прогноз спроса</span>
<span className={styles.scV}>
{fc ? `${fmtInt(fc.projected_demand_units)} кв` : "—"}
</span>
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>Прогноз предложения</span>
<span className={styles.scV}>
{fc ? `${fmtInt(fc.projected_supply_units)} кв` : "—"}
</span>
</div>
<div className={styles.scKvRow}>
<span className={styles.scK}>Месяцев запаса (MOI)</span>
<span className={styles.scV}>
{fc ? fmtNum(fc.months_of_inventory, 1) : "—"}
</span>
</div>
<div className={styles.scNote}>{SCENARIO_HINT[key]}</div>
</div>
); );
})} })}
</section> </section>

View file

@ -46,6 +46,12 @@ export function ScenarioCompareTable({
scenariosSummary, scenariosSummary,
targetHorizon, targetHorizon,
}: Props) { }: 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( const present = SCENARIO_ORDER.filter(
(k) => scenarios.by_scenario[k] != null || scenariosSummary != null, (k) => scenarios.by_scenario[k] != null || scenariosSummary != null,
); );

View file

@ -150,6 +150,12 @@ export interface ScenarioForecast {
export interface ReportScenarios { export interface ReportScenarios {
summary: string | null; summary: string | null;
by_scenario: Partial<Record<ScenarioKey, ScenarioForecast>>; 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 ─────────────────────────────────────────────────────────────── // ── Confidence ───────────────────────────────────────────────────────────────