#![cfg(feature = "std")]
use crate::domain::{AnomalyScore, DiVector};
use crate::error::{RcfError, RcfResult};
use crate::forest::RandomCutForest;
use crate::{ForestBuilder, RcfConfig};
#[derive(Debug)]
pub struct ShingledForestBuilder<const D: usize> {
inner: ForestBuilder<D>,
}
impl<const D: usize> Default for ShingledForestBuilder<D> {
fn default() -> Self {
Self::new()
}
}
impl<const D: usize> ShingledForestBuilder<D> {
#[must_use]
pub fn new() -> Self {
Self {
inner: ForestBuilder::<D>::new(),
}
}
#[must_use]
pub fn num_trees(mut self, trees: usize) -> Self {
self.inner = self.inner.num_trees(trees);
self
}
#[must_use]
pub fn sample_size(mut self, sample: usize) -> Self {
self.inner = self.inner.sample_size(sample);
self
}
#[must_use]
pub fn seed(mut self, seed: u64) -> Self {
self.inner = self.inner.seed(seed);
self
}
#[must_use]
pub fn time_decay(mut self, decay: f64) -> Self {
self.inner = self.inner.time_decay(decay);
self
}
#[must_use]
pub fn config(&self) -> &RcfConfig {
self.inner.config()
}
#[must_use = "detector output should be checked — dropping it silently usually indicates a logic bug"]
pub fn build(self) -> RcfResult<ShingledForest<D>> {
let forest = self.inner.build()?;
Ok(ShingledForest {
forest,
ring: [0.0_f64; D],
filled: 0,
cursor: 0,
warmed: false,
})
}
}
pub struct ShingledForest<const D: usize> {
forest: RandomCutForest<D>,
ring: [f64; D],
filled: usize,
cursor: usize,
warmed: bool,
}
impl<const D: usize> core::fmt::Debug for ShingledForest<D> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("ShingledForest")
.field("shingle_size", &D)
.field("filled", &self.filled)
.field("warmed", &self.warmed)
.finish_non_exhaustive()
}
}
impl<const D: usize> ShingledForest<D> {
#[must_use]
pub const fn shingle_size(&self) -> usize {
D
}
#[must_use]
pub const fn is_warmed(&self) -> bool {
self.warmed
}
#[must_use]
pub fn forest(&self) -> &RandomCutForest<D> {
&self.forest
}
pub fn forest_mut(&mut self) -> &mut RandomCutForest<D> {
&mut self.forest
}
#[must_use]
pub fn current_shingle(&self) -> Option<[f64; D]> {
if self.filled < D {
return None;
}
Some(self.materialise_shingle())
}
#[must_use = "detector output should be checked — dropping it silently usually indicates a logic bug"]
pub fn update_scalar(&mut self, value: f64) -> RcfResult<bool> {
if !value.is_finite() {
return Err(RcfError::NaNValue);
}
let submitted = if self.filled >= D {
let shingle = self.materialise_shingle();
self.forest.update(shingle)?;
self.warmed = true;
true
} else {
false
};
self.ring[self.cursor] = value;
self.cursor = (self.cursor + 1) % D;
if self.filled < D {
self.filled += 1;
}
Ok(submitted)
}
#[must_use = "detector output should be checked — dropping it silently usually indicates a logic bug"]
pub fn score_scalar(&self, value: f64) -> RcfResult<AnomalyScore> {
if !value.is_finite() {
return Err(RcfError::NaNValue);
}
let shingle = self.shingle_with(value)?;
self.forest.score(&shingle)
}
#[must_use = "detector output should be checked — dropping it silently usually indicates a logic bug"]
pub fn attribution_scalar(&self, value: f64) -> RcfResult<DiVector> {
if !value.is_finite() {
return Err(RcfError::NaNValue);
}
let shingle = self.shingle_with(value)?;
self.forest.attribution(&shingle)
}
pub fn score_codisp_stateless_scalar(&self, value: f64) -> RcfResult<AnomalyScore> {
if !value.is_finite() {
return Err(RcfError::NaNValue);
}
let shingle = self.shingle_with(value)?;
self.forest.score_codisp_stateless(&shingle)
}
pub fn reset_ring(&mut self) {
self.ring = [0.0_f64; D];
self.filled = 0;
self.cursor = 0;
self.warmed = false;
}
fn materialise_shingle(&self) -> [f64; D] {
let mut out = [0.0_f64; D];
for (i, slot) in out.iter_mut().enumerate() {
*slot = self.ring[(self.cursor + i) % D];
}
out
}
fn shingle_with(&self, value: f64) -> RcfResult<[f64; D]> {
if self.filled < D {
return Err(RcfError::EmptyForest);
}
let mut out = [0.0_f64; D];
for (i, slot) in out.iter_mut().enumerate().take(D - 1) {
*slot = self.ring[(self.cursor + 1 + i) % D];
}
out[D - 1] = value;
Ok(out)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
clippy::float_cmp,
clippy::cast_lossless,
clippy::cast_precision_loss
)]
mod tests {
use super::*;
fn small() -> ShingledForest<4> {
ShingledForestBuilder::<4>::new()
.num_trees(50)
.sample_size(64)
.seed(2026)
.build()
.unwrap()
}
#[test]
fn warm_up_requires_d_scalars() {
let mut f = small();
for i in 0..3 {
let submitted = f.update_scalar(i as f64).unwrap();
assert!(!submitted, "shouldn't submit before ring is full");
assert!(!f.is_warmed());
}
let submitted = f.update_scalar(3.0).unwrap();
assert!(!submitted);
assert_eq!(f.current_shingle(), Some([0.0, 1.0, 2.0, 3.0]));
let submitted = f.update_scalar(4.0).unwrap();
assert!(submitted);
assert!(f.is_warmed());
assert_eq!(f.current_shingle(), Some([1.0, 2.0, 3.0, 4.0]));
}
#[test]
fn update_scalar_rejects_nan() {
let mut f = small();
assert!(matches!(
f.update_scalar(f64::NAN).unwrap_err(),
RcfError::NaNValue
));
assert!(matches!(
f.update_scalar(f64::INFINITY).unwrap_err(),
RcfError::NaNValue
));
}
#[test]
fn score_before_warm_fails() {
let f = small();
assert!(matches!(
f.score_scalar(1.0).unwrap_err(),
RcfError::EmptyForest
));
}
#[test]
fn score_after_warm_returns_non_negative() {
let mut f = small();
for i in 0..200 {
let _ = f.update_scalar(i as f64 * 0.01).unwrap();
}
let s: f64 = f.score_scalar(10.0).unwrap().into();
assert!(s.is_finite());
assert!(s >= 0.0);
}
#[test]
fn outlier_scalar_scores_higher_than_in_cluster() {
let mut f = ShingledForestBuilder::<8>::new()
.num_trees(100)
.sample_size(128)
.seed(7)
.build()
.unwrap();
let mut tick = 0.0_f64;
for _ in 0..1_000 {
let _ = f.update_scalar((tick.sin() + 1.0) * 0.1).unwrap();
tick += 0.1;
}
let normal: f64 = f.score_scalar(0.10).unwrap().into();
let outlier: f64 = f.score_scalar(100.0).unwrap().into();
assert!(
outlier > normal,
"outlier {outlier} should exceed in-cluster {normal}"
);
}
#[test]
fn shingle_with_does_not_mutate_ring() {
let mut f = small();
for i in 0..5 {
let _ = f.update_scalar(i as f64).unwrap();
}
let before = f.current_shingle().unwrap();
let _ = f.score_scalar(99.0).unwrap();
let after = f.current_shingle().unwrap();
assert_eq!(before, after);
}
#[test]
fn reset_ring_clears_warm_state_but_preserves_forest() {
let mut f = small();
for i in 0..10 {
let _ = f.update_scalar(i as f64).unwrap();
}
assert!(f.is_warmed());
f.reset_ring();
assert!(!f.is_warmed());
assert_eq!(f.current_shingle(), None);
for i in 0..10 {
let _ = f.update_scalar(i as f64).unwrap();
}
let s: f64 = f.score_scalar(100.0).unwrap().into();
assert!(s.is_finite());
}
#[test]
fn codisp_stateless_on_shingle_matches_bare_forest() {
let mut f = small();
for i in 0..50 {
let _ = f.update_scalar(i as f64 * 0.01).unwrap();
}
let scalar_codisp: f64 = f.score_codisp_stateless_scalar(5.0).unwrap().into();
let shingle = f.shingle_with(5.0).unwrap();
let direct: f64 = f.forest().score_codisp_stateless(&shingle).unwrap().into();
assert!((scalar_codisp - direct).abs() < 1.0e-12);
}
}