fix(forecasting): honest USP gate (di>0) + unit-explicit coverage fraction
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m16s
Deploy / build-worker (push) Successful in 2m31s
Deploy / deploy (push) Successful in 1m3s

Found by read-only services audit.
- recommendation._usp_from_deficits: skip di<=0 so «стройте его» is never emitted
  for OVERSUPPLIED formats; all-surplus top-K → [] (no white-space niches).
  Aligns with product_scoring._count_positive_usp (di>0). Was: «Дефицит формата
  X — стройте его» for a surplus format, reaching PDF/Excel USP-ниши.
- report_assembler._domrf_coverage: drop ambiguous >1.0 percent-guess; normalize
  per-branch (analyze pct /100, supply_layers fraction as-is). Sub-1% coverage
  (0.8%) no longer read as 80% → no inflated confidence in the near-zero-coverage
  case §15 flags. tests for both + end-to-end no-inflation. 241+148 pass.
This commit is contained in:
Light1YT 2026-06-04 13:25:25 +05:00
parent 5f60668642
commit b4eb6a8ad5
5 changed files with 66 additions and 16 deletions

View file

@ -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(
{

View file

@ -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(

View file

@ -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 ──────────────────────────────────────────────

View file

@ -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:

View file

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