feat(forecasting): §15 confidence engine v2 (#990, 955-A4) #1020

Merged
bot-backend merged 1 commit from feat/955a4-confidence-engine-v2 into main 2026-06-03 08:41:07 +00:00
3 changed files with 853 additions and 0 deletions

View file

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

View 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…) + СЫРЫЕ счётчики качества данных (число сделок, число ЖК-аналогов,
покрытие domrfobjective, глубина истории, шок-окно) в ОДИН отчётный уровень
High/Medium/Low + RU-причину, которая ЯВНО НАЗЫВАЕТ, ЧТО утянуло уровень вниз с
РЕАЛЬНЫМИ числами («Low потому что 7 сделок за 6 мес / только 1 ЖК-аналог /
покрытие domrfobjective 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) high3 / medium2 / 1 low
(точная копия _CONF_HIGH_MIN_OBJ=3 / _CONF_MEDIUM_MIN_OBJ=2; «1 ЖК» ТЗ §15-пример).
domrf_coverage главный риск проекта (domrfobjective ~2.5%, см. market_metrics
docstring): низкое покрытие скрытый/будущий слой §9.3 недооценён.
history_months зеркало §9.6 _CONF_HIGH_MIN_OBS=24 (2 года) / _MIN_OBS=8: короткий
ряд связь ratesales / тренды не установлены.
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-пример «покрытие domrfobjective 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: доля domrfobjective [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,
)

View 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 покрытие domrfobjective в % в ноте (структурный §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