diff --git a/backend/app/services/forecasting/recommendation.py b/backend/app/services/forecasting/recommendation.py index 1b55056e..fb06020b 100644 --- a/backend/app/services/forecasting/recommendation.py +++ b/backend/app/services/forecasting/recommendation.py @@ -344,7 +344,10 @@ def _usp_from_deficits( `all_ranked` уже DESC по deficit_index → первые top_k = сильнейшая недообеспеченность. Каждый → USP-пункт «Дефицит формата X — стройте его» + §16 reason. Пустой вход → - пустой список (НЕ фабрикуем USP). Берём только ячейки с deficit_index не None. + пустой список (НЕ фабрикуем USP). Берём ТОЛЬКО ячейки с genuine-дефицитом + (deficit_index > 0): «стройте его» оправдан лишь для реально недонасыщенного формата. + Затоварка (di ≤ 0) — не белое пятно, в USP не идёт; если в top_k все di ≤ 0 → []. + Зеркало product_scoring._count_positive_usp (тот же gate di > 0). Args: all_ranked: DESC-проекция ранкинга (live segment-dict'ы). @@ -358,7 +361,7 @@ def _usp_from_deficits( usp: list[dict[str, Any]] = [] for seg in all_ranked[:top_k]: di = seg.get("deficit_index") - if di is None: + if di is None or di <= 0: continue usp.append( { diff --git a/backend/app/services/forecasting/report_assembler.py b/backend/app/services/forecasting/report_assembler.py index a255826e..fcebe693 100644 --- a/backend/app/services/forecasting/report_assembler.py +++ b/backend/app/services/forecasting/report_assembler.py @@ -179,30 +179,31 @@ def _analog_count(analyze: dict[str, Any], market_metrics: dict[str, Any] | None def _domrf_coverage(analyze: dict[str, Any], supply_layers: dict[str, Any] | None) -> float | None: """Покрытие domrf↔objective ∈ [0,1] — для domrf_coverage #990. PURE. - Главный sparse-риск проекта (~2.5%). Источники по приоритету: - • `supply_layers.domrf_coverage` (если §9.3-слой его несёт явно). - • `analyze.market_data_coverage_pct` (доля конкурентов с ценой из objective, %) — - прокси покрытия objective-данными; делим на 100 → доля. + Главный sparse-риск проекта (~2.5%). Источники по приоритету (единица ЯВНАЯ + per-branch — НЕ угадываем по величине, иначе настоящий sub-1% процент типа 0.8% + спутался бы с долей 0.8 = 80% и инфлировал бы confidence в exactly near-zero кейсе, + который §15 призван флагать): + • `supply_layers.domrf_coverage` — уже ДОЛЯ ∈ [0,1] (0.025) → берём как есть. + • `analyze.market_data_coverage_pct` — всегда ПРОЦЕНТ (2.5 == 2.5%) → /100 → доля. Нет сигнала → None (#990 → тянет в low: слой §9.3 недооценён). """ if supply_layers is not None: coverage = supply_layers.get("domrf_coverage") if isinstance(coverage, (int, float)) and not isinstance(coverage, bool): - return _coverage_to_fraction(float(coverage)) + return _clamp_fraction(float(coverage)) pct = analyze.get("market_data_coverage_pct") if isinstance(pct, (int, float)) and not isinstance(pct, bool): - return _coverage_to_fraction(float(pct)) + return _clamp_fraction(float(pct) / 100.0) return None -def _coverage_to_fraction(value: float) -> float: - """Привести покрытие к доле ∈ [0,1]: значения >1 трактуем как проценты. PURE. +def _clamp_fraction(value: float) -> float: + """Зажать долю покрытия в [0,1] (защита от грязных данных). PURE. - supply_layers может отдать долю (0.025), analyze — проценты (2.5). Эвристика: >1 → - делим на 100. Зажимаем в [0,1] (защита от грязных данных). + Единица приводится у источника (`_domrf_coverage`: percent-ветка делит на 100, + fraction-ветка — как есть) — здесь только clamp, БЕЗ догадок про percent-vs-fraction. """ - fraction = value / 100.0 if value > 1.0 else value - return max(0.0, min(1.0, fraction)) + return max(0.0, min(1.0, value)) def _history_months( diff --git a/backend/tests/services/forecasting/test_confidence_engine.py b/backend/tests/services/forecasting/test_confidence_engine.py index 1a52bb60..f14c5991 100644 --- a/backend/tests/services/forecasting/test_confidence_engine.py +++ b/backend/tests/services/forecasting/test_confidence_engine.py @@ -154,6 +154,14 @@ class TestCoverageFactor: assert f.level == "low" assert "неизвестно" in f.note + def test_sub_one_percent_fraction_stays_low_not_inflated(self) -> None: + # BUG #3 регрессия: 0.8% покрытия как доля = 0.008 → low (sparse-риск виден). + # До фикта report_assembler отдавал бы 0.8 → high (мнимые 80% покрытия) — + # инфляция confidence в exactly near-zero кейсе, который §15 призван флагать. + f = _coverage_factor(0.008) + assert f.level == "low" + assert "0.8%" in f.note + # ── _aggregate — weakest-link MIN ────────────────────────────────────────────── diff --git a/backend/tests/services/forecasting/test_recommendation.py b/backend/tests/services/forecasting/test_recommendation.py index 41903812..bbde74f9 100644 --- a/backend/tests/services/forecasting/test_recommendation.py +++ b/backend/tests/services/forecasting/test_recommendation.py @@ -671,6 +671,33 @@ class TestUspFromDeficits: def test_empty_input_empty_list(self) -> None: assert _usp_from_deficits([], horizon_months=12) == [] + def test_all_negative_deficits_emit_no_usp(self) -> None: + # Затоварка: ВСЕ сегменты в surplus (di < 0) → честное «нет белых пятен», не + # «стройте его» для формата, которого и так избыток (BUG #1). + ranked = [ + _seg("1-Студия", -0.1), + _seg("2-1-к", -0.4), + _seg("3-2-к", -0.9), + ] + assert _usp_from_deficits(ranked, horizon_months=12) == [] + + def test_zero_deficit_not_emitted(self) -> None: + # di == 0 (баланс) — не дефицит → не USP (gate строго di > 0, зеркало + # product_scoring._count_positive_usp). + assert _usp_from_deficits([_seg("2-1-к", 0.0)], horizon_months=12) == [] + + def test_mixed_only_positive_deficits_returned(self) -> None: + # Смесь дефицит/затоварка → только positive-deficit формат становится USP. + ranked = [ + _seg("1-Студия", 0.6), + _seg("2-1-к", -0.2), + _seg("3-2-к", 0.3), + _seg("4-3-к", -0.5), + ] + usp = _usp_from_deficits(ranked, horizon_months=12, top_k=4) + assert [u["segment"] for u in usp] == ["1-Студия", "3-2-к"] + assert all(u["deficit_index"] > 0 for u in usp) + # ── §10.4: _commercial_signal (degraded-honest, never crash) ────────────────── @@ -850,8 +877,7 @@ class TestOverlayForecast983Additions: ) assert out["ranked_segments"] assert all( - "предложение участка НЕ учтено" in s["reason"]["why"] - for s in out["ranked_segments"] + "предложение участка НЕ учтено" in s["reason"]["why"] for s in out["ranked_segments"] ) def test_overlay_with_983_fields_validates_against_schema(self) -> None: diff --git a/backend/tests/services/forecasting/test_report_assembler.py b/backend/tests/services/forecasting/test_report_assembler.py index b4fca208..150a53a5 100644 --- a/backend/tests/services/forecasting/test_report_assembler.py +++ b/backend/tests/services/forecasting/test_report_assembler.py @@ -605,6 +605,18 @@ class TestSignalExtractionHelpers: assert _domrf_coverage({"market_data_coverage_pct": 75.0}, None) == 0.75 assert _domrf_coverage({}, None) is None + def test_domrf_coverage_sub_one_percent_not_read_as_fraction(self) -> None: + # BUG #3: настоящий sub-1% процент (0.8% покрытия) — percent-ветка делит на 100 + # → 0.008, НЕ 0.8 (что было бы 80% и инфлировало бы confidence в exactly + # near-zero кейсе, который §15 призван флагать). + assert _domrf_coverage({"market_data_coverage_pct": 0.8}, None) == 0.008 + # Каноничный sparse-сигнал проекта ~2.5%. + assert _domrf_coverage({"market_data_coverage_pct": 2.5}, None) == 0.025 + + def test_domrf_coverage_fraction_source_unchanged(self) -> None: + # supply_layers — уже доля: 0.025 остаётся 0.025 (НЕ делим на 100). + assert _domrf_coverage({}, {"domrf_coverage": 0.025}) == 0.025 + def test_history_months_from_window(self) -> None: assert _history_months({"window_months": 18}, []) == 18 assert _history_months(None, []) is None