use std::collections::HashSet;
use wasm4pm::prediction_drift::{classify_trend, ewma_series, jaccard_distance};
#[test]
fn test_jaccard_empty_sets() {
let a: HashSet<String> = HashSet::new();
let b: HashSet<String> = HashSet::new();
assert_eq!(
jaccard_distance(&a, &b),
0.0,
"Empty sets should have distance 0.0"
);
}
#[test]
fn test_jaccard_identical_sets() {
let a: HashSet<String> = ["A", "B", "C"].iter().map(|s| s.to_string()).collect();
assert_eq!(
jaccard_distance(&a, &a),
0.0,
"Identical sets should have distance 0.0"
);
}
#[test]
fn test_jaccard_disjoint_sets() {
let a: HashSet<String> = ["A", "B", "C"].iter().map(|s| s.to_string()).collect();
let b: HashSet<String> = ["X", "Y", "Z"].iter().map(|s| s.to_string()).collect();
assert_eq!(
jaccard_distance(&a, &b),
1.0,
"Disjoint sets should have distance 1.0"
);
}
#[test]
fn test_jaccard_partial_overlap() {
let a: HashSet<String> = ["A", "B"].iter().map(|s| s.to_string()).collect();
let b: HashSet<String> = ["B", "C"].iter().map(|s| s.to_string()).collect();
let distance = jaccard_distance(&a, &b);
assert!(
(distance - 2.0 / 3.0).abs() < 1e-12,
"Distance should be 2/3"
);
}
#[test]
fn test_jaccard_50_percent_overlap() {
let a: HashSet<String> = (0..20).map(|i| format!("act_{}", i)).collect();
let b: HashSet<String> = (10..30).map(|i| format!("act_{}", i)).collect();
let distance = jaccard_distance(&a, &b);
assert!((distance - 2.0 / 3.0).abs() < 1e-12);
}
#[test]
fn test_jaccard_symmetry() {
let a: HashSet<String> = ["A", "B", "C", "D"].iter().map(|s| s.to_string()).collect();
let b: HashSet<String> = ["C", "D", "E"].iter().map(|s| s.to_string()).collect();
let d_ab = jaccard_distance(&a, &b);
let d_ba = jaccard_distance(&b, &a);
assert!(
(d_ab - d_ba).abs() < 1e-12,
"Jaccard distance should be symmetric"
);
}
#[test]
fn test_ewma_empty_input() {
assert!(ewma_series(&[], 0.5).is_empty());
}
#[test]
fn test_ewma_single_value() {
assert_eq!(ewma_series(&[42.0], 0.5), vec![42.0]);
}
#[test]
fn test_ewma_constant_series() {
let series = vec![5.0; 100];
let result = ewma_series(&series, 0.3);
for (i, &val) in result.iter().enumerate() {
assert!(
(val - 5.0).abs() < 1e-10,
"EWMA of constant series should remain constant at index {}",
i
);
}
}
#[test]
fn test_ewma_recurrence_relation() {
let series = vec![1.0, 4.0, 9.0, 16.0, 25.0];
let alpha = 0.4;
let result = ewma_series(&series, alpha);
assert_eq!(result[0], 1.0, "First element should equal first input");
for i in 1..series.len() {
let expected = alpha * series[i] + (1.0 - alpha) * result[i - 1];
assert!(
(result[i] - expected).abs() < 1e-12,
"Recurrence relation violated at index {}",
i
);
}
}
#[test]
fn test_ewma_alpha_clamping() {
let series = vec![1.0, 2.0, 3.0];
let result_low = ewma_series(&series, 0.0);
assert_eq!(result_low.len(), 3);
assert!((result_low[1] - 1.0).abs() < 1e-9);
let result_high = ewma_series(&series, 5.0);
assert_eq!(
result_high, series,
"Alpha=5.0 clamped to 1.0 should track input"
);
}
#[test]
fn test_ewma_convergence_to_constant() {
let mut series = vec![0.0];
series.extend(std::iter::repeat(10.0).take(200));
let result = ewma_series(&series, 0.3);
let last = *result.last().unwrap();
assert!(
(last - 10.0).abs() < 1e-6,
"EWMA should converge to constant: last={}, expected ~10.0",
last
);
}
#[test]
fn test_ewma_responsiveness_alpha_comparison() {
let series: Vec<f64> = (0..100).map(|i| if i < 50 { 1.0 } else { 10.0 }).collect();
let result_low_alpha = ewma_series(&series, 0.1); let result_high_alpha = ewma_series(&series, 0.9);
let low_jump = result_low_alpha[51] - result_low_alpha[50];
let high_jump = result_high_alpha[51] - result_high_alpha[50];
assert!(
high_jump > low_jump,
"High alpha should be more responsive: high_jump={}, low_jump={}",
high_jump,
low_jump
);
}
#[test]
fn test_trend_empty_and_short_series() {
assert_eq!(classify_trend(&[]), "stable");
assert_eq!(classify_trend(&[1.0]), "stable");
}
#[test]
fn test_trend_rising() {
let series = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(classify_trend(&series), "rising");
}
#[test]
fn test_trend_falling() {
let series = vec![10.0, 8.0, 6.0, 4.0, 2.0];
assert_eq!(classify_trend(&series), "falling");
}
#[test]
fn test_trend_stable_small_variation() {
let series = vec![5.0, 5.001, 5.0, 4.999, 5.002];
assert_eq!(classify_trend(&series), "stable");
}
#[test]
fn test_trend_stable_with_noise() {
let series: Vec<f64> = (0..50)
.map(|i| 10.0 + (i as f64 * 0.01).sin() * 0.2) .collect();
assert_eq!(classify_trend(&series), "stable");
}
#[test]
fn test_window_size_zero_becomes_one() {
for n in [0, 1, 5, 10, 50, 100] {
let set: HashSet<String> = (0..n).map(|i| format!("act_{}", i)).collect();
let distance = jaccard_distance(&set, &set);
assert_eq!(
distance, 0.0,
"Identical sets should always have distance 0"
);
}
}
#[test]
fn test_jaccard_performance_scaling() {
let mut distances = Vec::new();
for size in [10, 20, 50, 100, 200] {
let a: HashSet<String> = (0..size).map(|i| format!("act_{}", i)).collect();
let b: HashSet<String> = (size / 2..(size + size / 2))
.map(|i| format!("act_{}", i))
.collect();
let d = jaccard_distance(&a, &b);
distances.push((size, d));
}
for (size, distance) in distances {
assert!(
(distance - 2.0 / 3.0).abs() < 0.01,
"50% overlap should give distance ~0.667 at size={}",
size
);
}
}
#[test]
fn test_alpha_parameter_effects() {
let series = vec![1.0, 5.0, 2.0, 8.0, 3.0, 9.0];
let alphas = [0.1, 0.2, 0.3, 0.5];
let mut results = Vec::new();
for &alpha in &alphas {
let result = ewma_series(&series, alpha);
results.push((alpha, result));
}
let variance_01 = compute_variance(&results[0].1);
let variance_05 = compute_variance(&results[3].1);
assert!(
variance_05 > variance_01,
"Higher alpha should have higher variance: {:.4} > {:.4}",
variance_05,
variance_01
);
}
#[test]
fn test_alpha_default_choice() {
let series: Vec<f64> = (0..100).map(|i| 10.0 + ((i as f64) * 0.1).sin()).collect();
let smoothed = ewma_series(&series, 0.2);
let input_variance = compute_variance(&series);
let smoothed_variance = compute_variance(&smoothed);
assert!(
smoothed_variance < input_variance,
"Smoothed series should have lower variance"
);
assert_eq!(classify_trend(&smoothed), "stable");
}
#[test]
fn test_drift_scenario_abrupt_detection() {
let before = ["A", "B", "C"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let after = ["X", "Y", "Z"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let distance = jaccard_distance(&before, &after);
assert_eq!(
distance, 1.0,
"Completely disjoint vocabularies should have distance 1.0"
);
}
#[test]
fn test_drift_scenario_gradual_detection() {
let step0 = ["A", "B", "C"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let step1 = ["A", "B", "C", "X"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let step2 = ["A", "B", "X", "Y"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let step3 = ["X", "Y", "Z"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let d01 = jaccard_distance(&step0, &step1);
let d12 = jaccard_distance(&step1, &step2);
let d23 = jaccard_distance(&step2, &step3);
assert!(d01 < 0.3, "Initial change should be small");
assert!(d12 > d01, "Distance should increase over time");
assert!(d23 > d12, "Distance should continue increasing");
}
#[test]
fn test_drift_scenario_seasonal_periodicity() {
let summer = ["A", "B", "C"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let winter = ["X", "Y", "Z"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let distances = [
jaccard_distance(&summer, &summer), jaccard_distance(&summer, &winter), jaccard_distance(&winter, &winter), jaccard_distance(&winter, &summer), ];
assert_eq!(distances[0], 0.0);
assert_eq!(distances[1], 1.0);
assert_eq!(distances[2], 0.0);
assert_eq!(distances[3], 1.0);
}
#[test]
fn test_drift_scenario_oscillating_reversibility() {
let vocab_a = ["A", "B"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let vocab_b = ["X", "Y"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let d_ab = jaccard_distance(&vocab_a, &vocab_b);
let d_ba = jaccard_distance(&vocab_b, &vocab_a);
let d_aa = jaccard_distance(&vocab_a, &vocab_a);
assert!(d_ab > 0.9, "Transition distance should be high");
assert!((d_ab - d_ba).abs() < 1e-12, "Distance should be symmetric");
assert_eq!(d_aa, 0.0, "No transition should have zero distance");
}
#[test]
fn test_drift_scenario_stable_no_drift() {
let vocab = ["A", "B", "C", "D", "E"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let mut distances = Vec::new();
for _ in 0..10 {
distances.push(jaccard_distance(&vocab, &vocab));
}
for d in distances {
assert_eq!(d, 0.0, "Stable vocabulary should always have distance 0");
}
}
#[test]
fn test_threshold_effects_on_detection() {
let vocab_a = ["A", "B", "C"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let vocab_b1 = ["A", "B", "C", "D"] .iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let vocab_b2 = ["A", "B", "X", "Y"] .iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let vocab_b3 = ["X", "Y", "Z"] .iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let d1 = jaccard_distance(&vocab_a, &vocab_b1);
let d2 = jaccard_distance(&vocab_a, &vocab_b2);
let d3 = jaccard_distance(&vocab_a, &vocab_b3);
assert!(d1 < 0.5 && d1 > 0.0, "Small change: {:.3}", d1);
assert!(d2 > 0.3 && d2 < 0.8, "Medium change: {:.3}", d2);
assert_eq!(d3, 1.0, "Complete change = 1.0");
}
#[test]
fn test_edge_case_single_activity() {
let single = ["A"].iter().map(|s| s.to_string()).collect::<HashSet<_>>();
let distance = jaccard_distance(&single, &single);
assert_eq!(distance, 0.0);
}
#[test]
fn test_edge_case_large_set_operations() {
let large_a: HashSet<String> = (0..1000).map(|i| format!("act_{}", i)).collect();
let large_b: HashSet<String> = (500..1500).map(|i| format!("act_{}", i)).collect();
let distance = jaccard_distance(&large_a, &large_b);
assert!((distance - 2.0 / 3.0).abs() < 0.01);
}
#[test]
fn test_determinism_ewma_same_input() {
let series = vec![1.0, 2.5, 3.7, 2.1, 4.9, 1.2, 3.4];
let alpha = 0.3;
let result1 = ewma_series(&series, alpha);
let result2 = ewma_series(&series, alpha);
let result3 = ewma_series(&series, alpha);
assert_eq!(result1, result2, "EWMA should be deterministic");
assert_eq!(result2, result3, "EWMA should be deterministic");
}
#[test]
fn test_determinism_jaccard_same_sets() {
let a = ["A", "B", "C", "D"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let b = ["C", "D", "E", "F"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>();
let d1 = jaccard_distance(&a, &b);
let d2 = jaccard_distance(&a, &b);
let d3 = jaccard_distance(&a, &b);
assert_eq!(d1, d2, "Jaccard should be deterministic");
assert_eq!(d2, d3, "Jaccard should be deterministic");
}
#[test]
fn test_determinism_trend_classification() {
let series = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let trend1 = classify_trend(&series);
let trend2 = classify_trend(&series);
let trend3 = classify_trend(&series);
assert_eq!(trend1, trend2);
assert_eq!(trend2, trend3);
}
#[test]
fn test_jaccard_performance_baseline() {
let a: HashSet<String> = (0..50).map(|i| format!("act_{}", i)).collect();
let b: HashSet<String> = (25..75).map(|i| format!("act_{}", i)).collect();
let start = std::time::Instant::now();
for _ in 0..1000 {
let _ = jaccard_distance(&a, &b);
}
let elapsed_us = start.elapsed().as_micros();
assert!(
elapsed_us < 100_000,
"Jaccard 1000x should be <100ms, got {}us",
elapsed_us
);
}
#[test]
fn test_ewma_performance_baseline() {
let series: Vec<f64> = (0..1000)
.map(|i| (i as f64 * 0.01).sin() + (i as f64 * 0.001).cos())
.collect();
let start = std::time::Instant::now();
for _ in 0..100 {
let _ = ewma_series(&series, 0.3);
}
let elapsed_us = start.elapsed().as_micros();
assert!(
elapsed_us < 100_000,
"EWMA 100x should be <100ms, got {}us",
elapsed_us
);
}
fn compute_variance(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let mean = values.iter().sum::<f64>() / values.len() as f64;
let variance = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / values.len() as f64;
variance
}