diff --git a/backend/app/services/forecasting/demand_supply_forecast.py b/backend/app/services/forecasting/demand_supply_forecast.py index 5aec90c1..e4396b82 100644 --- a/backend/app/services/forecasting/demand_supply_forecast.py +++ b/backend/app/services/forecasting/demand_supply_forecast.py @@ -176,6 +176,13 @@ class DemandSupplyForecast: advisory: bool # ВСЕГДА True (движок не для production-решений) confidence: Confidence # MIN(компоненты), жёстко ≤ _CONFIDENCE_CAP + # Шок-окно (PR2): True, если §9.5 macro_coefficient ИЛИ §9.6 rate_sensitivity + # сообщили `confounded` (ряд пересекает структурный разрыв 2024-07-01). Прокидываем + # в `as_dict()` → `report_assembler._confounded` → `compute_report_confidence` (#990) + # confounded factor: НИКОГДА не позволяет §15 объявить 'high'. Дефолт False (чистое + # окно). Без этого поля §15-фактор шок-окна (#1222) перманентно мёртв. + confounded: bool = False + def as_dict(self) -> dict[str, Any]: return { "segment": dict(self.segment), @@ -197,6 +204,7 @@ class DemandSupplyForecast: "future_competitors": list(self.future_competitors), "advisory": self.advisory, "confidence": self.confidence, + "confounded": self.confounded, } @@ -601,6 +609,13 @@ def compute_demand_supply_forecast( # ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика) sensitivity = compute_rate_regime_sensitivity(db, spec=spec) + # Шок-окно (PR2): берём ИЗ §9.5/§9.6 ровно один раз и прокидываем во все per- + # горизонт DemandSupplyForecast — иначе #990 confounded-factor мёртв (#1222). + # MagicMock-стабы в тестах могут не задавать атрибут → `getattr(..., False)` + + # `bool(...)` для безопасности (Mock-attribute truthy исказил бы фактическое + # значение). + confounded = _series_confounded(macro_coef, sensitivity) + out: list[DemandSupplyForecast] = [] for h in horizon_list: out.append( @@ -616,12 +631,27 @@ def compute_demand_supply_forecast( market_confidence=metrics.confidence, macro_coef=macro_coef, sensitivity_phrase=sensitivity.phrase, + confounded=confounded, premise_kind=premise_kind, ) ) return out +def _series_confounded(macro_coef: Any, sensitivity: Any) -> bool: + """Окно §9.5 macro_coefficient ИЛИ §9.6 rate_sensitivity пересекает шок-период. + + PR2-флаг приходит ИЗ под-сервисов готовым `.confounded: bool` (см. + `MacroCoefficient` / `RateSensitivity`). Берём ИЛИ-агрегат: достаточно одного + True, чтобы окно считалось confounded. `getattr` с дефолтом False для + устойчивости к стабам/будущим под-вариантам без поля. Используется в `as_dict()` + → `report_assembler._confounded` → `compute_report_confidence` (#990, #1222). + """ + macro_flag = bool(getattr(macro_coef, "confounded", False) or False) + sens_flag = bool(getattr(sensitivity, "confounded", False) or False) + return macro_flag or sens_flag + + def _forecast_for_horizon( db: Session, *, @@ -635,6 +665,7 @@ def _forecast_for_horizon( market_confidence: Confidence, macro_coef: Any, sensitivity_phrase: str | None, + confounded: bool, premise_kind: str, ) -> DemandSupplyForecast: """Собрать прогноз для ОДНОГО горизонта (тонкий — pure-логика выше). Graceful.""" @@ -682,7 +713,8 @@ def _forecast_for_horizon( logger.info( "demand_supply_forecast: segment=%s h=%d base_pace=%s norm=%s macro=%s " - "demand=%s supply=%.1f balance=%s ratio=%s deficit_index=%s moi=%s confidence=%s", + "demand=%s supply=%.1f balance=%s ratio=%s deficit_index=%s moi=%s " + "confidence=%s confounded=%s", segment, horizon, _round_or_none(base_pace, 2), @@ -695,6 +727,7 @@ def _forecast_for_horizon( _round_or_none(deficit_index, 3), _round_or_none(months_of_inventory, 1), confidence, + confounded, ) return DemandSupplyForecast( @@ -717,6 +750,7 @@ def _forecast_for_horizon( future_competitors=future_competitors, advisory=True, confidence=confidence, + confounded=confounded, ) diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index b1d2d0df..cd45e8f1 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -226,9 +226,13 @@ def _history_months( def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool: """Пересекает ли окно прогноза шок-период — для confounded #990. PURE. - Любой per-горизонт forecast несёт флаг `confounded`/`is_confounded_window` (PR2)? - Если хоть один True → отчётное окно считаем confounded (оценки смещены, #990 → - НИКОГДА не 'high'). Нет флага нигде → False (чистое окно). + Канонический ключ — `confounded` (см. `DemandSupplyForecast.as_dict()`, #1222: + прокинут из §9.5 macro_coefficient / §9.6 rate_sensitivity). Исторический алиас + `is_confounded_window` оставляем ради forward-совместимости со старыми форматами + forecast-диктов. Всегда через `.get()` (без default → None) — отсутствие ключа + НЕ KeyError, а «нет сигнала» (None ≠ True → False). Если хоть один forecast + True → отчётное окно считаем confounded (оценки смещены, #990 → НИКОГДА не + 'high'). Нет флага нигде → False (чистое окно). """ for f in forecasts: if f.get("confounded") is True or f.get("is_confounded_window") is True: diff --git a/backend/tests/services/forecasting/test_demand_supply_forecast.py b/backend/tests/services/forecasting/test_demand_supply_forecast.py index c4a1378e..78e3bfcc 100644 --- a/backend/tests/services/forecasting/test_demand_supply_forecast.py +++ b/backend/tests/services/forecasting/test_demand_supply_forecast.py @@ -509,6 +509,7 @@ def _make_forecast(**over: object) -> DemandSupplyForecast: "future_competitors": [{"obj_id": 1, "relevance_weight": 0.7}], "advisory": True, "confidence": "medium", + "confounded": False, } base.update(over) return DemandSupplyForecast(**base) # type: ignore[arg-type] @@ -564,10 +565,13 @@ def _norm_stub(*, coefficient: float = 0.8, confidence: str = "high") -> MagicMo return m -def _macro_coef_stub(*, coefficient: float = 1.1, confidence: str = "high") -> MagicMock: +def _macro_coef_stub( + *, coefficient: float = 1.1, confidence: str = "high", confounded: bool = False +) -> MagicMock: m = MagicMock() m.coefficient = coefficient m.confidence = confidence + m.confounded = confounded return m diff --git a/backend/tests/services/forecasting/test_report_assembler.py b/backend/tests/services/forecasting/test_report_assembler.py index 2bc4604f..20fddbdb 100644 --- a/backend/tests/services/forecasting/test_report_assembler.py +++ b/backend/tests/services/forecasting/test_report_assembler.py @@ -439,6 +439,43 @@ class TestConfidence: assert "analog_count" in factors assert "domrf_coverage" in factors + def test_confounded_window_factor_surfaces_when_any_forecast_confounded(self) -> None: + # #1222 end-to-end: хотя бы один forecast.confounded=True → confounded_window + # фактор присутствует и тянет уровень в 'low' (#990: confounded fact-level low, + # weakest-link → итог 'low'; advisory cap ниже 'low' не двигает). + forecasts = _sample_forecasts() + forecasts[2]["confounded"] = True # 18-мес forecast confounded + report = assemble_report( + _sample_analyze(), + market_metrics=_sample_market_metrics(), + supply_layers=_sample_supply_layers(), + forecasts=forecasts, + future_supply=_sample_future_supply(), + scenarios=_sample_scenarios(), + recommendation_overlay=_sample_overlay(), + product_scores=_sample_product_scores(), + special_indices=_sample_special_indices(), + cad_num="66:41:0000000:1", + district="Верх-Исетский", + ).as_dict() + factors = report["confidence"]["factors"] + assert "confounded_window" in factors, "шок-окно перманентно мёртв — #1222 регрессия" + assert factors["confounded_window"]["value"] is True + assert factors["confounded_window"]["level"] == "low" + # Weakest-link MIN → итоговый уровень тоже 'low' (был бы 'medium' без шока). + assert report["confidence"]["level"] == "low" + + def test_confounded_window_factor_absent_when_no_forecast_confounded(self) -> None: + # Зеркальный кейс: все forecast'ы confounded=False → factor отсутствует + # (confidence_engine добавляет confounded factor ТОЛЬКО при True, line 450 — + # чистое окно не тянет искусственно вверх, иначе тонкие отчёты получили бы + # фантомный 'high'-вклад). Гарантия, что previous test НЕ false-positive + # (factor не «всегда low»), достигается тем, что предыдущий тест проверяет + # уровень: «low» доступен только если confounded factor реально добавлен. + conf = _full_assemble().as_dict()["confidence"] + factors = conf["factors"] + assert "confounded_window" not in factors + # ── exec_summary — синтез ───────────────────────────────────────────────────── @@ -649,6 +686,69 @@ class TestSignalExtractionHelpers: assert _confounded([{"confounded": False}]) is False assert _confounded([]) is False + def test_confounded_missing_key_is_false_not_keyerror(self) -> None: + # #1222: forecast БЕЗ confounded-ключа НЕ должен ронять (.get() default → None + # ≠ True → False). Раньше .get() уже стоял, но это контракт-тест: гарантирует, + # что defensive-чтение НЕ деградирует в KeyError при произвольных формах. + assert _confounded([{}, {"horizon_months": 12}]) is False + assert _confounded([{"confounded": "no"}]) is False # truthy-but-not-True → False + assert _confounded([{"confounded": 1}]) is False # «is True» строго, не truthy + + def test_confounded_reads_real_demand_supply_forecast_as_dict(self) -> None: + # #1222 regression: DemandSupplyForecast.as_dict() ОБЯЗАН нести ключ `confounded`, + # иначе шок-окно §15 (#990) перманентно мёртв. Строим РЕАЛЬНЫЙ frozen-dataclass + # БЕЗ БД, сериализуем через его собственный `as_dict()` и убеждаемся, что + # `_confounded` видит флаг (контракт-тест: ловит дрейф ключа продьюсера). + from app.services.forecasting.demand_supply_forecast import DemandSupplyForecast + + clean = DemandSupplyForecast( + segment={"obj_class": "комфорт"}, + horizon_months=12, + base_pace_units_per_mo=8.0, + demand_norm_coefficient=1.0, + macro_coefficient=1.0, + projected_demand_units=100.0, + open_units=300, + hidden_release_units=80.0, + future_online_units=20.0, + projected_supply_units=400.0, + balance_units=-300.0, + balance_ratio=0.25, + deficit_index=-0.5, + months_of_inventory=48.0, + rate_future=18.0, + rate_sensitivity_phrase=None, + future_competitors=[], + advisory=True, + confidence="medium", + confounded=False, + ).as_dict() + shock = DemandSupplyForecast( + segment={"obj_class": "комфорт"}, + horizon_months=24, + base_pace_units_per_mo=8.0, + demand_norm_coefficient=1.0, + macro_coefficient=1.0, + projected_demand_units=200.0, + open_units=300, + hidden_release_units=80.0, + future_online_units=20.0, + projected_supply_units=400.0, + balance_units=-200.0, + balance_ratio=0.5, + deficit_index=-0.3, + months_of_inventory=24.0, + rate_future=18.0, + rate_sensitivity_phrase=None, + future_competitors=[], + advisory=True, + confidence="medium", + confounded=True, + ).as_dict() + assert "confounded" in clean and "confounded" in shock # контракт ключа + assert _confounded([clean]) is False + assert _confounded([clean, shock]) is True + def test_primary_deficit_prefers_12mo(self) -> None: forecasts = [ {"horizon_months": 6, "deficit_index": 0.21},