fix(forecasting): propagate confounded flag DemandSupplyForecast → §15 (#1222)

DemandSupplyForecast.as_dict() не эмитил 'confounded'/'is_confounded_window',
report_assembler._confounded() всегда возвращал False и §15 confounded_window
factor в compute_report_confidence был мёртв: 48-мес окна, пересекающие
2024-07-01 шок никогда не тянули report confidence к 'low' и шок не назывался
в rationale.

Patch: добавлено confounded: bool в DemandSupplyForecast (от §9.5 macro_coef
OR §9.6 rate_sensitivity), exposed в as_dict(). _confounded() уже использовал
.get() defensively — блокер был в producer'е.

+3 теста: contract на real DemandSupplyForecast.as_dict(), end-to-end
assemble_report → confounded_window factor surfaces at level=low, weakest-link
тянет overall к 'low'. 61 report_assembler + 1034 forecasting тестов зелёные.

Closes #1222
This commit is contained in:
Light1YT 2026-06-13 13:15:18 +05:00
parent fbafb1bf68
commit 8a30238564
4 changed files with 147 additions and 5 deletions

View file

@ -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,
)

View file

@ -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:

View file

@ -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

View file

@ -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},