"""TDD per :mod:`cerbero_bite.core.adaptive_threshold`. Spec: ``docs/superpowers/specs/2026-05-08-iv-rv-adaptive-gate-design.md``. La funzione è una pura statistica: riceve già la finestra di valori scelta dal caller e il numero di giorni distinti coperti dalla storia disponibile (``n_days``), e restituisce ``max(percentile, floor)`` o ``None`` durante il warmup hard. La selezione della finestra (target_days vs min_days vs intera storia) è responsabilità del caller (repository + entry_cycle). """ from __future__ import annotations from decimal import Decimal import pytest from cerbero_bite.core.adaptive_threshold import compute_adaptive_threshold # --------------------------------------------------------------------------- # Warmup hard: nessun giorno disponibile # --------------------------------------------------------------------------- def test_n_days_zero_returns_none() -> None: """Storia vuota o nessun giorno coperto → warmup hard.""" out = compute_adaptive_threshold( history=[], n_days=0, percentile=Decimal("0.25"), absolute_floor=Decimal("0"), ) assert out is None def test_n_days_zero_with_values_still_returns_none() -> None: """Difensivo: se il caller passa n_days=0 ma valori non vuoti, warmup hard vince comunque (gate disabilitato).""" out = compute_adaptive_threshold( history=[Decimal("3")] * 10, n_days=0, percentile=Decimal("0.25"), absolute_floor=Decimal("0"), ) assert out is None def test_empty_history_with_positive_n_days_returns_none() -> None: """Difensivo: history vuota anche con n_days>0 → None.""" out = compute_adaptive_threshold( history=[], n_days=5, percentile=Decimal("0.25"), absolute_floor=Decimal("0"), ) assert out is None # --------------------------------------------------------------------------- # Calcolo percentile sulla finestra ricevuta # --------------------------------------------------------------------------- def test_n_days_one_returns_percentile_of_history() -> None: """Singolo giorno con tick a 15 min (96 valori): P25 standard.""" history = [Decimal(i) / Decimal("10") for i in range(96)] # 0.0..9.5 out = compute_adaptive_threshold( history=history, n_days=1, percentile=Decimal("0.25"), absolute_floor=Decimal("0"), ) # 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 test_window_chosen_by_caller_is_used_verbatim() -> None: """La funzione NON fa slicing: usa esattamente la history ricevuta.""" history = [Decimal(i) for i in range(1, 201)] # 1..200 out = compute_adaptive_threshold( history=history, n_days=30, percentile=Decimal("0.5"), absolute_floor=Decimal("0"), ) # P50 di [1..200] = (200+1)/2 = 100.5 assert out is not None assert Decimal("100") <= out <= Decimal("101") def test_mixed_cadence_window_no_special_treatment() -> None: """Mix di valori (es. backfill daily + tick live) trattato come una distribuzione qualunque: il caller ha già scelto la finestra; la funzione calcola il percentile sui valori ricevuti uno-a-uno.""" # 30 valori "daily backfill" (uno per giorno) + 96 tick "live" history = [Decimal("5")] * 30 + [Decimal("8")] * 96 out = compute_adaptive_threshold( history=history, n_days=31, percentile=Decimal("0.25"), absolute_floor=Decimal("0"), ) # Sorted: 30 ×5, 96 ×8. P25 a indice 0.25*125 = 31.25 → tra 5 e 8. # NumPy linear: sorted_v[31]=8, sorted_v[32]=8 → 8. # Verifica solo l'estremo superiore della famiglia di valori sorted. assert out is not None assert out in (Decimal("5"), Decimal("8")) # --------------------------------------------------------------------------- # Floor binding # --------------------------------------------------------------------------- def test_floor_binding_overrides_low_percentile() -> None: history = [Decimal("0.5")] * 200 out = compute_adaptive_threshold( history=history, n_days=30, percentile=Decimal("0.25"), absolute_floor=Decimal("3"), ) assert out == Decimal("3") def test_floor_not_binding_returns_percentile() -> None: history = [Decimal("5")] * 200 out = compute_adaptive_threshold( history=history, n_days=30, percentile=Decimal("0.25"), absolute_floor=Decimal("0"), ) assert out == Decimal("5") # --------------------------------------------------------------------------- # 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, n_days=10, percentile=Decimal("1.5"), absolute_floor=Decimal("0"), ) def test_invalid_percentile_negative_raises() -> None: with pytest.raises(ValueError, match="percentile must be in"): compute_adaptive_threshold( history=[Decimal("1")] * 200, n_days=10, percentile=Decimal("-0.1"), absolute_floor=Decimal("0"), ) def test_invalid_negative_n_days_raises() -> None: with pytest.raises(ValueError, match="n_days must be >= 0"): compute_adaptive_threshold( history=[Decimal("1")] * 10, n_days=-1, percentile=Decimal("0.25"), absolute_floor=Decimal("0"), )