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