use super::*;
fn result(is_anomaly: bool, score: f64) -> AnomalyDetectionResult<f64> {
AnomalyDetectionResult {
is_anomaly,
anomaly_score: score,
confidence: 0.5,
anomaly_type: is_anomaly.then_some(AnomalyType::StatisticalOutlier),
severity: AnomalySeverity::Low,
metadata: HashMap::new(),
}
}
fn split_results(truth: bool) -> HashMap<String, AnomalyDetectionResult<f64>> {
let mut results = HashMap::new();
results.insert("reliable".to_string(), result(truth, 0.9));
results.insert("noisy".to_string(), result(!truth, 0.1));
results
}
#[test]
fn adaptive_falls_back_to_uniform_weights_without_ground_truth() {
let mut ensemble =
EnsembleAnomalyDetector::<f64>::new(EnsembleVotingStrategy::Adaptive).expect("ensemble");
assert!(
ensemble.measured_weights().is_none(),
"no ground truth has been recorded, so there is nothing measured to weight by"
);
let combined = ensemble
.combine_results(split_results(true))
.expect("adaptive voting must work before any label arrives");
assert!(!combined.is_anomaly);
assert!(
(combined.anomaly_score - 0.5).abs() < 1e-12,
"uniform weights must give the plain mean score, got {}",
combined.anomaly_score
);
}
#[test]
fn adaptive_follows_the_measured_member() {
let mut ensemble =
EnsembleAnomalyDetector::<f64>::new(EnsembleVotingStrategy::Adaptive).expect("ensemble");
for round in 0..20 {
let truth = round % 2 == 0;
ensemble
.combine_results(split_results(truth))
.expect("combine");
ensemble.record_outcome(truth);
}
let reliable = ensemble
.detector_balanced_accuracy("reliable")
.expect("reliable has labelled outcomes");
let noisy = ensemble
.detector_balanced_accuracy("noisy")
.expect("noisy has labelled outcomes");
assert!(
(reliable - 1.0).abs() < 1e-12,
"a member that is always right must measure 1.0, got {reliable}"
);
assert!(
noisy.abs() < 1e-12,
"a member that is always wrong must measure 0.0, got {noisy}"
);
let weights = ensemble.measured_weights().expect("measured weights");
assert!(weights["reliable"] > weights["noisy"]);
let combined = ensemble.combine_results(split_results(true)).expect("vote");
assert!(
combined.is_anomaly,
"the adaptive vote ignored the only member measured to be reliable"
);
assert!(
(combined.anomaly_score - 0.9).abs() < 1e-12,
"the weighted mean score must follow the reliable member, got {}",
combined.anomaly_score
);
}
#[test]
fn adaptive_stays_uniform_when_no_member_has_measured_skill() {
let mut ensemble =
EnsembleAnomalyDetector::<f64>::new(EnsembleVotingStrategy::Adaptive).expect("ensemble");
for round in 0..20 {
let truth = round % 2 == 0;
let mut results = HashMap::new();
results.insert("a".to_string(), result(!truth, 0.9));
results.insert("b".to_string(), result(!truth, 0.9));
ensemble.combine_results(results).expect("combine");
ensemble.record_outcome(truth);
}
let weights = ensemble.measured_weights().expect("measured weights");
assert!(weights.values().all(|w| w.abs() < 1e-12));
let mut results = HashMap::new();
results.insert("a".to_string(), result(true, 0.9));
results.insert("b".to_string(), result(true, 0.9));
let combined = ensemble
.combine_results(results)
.expect("a zero total weight must fall back to a uniform vote, not fail");
assert!(combined.is_anomaly);
}
#[test]
fn one_detection_is_scored_once() {
let mut ensemble =
EnsembleAnomalyDetector::<f64>::new(EnsembleVotingStrategy::Adaptive).expect("ensemble");
ensemble
.combine_results(split_results(true))
.expect("combine");
ensemble.record_outcome(true);
ensemble.record_outcome(true);
ensemble.record_outcome(true);
let counters = ensemble
.detector_counters
.get("reliable")
.expect("counters exist");
assert_eq!(counters.labelled(), 1, "the detection was labelled twice");
}
#[test]
fn configured_weights_decide_the_weighted_vote() {
let mut ensemble =
EnsembleAnomalyDetector::<f64>::new(EnsembleVotingStrategy::Weighted).expect("ensemble");
assert!(
!ensemble
.combine_results(split_results(true))
.expect("combine")
.is_anomaly
);
ensemble.set_detector_weight("reliable", 9.0);
let combined = ensemble.combine_results(split_results(true)).expect("vote");
assert!(
combined.is_anomaly,
"the configured weight was not consulted"
);
}
#[test]
fn stacking_is_an_honest_error_naming_what_is_missing() {
let mut ensemble =
EnsembleAnomalyDetector::<f64>::new(EnsembleVotingStrategy::Stacking).expect("ensemble");
let error = ensemble
.combine_results(split_results(true))
.expect_err("stacking has no meta-learner");
assert!(error.contains("meta-learner"), "{error}");
assert!(
error.contains("Adaptive"),
"the error should point at the strategy that *is* implemented: {error}"
);
assert!(
!error.contains("`Adaptive` is not implemented"),
"the message must not claim Adaptive is missing: {error}"
);
}
#[test]
fn score_strategies_compute_what_they_name() {
let mut results = HashMap::new();
results.insert("a".to_string(), result(false, 0.10));
results.insert("b".to_string(), result(false, 0.40));
results.insert("c".to_string(), result(true, 0.90));
let mut max_score =
EnsembleAnomalyDetector::<f64>::new(EnsembleVotingStrategy::MaxScore).expect("ensemble");
assert!(
(max_score
.combine_results(results.clone())
.expect("max")
.anomaly_score
- 0.9)
.abs()
< 1e-12
);
let mut median =
EnsembleAnomalyDetector::<f64>::new(EnsembleVotingStrategy::MedianScore).expect("ensemble");
assert!(
(median
.combine_results(results.clone())
.expect("median")
.anomaly_score
- 0.4)
.abs()
< 1e-12
);
let mut average = EnsembleAnomalyDetector::<f64>::new(EnsembleVotingStrategy::AverageScore)
.expect("ensemble");
let expected = (0.10 + 0.40 + 0.90) / 3.0;
assert!(
(average
.combine_results(results)
.expect("average")
.anomaly_score
- expected)
.abs()
< 1e-12
);
}