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:
parent
fbafb1bf68
commit
8a30238564
4 changed files with 147 additions and 5 deletions
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue