feat(forecasting): §15 confidence engine v2 (#990, 955-A4) #1020
3 changed files with 853 additions and 0 deletions
|
|
@ -37,6 +37,11 @@ from app.services.forecasting.affordability import (
|
|||
MortgageAffordabilityIndex,
|
||||
compute_affordability,
|
||||
)
|
||||
from app.services.forecasting.confidence_engine import (
|
||||
ConfidenceFactor,
|
||||
ReportConfidenceResult,
|
||||
compute_report_confidence,
|
||||
)
|
||||
from app.services.forecasting.demand_normalization import (
|
||||
DemandNormalization,
|
||||
compute_demand_normalization,
|
||||
|
|
@ -114,6 +119,7 @@ from app.services.forecasting.what_to_build import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
"ConfidenceFactor",
|
||||
"DemandNormalization",
|
||||
"DemandSupplyForecast",
|
||||
"MacroCoefficient",
|
||||
|
|
@ -124,6 +130,7 @@ __all__ = [
|
|||
"RankedSegment",
|
||||
"RateSensitivity",
|
||||
"ReportConfidence",
|
||||
"ReportConfidenceResult",
|
||||
"ReportExecSummary",
|
||||
"ReportFutureMarket",
|
||||
"ReportMarketNow",
|
||||
|
|
@ -148,6 +155,7 @@ __all__ = [
|
|||
"compute_demand_supply_forecast",
|
||||
"compute_macro_coefficient",
|
||||
"compute_rate_sensitivity",
|
||||
"compute_report_confidence",
|
||||
"compute_scenarios",
|
||||
"compute_score_card",
|
||||
"compute_special_indices",
|
||||
|
|
|
|||
488
backend/app/services/forecasting/confidence_engine.py
Normal file
488
backend/app/services/forecasting/confidence_engine.py
Normal file
|
|
@ -0,0 +1,488 @@
|
|||
"""§15 движок ОТЧЁТНОЙ уверенности v2 — weakest-link + СТРУКТУРНАЯ причина.
|
||||
|
||||
#990 (955-A4, Site Finder v2 / «GG-форсайт» ТЗ §15), EPIC 11 «Отчёт». Это ЧИСТЫЙ
|
||||
агрегатор уверенности: он сводит per-component confidence под-сервисов (#950/#952/
|
||||
#985/#986…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
|
||||
покрытие domrf↔objective, глубина истории, шок-окно) в ОДИН отчётный уровень
|
||||
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
|
||||
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
|
||||
покрытие domrf↔objective 2.5%»). Наполняет слот `ReportConfidence` отчёта #987.
|
||||
|
||||
ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, СОВЕТУЮЩИЙ. Никакого SQL/сети/print/вычислений §9.x —
|
||||
движок ЧИСТЫЙ: берёт уже-посчитанные входы (их кормит сборщик #988) и только
|
||||
агрегирует + формулирует. Поэтому он ЮНИТ-ТЕСТИРУЕТСЯ БЕЗ БД.
|
||||
|
||||
ЛОГИКА (ТЗ §15):
|
||||
• Отчётная уверенность = WEAKEST-LINK (MIN ранга) вкладывающих факторов — самый
|
||||
тонкий сигнал честно роняет общий уровень (зеркало future_supply._min_confidence
|
||||
и product_scoring._min_confidence). Один 'low'-фактор → отчёт 'low'.
|
||||
• Причина — СТРУКТУРНАЯ: называет КОНКРЕТНЫЙ фактор(ы)-виновник с его ЧИСЛОМ
|
||||
(сделки / ЖК-аналоги / покрытие % / месяцы истории / шок-окно / advisory-cap),
|
||||
формат «Low потому что N сделок … / только M ЖК … / покрытие X%».
|
||||
• Весь форсайт-стек СОВЕТУЮЩИЙ (движок не провалидирован до бэктеста #951) →
|
||||
advisory-cap: при advisory=True уровень НИКОГДА не 'high' (потолок 'medium'),
|
||||
и причина это ПРОГОВАРИВАЕТ. Честность важнее оптимистичной метки.
|
||||
|
||||
ПОРОГИ (align с per-service gate'ами, которые читает движок):
|
||||
• deal_count — зеркало market_metrics._confidence (n_lots/n_sold) + §9.6 _MIN_OBS:
|
||||
мало сделок → скоростные метрики статистически ненадёжны.
|
||||
• analog_count (ЖК-аналоги, = market_metrics.obj_count) — high≥3 / medium≥2 / 1 → low
|
||||
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» — ТЗ §15-пример).
|
||||
• domrf_coverage — главный риск проекта (domrf↔objective ~2.5%, см. market_metrics
|
||||
docstring): низкое покрытие → скрытый/будущий слой §9.3 недооценён.
|
||||
• history_months — зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) / _MIN_OBS=8: короткий
|
||||
ряд → связь rate↔sales / тренды не установлены.
|
||||
• confounded — шок-окно (is_confounded_window, PR2): ряд пересекает структурный
|
||||
разрыв → оценки смещены (НИКОГДА не 'high').
|
||||
|
||||
GRACEFUL (дух всего форсайт-стека): все входы None/пусто → 'low' + «недостаточно
|
||||
данных» (тонкий анализ всё равно даёт честный вердикт, не crash). `as_dict()`
|
||||
JSON-safe и ложится в слот ReportConfidence (#987): {level, rationale, factors}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
Confidence = Literal["high", "medium", "low"]
|
||||
|
||||
# ── Порядок уверенности для weakest-link MIN (хуже = ниже). Зеркало vocab
|
||||
# future_supply._CONFIDENCE_RANK / product_scoring._CONFIDENCE_RANK. ───────────────
|
||||
_CONFIDENCE_RANK: dict[Confidence, int] = {"low": 0, "medium": 1, "high": 2}
|
||||
_RANK_TO_CONFIDENCE: dict[int, Confidence] = {0: "low", 1: "medium", 2: "high"}
|
||||
|
||||
# ── Имена факторов (named — переименование не рассинхронит причину/тесты молча) ──
|
||||
_F_DEAL_COUNT: str = "deal_count"
|
||||
_F_ANALOG_COUNT: str = "analog_count"
|
||||
_F_DOMRF_COVERAGE: str = "domrf_coverage"
|
||||
_F_HISTORY_MONTHS: str = "history_months"
|
||||
_F_CONFOUNDED_WINDOW: str = "confounded_window"
|
||||
_F_ADVISORY_CAP: str = "advisory_cap"
|
||||
_F_COMPONENT: str = "component" # один вкладывающий per-service confidence
|
||||
|
||||
# ── Пороги счётчиков (align с per-service gate'ами, прочитанными в #949/#951) ──────
|
||||
|
||||
# deal_count: число сделок (продаж) за окно. high — длинная плотная выборка,
|
||||
# medium — рабочий минимум, low — статистически ненадёжно (зеркало духа
|
||||
# market_metrics: n_sold>0 обязателен; §9.6 _MIN_OBS=8 — пол для регрессии).
|
||||
_DEAL_COUNT_HIGH: int = 50
|
||||
_DEAL_COUNT_LOW: int = 15
|
||||
|
||||
# analog_count: число ЖК-аналогов (= market_metrics.obj_count). ТОЧНАЯ копия
|
||||
# _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2: 1 ЖК → low (ТЗ §15-пример «только 1 ЖК»).
|
||||
_ANALOG_COUNT_HIGH: int = 3
|
||||
_ANALOG_COUNT_LOW: int = 2 # < этого (т.е. ≤1 ЖК) → low
|
||||
|
||||
# domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта ~2.5%).
|
||||
# high — покрытие плотное; low — слой §9.3 (скрытое/будущее) недооценён. medium-порог
|
||||
# созвучен supply_layers._L2_MEDIUM_MIN_COVERAGE=0.6 (доверяем при покрытии большинства).
|
||||
_DOMRF_COVERAGE_HIGH: float = 0.6
|
||||
_DOMRF_COVERAGE_LOW: float = 0.2
|
||||
|
||||
# history_months: глубина ряда (мес). Зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (≥2 года) /
|
||||
# _MIN_OBS=8 (пол): короткий ряд → тренды/чувствительность не установлены.
|
||||
_HISTORY_MONTHS_HIGH: int = 24
|
||||
_HISTORY_MONTHS_LOW: int = 12
|
||||
|
||||
# Потолок при advisory=True: весь стек советующий (не провалидирован) → НИКОГДА 'high'.
|
||||
_ADVISORY_CEILING: Confidence = "medium"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfidenceFactor:
|
||||
"""Один вклад в отчётную уверенность — фактор-сигнал с числом, уровнем и RU-нотой.
|
||||
|
||||
`name` — машинное имя (_F_*); `value` — сырое число/флаг, которым фактор оценён
|
||||
(число сделок / ЖК / покрытие / месяцы / bool шок-окна — или None); `level` —
|
||||
его собственный вклад high/medium/low; `note` — короткая RU-фраза с числом
|
||||
(«7 сделок за 6 мес — мало»), из которой собирается структурная причина §15.
|
||||
"""
|
||||
|
||||
name: str
|
||||
value: Any # число/доля/флаг, обосновавшие level (или None)
|
||||
level: Confidence
|
||||
note: str # RU, с реальным числом
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"value": self.value,
|
||||
"level": self.level,
|
||||
"note": self.note,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportConfidenceResult:
|
||||
"""§15 результат движка отчётной уверенности — уровень + СТРУКТУРНАЯ причина.
|
||||
|
||||
`level` — weakest-link MIN факторов, capped в 'medium' при advisory. `rationale` —
|
||||
RU-предложение «Low потому что …», НАЗЫВАЮЩЕЕ тянущие вниз факторы с их числами.
|
||||
`factors` — все учтённые ConfidenceFactor (explainability §16). `advisory_capped` —
|
||||
True, если потолок advisory реально понизил уровень (был бы 'high'). `as_dict()`
|
||||
ложится в слот ReportConfidence #987: {level, rationale, factors}.
|
||||
"""
|
||||
|
||||
level: Confidence
|
||||
rationale: str
|
||||
factors: list[ConfidenceFactor] = field(default_factory=list)
|
||||
advisory_capped: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""JSON-safe вид под слот ReportConfidence (#987): {level, rationale, factors}.
|
||||
|
||||
`factors` — плоский dict {name: {value, level, note}} (стабильно, удобно
|
||||
экспортёрам/чату; advisory_capped дублируем внутрь для полноты explainability).
|
||||
Повторяющиеся имена (несколько `component`) суффиксуем `_2/_3…`, чтобы ни один
|
||||
фактор не потерялся в плоском dict (ключи иначе бы перезаписали друг друга).
|
||||
"""
|
||||
factors: dict[str, Any] = {}
|
||||
for f in self.factors:
|
||||
key = f.name
|
||||
if key in factors:
|
||||
i = 2
|
||||
while f"{key}_{i}" in factors:
|
||||
i += 1
|
||||
key = f"{key}_{i}"
|
||||
factors[key] = {"value": f.value, "level": f.level, "note": f.note}
|
||||
factors["advisory_capped"] = self.advisory_capped
|
||||
return {
|
||||
"level": self.level,
|
||||
"rationale": self.rationale,
|
||||
"factors": factors,
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Pure-хелперы — без БД, полностью юнит-тестируемо. Каждый graceful: None/пусто →
|
||||
# безопасная деградация ('low' / нейтраль), НИКОГДА не бросает.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _level_from_value(value: float | None, *, high_at: float, low_below: float) -> Confidence:
|
||||
"""Сырое число → high/medium/low по двум именованным порогам. PURE.
|
||||
|
||||
value ≥ high_at → 'high'; value < low_below → 'low'; между → 'medium'. None →
|
||||
'low' (нет сигнала ≠ хороший сигнал). Монотонно (выше число → не хуже уровень).
|
||||
"""
|
||||
if value is None:
|
||||
return "low"
|
||||
if value >= high_at:
|
||||
return "high"
|
||||
if value < low_below:
|
||||
return "low"
|
||||
return "medium"
|
||||
|
||||
|
||||
def _factor_from_count(
|
||||
name: str,
|
||||
count: float | None,
|
||||
*,
|
||||
high_at: float,
|
||||
low_below: float,
|
||||
unit: str,
|
||||
suffix: str = "",
|
||||
) -> ConfidenceFactor:
|
||||
"""Сырой счётчик → ConfidenceFactor (уровень по порогам + RU-нота с числом). PURE.
|
||||
|
||||
Маппит `count` в high/medium/low через _level_from_value(high_at/low_below) и
|
||||
собирает RU-ноту с РЕАЛЬНЫМ числом и оценкой («7 сделок за 6 мес — мало» /
|
||||
«3 ЖК-аналога — достаточно»). `unit` — что считаем («сделок» / «ЖК-аналога»);
|
||||
`suffix` — хвост контекста («за 6 мес»). count None → low + нота «нет данных».
|
||||
|
||||
Args:
|
||||
name: машинное имя фактора (_F_*).
|
||||
count: сырое число (или None).
|
||||
high_at: порог 'high' (count ≥ → high).
|
||||
low_below: порог 'low' (count < → low).
|
||||
unit: единица в RU-ноте (родительный падеж: «сделок», «ЖК-аналога»).
|
||||
suffix: необязательный хвост ноты (например «за 6 мес»).
|
||||
|
||||
Returns:
|
||||
ConfidenceFactor с level/value/note.
|
||||
"""
|
||||
level = _level_from_value(count, high_at=high_at, low_below=low_below)
|
||||
tail = f" {suffix}" if suffix else ""
|
||||
if count is None:
|
||||
note = f"нет данных о количестве ({unit})"
|
||||
else:
|
||||
shown = int(count) if float(count).is_integer() else round(float(count), 1)
|
||||
note = f"{shown} {unit}{tail} — {_QUALITY_WORD[level]}"
|
||||
return ConfidenceFactor(name=name, value=count, level=level, note=note)
|
||||
|
||||
|
||||
# Оценочное слово на уровень (для RU-ноты фактора). Зеркало духа per-service фраз.
|
||||
_QUALITY_WORD: dict[Confidence, str] = {
|
||||
"high": "достаточно",
|
||||
"medium": "умеренно",
|
||||
"low": "мало",
|
||||
}
|
||||
|
||||
|
||||
def _coverage_factor(coverage: float | None) -> ConfidenceFactor:
|
||||
"""domrf↔objective покрытие ∈ [0,1] → ConfidenceFactor с % в ноте. PURE.
|
||||
|
||||
Главный sparse-риск проекта (~2.5%). Нота показывает покрытие В ПРОЦЕНТАХ
|
||||
(структурный §15-пример «покрытие domrf↔objective 2.5%»). None → low.
|
||||
"""
|
||||
level = _level_from_value(coverage, high_at=_DOMRF_COVERAGE_HIGH, low_below=_DOMRF_COVERAGE_LOW)
|
||||
if coverage is None:
|
||||
note = "покрытие domrf↔objective неизвестно"
|
||||
else:
|
||||
pct = round(float(coverage) * 100.0, 1)
|
||||
note = f"покрытие domrf↔objective {pct}% — {_QUALITY_WORD[level]}"
|
||||
return ConfidenceFactor(name=_F_DOMRF_COVERAGE, value=coverage, level=level, note=note)
|
||||
|
||||
|
||||
def _confounded_factor(confounded: bool) -> ConfidenceFactor:
|
||||
"""Шок-окно → ConfidenceFactor. PURE.
|
||||
|
||||
confounded=True (ряд пересекает структурный разрыв, PR2) → 'low' (оценки смещены,
|
||||
НИКОГДА не 'high'). False → 'high' (окно чистое — фактор не тянет вниз).
|
||||
"""
|
||||
if confounded:
|
||||
return ConfidenceFactor(
|
||||
name=_F_CONFOUNDED_WINDOW,
|
||||
value=True,
|
||||
level="low",
|
||||
note="окно ряда пересекает шок-период (оценки смещены)",
|
||||
)
|
||||
return ConfidenceFactor(
|
||||
name=_F_CONFOUNDED_WINDOW,
|
||||
value=False,
|
||||
level="high",
|
||||
note="окно ряда вне шок-периодов",
|
||||
)
|
||||
|
||||
|
||||
def _component_factor(level: Confidence) -> ConfidenceFactor:
|
||||
"""Один вкладывающий per-service confidence → ConfidenceFactor. PURE.
|
||||
|
||||
Per-service уже свернул свои тонкие сигналы в high/medium/low; берём как факт,
|
||||
нота поясняет вклад. value=None (число — внутри самого под-сервиса).
|
||||
"""
|
||||
return ConfidenceFactor(
|
||||
name=_F_COMPONENT,
|
||||
value=None,
|
||||
level=level,
|
||||
note=f"вкладывающий сервис: {level}",
|
||||
)
|
||||
|
||||
|
||||
def _aggregate(factors: list[ConfidenceFactor]) -> Confidence:
|
||||
"""Weakest-link агрегат: MIN ранга факторов (худший тянет вниз). PURE.
|
||||
|
||||
Зеркало future_supply._min_confidence / product_scoring._min_confidence: один
|
||||
'low'-фактор → общий 'low'. Пустой список → 'low' (нет ни одного сигнала).
|
||||
"""
|
||||
ranks = [_CONFIDENCE_RANK[f.level] for f in factors]
|
||||
if not ranks:
|
||||
return "low"
|
||||
return _RANK_TO_CONFIDENCE[min(ranks)]
|
||||
|
||||
|
||||
def _cap(level: Confidence, ceiling: Confidence) -> Confidence:
|
||||
"""Опустить level до потолка ceiling (MIN ранга). PURE.
|
||||
|
||||
advisory-cap: при ceiling='medium' любое 'high' становится 'medium'. Зеркало
|
||||
advisory-духа recommendation/product_scoring (confidence ≤ medium в advisory).
|
||||
"""
|
||||
return _RANK_TO_CONFIDENCE[min(_CONFIDENCE_RANK[level], _CONFIDENCE_RANK[ceiling])]
|
||||
|
||||
|
||||
def _dragging_factors(factors: list[ConfidenceFactor], level: Confidence) -> list[ConfidenceFactor]:
|
||||
"""Факторы, чей уровень == итоговому (т.е. ИМЕННО они тянут вниз). PURE.
|
||||
|
||||
Для 'low'-итога — все low-факторы; для 'medium' — все medium-факторы. Это
|
||||
«виновники» структурной причины §15. Component-факторы (без своего числа)
|
||||
отодвигаем в конец — приоритет адресным счётчикам с числами в ноте.
|
||||
"""
|
||||
same = [f for f in factors if f.level == level]
|
||||
same.sort(key=lambda f: f.name == _F_COMPONENT) # named-счётчики раньше component
|
||||
return same
|
||||
|
||||
|
||||
def _build_rationale(
|
||||
level: Confidence,
|
||||
factors: list[ConfidenceFactor],
|
||||
*,
|
||||
advisory_capped: bool,
|
||||
) -> str:
|
||||
"""Структурная §15-причина: «Low потому что <факторы с числами>». PURE.
|
||||
|
||||
Берёт тянущие-вниз факторы (_dragging_factors) и НАЗЫВАЕТ их ноты (с реальными
|
||||
числами) после «<Level> потому что …». Если уровень понижен именно advisory-cap'ом
|
||||
(был бы 'high') — проговаривает, что движок советующий. Пустые/нейтральные факторы
|
||||
→ «недостаточно данных» (graceful).
|
||||
|
||||
Args:
|
||||
level: итоговый (уже capped) уровень.
|
||||
factors: все учтённые факторы.
|
||||
advisory_capped: True, если потолок advisory реально понизил уровень.
|
||||
|
||||
Returns:
|
||||
RU-предложение-причина.
|
||||
"""
|
||||
label = _LEVEL_LABEL[level]
|
||||
drag = _dragging_factors(factors, level)
|
||||
notes = [f.note for f in drag]
|
||||
|
||||
if advisory_capped and level == _ADVISORY_CEILING:
|
||||
# Уровень упёрся в advisory-потолок (не данные) — это и есть главная причина.
|
||||
base = f"{label}: прогноз советующий (не провалидирован) — уровень ограничен «medium»"
|
||||
if notes:
|
||||
base += "; также " + _join_notes(notes)
|
||||
return base + "."
|
||||
|
||||
if not notes:
|
||||
return f"{label}: недостаточно данных для оценки уверенности."
|
||||
|
||||
return f"{label} потому что {_join_notes(notes)}."
|
||||
|
||||
|
||||
# RU-метка уровня для причины (заглавная — начало предложения).
|
||||
_LEVEL_LABEL: dict[Confidence, str] = {"high": "High", "medium": "Medium", "low": "Low"}
|
||||
|
||||
|
||||
def _join_notes(notes: list[str], *, limit: int = 3) -> str:
|
||||
"""Склеить ноты факторов в RU-перечисление через « / » (не более limit). PURE.
|
||||
|
||||
Ограничиваем limit (по умолчанию 3), чтобы причина оставалась фразой, а не
|
||||
простынёй; хвост сворачиваем в «… и др.». Дубли убираем, порядок сохраняем.
|
||||
"""
|
||||
seen: list[str] = []
|
||||
for n in notes:
|
||||
if n not in seen:
|
||||
seen.append(n)
|
||||
head = seen[:limit]
|
||||
joined = " / ".join(head)
|
||||
if len(seen) > limit:
|
||||
# Без точки в маркере — её добавит вызывающий концом предложения (не «др..»).
|
||||
joined += " и др"
|
||||
return joined
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Публичный API — PURE-сборка (входы уже посчитаны сборщиком #988, без БД).
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_report_confidence(
|
||||
*,
|
||||
component_confidences: list[Confidence] | None = None,
|
||||
deal_count: int | None = None,
|
||||
analog_count: int | None = None,
|
||||
domrf_coverage: float | None = None,
|
||||
history_months: int | None = None,
|
||||
confounded: bool = False,
|
||||
advisory: bool = True,
|
||||
) -> ReportConfidenceResult:
|
||||
"""§15 свести входы в ОДНУ отчётную уверенность + структурную причину. PURE.
|
||||
|
||||
СОВЕТУЮЩИЙ агрегатор: НЕ ходит в БД и НЕ зовёт сервисы — берёт уже-посчитанные
|
||||
счётчики качества данных + per-service confidence (их кормит сборщик #988),
|
||||
строит факторы, агрегирует weakest-link (MIN), при advisory опускает потолок до
|
||||
'medium' и собирает причину «Low потому что …» с реальными числами виновников.
|
||||
|
||||
Шаги:
|
||||
1. Из сырых счётчиков (deal_count / analog_count / domrf_coverage /
|
||||
history_months / confounded) → ConfidenceFactor'ы с RU-нотами (с числами).
|
||||
2. Свернуть component_confidences под-сервисов в факторы (как есть).
|
||||
3. level = weakest-link MIN всех факторов (худший тянет вниз).
|
||||
4. advisory=True → cap level в 'medium' (НИКОГДА не 'high'); пометить
|
||||
advisory_capped, если потолок реально понизил.
|
||||
5. Структурная §15-причина, называющая тянущие-вниз факторы с числами.
|
||||
|
||||
Graceful: все входы None/пусто → 'low' + «недостаточно данных». Никогда не бросает.
|
||||
|
||||
Args:
|
||||
component_confidences: per-service confidence (#950/#952/#985/#986…), None/[]→
|
||||
нет вкладывающих компонентов.
|
||||
deal_count: число сделок за окно (None → нет данных, тянет в low).
|
||||
analog_count: число ЖК-аналогов в выборке (= market_metrics.obj_count).
|
||||
domrf_coverage: доля domrf↔objective ∈ [0,1] (главный sparse-риск проекта).
|
||||
history_months: глубина ряда (мес).
|
||||
confounded: True, если окно ряда пересекает шок-период (PR2).
|
||||
advisory: весь стек советующий → cap 'medium' (по умолчанию True; почти всегда).
|
||||
|
||||
Returns:
|
||||
ReportConfidenceResult (level / rationale / factors / advisory_capped);
|
||||
`as_dict()` ложится в слот ReportConfidence отчёта #987.
|
||||
"""
|
||||
factors: list[ConfidenceFactor] = []
|
||||
|
||||
# ── 1. Сырые счётчики качества данных → факторы (только заданные) ──────────
|
||||
if deal_count is not None:
|
||||
factors.append(
|
||||
_factor_from_count(
|
||||
_F_DEAL_COUNT,
|
||||
deal_count,
|
||||
high_at=_DEAL_COUNT_HIGH,
|
||||
low_below=_DEAL_COUNT_LOW,
|
||||
unit="сделок",
|
||||
)
|
||||
)
|
||||
if analog_count is not None:
|
||||
factors.append(
|
||||
_factor_from_count(
|
||||
_F_ANALOG_COUNT,
|
||||
analog_count,
|
||||
high_at=_ANALOG_COUNT_HIGH,
|
||||
low_below=_ANALOG_COUNT_LOW,
|
||||
unit="ЖК-аналога",
|
||||
)
|
||||
)
|
||||
if domrf_coverage is not None:
|
||||
factors.append(_coverage_factor(domrf_coverage))
|
||||
if history_months is not None:
|
||||
factors.append(
|
||||
_factor_from_count(
|
||||
_F_HISTORY_MONTHS,
|
||||
history_months,
|
||||
high_at=_HISTORY_MONTHS_HIGH,
|
||||
low_below=_HISTORY_MONTHS_LOW,
|
||||
unit="мес истории",
|
||||
)
|
||||
)
|
||||
# Шок-окно учитываем ТОЛЬКО когда оно есть (True): чистое окно не должно
|
||||
# искусственно тянуть к 'high', если других сигналов нет (см. graceful ниже).
|
||||
if confounded:
|
||||
factors.append(_confounded_factor(True))
|
||||
|
||||
# ── 2. Вкладывающие per-service confidence → факторы ──────────────────────
|
||||
for c in component_confidences or []:
|
||||
if c in _CONFIDENCE_RANK:
|
||||
factors.append(_component_factor(c))
|
||||
|
||||
# ── 3. weakest-link агрегат (худший тянет вниз) ───────────────────────────
|
||||
raw_level = _aggregate(factors)
|
||||
|
||||
# ── 4. advisory-cap (НИКОГДА не 'high', пока стек советующий) ──────────────
|
||||
if advisory:
|
||||
capped_level = _cap(raw_level, _ADVISORY_CEILING)
|
||||
advisory_capped = capped_level != raw_level
|
||||
if advisory_capped:
|
||||
# Зафиксировать причину-cap как явный фактор (explainability §16).
|
||||
factors.append(
|
||||
ConfidenceFactor(
|
||||
name=_F_ADVISORY_CAP,
|
||||
value=True,
|
||||
level=_ADVISORY_CEILING,
|
||||
note="прогноз советующий — уровень ограничен «medium»",
|
||||
)
|
||||
)
|
||||
level = capped_level
|
||||
else:
|
||||
level = raw_level
|
||||
advisory_capped = False
|
||||
|
||||
# ── 5. структурная §15-причина ────────────────────────────────────────────
|
||||
rationale = _build_rationale(level, factors, advisory_capped=advisory_capped)
|
||||
|
||||
return ReportConfidenceResult(
|
||||
level=level,
|
||||
rationale=rationale,
|
||||
factors=factors,
|
||||
advisory_capped=advisory_capped,
|
||||
)
|
||||
357
backend/tests/services/forecasting/test_confidence_engine.py
Normal file
357
backend/tests/services/forecasting/test_confidence_engine.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"""Unit-тесты §15 движка отчётной уверенности (#990, 955-A4).
|
||||
|
||||
Чистые тесты — БЕЗ БД (движок PURE: берёт уже-посчитанные входы, ничего не считает
|
||||
из §9.x и не ходит в БД):
|
||||
• _level_from_value / _factor_from_count — каждый порог-бэнд (high/medium/low) +
|
||||
RU-нота с РЕАЛЬНЫМ числом и оценочным словом; None → low + «нет данных».
|
||||
• _coverage_factor — покрытие domrf↔objective в % в ноте (структурный §15-пример).
|
||||
• _aggregate — weakest-link MIN (один low → общий low); пустой → low.
|
||||
• _cap — advisory-потолок опускает high → medium.
|
||||
• _build_rationale — называет тянущий-вниз фактор с его числом, «Low потому что N…»;
|
||||
advisory-cap проговаривается; пусто → «недостаточно данных».
|
||||
• compute_report_confidence — смешанные факторы → правильный level + структурная
|
||||
причина; advisory НИКОГДА не отдаёт high; all-None → low + «недостаточно данных»;
|
||||
as_dict() ложится в слот ReportConfidence (#987) и JSON-сериализуем.
|
||||
|
||||
Детерминированно, без LLM, без сети. DATABASE_URL до импорта app-модулей (зеркало
|
||||
соседних тестов — на случай side-effect'ов импорта пакета forecasting).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||
|
||||
from app.services.forecasting.confidence_engine import (
|
||||
_ANALOG_COUNT_HIGH,
|
||||
_DEAL_COUNT_HIGH,
|
||||
_DEAL_COUNT_LOW,
|
||||
_HISTORY_MONTHS_HIGH,
|
||||
ConfidenceFactor,
|
||||
ReportConfidenceResult,
|
||||
_aggregate,
|
||||
_build_rationale,
|
||||
_cap,
|
||||
_coverage_factor,
|
||||
_factor_from_count,
|
||||
_level_from_value,
|
||||
compute_report_confidence,
|
||||
)
|
||||
|
||||
|
||||
def _factor(name: str, level: str) -> ConfidenceFactor:
|
||||
"""Лёгкий фактор фиксированного уровня (для агрегатных/причинных тестов)."""
|
||||
return ConfidenceFactor(name=name, value=None, level=level, note=f"{name}:{level}") # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ── _level_from_value — пороговые бэнды ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLevelFromValue:
|
||||
def test_high_band(self) -> None:
|
||||
assert _level_from_value(50, high_at=50, low_below=15) == "high"
|
||||
assert _level_from_value(120, high_at=50, low_below=15) == "high"
|
||||
|
||||
def test_medium_band(self) -> None:
|
||||
# Между low_below (вкл.) и high_at (искл.) → medium.
|
||||
assert _level_from_value(15, high_at=50, low_below=15) == "medium"
|
||||
assert _level_from_value(30, high_at=50, low_below=15) == "medium"
|
||||
|
||||
def test_low_band(self) -> None:
|
||||
assert _level_from_value(14, high_at=50, low_below=15) == "low"
|
||||
assert _level_from_value(0, high_at=50, low_below=15) == "low"
|
||||
|
||||
def test_none_is_low(self) -> None:
|
||||
# Нет сигнала ≠ хороший сигнал.
|
||||
assert _level_from_value(None, high_at=50, low_below=15) == "low"
|
||||
|
||||
|
||||
# ── _factor_from_count — бэнды + RU-нота с числом ──────────────────────────────
|
||||
|
||||
|
||||
class TestFactorFromCount:
|
||||
def test_low_count_note_has_number_and_word(self) -> None:
|
||||
f = _factor_from_count(
|
||||
"deal_count",
|
||||
7,
|
||||
high_at=_DEAL_COUNT_HIGH,
|
||||
low_below=_DEAL_COUNT_LOW,
|
||||
unit="сделок",
|
||||
suffix="за 6 мес",
|
||||
)
|
||||
assert f.level == "low"
|
||||
assert f.value == 7
|
||||
# Структурный §15-стиль: реальное число + контекст + оценка.
|
||||
assert "7 сделок" in f.note
|
||||
assert "за 6 мес" in f.note
|
||||
assert "мало" in f.note
|
||||
|
||||
def test_medium_count_band(self) -> None:
|
||||
f = _factor_from_count(
|
||||
"deal_count", 20, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
|
||||
)
|
||||
assert f.level == "medium"
|
||||
assert "20 сделок" in f.note
|
||||
assert "умеренно" in f.note
|
||||
|
||||
def test_high_count_band(self) -> None:
|
||||
f = _factor_from_count(
|
||||
"deal_count", 80, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
|
||||
)
|
||||
assert f.level == "high"
|
||||
assert "80 сделок" in f.note
|
||||
assert "достаточно" in f.note
|
||||
|
||||
def test_one_analog_is_low_with_number(self) -> None:
|
||||
# ТЗ §15-пример «только 1 ЖК» — 1 аналог обязан давать low с числом в ноте.
|
||||
f = _factor_from_count(
|
||||
"analog_count", 1, high_at=_ANALOG_COUNT_HIGH, low_below=2, unit="ЖК-аналога"
|
||||
)
|
||||
assert f.level == "low"
|
||||
assert "1 ЖК-аналога" in f.note
|
||||
|
||||
def test_none_count_low_no_data_note(self) -> None:
|
||||
f = _factor_from_count(
|
||||
"deal_count", None, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
|
||||
)
|
||||
assert f.level == "low"
|
||||
assert f.value is None
|
||||
assert "нет данных" in f.note
|
||||
|
||||
def test_float_count_rendered_compactly(self) -> None:
|
||||
# Целое-как-float → без хвоста; дробное → 1 знак.
|
||||
f_int = _factor_from_count(
|
||||
"history_months", 12.0, high_at=_HISTORY_MONTHS_HIGH, low_below=12, unit="мес истории"
|
||||
)
|
||||
assert "12 мес истории" in f_int.note # не "12.0"
|
||||
f_frac = _factor_from_count(
|
||||
"history_months", 12.5, high_at=_HISTORY_MONTHS_HIGH, low_below=12, unit="мес истории"
|
||||
)
|
||||
assert "12.5 мес истории" in f_frac.note
|
||||
|
||||
|
||||
# ── _coverage_factor — покрытие domrf↔objective в % ────────────────────────────
|
||||
|
||||
|
||||
class TestCoverageFactor:
|
||||
def test_low_coverage_percent_in_note(self) -> None:
|
||||
# Главный sparse-риск проекта: 2.5% покрытие → low, % в ноте (структурный §15).
|
||||
f = _coverage_factor(0.025)
|
||||
assert f.level == "low"
|
||||
assert f.value == 0.025
|
||||
assert "2.5%" in f.note
|
||||
assert "domrf↔objective" in f.note
|
||||
|
||||
def test_high_coverage(self) -> None:
|
||||
f = _coverage_factor(0.75)
|
||||
assert f.level == "high"
|
||||
assert "75.0%" in f.note
|
||||
|
||||
def test_none_coverage_low(self) -> None:
|
||||
f = _coverage_factor(None)
|
||||
assert f.level == "low"
|
||||
assert "неизвестно" in f.note
|
||||
|
||||
|
||||
# ── _aggregate — weakest-link MIN ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAggregate:
|
||||
def test_one_low_drags_overall_to_low(self) -> None:
|
||||
factors = [_factor("a", "high"), _factor("b", "medium"), _factor("c", "low")]
|
||||
assert _aggregate(factors) == "low"
|
||||
|
||||
def test_all_high_stays_high(self) -> None:
|
||||
assert _aggregate([_factor("a", "high"), _factor("b", "high")]) == "high"
|
||||
|
||||
def test_medium_floor_when_no_low(self) -> None:
|
||||
assert _aggregate([_factor("a", "high"), _factor("b", "medium")]) == "medium"
|
||||
|
||||
def test_empty_is_low(self) -> None:
|
||||
# Нет ни одного сигнала → low (graceful).
|
||||
assert _aggregate([]) == "low"
|
||||
|
||||
|
||||
# ── _cap — advisory-потолок ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCap:
|
||||
def test_high_capped_to_medium(self) -> None:
|
||||
assert _cap("high", "medium") == "medium"
|
||||
|
||||
def test_low_unchanged_below_ceiling(self) -> None:
|
||||
assert _cap("low", "medium") == "low"
|
||||
|
||||
def test_medium_unchanged_at_ceiling(self) -> None:
|
||||
assert _cap("medium", "medium") == "medium"
|
||||
|
||||
|
||||
# ── _build_rationale — структурная §15-причина ─────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildRationale:
|
||||
def test_low_names_dragging_factor_with_number(self) -> None:
|
||||
# «Low потому что 7 сделок …» — называет виновника с его числом.
|
||||
deal = _factor_from_count(
|
||||
"deal_count",
|
||||
7,
|
||||
high_at=_DEAL_COUNT_HIGH,
|
||||
low_below=_DEAL_COUNT_LOW,
|
||||
unit="сделок",
|
||||
suffix="за 6 мес",
|
||||
)
|
||||
good = _factor("history_months", "high")
|
||||
text = _build_rationale("low", [deal, good], advisory_capped=False)
|
||||
assert text.startswith("Low потому что")
|
||||
assert "7 сделок" in text
|
||||
# Не-тянущий (high) фактор НЕ называется.
|
||||
assert "history_months" not in text
|
||||
|
||||
def test_low_lists_multiple_dragging_factors(self) -> None:
|
||||
deal = _factor_from_count(
|
||||
"deal_count", 7, high_at=_DEAL_COUNT_HIGH, low_below=_DEAL_COUNT_LOW, unit="сделок"
|
||||
)
|
||||
analog = _factor_from_count(
|
||||
"analog_count", 1, high_at=_ANALOG_COUNT_HIGH, low_below=2, unit="ЖК-аналога"
|
||||
)
|
||||
cov = _coverage_factor(0.025)
|
||||
text = _build_rationale("low", [deal, analog, cov], advisory_capped=False)
|
||||
assert "7 сделок" in text
|
||||
assert "1 ЖК-аналога" in text
|
||||
assert "2.5%" in text
|
||||
assert " / " in text # перечисление виновников
|
||||
|
||||
def test_advisory_cap_spoken_in_rationale(self) -> None:
|
||||
# Уровень упёрся в advisory-потолок (не данные) → причина это проговаривает.
|
||||
text = _build_rationale("medium", [_factor("x", "medium")], advisory_capped=True)
|
||||
assert "советующий" in text
|
||||
assert "medium" in text
|
||||
|
||||
def test_empty_factors_insufficient(self) -> None:
|
||||
assert "недостаточно данных" in _build_rationale("low", [], advisory_capped=False)
|
||||
|
||||
|
||||
# ── compute_report_confidence — интеграция (PURE, без БД) ──────────────────────
|
||||
|
||||
|
||||
class TestComputeReportConfidence:
|
||||
def test_mixed_factors_weakest_link_low(self) -> None:
|
||||
# Сильная история, но 7 сделок и 1 ЖК → weakest-link low; причина структурная.
|
||||
res = compute_report_confidence(
|
||||
component_confidences=["medium", "high"],
|
||||
deal_count=7,
|
||||
analog_count=1,
|
||||
domrf_coverage=0.4,
|
||||
history_months=36,
|
||||
confounded=False,
|
||||
advisory=True,
|
||||
)
|
||||
assert isinstance(res, ReportConfidenceResult)
|
||||
assert res.level == "low"
|
||||
assert res.rationale.startswith("Low потому что")
|
||||
assert "7 сделок" in res.rationale
|
||||
assert "1 ЖК-аналога" in res.rationale
|
||||
|
||||
def test_advisory_never_high(self) -> None:
|
||||
# Все сигналы отличные, но advisory=True → НИКОГДА не high (потолок medium).
|
||||
res = compute_report_confidence(
|
||||
component_confidences=["high", "high"],
|
||||
deal_count=200,
|
||||
analog_count=10,
|
||||
domrf_coverage=0.9,
|
||||
history_months=48,
|
||||
confounded=False,
|
||||
advisory=True,
|
||||
)
|
||||
assert res.level == "medium"
|
||||
assert res.advisory_capped is True
|
||||
assert "советующий" in res.rationale
|
||||
|
||||
def test_non_advisory_allows_high(self) -> None:
|
||||
# advisory=False (гипотетически провалидированный стек) → high достижим.
|
||||
res = compute_report_confidence(
|
||||
component_confidences=["high"],
|
||||
deal_count=200,
|
||||
analog_count=10,
|
||||
domrf_coverage=0.9,
|
||||
history_months=48,
|
||||
confounded=False,
|
||||
advisory=False,
|
||||
)
|
||||
assert res.level == "high"
|
||||
assert res.advisory_capped is False
|
||||
|
||||
def test_confounded_window_drags_down(self) -> None:
|
||||
# Шок-окно → low даже при прочих хороших сигналах; названо в причине.
|
||||
res = compute_report_confidence(
|
||||
component_confidences=["high"],
|
||||
deal_count=200,
|
||||
analog_count=10,
|
||||
domrf_coverage=0.9,
|
||||
history_months=48,
|
||||
confounded=True,
|
||||
advisory=True,
|
||||
)
|
||||
assert res.level == "low"
|
||||
assert "шок" in res.rationale
|
||||
|
||||
def test_all_none_is_low_insufficient(self) -> None:
|
||||
# Граничный graceful: ни одного входа → low + «недостаточно данных».
|
||||
res = compute_report_confidence()
|
||||
assert res.level == "low"
|
||||
assert "недостаточно данных" in res.rationale
|
||||
assert res.factors == []
|
||||
assert res.advisory_capped is False
|
||||
|
||||
def test_clean_inputs_medium_under_advisory(self) -> None:
|
||||
# Рабочие (medium-бэнд) счётчики без low-факторов → medium под advisory-cap.
|
||||
res = compute_report_confidence(
|
||||
component_confidences=["medium"],
|
||||
deal_count=20,
|
||||
analog_count=2,
|
||||
domrf_coverage=0.4,
|
||||
history_months=18,
|
||||
advisory=True,
|
||||
)
|
||||
assert res.level == "medium"
|
||||
# Здесь потолок не понижал (raw уже medium) → не помечаем capped.
|
||||
assert res.advisory_capped is False
|
||||
|
||||
def test_as_dict_fits_report_confidence_slot(self) -> None:
|
||||
# as_dict() ложится в слот ReportConfidence (#987): {level, rationale, factors}.
|
||||
res = compute_report_confidence(deal_count=7, analog_count=1, advisory=True)
|
||||
d = res.as_dict()
|
||||
assert set(d.keys()) == {"level", "rationale", "factors"}
|
||||
assert d["level"] == "low"
|
||||
assert isinstance(d["rationale"], str)
|
||||
assert isinstance(d["factors"], dict)
|
||||
# Факторы по именам + флаг advisory_capped внутри.
|
||||
assert "deal_count" in d["factors"]
|
||||
assert d["factors"]["deal_count"]["value"] == 7
|
||||
assert "advisory_capped" in d["factors"]
|
||||
# Полностью JSON-сериализуем (контракт для экспортёров/чата).
|
||||
assert json.loads(json.dumps(d, ensure_ascii=False)) == d
|
||||
|
||||
def test_ignores_garbage_component_confidence(self) -> None:
|
||||
# Мусорный component-уровень не учитывается (whitelist), не роняет искусственно.
|
||||
res = compute_report_confidence(
|
||||
component_confidences=["high", "bogus"], # type: ignore[list-item]
|
||||
deal_count=200,
|
||||
analog_count=10,
|
||||
domrf_coverage=0.9,
|
||||
history_months=48,
|
||||
advisory=False,
|
||||
)
|
||||
assert res.level == "high"
|
||||
|
||||
def test_as_dict_keeps_all_component_factors(self) -> None:
|
||||
# Несколько component-факторов не теряются в плоском dict (суффиксуются).
|
||||
res = compute_report_confidence(
|
||||
component_confidences=["high", "medium", "low"], advisory=False
|
||||
)
|
||||
d = res.as_dict()
|
||||
component_keys = [k for k in d["factors"] if k.startswith("component")]
|
||||
assert len(component_keys) == 3 # ни один не перезаписан
|
||||
assert json.loads(json.dumps(d, ensure_ascii=False)) == d
|
||||
Loading…
Add table
Reference in a new issue