#[cfg(not(feature = "std"))]
#[allow(unused_imports)]
use num_traits::Float;
use crate::domain::AnomalyScore;
pub const DEFAULT_Z_FACTOR: f64 = 1.96;
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ScoreWithConfidence {
pub score: AnomalyScore,
pub trees_evaluated: usize,
pub stddev: f64,
pub stderr: f64,
}
impl ScoreWithConfidence {
#[must_use]
pub fn ci(&self, z: f64) -> (f64, f64) {
let mean = f64::from(self.score);
let half = z * self.stderr;
((mean - half).max(0.0), mean + half)
}
#[must_use]
pub fn ci95(&self) -> (f64, f64) {
self.ci(DEFAULT_Z_FACTOR)
}
#[must_use]
pub fn relative_stderr(&self) -> f64 {
let denom = f64::from(self.score).abs().max(f64::EPSILON);
self.stderr / denom
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic, clippy::float_cmp)]
mod tests {
use super::*;
fn mk(score: f64, n: usize, stddev: f64) -> ScoreWithConfidence {
let stderr = if n > 0 {
#[allow(clippy::cast_precision_loss)]
let inv = (n as f64).sqrt();
stddev / inv
} else {
0.0
};
ScoreWithConfidence {
score: AnomalyScore::new(score).unwrap(),
trees_evaluated: n,
stddev,
stderr,
}
}
#[test]
fn ci95_width_matches_1_96_stderr() {
let s = mk(2.0, 100, 0.5);
let (lo, hi) = s.ci95();
let width = hi - lo;
let expected = 2.0 * DEFAULT_Z_FACTOR * s.stderr;
assert!((width - expected).abs() < 1e-9);
}
#[test]
fn ci_lower_bound_clamps_to_zero() {
let s = mk(0.1, 4, 5.0);
let (lo, _) = s.ci95();
assert!(lo >= 0.0);
}
#[test]
fn relative_stderr_equals_stderr_over_mean() {
let s = mk(2.0, 100, 0.5);
let rel = s.relative_stderr();
assert!((rel - s.stderr / 2.0).abs() < 1e-9);
}
#[test]
fn custom_z_factor() {
let s = mk(2.0, 100, 0.5);
let (lo99, hi99) = s.ci(2.576); let (lo95, hi95) = s.ci95();
assert!(lo99 <= lo95);
assert!(hi99 >= hi95);
}
}