Files
fxhnt/tests/unit/test_edge_decay.py

146 lines
5.7 KiB
Python

import json
import math
import numpy as np
import pytest
from fxhnt.domain.edge_decay import PageHinkleyDecay, WindowedEdgeHealth
def test_stationary_positive_never_kills():
# No false-positive on a clean positive-drift signal at the production default lam.
# Noise 0.003 < the lam=0.20 cumulative-deviation budget; proves the detector does not
# kill on ordinary variance (production lam calibration is validated separately, by the
# forward track). Deterministic via fixed seed.
ph = PageHinkleyDecay(reenter_days=10) # default lam=0.20
rng = np.random.default_rng(0)
alive = [ph.update(0.003 + 0.003 * float(rng.standard_normal())) for _ in range(500)]
assert all(alive)
assert not ph.killed
def test_mean_flip_kills():
ph = PageHinkleyDecay(lam=0.05, reenter_days=10)
for _ in range(200):
ph.update(0.002)
killed_at = None
for i in range(200):
if not ph.update(-0.01):
killed_at = i
break
assert killed_at is not None
assert ph.killed
def test_rearm_after_hysteresis():
ph = PageHinkleyDecay(lam=0.01, reenter_days=5)
for _ in range(50):
ph.update(0.001)
for _ in range(50):
if not ph.update(-0.02):
break
assert ph.killed
states = [ph.update(0.0) for _ in range(5)]
assert states[-1] is True
assert not ph.killed
def test_to_from_dict_roundtrip():
ph = PageHinkleyDecay(lam=0.05)
for _ in range(20):
ph.update(0.001)
d = json.loads(json.dumps(ph.to_dict()))
ph2 = PageHinkleyDecay.from_dict(d)
assert ph2.to_dict() == ph.to_dict()
# --- WindowedEdgeHealth: scale-invariant, bounded, continuously-recovering edge health -----------
def _deterministic_series(n: int, mu: float, amp: float = 0.0, *, freq: float = 0.37) -> list[float]:
"""Fixed pseudo-series: constant drift `mu` + a deterministic sine oscillation (NO RNG / time).
Reproducible across runs and across scaling."""
return [mu + amp * math.sin(freq * i) for i in range(n)]
def _health_seq(detector: WindowedEdgeHealth, series: list[float]) -> list[float]:
out = []
for r in series:
detector.update(r)
out.append(detector.health())
return out
def test_windowed_health_scale_invariant():
# Property 1: t-stat is unitless -> feeding [r*1000] yields the SAME health sequence as the base.
base = _deterministic_series(300, mu=0.001, amp=0.003)
scaled = [r * 1000.0 for r in base]
seq_base = _health_seq(WindowedEdgeHealth(), base)
seq_scaled = _health_seq(WindowedEdgeHealth(), scaled)
assert len(seq_base) == len(seq_scaled)
for a, b in zip(seq_base, seq_scaled):
assert a == pytest.approx(b, rel=1e-9, abs=1e-12)
def test_windowed_health_bounded_on_stationary_positive():
# Property 2: a long STATIONARY positive-mean series stays HIGH (>= ~0.8 most of the time);
# it must NOT pin at 0 like the inception-cumulative PH eventually does.
series = _deterministic_series(1000, mu=0.0015, amp=0.003)
seq = _health_seq(WindowedEdgeHealth(), series)
settled = seq[100:] # past bootstrap / warmup
high = sum(1 for h in settled if h >= 0.8)
assert high / len(settled) >= 0.8
assert min(settled) > 0.0 # never pinned to floor
def test_windowed_health_detects_sustained_decay():
# Property 3: positive regime then a sustained negative-mean regime -> health -> theta_min
# within ~window days of the flip.
window = 63
pos = _deterministic_series(200, mu=0.002, amp=0.002)
neg = _deterministic_series(200, mu=-0.004, amp=0.002)
det = WindowedEdgeHealth(window=window)
_health_seq(det, pos)
assert det.health() > 0.8 # healthy at the flip
seq_after = _health_seq(det, neg)
assert seq_after[window] < 0.2 # decayed toward theta_min within `window` days
assert min(seq_after) == pytest.approx(0.0, abs=1e-9) # reaches the floor
def test_windowed_health_rearms_after_recovery():
# Property 4: after decay, recovering to positive returns climbs health back toward 1.0
# within ~window days (no consecutive-day requirement).
window = 63
det = WindowedEdgeHealth(window=window)
_health_seq(det, _deterministic_series(200, mu=0.002, amp=0.002)) # healthy
_health_seq(det, _deterministic_series(200, mu=-0.004, amp=0.002)) # decay
assert det.health() < 0.2 # decayed
seq_recover = _health_seq(det, _deterministic_series(200, mu=0.003, amp=0.002))
assert seq_recover[window] > 0.8 # re-armed within `window` days
assert max(seq_recover) == pytest.approx(1.0)
def test_windowed_health_bootstrap_is_one():
# Property 5: health == 1.0 for the first `min_obs` updates (don't cut a young edge).
min_obs = 10
det = WindowedEdgeHealth(min_obs=min_obs)
# Even a clearly-negative young series (with variance, so it has a real t-stat) stays at 1.0
# while fewer than min_obs observations have accumulated.
neg = _deterministic_series(min_obs + 1, mu=-0.05, amp=0.01)
for i in range(min_obs - 1):
det.update(neg[i])
assert det.health() == 1.0, f"bootstrap broke at obs {i + 1}"
# Reaching min_obs observations ends the bootstrap; a negative series is no longer 1.0.
det.update(neg[min_obs - 1])
assert det.n == min_obs
assert det.health() < 1.0
def test_windowed_health_to_from_dict_roundtrip():
det = WindowedEdgeHealth(window=40, t_full=0.6, t_dead=-1.5, theta_min=0.05, min_obs=8)
for r in _deterministic_series(50, mu=0.001, amp=0.002):
det.update(r)
d = json.loads(json.dumps(det.to_dict()))
det2 = WindowedEdgeHealth.from_dict(d)
assert det2.to_dict() == det.to_dict()
assert det2.health() == det.health()