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-решений)
|
advisory: bool # ВСЕГДА True (движок не для production-решений)
|
||||||
confidence: Confidence # MIN(компоненты), жёстко ≤ _CONFIDENCE_CAP
|
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]:
|
def as_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
"segment": dict(self.segment),
|
"segment": dict(self.segment),
|
||||||
|
|
@ -197,6 +204,7 @@ class DemandSupplyForecast:
|
||||||
"future_competitors": list(self.future_competitors),
|
"future_competitors": list(self.future_competitors),
|
||||||
"advisory": self.advisory,
|
"advisory": self.advisory,
|
||||||
"confidence": self.confidence,
|
"confidence": self.confidence,
|
||||||
|
"confounded": self.confounded,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -601,6 +609,13 @@ def compute_demand_supply_forecast(
|
||||||
# ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика)
|
# ── Один раз: §9.6 чувствительность — ТОЛЬКО для explain-фразы (НЕ арифметика)
|
||||||
sensitivity = compute_rate_regime_sensitivity(db, spec=spec)
|
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] = []
|
out: list[DemandSupplyForecast] = []
|
||||||
for h in horizon_list:
|
for h in horizon_list:
|
||||||
out.append(
|
out.append(
|
||||||
|
|
@ -616,12 +631,27 @@ def compute_demand_supply_forecast(
|
||||||
market_confidence=metrics.confidence,
|
market_confidence=metrics.confidence,
|
||||||
macro_coef=macro_coef,
|
macro_coef=macro_coef,
|
||||||
sensitivity_phrase=sensitivity.phrase,
|
sensitivity_phrase=sensitivity.phrase,
|
||||||
|
confounded=confounded,
|
||||||
premise_kind=premise_kind,
|
premise_kind=premise_kind,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return out
|
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(
|
def _forecast_for_horizon(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -635,6 +665,7 @@ def _forecast_for_horizon(
|
||||||
market_confidence: Confidence,
|
market_confidence: Confidence,
|
||||||
macro_coef: Any,
|
macro_coef: Any,
|
||||||
sensitivity_phrase: str | None,
|
sensitivity_phrase: str | None,
|
||||||
|
confounded: bool,
|
||||||
premise_kind: str,
|
premise_kind: str,
|
||||||
) -> DemandSupplyForecast:
|
) -> DemandSupplyForecast:
|
||||||
"""Собрать прогноз для ОДНОГО горизонта (тонкий — pure-логика выше). Graceful."""
|
"""Собрать прогноз для ОДНОГО горизонта (тонкий — pure-логика выше). Graceful."""
|
||||||
|
|
@ -682,7 +713,8 @@ def _forecast_for_horizon(
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"demand_supply_forecast: segment=%s h=%d base_pace=%s norm=%s macro=%s "
|
"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,
|
segment,
|
||||||
horizon,
|
horizon,
|
||||||
_round_or_none(base_pace, 2),
|
_round_or_none(base_pace, 2),
|
||||||
|
|
@ -695,6 +727,7 @@ def _forecast_for_horizon(
|
||||||
_round_or_none(deficit_index, 3),
|
_round_or_none(deficit_index, 3),
|
||||||
_round_or_none(months_of_inventory, 1),
|
_round_or_none(months_of_inventory, 1),
|
||||||
confidence,
|
confidence,
|
||||||
|
confounded,
|
||||||
)
|
)
|
||||||
|
|
||||||
return DemandSupplyForecast(
|
return DemandSupplyForecast(
|
||||||
|
|
@ -717,6 +750,7 @@ def _forecast_for_horizon(
|
||||||
future_competitors=future_competitors,
|
future_competitors=future_competitors,
|
||||||
advisory=True,
|
advisory=True,
|
||||||
confidence=confidence,
|
confidence=confidence,
|
||||||
|
confounded=confounded,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -226,9 +226,13 @@ def _history_months(
|
||||||
def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool:
|
def _confounded(forecasts: Sequence[dict[str, Any]]) -> bool:
|
||||||
"""Пересекает ли окно прогноза шок-период — для confounded #990. PURE.
|
"""Пересекает ли окно прогноза шок-период — для confounded #990. PURE.
|
||||||
|
|
||||||
Любой per-горизонт forecast несёт флаг `confounded`/`is_confounded_window` (PR2)?
|
Канонический ключ — `confounded` (см. `DemandSupplyForecast.as_dict()`, #1222:
|
||||||
Если хоть один True → отчётное окно считаем confounded (оценки смещены, #990 →
|
прокинут из §9.5 macro_coefficient / §9.6 rate_sensitivity). Исторический алиас
|
||||||
НИКОГДА не 'high'). Нет флага нигде → False (чистое окно).
|
`is_confounded_window` оставляем ради forward-совместимости со старыми форматами
|
||||||
|
forecast-диктов. Всегда через `.get()` (без default → None) — отсутствие ключа
|
||||||
|
НЕ KeyError, а «нет сигнала» (None ≠ True → False). Если хоть один forecast
|
||||||
|
True → отчётное окно считаем confounded (оценки смещены, #990 → НИКОГДА не
|
||||||
|
'high'). Нет флага нигде → False (чистое окно).
|
||||||
"""
|
"""
|
||||||
for f in forecasts:
|
for f in forecasts:
|
||||||
if f.get("confounded") is True or f.get("is_confounded_window") is True:
|
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}],
|
"future_competitors": [{"obj_id": 1, "relevance_weight": 0.7}],
|
||||||
"advisory": True,
|
"advisory": True,
|
||||||
"confidence": "medium",
|
"confidence": "medium",
|
||||||
|
"confounded": False,
|
||||||
}
|
}
|
||||||
base.update(over)
|
base.update(over)
|
||||||
return DemandSupplyForecast(**base) # type: ignore[arg-type]
|
return DemandSupplyForecast(**base) # type: ignore[arg-type]
|
||||||
|
|
@ -564,10 +565,13 @@ def _norm_stub(*, coefficient: float = 0.8, confidence: str = "high") -> MagicMo
|
||||||
return m
|
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 = MagicMock()
|
||||||
m.coefficient = coefficient
|
m.coefficient = coefficient
|
||||||
m.confidence = confidence
|
m.confidence = confidence
|
||||||
|
m.confounded = confounded
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -439,6 +439,43 @@ class TestConfidence:
|
||||||
assert "analog_count" in factors
|
assert "analog_count" in factors
|
||||||
assert "domrf_coverage" 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 — синтез ─────────────────────────────────────────────────────
|
# ── exec_summary — синтез ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -649,6 +686,69 @@ class TestSignalExtractionHelpers:
|
||||||
assert _confounded([{"confounded": False}]) is False
|
assert _confounded([{"confounded": False}]) is False
|
||||||
assert _confounded([]) 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:
|
def test_primary_deficit_prefers_12mo(self) -> None:
|
||||||
forecasts = [
|
forecasts = [
|
||||||
{"horizon_months": 6, "deficit_index": 0.21},
|
{"horizon_months": 6, "deficit_index": 0.21},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue