6eff8aab0f
Risponde alla code review di 7dc2fda:
- Valida percentile in [0,1] e 0 < min_days < target_days, raise
ValueError quando out-of-range. Fail-fast invece di IndexError o
silent wrong result.
- Aggiunge test boundary esattamente a min_days*96 e target_days*96
(spec §9.1 item 9 era mancante).
- Aggiunge 4 test sulle nuove guards.
- Fix typo docstring (Determinismic → Deterministic).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
6.7 KiB
Python
222 lines
6.7 KiB
Python
"""TDD per :mod:`cerbero_bite.core.adaptive_threshold`.
|
|
|
|
Spec: ``docs/superpowers/specs/2026-05-08-iv-rv-adaptive-gate-design.md``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
|
|
from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Warmup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_empty_history_returns_none() -> None:
|
|
out = compute_adaptive_threshold(
|
|
history=[],
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
assert out is None
|
|
|
|
|
|
def test_history_under_one_day_returns_none() -> None:
|
|
out = compute_adaptive_threshold(
|
|
history=[Decimal("3")] * 50, # 50 tick < 96
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
assert out is None
|
|
|
|
|
|
def test_history_exactly_one_day_returns_percentile() -> None:
|
|
history = [Decimal(i) / Decimal("10") for i in range(96)] # 0.0..9.5
|
|
out = compute_adaptive_threshold(
|
|
history=history,
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
# P25 di [0.0..9.5] passo 0.1 con method='linear': k=23.75, val ≈ 2.375
|
|
assert out is not None
|
|
assert Decimal("2.3") < out < Decimal("2.5")
|
|
|
|
|
|
def _ramp(n: int, base: Decimal = Decimal("1")) -> list[Decimal]:
|
|
"""Ramp lineare 1, 2, 3, ... per testare in modo predicibile il P25."""
|
|
return [base * Decimal(i + 1) for i in range(n)]
|
|
|
|
|
|
def test_below_min_days_uses_full_history() -> None:
|
|
# 5 giorni di storia (5*96=480 tick), min_days=30, target=60.
|
|
# Window = full history.
|
|
history = _ramp(480)
|
|
out = compute_adaptive_threshold(
|
|
history=history,
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
# P25 di ramp [1..480] = ~120.75 (k=479*0.25=119.75, ramp[119]=120, ramp[120]=121)
|
|
assert out is not None
|
|
assert Decimal("120") <= out <= Decimal("121")
|
|
|
|
|
|
def test_between_min_and_target_uses_min_window() -> None:
|
|
# 50 giorni di storia (4800 tick), min_days=30, target=60. Window = ultimi 30g.
|
|
history = _ramp(4800) # values 1..4800
|
|
out = compute_adaptive_threshold(
|
|
history=history,
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
# Window = ultimi 30*96=2880, valori 1921..4800, P25 ≈ 2640
|
|
assert out is not None
|
|
assert Decimal("2630") <= out <= Decimal("2650")
|
|
|
|
|
|
def test_above_target_uses_target_window() -> None:
|
|
# 100 giorni (9600 tick), target=60. Window = ultimi 60g.
|
|
history = _ramp(9600) # values 1..9600
|
|
out = compute_adaptive_threshold(
|
|
history=history,
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
# Window = ultimi 5760, valori 3841..9600, P25 ≈ 5280
|
|
assert out is not None
|
|
assert Decimal("5270") <= out <= Decimal("5290")
|
|
|
|
|
|
def test_floor_binding_overrides_low_percentile() -> None:
|
|
history = [Decimal("0.5")] * 200
|
|
out = compute_adaptive_threshold(
|
|
history=history,
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("3"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
assert out == Decimal("3")
|
|
|
|
|
|
def test_floor_not_binding_returns_percentile() -> None:
|
|
history = [Decimal("5")] * 200
|
|
out = compute_adaptive_threshold(
|
|
history=history,
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
assert out == Decimal("5")
|
|
|
|
|
|
def test_median_percentile_returns_p50() -> None:
|
|
history = _ramp(200)
|
|
out = compute_adaptive_threshold(
|
|
history=history,
|
|
percentile=Decimal("0.5"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
# P50 di [1..200] = (200+1)/2 = 100.5
|
|
assert out is not None
|
|
assert Decimal("100") <= out <= Decimal("101")
|
|
|
|
|
|
def test_exactly_min_days_uses_min_window() -> None:
|
|
"""Boundary: history == min_days*96 → window is min_days (per spec 9.1 item 9)."""
|
|
history = _ramp(30 * 96) # exactly 2880 ticks
|
|
out = compute_adaptive_threshold(
|
|
history=history,
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
# Window = last 2880 = all history; P25 of ramp [1..2880] ≈ 720.75
|
|
assert out is not None
|
|
assert Decimal("720") <= out <= Decimal("721")
|
|
|
|
|
|
def test_exactly_target_days_uses_target_window() -> None:
|
|
"""Boundary: history == target_days*96 → window is target_days."""
|
|
history = _ramp(60 * 96) # exactly 5760 ticks
|
|
out = compute_adaptive_threshold(
|
|
history=history,
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
# Window = last 5760 = all history; P25 of ramp [1..5760] ≈ 1440.75
|
|
assert out is not None
|
|
assert Decimal("1440") <= out <= Decimal("1441")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Input validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_invalid_percentile_above_one_raises() -> None:
|
|
with pytest.raises(ValueError, match="percentile must be in"):
|
|
compute_adaptive_threshold(
|
|
history=[Decimal("1")] * 200,
|
|
percentile=Decimal("1.5"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
|
|
|
|
def test_invalid_percentile_negative_raises() -> None:
|
|
with pytest.raises(ValueError, match="percentile must be in"):
|
|
compute_adaptive_threshold(
|
|
history=[Decimal("1")] * 200,
|
|
percentile=Decimal("-0.1"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=30,
|
|
target_days=60,
|
|
)
|
|
|
|
|
|
def test_invalid_window_inverted_raises() -> None:
|
|
with pytest.raises(ValueError, match="min_days < target_days"):
|
|
compute_adaptive_threshold(
|
|
history=[Decimal("1")] * 200,
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=60,
|
|
target_days=30,
|
|
)
|
|
|
|
|
|
def test_invalid_window_zero_raises() -> None:
|
|
with pytest.raises(ValueError, match="min_days < target_days"):
|
|
compute_adaptive_threshold(
|
|
history=[Decimal("1")] * 200,
|
|
percentile=Decimal("0.25"),
|
|
absolute_floor=Decimal("0"),
|
|
min_days=0,
|
|
target_days=60,
|
|
)
|