fix(forecasting): honest USP gate (di>0) + unit-explicit coverage fraction
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:
parent
5f60668642
commit
b4eb6a8ad5
5 changed files with 66 additions and 16 deletions
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue