From dcccebf2c23f4c8cb9f180922dc317067dea6a03 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Mon, 22 Jun 2026 19:06:22 +0500 Subject: [PATCH] =?UTF-8?q?fix(forecasting):=20honest=20scenarios=20collap?= =?UTF-8?q?se=20when=20=CE=B2=20rate-sensitivity=20gate=20fails=20(audit?= =?UTF-8?q?=20#1871=20P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Когда 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 --- backend/app/services/exporters/excel.py | 42 ++- backend/app/services/exporters/report_pdf.py | 57 ++- .../app/services/forecasting/orchestrator.py | 14 +- backend/app/services/forecasting/report.py | 14 + .../services/forecasting/report_assembler.py | 34 +- backend/app/services/forecasting/scenarios.py | 123 ++++++- .../services/forecasting/test_orchestrator.py | 4 +- .../services/forecasting/test_scenarios.py | 326 +++++++++++++++++- .../analysis/[cad]/ptica/ptica.module.css | 15 + .../site-finder/analysis/ForecastChart.tsx | 20 +- .../site-finder/analysis/ScenariosBlock.tsx | 62 ++++ .../site-finder/analysis/Section6Forecast.tsx | 12 + .../ptica/scenarios/ScenarioCards.tsx | 162 ++++++--- .../ptica/scenarios/ScenarioCompareTable.tsx | 6 + frontend/src/types/forecast.ts | 6 + 15 files changed, 832 insertions(+), 65 deletions(-) diff --git a/backend/app/services/exporters/excel.py b/backend/app/services/exporters/excel.py index 3639e987..22b487d9 100644 --- a/backend/app/services/exporters/excel.py +++ b/backend/app/services/exporters/excel.py @@ -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")]) diff --git a/backend/app/services/exporters/report_pdf.py b/backend/app/services/exporters/report_pdf.py index 3a9faefd..04452b86 100644 --- a/backend/app/services/exporters/report_pdf.py +++ b/backend/app/services/exporters/report_pdf.py @@ -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'' + f'' + f'' + f"
{html.escape(info_label)}{_esc(base_deficit)}
" + if base_deficit is not None + else "" + ) + reason_text = ( + collapse_reason + if isinstance(collapse_reason, str) and collapse_reason + else "Сценарная дифференциация недоступна на текущих данных." + ) + return f""" +
+

{html.escape(_TITLE_SCENARIOS)}

+
+
Сценарная дифференциация недоступна
+
{html.escape(reason_text)}
+
+ {info_row} +
+""" + rows: list[list[Any]] = [] for name, payload in by_scenario.items(): data = _as_dict(payload) diff --git a/backend/app/services/forecasting/orchestrator.py b/backend/app/services/forecasting/orchestrator.py index 247179b2..05e07a9f 100644 --- a/backend/app/services/forecasting/orchestrator.py +++ b/backend/app/services/forecasting/orchestrator.py @@ -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, diff --git a/backend/app/services/forecasting/report.py b/backend/app/services/forecasting/report.py index 5daca21b..059a3547 100644 --- a/backend/app/services/forecasting/report.py +++ b/backend/app/services/forecasting/report.py @@ -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, } diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index b9e4d11c..6e9ae21e 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -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, diff --git a/backend/app/services/forecasting/scenarios.py b/backend/app/services/forecasting/scenarios.py index f0b3eb0e..4b412076 100644 --- a/backend/app/services/forecasting/scenarios.py +++ b/backend/app/services/forecasting/scenarios.py @@ -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: diff --git a/backend/tests/services/forecasting/test_orchestrator.py b/backend/tests/services/forecasting/test_orchestrator.py index b213f940..daf2b83f 100644 --- a/backend/tests/services/forecasting/test_orchestrator.py +++ b/backend/tests/services/forecasting/test_orchestrator.py @@ -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"), diff --git a/backend/tests/services/forecasting/test_scenarios.py b/backend/tests/services/forecasting/test_scenarios.py index 68bcb0c3..94999a10 100644 --- a/backend/tests/services/forecasting/test_scenarios.py +++ b/backend/tests/services/forecasting/test_scenarios.py @@ -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 diff --git a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css index e392f924..209ff344 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/ptica/ptica.module.css @@ -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); } diff --git a/frontend/src/components/site-finder/analysis/ForecastChart.tsx b/frontend/src/components/site-finder/analysis/ForecastChart.tsx index 4cdd7efc..469d77cf 100644 --- a/frontend/src/components/site-finder/analysis/ForecastChart.tsx +++ b/frontend/src/components/site-finder/analysis/ForecastChart.tsx @@ -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[] = 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, ]); diff --git a/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx b/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx index 35d2455b..2fb6eacb 100644 --- a/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx +++ b/frontend/src/components/site-finder/analysis/ScenariosBlock.tsx @@ -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 ( +
+
+
+ Сценарная дифференциация недоступна +
+ {scenarios.scenarios_collapse_reason && ( +
+ {scenarios.scenarios_collapse_reason} +
+ )} +
+
+ +
+
+ ); + } + return (
+ {report.scenarios.scenarios_collapse_reason} +

+ )} {/* 6.1 Прогноз по горизонтам */} diff --git a/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx b/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx index a14d8737..db86f0c1 100644 --- a/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx +++ b/frontend/src/components/site-finder/ptica/scenarios/ScenarioCards.tsx @@ -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,6 +55,74 @@ const ACCENT_CLASS = { cons: styles.scAccentCons, } 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 ( +
+
{SCENARIO_RU[scenarioKey]}
+
+ {value != null ? fmtSignedDeficit(value) : "—"} +
+
+ + Дефицит-индекс ({labelHorizon} мес) + + {word} +
+
+ Прогноз спроса + + {fc ? `${fmtInt(fc.projected_demand_units)} кв` : "—"} + +
+
+ Прогноз предложения + + {fc ? `${fmtInt(fc.projected_supply_units)} кв` : "—"} + +
+
+ Месяцев запаса (MOI) + + {fc ? fmtNum(fc.months_of_inventory, 1) : "—"} + +
+
{SCENARIO_HINT[scenarioKey]}
+
+ ); +} + export function ScenarioCards({ scenarios, 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 ( +
+ + {scenarios.scenarios_collapse_reason && ( +
+ {scenarios.scenarios_collapse_reason} +
+ )} +
+ ); + } + return (
{present.map((key) => { @@ -71,51 +181,15 @@ export function ScenarioCards({ targetHorizon, ); 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 ( -
-
{SCENARIO_RU[key]}
-
- {value != null ? fmtSignedDeficit(value) : "—"} -
-
- - Дефицит-индекс ({labelHorizon} мес) - - {word} -
-
- Прогноз спроса - - {fc ? `${fmtInt(fc.projected_demand_units)} кв` : "—"} - -
-
- Прогноз предложения - - {fc ? `${fmtInt(fc.projected_supply_units)} кв` : "—"} - -
-
- Месяцев запаса (MOI) - - {fc ? fmtNum(fc.months_of_inventory, 1) : "—"} - -
-
{SCENARIO_HINT[key]}
-
+ scenarioKey={key} + sc={sc} + di={di} + fc={fc} + targetHorizon={targetHorizon} + /> ); })}
diff --git a/frontend/src/components/site-finder/ptica/scenarios/ScenarioCompareTable.tsx b/frontend/src/components/site-finder/ptica/scenarios/ScenarioCompareTable.tsx index d76602db..615c2e94 100644 --- a/frontend/src/components/site-finder/ptica/scenarios/ScenarioCompareTable.tsx +++ b/frontend/src/components/site-finder/ptica/scenarios/ScenarioCompareTable.tsx @@ -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, ); diff --git a/frontend/src/types/forecast.ts b/frontend/src/types/forecast.ts index d8f4f47d..afbcbd56 100644 --- a/frontend/src/types/forecast.ts +++ b/frontend/src/types/forecast.ts @@ -150,6 +150,12 @@ export interface ScenarioForecast { export interface ReportScenarios { summary: string | null; by_scenario: Partial>; + /** #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 ─────────────────────────────────────────────────────────────── -- 2.45.3