#[cfg(not(feature = "std"))]
#[allow(unused_imports)]
use num_traits::Float;
use crate::domain::{AnomalyScore, BoundingBox, Cut};
use crate::visitor::Visitor;
use crate::visitor::scoring::{damp, normalizer, score_seen, score_unseen};
#[derive(Debug, Clone)]
pub struct ScalarScoreVisitor {
accumulated: f64,
total_mass: u64,
}
impl ScalarScoreVisitor {
#[inline]
#[must_use]
pub fn new(total_mass: u64) -> Self {
Self {
accumulated: 0.0,
total_mass,
}
}
#[inline]
#[must_use]
pub fn accumulated(&self) -> f64 {
self.accumulated
}
#[inline]
#[must_use]
pub fn total_mass(&self) -> u64 {
self.total_mass
}
}
impl<const D: usize> Visitor<D> for ScalarScoreVisitor {
type Output = AnomalyScore;
#[inline]
fn accept_internal(
&mut self,
depth: usize,
mass: u64,
_cut: &Cut,
_bbox: &BoundingBox<D>,
prob_cut: f64,
_per_dim_prob: &[f64],
) {
let p = prob_cut.clamp(0.0, 1.0);
let blend = (1.0 - p) * score_seen(depth, mass) + p * score_unseen(depth, mass);
self.accumulated += blend * damp(mass, self.total_mass);
}
#[inline]
fn accept_leaf(&mut self, depth: usize, mass: u64, _point_idx: usize) {
self.accumulated += score_seen(depth, mass) * damp(mass, self.total_mass);
}
#[inline]
fn result(self) -> AnomalyScore {
let norm = normalizer(self.total_mass);
let score = if norm > 0.0 {
self.accumulated / norm
} else {
self.accumulated
};
let clamped = if score.is_finite() {
score.max(0.0)
} else {
0.0
};
AnomalyScore::new(clamped).expect("clamped score is finite and non-negative")
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)] mod tests {
use super::*;
use crate::domain::BoundingBox;
fn unit_bbox<const D: usize>() -> BoundingBox<D> {
let mut b = BoundingBox::<D>::from_point(&vec![0.0; D]).unwrap();
b.extend(&vec![1.0; D]).unwrap();
b
}
#[test]
fn fresh_visitor_starts_at_zero() {
let v = ScalarScoreVisitor::new(8);
assert_eq!(v.accumulated(), 0.0);
assert_eq!(v.total_mass(), 8);
}
#[test]
fn accept_internal_accumulates_blend() {
let mut v = ScalarScoreVisitor::new(8);
v.accept_internal(
1,
4,
&Cut::new(0, 0.5),
&unit_bbox::<2>(),
0.5,
&[0.25, 0.25],
);
let expected = (0.5 * (1.0 / 3.0) + 0.5 * 3.0) * (1.0 / (1.0 + 4f64.ln() / 8f64.ln()));
assert!((v.accumulated() - expected).abs() < 1e-9);
}
#[test]
fn accept_leaf_adds_seen_contribution() {
let mut v = ScalarScoreVisitor::new(8);
<ScalarScoreVisitor as Visitor<2>>::accept_leaf(&mut v, 3, 1, 7);
let expected = (1.0 / 3.0) * 1.0;
assert!((v.accumulated() - expected).abs() < 1e-12);
}
#[test]
fn result_normalises_by_log2_total() {
let mut v = ScalarScoreVisitor::new(4);
<ScalarScoreVisitor as Visitor<2>>::accept_leaf(&mut v, 2, 1, 0);
let score: f64 = <ScalarScoreVisitor as Visitor<2>>::result(v).into();
assert!((score - 0.25).abs() < 1e-12);
}
#[test]
fn result_with_total_mass_one_returns_zero() {
let mut v = ScalarScoreVisitor::new(1);
<ScalarScoreVisitor as Visitor<2>>::accept_leaf(&mut v, 0, 1, 0);
let score: f64 = <ScalarScoreVisitor as Visitor<2>>::result(v).into();
assert_eq!(score, 0.0);
}
#[test]
fn result_returns_non_negative() {
let v = ScalarScoreVisitor::new(8);
let score: f64 = <ScalarScoreVisitor as Visitor<2>>::result(v).into();
assert!(score >= 0.0);
}
#[test]
fn prob_cut_outside_range_is_clamped() {
let mut v = ScalarScoreVisitor::new(8);
v.accept_internal(
1,
4,
&Cut::new(0, 0.5),
&unit_bbox::<2>(),
1.5, &[],
);
let expected = 3.0 * (1.0 / (1.0 + 4f64.ln() / 8f64.ln()));
assert!((v.accumulated() - expected).abs() < 1e-9);
}
#[test]
fn higher_prob_cut_yields_higher_contribution() {
let mut low = ScalarScoreVisitor::new(64);
let mut high = ScalarScoreVisitor::new(64);
low.accept_internal(2, 16, &Cut::new(0, 0.5), &unit_bbox::<2>(), 0.0, &[]);
high.accept_internal(2, 16, &Cut::new(0, 0.5), &unit_bbox::<2>(), 1.0, &[]);
assert!(high.accumulated() > low.accumulated());
}
}