#![allow(deprecated)]
#![allow(clippy::float_cmp)]
use alice_analytics::anomaly::{
CompositeDetector, EwmaDetector, MadDetector, StreamingMedian, ZScoreDetector,
};
use alice_analytics::pipeline::RingBuffer;
use alice_analytics::privacy::{
LaplaceNoise, PrivacyBudget, PrivateAggregator, RandomizedResponse,
};
use alice_analytics::sketch::{CountMinSketch, DDSketch, HyperLogLog, Mergeable};
use alice_analytics::stats::{iqr, percentile_rank, quantile_sorted, StreamingStats};
use alice_analytics::streaming_ops::{
ChangeRate, ExponentialMovingAverage, LinearRegression, SimpleMovingAverage,
};
use alice_analytics::window::{HierarchicalRollup, SlidingWindow, TumblingWindow};
#[test]
#[should_panic(expected = "SlidingWindow needs N > 0")]
fn sliding_window_of_width_zero_panics_on_push() {
let mut w = SlidingWindow::<0>::new();
w.push(1.0);
}
#[test]
#[should_panic(expected = "SimpleMovingAverage needs N > 0")]
fn simple_moving_average_of_width_zero_panics_on_observe() {
let mut sma = SimpleMovingAverage::<0>::new();
sma.observe(1.0);
}
#[test]
#[should_panic(expected = "RingBuffer needs N > 0")]
fn ring_buffer_of_capacity_zero_panics_on_push() {
let mut rb = RingBuffer::<u8, 0>::new();
let _ = rb.push(1);
}
#[test]
fn zero_width_buffers_construct_and_read_as_empty() {
let w = SlidingWindow::<0>::new();
assert_eq!(w.len(), 0);
assert!(w.is_empty());
assert_eq!(w.mean(), 0.0);
assert_eq!(w.sum(), 0.0);
let sma = SimpleMovingAverage::<0>::new();
assert_eq!(sma.value(), 0.0);
assert!(sma.is_empty());
let mut rb = RingBuffer::<u8, 0>::new();
assert_eq!(rb.capacity(), 0, "no usable slot");
assert!(
rb.is_full(),
"a buffer with no usable slot is full from the start"
);
assert!(rb.is_empty());
assert_eq!(rb.len(), 0);
assert_eq!(rb.pop(), None);
let mut rb1 = RingBuffer::<u8, 1>::new();
assert_eq!(rb1.capacity(), 0);
assert!(!rb1.push(7), "a full buffer refuses the item");
assert_eq!(rb1.dropped(), 1);
}
#[test]
#[should_panic(expected = "alpha must be in (0.0, 1.0]")]
fn ema_rejects_a_zero_smoothing_factor() {
let _ = ExponentialMovingAverage::new(0.0);
}
#[test]
#[should_panic(expected = "alpha must be in (0.0, 1.0]")]
fn ema_rejects_a_smoothing_factor_above_one() {
let _ = ExponentialMovingAverage::new(1.000_001);
}
#[test]
#[should_panic(expected = "alpha must be in (0.0, 1.0]")]
fn ema_rejects_a_nan_smoothing_factor() {
let _ = ExponentialMovingAverage::new(f64::NAN);
}
#[test]
#[should_panic(expected = "span must be > 0")]
fn ema_rejects_a_zero_span() {
let _ = ExponentialMovingAverage::from_span(0);
}
#[test]
fn ema_accepts_the_boundary_factor_of_one() {
let mut ema = ExponentialMovingAverage::new(1.0);
ema.observe(3.0);
ema.observe(11.0);
assert_eq!(ema.value(), 11.0);
let tiny = ExponentialMovingAverage::from_span(u64::MAX);
assert!(tiny.alpha() > 0.0 && tiny.alpha() <= 1.0);
}
#[test]
fn a_non_finite_sample_is_counted_on_its_own_and_touches_nothing_else() {
let mut dd = DDSketch::new(0.01);
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
dd.insert(v);
}
assert_eq!(dd.non_finite(), 3);
assert_eq!(dd.count(), 0, "a non-finite sample is not summarised");
assert_eq!(dd.sum(), 0.0, "and cannot poison the sum");
assert_eq!(dd.mean(), 0.0);
assert_eq!(
dd.min(),
f64::INFINITY,
"the empty-state sentinel is untouched"
);
assert_eq!(dd.max(), f64::NEG_INFINITY);
assert_eq!(dd.quantile(0.5), 0.0);
let mut dd = DDSketch::new(0.01);
for v in [1.0, 2.0, 3.0] {
dd.insert(v);
}
dd.insert(f64::NAN);
dd.insert(f64::INFINITY);
dd.insert(f64::NEG_INFINITY);
assert_eq!(dd.count(), 3);
assert_eq!(dd.non_finite(), 3);
assert_eq!(dd.sum(), 6.0);
assert_eq!(
dd.min(),
1.0,
"an infinity must not widen the observed range"
);
assert_eq!(dd.max(), 3.0);
for (q, want) in [(0.0f64, 1.0f64), (0.5, 2.0), (1.0, 3.0)] {
let got = dd.quantile(q);
assert!(
(got - want).abs() <= 0.01 * want,
"quantile({q}) = {got}, expected {want} within alpha"
);
}
let mut a = DDSketch::new(0.01);
let mut b = DDSketch::new(0.01);
a.insert(f64::NAN);
a.insert(1.0);
b.insert(f64::INFINITY);
b.insert(2.0);
a.merge(&b);
assert_eq!((a.count(), a.non_finite()), (2, 2));
a.clear();
assert_eq!((a.count(), a.non_finite()), (0, 0));
}
fn top_edge_representative(alpha: f64, hi: f64) -> f64 {
let gamma = (1.0 + alpha) / (1.0 - alpha);
2.0 * hi / (gamma + 1.0)
}
#[test]
fn the_largest_finite_magnitudes_are_filed_in_the_edge_bins() {
let mut dd = DDSketch::new(0.01);
dd.insert(f64::MAX);
dd.insert(f64::MIN_POSITIVE);
dd.insert(-f64::MAX);
assert_eq!(dd.count(), 3);
let (_, hi) = dd.accurate_range();
let top = top_edge_representative(0.01, hi);
assert!((dd.quantile(1.0) / top - 1.0).abs() < 1e-9);
assert!((dd.quantile(0.0) / -top - 1.0).abs() < 1e-9);
}
#[test]
fn a_zero_relative_accuracy_collapses_the_range_instead_of_overflowing() {
let mut dd = DDSketch::new(0.0);
dd.insert(2.0);
dd.insert(0.5);
assert_eq!(dd.count(), 2);
assert_eq!(dd.alpha(), 0.0);
assert_eq!(dd.accurate_range(), (1.0, 1.0));
assert!(dd.quantile(0.5).is_finite());
}
#[test]
fn quantile_arguments_outside_the_unit_interval_are_clamped() {
let mut dd = DDSketch::new(0.01);
for v in 1..=100 {
dd.insert(f64::from(v));
}
for q in [-1.0f64, 2.0, f64::NAN] {
let v = dd.quantile(q);
assert!(
(1.0..=100.0).contains(&v),
"quantile({q}) returned {v}, outside the observed [1, 100]"
);
}
}
#[test]
fn empty_estimators_report_their_neutral_value() {
let dd = DDSketch::new(0.01);
assert_eq!(dd.count(), 0);
assert_eq!(dd.quantile(0.5), 0.0);
let hll = HyperLogLog::new();
assert_eq!(hll.cardinality(), 0.0);
let cm = CountMinSketch::new();
assert_eq!(cm.estimate(&7u64), 0);
let st = StreamingStats::new();
assert_eq!(st.count(), 0);
assert_eq!(st.mean(), 0.0);
assert_eq!(st.variance(), 0.0);
assert_eq!(st.std_dev(), 0.0);
assert_eq!(st.skewness(), 0.0);
assert_eq!(st.kurtosis(), 0.0);
let mut med = StreamingMedian::new();
assert_eq!(med.count(), 0);
assert_eq!(med.median(), 0.0);
let z = ZScoreDetector::new(3.0);
assert_eq!(z.variance(), 0.0);
assert_eq!(z.std_dev(), 0.0);
assert_eq!(z.z_score(1.0), f64::INFINITY);
assert!(!z.is_anomaly(1e9));
let mut mad = MadDetector::new(3.0);
assert_eq!(mad.mad(), 0.0);
assert!(!mad.is_anomaly(1e9));
let agg = PrivateAggregator::new(1.0);
assert_eq!(agg.estimate_mean(), 0.0);
assert_eq!(agg.estimate_sum(), 0.0);
assert_eq!(agg.standard_error(), f64::INFINITY);
}
#[test]
fn sorted_slice_statistics_handle_empty_and_short_inputs() {
assert_eq!(quantile_sorted(&[], 0.5), 0.0);
assert_eq!(percentile_rank(&[], 1.0), 0.0);
assert_eq!(quantile_sorted(&[42.0], 0.5), 42.0);
assert!(iqr(&[]).is_none());
assert!(iqr(&[1.0, 2.0, 3.0]).is_none());
assert!(iqr(&[1.0, 2.0, 3.0, 4.0]).is_some());
for q in [-1.0f64, 2.0, f64::NAN] {
let v = quantile_sorted(&[1.0, 2.0, 3.0], q);
assert!((1.0..=3.0).contains(&v), "q {q} gave {v}");
}
}
#[test]
fn window_boundaries_saturate_at_the_end_of_the_timestamp_range() {
let mut tw = TumblingWindow::new(1_000, 0.01);
let mut emitted = 0u64;
for (v, ts) in [(1.0, u64::MAX), (2.0, u64::MAX), (3.0, u64::MAX)] {
if let Some(r) = tw.insert(v, ts) {
assert_eq!(r.end_ms, u64::MAX, "the boundary must saturate, not wrap");
assert!(
r.end_ms >= r.start_ms,
"a wrapped boundary gives end_ms {} < start_ms {}",
r.end_ms,
r.start_ms
);
emitted += r.event_count;
}
}
let r = tw.flush();
assert_eq!(r.end_ms, u64::MAX);
assert!(r.end_ms >= r.start_ms);
assert_eq!(
emitted + r.event_count,
3,
"three inserts must be accounted for exactly once"
);
let mut tw = TumblingWindow::new(1_000, 0.01);
assert!(
tw.insert(1.0, u64::MAX - 500).is_none(),
"the first sample opens the window"
);
assert!(
tw.insert(2.0, u64::MAX - 400).is_none(),
"u64::MAX - 400 is inside the window that opened at u64::MAX - 500; \
a wrapped boundary (499) would close it here"
);
assert!(tw.insert(3.0, u64::MAX).is_some());
let r = tw.flush();
assert_eq!(
r.event_count, 1,
"only the third sample is in the new window"
);
assert_eq!(r.end_ms, u64::MAX, "the boundary saturates");
let mut hr = HierarchicalRollup::new(1, 2, 3, 0.01);
hr.insert(1.0, u64::MAX);
hr.insert(2.0, u64::MAX);
assert_eq!(hr.level_count(), 3);
}
#[test]
fn a_zero_window_width_is_clamped_to_one() {
let mut tw = TumblingWindow::new(0, 0.01);
tw.insert(1.0, 5);
tw.insert(2.0, 9);
let r = tw.flush();
assert!(r.end_ms > r.start_ms);
}
#[test]
fn a_repeated_timestamp_repeats_the_last_change_rate() {
let mut cr = ChangeRate::new();
assert_eq!(cr.observe(1.0, 10), None, "the first sample has no rate");
assert_eq!(cr.observe(3.0, 20), Some(0.2));
assert_eq!(
cr.observe(999.0, 20),
Some(0.2),
"dt = 0 keeps the last rate"
);
assert_eq!(cr.observe(999.0, 5), Some(0.2));
}
#[test]
fn a_single_point_regression_reports_a_zero_slope() {
let mut lr = LinearRegression::new();
lr.observe(1.0, 2.0);
assert_eq!(lr.slope(), 0.0);
assert_eq!(lr.r_squared(), 0.0);
assert_eq!(lr.intercept(), 2.0, "the intercept is the single y");
assert_eq!(lr.predict(100.0), 2.0);
}
#[test]
fn a_zero_epsilon_gives_an_infinite_scale_without_wrapping() {
let mut ln = LaplaceNoise::with_seed(1.0, 0.0, 7);
assert_eq!(ln.scale(), f64::INFINITY);
let s = ln.sample();
assert!(s.is_infinite(), "scale inf gives an infinite sample: {s}");
let mut big = LaplaceNoise::with_seed(1e300, 1e-300, 7);
let v = big.privatize_int(0);
assert!(
v == i64::MAX || v == i64::MIN,
"expected saturation, got {v}"
);
}
#[test]
fn a_randomized_response_accepts_its_whole_domain_and_only_that() {
assert_eq!(RandomizedResponse::with_probability(0.5, 7).p_true(), 0.5);
assert_eq!(RandomizedResponse::with_probability(1.0, 7).p_true(), 1.0);
assert_eq!(RandomizedResponse::with_probability(0.75, 7).p_true(), 0.75);
assert_eq!(RandomizedResponse::estimate_proportion(0.5, 10, 5), 0.5);
assert_eq!(RandomizedResponse::estimate_proportion(0.75, 0, 0), 0.0);
}
#[test]
#[should_panic(expected = "p_true must be in [0.5, 1.0]")]
fn a_randomized_response_rejects_a_probability_below_the_domain() {
let _ = RandomizedResponse::with_probability(0.0, 7);
}
#[test]
#[should_panic(expected = "p_true must be in [0.5, 1.0]")]
fn a_randomized_response_rejects_a_probability_above_the_domain() {
let _ = RandomizedResponse::with_probability(2.0, 7);
}
#[test]
#[should_panic(expected = "p_true must be in [0.5, 1.0]")]
fn a_randomized_response_rejects_a_nan_probability() {
let _ = RandomizedResponse::with_probability(f64::NAN, 7);
}
#[test]
#[should_panic(expected = "p_true must be in [0.5, 1.0]")]
fn a_randomized_response_rejects_an_infinite_probability() {
let _ = RandomizedResponse::with_probability(f64::INFINITY, 7);
}
#[test]
fn a_malformed_spend_is_refused_and_leaves_the_ledger_untouched() {
for bad in [
-10.0f64,
-f64::MIN_POSITIVE,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NAN,
] {
let mut b = PrivacyBudget::new(1.0);
assert!(b.try_spend(0.25));
let (spent, remaining, queries) = (b.spent(), b.remaining(), b.query_count());
assert_eq!((spent, remaining, queries), (0.25, 0.75, 1));
assert!(!b.try_spend(bad), "{bad:e} must be refused");
assert_eq!(b.spent(), spent, "{bad:e} moved `spent`");
assert_eq!(b.remaining(), remaining, "{bad:e} moved `remaining`");
assert_eq!(b.query_count(), queries, "{bad:e} counted a query");
assert!(!b.is_exhausted(), "{bad:e} changed exhaustion");
}
}
#[test]
fn a_well_formed_spend_is_monotone_and_bounded() {
let mut b = PrivacyBudget::new(1.0);
assert_eq!(b.remaining(), 1.0);
for zero in [0.0f64, -0.0] {
assert!(b.try_spend(zero), "{zero:e} is a well-formed request");
assert_eq!(b.spent(), 0.0);
assert_eq!(b.remaining(), 1.0);
}
assert_eq!(b.query_count(), 2, "a zero spend still counts as a query");
assert!(b.try_spend(0.5));
assert_eq!(b.spent(), 0.5);
assert_eq!(b.remaining(), 0.5);
assert!(!b.try_spend(0.75), "over budget is refused");
assert_eq!(b.spent(), 0.5, "the refused amount is not charged");
assert_eq!(b.remaining(), 0.5);
assert!(b.try_spend(0.5));
assert_eq!(b.remaining(), 0.0);
assert!(b.is_exhausted());
assert!(!b.try_spend(f64::EPSILON), "nothing fits once exhausted");
assert!(b.try_spend(f64::MIN_POSITIVE));
assert_eq!(b.spent(), 1.0, "an absorbed request charges nothing");
assert_eq!(b.remaining(), 0.0);
let mut none = PrivacyBudget::new(-1.0);
assert!(!none.try_spend(0.5));
assert_eq!(none.spent(), 0.0);
assert_eq!(none.query_count(), 0);
}
#[test]
fn ewma_detector_accepts_its_whole_domain_and_only_that() {
for alpha in [f64::MIN_POSITIVE, 1e-9, 0.0005, 0.3, 1.0] {
let d = EwmaDetector::new(alpha, 3.0);
assert_eq!(
d.alpha(),
alpha,
"the factor must be stored as given, not corrected"
);
}
let mut d = EwmaDetector::new(0.3, 3.0);
d.set_alpha(1.0);
assert_eq!(d.alpha(), 1.0);
}
#[test]
#[should_panic(expected = "EwmaDetector alpha must be in (0.0, 1.0]")]
fn ewma_detector_rejects_a_zero_smoothing_factor() {
let _ = EwmaDetector::new(0.0, 3.0);
}
#[test]
#[should_panic(expected = "EwmaDetector alpha must be in (0.0, 1.0]")]
fn ewma_detector_rejects_a_negative_smoothing_factor() {
let _ = EwmaDetector::new(-1.0, 3.0);
}
#[test]
#[should_panic(expected = "EwmaDetector alpha must be in (0.0, 1.0]")]
fn ewma_detector_rejects_a_smoothing_factor_above_one() {
let _ = EwmaDetector::new(2.0, 3.0);
}
#[test]
#[should_panic(expected = "EwmaDetector alpha must be in (0.0, 1.0]")]
fn ewma_detector_rejects_a_nan_smoothing_factor() {
let _ = EwmaDetector::new(f64::NAN, 3.0);
}
#[test]
#[should_panic(expected = "EwmaDetector alpha must be in (0.0, 1.0]")]
fn ewma_detector_setter_rejects_a_nan_smoothing_factor() {
let mut d = EwmaDetector::new(0.3, 3.0);
d.set_alpha(f64::NAN);
}
#[test]
#[should_panic(expected = "EwmaDetector alpha must be in (0.0, 1.0]")]
fn composite_detector_inherits_the_smoothing_factor_domain() {
let _ = CompositeDetector::with_thresholds(3.0, f64::NAN, 2.5, 3.0);
}
#[test]
fn a_series_with_no_spread_scores_any_deviation_as_infinite() {
let mut fast = EwmaDetector::new(1.0, 3.0);
for i in 0..10 {
fast.observe(f64::from(i));
}
assert_eq!(
fast.ewma(),
9.0,
"alpha = 1 tracks the latest value exactly"
);
assert_eq!(fast.std_dev(), 0.0);
assert_eq!(fast.anomaly_score(100.0), f64::INFINITY);
assert_eq!(
fast.anomaly_score(9.0),
0.0,
"the level itself is not a deviation"
);
assert!(fast.is_anomaly(100.0));
assert!(!fast.is_anomaly(9.0));
let mut slow = EwmaDetector::new(0.3, 3.0);
for _ in 0..50 {
slow.observe(7.0);
}
assert_eq!(slow.ewma(), 7.0);
assert_eq!(slow.std_dev(), 0.0);
assert_eq!(slow.anomaly_score(8.0), f64::INFINITY);
assert_eq!(slow.anomaly_score(7.0), 0.0);
let mut varied = EwmaDetector::new(0.3, 3.0);
for i in 0..50 {
varied.observe(f64::from(i % 7));
}
assert!(varied.std_dev() > 0.0);
assert!(varied.anomaly_score(100.0).is_finite());
}
#[cfg(feature = "std")] #[test]
fn a_randomized_response_epsilon_maps_onto_its_whole_domain() {
assert_eq!(RandomizedResponse::new(0.0).p_true(), 0.5);
assert_eq!(RandomizedResponse::new(-0.0).p_true(), 0.5);
assert!((RandomizedResponse::new(1.0).p_true() - 0.731_058_578_630_004_9).abs() < 1e-15);
let mut previous = 0.5;
for e in [
0.0f64, 0.25, 0.5, 1.0, 3.0, 10.0, 50.0, 100.0, 709.0, 710.0, 1e3, 1e300,
] {
let p = RandomizedResponse::new(e).p_true();
assert!(!p.is_nan(), "epsilon {e:e} produced NaN");
assert!(
(0.5..=1.0).contains(&p),
"epsilon {e:e} produced p_true {p}, outside the domain with_probability accepts"
);
assert!(
p >= previous,
"p_true must not decrease: {e:e} gave {p} after {previous}"
);
previous = p;
let _ = RandomizedResponse::with_probability(p, 1);
}
assert_eq!(RandomizedResponse::new(710.0).p_true(), 1.0);
assert_eq!(RandomizedResponse::new(f64::MAX).p_true(), 1.0);
}
#[cfg(feature = "std")] #[test]
#[should_panic(expected = "epsilon must be finite and non-negative")]
fn a_randomized_response_rejects_a_negative_epsilon() {
let _ = RandomizedResponse::new(-5.0);
}
#[cfg(feature = "std")] #[test]
#[should_panic(expected = "epsilon must be finite and non-negative")]
fn a_randomized_response_rejects_a_nan_epsilon() {
let _ = RandomizedResponse::new(f64::NAN);
}
#[cfg(feature = "std")] #[test]
#[should_panic(expected = "epsilon must be finite and non-negative")]
fn a_randomized_response_rejects_an_infinite_epsilon() {
let _ = RandomizedResponse::new(f64::INFINITY);
}
#[cfg(feature = "std")] #[test]
#[should_panic(expected = "epsilon must be finite and non-negative")]
fn a_randomized_response_rejects_a_negative_infinite_epsilon() {
let _ = RandomizedResponse::new(f64::NEG_INFINITY);
}
#[test]
fn nan_observations_do_not_panic_the_estimators() {
let mut st = StreamingStats::new();
for _ in 0..8 {
st.observe(f64::NAN);
}
assert_eq!(st.count(), 8, "NaN still counts as an observation");
let mut med = StreamingMedian::new();
med.push(f64::NAN);
med.push(1.0);
assert_eq!(med.count(), 2);
}
#[test]
fn an_extreme_finite_magnitude_saturates_the_bucket_index() {
let alpha = 1e-9;
let mut dd = DDSketch::new(alpha);
dd.insert(1.0);
dd.insert(1e300);
assert_eq!(dd.count(), 2);
assert_eq!(dd.non_finite(), 0, "1e300 is finite");
let (_, hi) = dd.accurate_range();
let top = top_edge_representative(alpha, hi);
let q_top = dd.quantile(1.0);
assert!(
q_top > 1.0,
"the largest sample must stay the largest: a wrapped index files it \
in bin 0 and reports {q_top} (= the smallest observed value)"
);
assert!(
(q_top / top - 1.0).abs() < 1e-9,
"expected the top edge-bin representative {top:e}, got {q_top:e}"
);
let mut dd = DDSketch::new(0.0);
dd.insert(2.0);
assert_eq!(dd.count(), 1);
assert!(dd.quantile(0.5).is_finite());
}