use crate::domain::{AnomalyScore, BoundingBox, Cut, DiVector, ensure_finite};
use crate::error::{RcfError, RcfResult};
use crate::visitor::Visitor;
use crate::visitor::scoring::{damp, normalizer, score_seen, score_unseen};
#[derive(Debug, Clone)]
pub struct ScoreAttributionVisitor<'a> {
accumulated: f64,
di: DiVector,
point: &'a [f64],
total_mass: u64,
}
impl<'a> ScoreAttributionVisitor<'a> {
pub fn new(point: &'a [f64], total_mass: u64) -> RcfResult<Self> {
if point.is_empty() {
return Err(RcfError::InvalidConfig(
"ScoreAttributionVisitor: point must not be empty".into(),
));
}
ensure_finite(point)?;
Ok(Self {
accumulated: 0.0,
di: DiVector::zeros(point.len()),
point,
total_mass,
})
}
#[must_use]
pub fn total_mass(&self) -> u64 {
self.total_mass
}
#[must_use]
pub fn accumulated(&self) -> f64 {
self.accumulated
}
}
impl<const D: usize> Visitor<D> for ScoreAttributionVisitor<'_> {
type Output = (AnomalyScore, DiVector);
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);
let dampened = blend * damp(mass, self.total_mass);
self.accumulated += dampened;
if p <= 0.0 {
return;
}
let dim = self.di.dim().min(per_dim_prob.len()).min(bbox.dim());
for (d, &dim_prob) in per_dim_prob.iter().take(dim).enumerate() {
if dim_prob <= 0.0 {
continue;
}
let share = dim_prob / p;
let contribution = dampened * share;
if self.point[d] > bbox.max()[d] {
let _ = self.di.add_high(d, contribution);
} else if self.point[d] < bbox.min()[d] {
let _ = self.di.add_low(d, contribution);
}
}
}
fn accept_leaf(&mut self, depth: usize, mass: u64, _point_idx: usize) {
self.accumulated += score_seen(depth, mass) * damp(mass, self.total_mass);
}
fn needs_per_dim_prob(&self) -> bool {
true
}
fn result(self) -> (AnomalyScore, DiVector) {
let norm = normalizer(self.total_mass);
let raw = if norm > 0.0 {
self.accumulated / norm
} else {
self.accumulated
};
let clamped = if raw.is_finite() { raw.max(0.0) } else { 0.0 };
let score = AnomalyScore::new(clamped).expect("clamped score is finite and non-negative");
let mut di = self.di;
if norm > 0.0 {
let _ = di.scale(norm);
}
(score, di)
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
use crate::visitor::{AttributionVisitor, ScalarScoreVisitor};
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 new_rejects_empty_point() {
let err = ScoreAttributionVisitor::new(&[], 4).unwrap_err();
assert!(matches!(err, RcfError::InvalidConfig(_)));
}
#[test]
fn new_rejects_non_finite() {
assert!(matches!(
ScoreAttributionVisitor::new(&[1.0, f64::NAN], 4).unwrap_err(),
RcfError::NaNValue
));
}
#[test]
fn merged_scalar_matches_scalar_visitor() {
let probe = [100.0_f64, 0.5];
let bbox = unit_bbox::<2>();
let mut scalar = ScalarScoreVisitor::new(8);
let mut merged = ScoreAttributionVisitor::new(&probe, 8).unwrap();
for (depth, mass, p, per_dim) in [
(1_usize, 4_u64, 0.5_f64, [0.4_f64, 0.1]),
(2, 2, 0.3, [0.3, 0.0]),
] {
scalar.accept_internal(depth, mass, &Cut::new(0, 0.5), &bbox, p, &per_dim);
<ScoreAttributionVisitor<'_> as Visitor<2>>::accept_internal(
&mut merged,
depth,
mass,
&Cut::new(0, 0.5),
&bbox,
p,
&per_dim,
);
}
<ScalarScoreVisitor as Visitor<2>>::accept_leaf(&mut scalar, 3, 1, 0);
<ScoreAttributionVisitor<'_> as Visitor<2>>::accept_leaf(&mut merged, 3, 1, 0);
let s_scalar = <ScalarScoreVisitor as Visitor<2>>::result(scalar);
let (s_merged, _di) = <ScoreAttributionVisitor<'_> as Visitor<2>>::result(merged);
assert!((f64::from(s_scalar) - f64::from(s_merged)).abs() < 1e-12);
}
#[test]
fn merged_attribution_matches_attribution_visitor() {
let probe = [100.0_f64, 0.5];
let bbox = unit_bbox::<2>();
let mut attr = AttributionVisitor::new(&probe, 8).unwrap();
let mut merged = ScoreAttributionVisitor::new(&probe, 8).unwrap();
for (depth, mass, p, per_dim) in [
(1_usize, 4_u64, 0.5_f64, [0.4_f64, 0.1]),
(2, 2, 0.3, [0.3, 0.0]),
] {
attr.accept_internal(depth, mass, &Cut::new(0, 0.5), &bbox, p, &per_dim);
<ScoreAttributionVisitor<'_> as Visitor<2>>::accept_internal(
&mut merged,
depth,
mass,
&Cut::new(0, 0.5),
&bbox,
p,
&per_dim,
);
}
<AttributionVisitor<'_> as Visitor<2>>::accept_leaf(&mut attr, 3, 1, 0);
<ScoreAttributionVisitor<'_> as Visitor<2>>::accept_leaf(&mut merged, 3, 1, 0);
let di_attr = <AttributionVisitor<'_> as Visitor<2>>::result(attr);
let (_s, di_merged) = <ScoreAttributionVisitor<'_> as Visitor<2>>::result(merged);
for d in 0..2 {
assert!((di_attr.high()[d] - di_merged.high()[d]).abs() < 1e-12);
assert!((di_attr.low()[d] - di_merged.low()[d]).abs() < 1e-12);
}
}
#[test]
fn needs_per_dim_prob_is_true() {
let v = ScoreAttributionVisitor::new(&[1.0, 2.0], 4).unwrap();
assert!(<ScoreAttributionVisitor<'_> as Visitor<2>>::needs_per_dim_prob(&v));
}
}