use crate::Direction;
macro_rules! impl_cusum_update {
(float, $ty:ty) => {
#[inline]
pub fn update(&mut self, sample: $ty) -> Result<Option<Direction>, crate::DataError> {
check_finite!(sample);
self.count += 1;
let diff = sample - self.target;
let s_high = self.upper + diff - self.slack_upper;
self.upper = s_high.max(0 as $ty);
let s_low = self.lower - diff - self.slack_lower;
self.lower = s_low.max(0 as $ty);
if self.count < self.min_samples {
return Ok(Option::None);
}
Ok(if self.upper > self.threshold_upper {
Option::Some(Direction::Rising)
} else if self.lower > self.threshold_lower {
Option::Some(Direction::Falling)
} else {
Option::Some(Direction::Neutral)
})
}
};
(int, $ty:ty) => {
#[inline]
#[must_use]
pub fn update(&mut self, sample: $ty) -> Option<Direction> {
self.count += 1;
let diff = sample - self.target;
let s_high = self.upper + diff - self.slack_upper;
self.upper = s_high.max(0 as $ty);
let s_low = self.lower - diff - self.slack_lower;
self.lower = s_low.max(0 as $ty);
if self.count < self.min_samples {
return Option::None;
}
if self.upper > self.threshold_upper {
Option::Some(Direction::Rising)
} else if self.lower > self.threshold_lower {
Option::Some(Direction::Falling)
} else {
Option::Some(Direction::Neutral)
}
}
};
}
macro_rules! impl_cusum {
($name:ident, $builder:ident, $ty:ty, $kind:tt, min_slack = $min_slack:expr) => {
#[derive(Debug, Clone)]
pub struct $name {
target: $ty,
slack_upper: $ty,
slack_lower: $ty,
threshold_upper: $ty,
threshold_lower: $ty,
upper: $ty,
lower: $ty,
count: u64,
min_samples: u64,
slack_upper_explicit: bool,
slack_lower_explicit: bool,
threshold_upper_explicit: bool,
threshold_lower_explicit: bool,
}
#[doc = stringify!($name)]
#[doc = concat!("let mut cusum = ", stringify!($name), "::builder(100 as ", stringify!($ty), ")")]
#[derive(Debug, Clone)]
pub struct $builder {
target: $ty,
slack_upper: Option<$ty>,
slack_lower: Option<$ty>,
threshold_upper: Option<$ty>,
threshold_lower: Option<$ty>,
min_samples: u64,
seed_upper: Option<$ty>,
seed_lower: Option<$ty>,
}
impl $name {
#[inline]
#[must_use]
pub fn builder(target: $ty) -> $builder {
$builder {
target,
slack_upper: Option::None,
slack_lower: Option::None,
threshold_upper: Option::None,
threshold_lower: Option::None,
min_samples: 0,
seed_upper: Option::None,
seed_lower: Option::None,
}
}
impl_cusum_update!($kind, $ty);
#[inline]
#[must_use]
pub fn upper(&self) -> $ty {
self.upper
}
#[inline]
#[must_use]
pub fn lower(&self) -> $ty {
self.lower
}
#[inline]
#[must_use]
pub fn count(&self) -> u64 {
self.count
}
#[inline]
#[must_use]
pub fn is_primed(&self) -> bool {
self.count >= self.min_samples
}
#[inline]
pub fn reset(&mut self) {
self.upper = 0 as $ty;
self.lower = 0 as $ty;
self.count = 0;
}
#[inline]
pub fn reset_with_target(&mut self, new_target: $ty) {
self.target = new_target;
self.upper = 0 as $ty;
self.lower = 0 as $ty;
self.count = 0;
if !self.slack_upper_explicit {
self.slack_upper = $builder::default_slack(new_target);
}
if !self.slack_lower_explicit {
self.slack_lower = $builder::default_slack(new_target);
}
if !self.threshold_upper_explicit {
self.threshold_upper = $builder::default_threshold(new_target);
}
if !self.threshold_lower_explicit {
self.threshold_lower = $builder::default_threshold(new_target);
}
}
#[inline]
#[must_use]
pub fn target(&self) -> $ty {
self.target
}
#[inline]
#[must_use]
pub fn slack_upper(&self) -> $ty {
self.slack_upper
}
#[inline]
#[must_use]
pub fn slack_lower(&self) -> $ty {
self.slack_lower
}
#[inline]
#[must_use]
pub fn threshold_upper(&self) -> $ty {
self.threshold_upper
}
#[inline]
#[must_use]
pub fn threshold_lower(&self) -> $ty {
self.threshold_lower
}
#[inline]
#[must_use]
pub fn min_samples(&self) -> u64 {
self.min_samples
}
#[inline]
pub fn reconfigure(
&mut self,
target: $ty,
slack_upper: $ty,
slack_lower: $ty,
threshold_upper: $ty,
threshold_lower: $ty,
) -> Result<(), crate::ConfigError> {
if slack_upper < (0 as $ty) {
return Err(crate::ConfigError::Invalid("slack_upper must be non-negative"));
}
if slack_lower < (0 as $ty) {
return Err(crate::ConfigError::Invalid("slack_lower must be non-negative"));
}
if threshold_upper <= (0 as $ty) {
return Err(crate::ConfigError::Invalid("threshold_upper must be positive"));
}
if threshold_lower <= (0 as $ty) {
return Err(crate::ConfigError::Invalid("threshold_lower must be positive"));
}
self.target = target;
self.slack_upper = slack_upper;
self.slack_lower = slack_lower;
self.threshold_upper = threshold_upper;
self.threshold_lower = threshold_lower;
self.slack_upper_explicit = true;
self.slack_lower_explicit = true;
self.threshold_upper_explicit = true;
self.threshold_lower_explicit = true;
Ok(())
}
}
impl $builder {
#[inline]
fn default_slack(target: $ty) -> $ty {
let abs_target = if target < (0 as $ty) { (0 as $ty) - target } else { target };
let slack = abs_target / (20 as $ty);
if slack < ($min_slack as $ty) { $min_slack as $ty } else { slack }
}
#[inline]
fn default_threshold(target: $ty) -> $ty {
let abs_target = if target < (0 as $ty) { (0 as $ty) - target } else { target };
abs_target / (2 as $ty)
}
#[inline]
#[must_use]
pub fn slack(mut self, slack: $ty) -> Self {
self.slack_upper = Option::Some(slack);
self.slack_lower = Option::Some(slack);
self
}
#[inline]
#[must_use]
pub fn slack_upper(mut self, slack: $ty) -> Self {
self.slack_upper = Option::Some(slack);
self
}
#[inline]
#[must_use]
pub fn slack_lower(mut self, slack: $ty) -> Self {
self.slack_lower = Option::Some(slack);
self
}
#[inline]
#[must_use]
pub fn threshold(mut self, threshold: $ty) -> Self {
self.threshold_upper = Option::Some(threshold);
self.threshold_lower = Option::Some(threshold);
self
}
#[inline]
#[must_use]
pub fn threshold_upper(mut self, threshold: $ty) -> Self {
self.threshold_upper = Option::Some(threshold);
self
}
#[inline]
#[must_use]
pub fn threshold_lower(mut self, threshold: $ty) -> Self {
self.threshold_lower = Option::Some(threshold);
self
}
#[inline]
#[must_use]
pub fn min_samples(mut self, min: u64) -> Self {
self.min_samples = min;
self
}
#[inline]
#[must_use]
pub fn seed_upper(mut self, val: $ty) -> Self {
self.seed_upper = Option::Some(val);
self
}
#[inline]
#[must_use]
pub fn seed_lower(mut self, val: $ty) -> Self {
self.seed_lower = Option::Some(val);
self
}
#[inline]
pub fn build(self) -> Result<$name, crate::ConfigError> {
let slack_upper_explicit = self.slack_upper.is_some();
let slack_lower_explicit = self.slack_lower.is_some();
let threshold_upper_explicit = self.threshold_upper.is_some();
let threshold_lower_explicit = self.threshold_lower.is_some();
let slack_upper = self.slack_upper.unwrap_or_else(|| Self::default_slack(self.target));
let slack_lower = self.slack_lower.unwrap_or_else(|| Self::default_slack(self.target));
let threshold_upper = self.threshold_upper.unwrap_or_else(|| Self::default_threshold(self.target));
let threshold_lower = self.threshold_lower.unwrap_or_else(|| Self::default_threshold(self.target));
if slack_upper < (0 as $ty) {
return Err(crate::ConfigError::Invalid("slack_upper must be non-negative"));
}
if slack_lower < (0 as $ty) {
return Err(crate::ConfigError::Invalid("slack_lower must be non-negative"));
}
if threshold_upper <= (0 as $ty) {
return Err(crate::ConfigError::Invalid("threshold_upper must be positive"));
}
if threshold_lower <= (0 as $ty) {
return Err(crate::ConfigError::Invalid("threshold_lower must be positive"));
}
let seeded = self.seed_upper.is_some() || self.seed_lower.is_some();
let initial_count = if seeded { self.min_samples } else { 0 };
Ok($name {
target: self.target,
slack_upper,
slack_lower,
threshold_upper,
threshold_lower,
upper: self.seed_upper.unwrap_or(0 as $ty),
lower: self.seed_lower.unwrap_or(0 as $ty),
count: initial_count,
min_samples: self.min_samples,
slack_upper_explicit,
slack_lower_explicit,
threshold_upper_explicit,
threshold_lower_explicit,
})
}
}
};
}
impl_cusum!(CusumF64, CusumF64Builder, f64, float, min_slack = 0.0);
impl_cusum!(CusumF32, CusumF32Builder, f32, float, min_slack = 0.0);
impl_cusum!(CusumI64, CusumI64Builder, i64, int, min_slack = 1);
impl_cusum!(CusumI32, CusumI32Builder, i32, int, min_slack = 1);
impl_cusum!(CusumI128, CusumI128Builder, i128, int, min_slack = 1);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_upward_shift() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
for _ in 0..10 {
let result = cusum.update(100.0).unwrap();
assert_eq!(result, Some(Direction::Neutral));
}
let mut triggered = false;
for _ in 0..100 {
if cusum.update(120.0).unwrap() == Some(Direction::Rising) {
triggered = true;
break;
}
}
assert!(triggered, "should have detected upward shift");
}
#[test]
fn detects_downward_shift() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
let mut triggered = false;
for _ in 0..100 {
if cusum.update(80.0).unwrap() == Some(Direction::Falling) {
triggered = true;
break;
}
}
assert!(triggered, "should have detected downward shift");
}
#[test]
fn no_false_positive_at_target() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
for _ in 0..1000 {
assert_eq!(cusum.update(100.0).unwrap(), Some(Direction::Neutral));
}
}
#[test]
fn returns_none_before_primed() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.min_samples(10)
.build()
.unwrap();
for _ in 0..9 {
assert_eq!(cusum.update(200.0).unwrap(), None);
}
assert!(!cusum.is_primed());
let result = cusum.update(200.0).unwrap();
assert!(result.is_some());
assert!(cusum.is_primed());
}
#[test]
fn primed_immediately_with_zero_min_samples() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert_eq!(cusum.min_samples(), 0);
assert!(cusum.update(100.0).unwrap().is_some());
}
#[test]
#[allow(clippy::float_cmp)]
fn reset_clears_state() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
for _ in 0..10 {
let _ = cusum.update(120.0);
}
assert!(cusum.upper() > 0.0);
assert!(cusum.count() > 0);
cusum.reset();
assert_eq!(cusum.upper(), 0.0);
assert_eq!(cusum.lower(), 0.0);
assert_eq!(cusum.count(), 0);
}
#[test]
fn reset_with_target_updates_defaults() {
let mut cusum = CusumF64::builder(100.0).build().unwrap();
let original_slack = cusum.slack_upper();
let original_threshold = cusum.threshold_upper();
cusum.reset_with_target(200.0);
assert!(cusum.slack_upper() > original_slack);
assert!(cusum.threshold_upper() > original_threshold);
}
#[test]
#[allow(clippy::float_cmp)]
fn reset_with_target_preserves_explicit_params() {
let mut cusum = CusumF64::builder(100.0)
.slack(10.0)
.threshold(75.0)
.build()
.unwrap();
cusum.reset_with_target(200.0);
assert_eq!(cusum.slack_upper(), 10.0);
assert_eq!(cusum.slack_lower(), 10.0);
assert_eq!(cusum.threshold_upper(), 75.0);
assert_eq!(cusum.threshold_lower(), 75.0);
}
#[test]
fn asymmetric_slack() {
let mut cusum = CusumF64::builder(100.0)
.slack_upper(2.0)
.slack_lower(10.0)
.threshold(50.0)
.build()
.unwrap();
for _ in 0..10 {
let _ = cusum.update(110.0);
}
let upper_after = cusum.upper();
cusum.reset();
for _ in 0..10 {
let _ = cusum.update(90.0);
}
let lower_after = cusum.lower();
assert!(
upper_after > lower_after,
"upper ({upper_after}) should accumulate faster than lower ({lower_after}) with tighter slack"
);
}
#[test]
fn asymmetric_threshold() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold_upper(20.0) .threshold_lower(500.0) .build()
.unwrap();
let mut upper_triggered = false;
for _ in 0..20 {
if cusum.update(120.0).unwrap() == Some(Direction::Rising) {
upper_triggered = true;
break;
}
}
assert!(upper_triggered);
cusum.reset();
let mut lower_triggered = false;
for _ in 0..20 {
if cusum.update(80.0).unwrap() == Some(Direction::Falling) {
lower_triggered = true;
break;
}
}
assert!(
!lower_triggered,
"lower should not trigger with high threshold"
);
}
#[test]
#[allow(clippy::float_cmp)]
fn symmetric_slack_sets_both() {
let cusum = CusumF64::builder(100.0).slack(7.5).build().unwrap();
assert_eq!(cusum.slack_upper(), 7.5);
assert_eq!(cusum.slack_lower(), 7.5);
}
#[test]
#[allow(clippy::float_cmp)]
fn symmetric_threshold_sets_both() {
let cusum = CusumF64::builder(100.0).threshold(42.0).build().unwrap();
assert_eq!(cusum.threshold_upper(), 42.0);
assert_eq!(cusum.threshold_lower(), 42.0);
}
#[test]
fn rejects_negative_slack_upper() {
let result = CusumF64::builder(100.0).slack_upper(-1.0).build();
assert!(matches!(
result,
Err(crate::ConfigError::Invalid(
"slack_upper must be non-negative"
))
));
}
#[test]
fn rejects_negative_slack_lower() {
let result = CusumF64::builder(100.0).slack_lower(-1.0).build();
assert!(matches!(
result,
Err(crate::ConfigError::Invalid(
"slack_lower must be non-negative"
))
));
}
#[test]
fn rejects_zero_threshold() {
let result = CusumF64::builder(100.0).threshold(0.0).build();
assert!(matches!(
result,
Err(crate::ConfigError::Invalid(
"threshold_upper must be positive"
))
));
}
#[test]
fn rejects_negative_threshold_lower() {
let result = CusumF64::builder(100.0).threshold_lower(-1.0).build();
assert!(matches!(
result,
Err(crate::ConfigError::Invalid(
"threshold_lower must be positive"
))
));
}
#[test]
fn i64_detects_upward_shift() {
let mut cusum = CusumI64::builder(1000)
.slack(50)
.threshold(500)
.build()
.unwrap();
let mut triggered = false;
for _ in 0..100 {
if cusum.update(1200) == Some(Direction::Rising) {
triggered = true;
break;
}
}
assert!(triggered);
}
#[test]
fn i32_basic() {
let mut cusum = CusumI32::builder(100)
.slack(5)
.threshold(50)
.build()
.unwrap();
assert_eq!(cusum.update(100), Some(Direction::Neutral));
}
#[test]
fn f32_basic() {
let mut cusum = CusumF32::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert_eq!(cusum.update(100.0).unwrap(), Some(Direction::Neutral));
}
#[test]
fn count_increments() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert_eq!(cusum.count(), 0);
let _ = cusum.update(100.0);
assert_eq!(cusum.count(), 1);
let _ = cusum.update(100.0);
assert_eq!(cusum.count(), 2);
}
#[test]
#[allow(clippy::float_cmp)]
fn upper_and_lower_start_at_zero() {
let cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert_eq!(cusum.upper(), 0.0);
assert_eq!(cusum.lower(), 0.0);
}
#[test]
#[allow(clippy::float_cmp)]
fn cusum_at_exactly_slack_no_accumulation() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
let _ = cusum.update(105.0);
assert_eq!(cusum.upper(), 0.0);
}
#[test]
#[allow(clippy::float_cmp)]
fn reconfigure_changes_params_preserves_state() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
for _ in 0..5 {
let _ = cusum.update(120.0);
}
let upper_before = cusum.upper();
let count_before = cusum.count();
assert!(upper_before > 0.0);
cusum.reconfigure(200.0, 10.0, 10.0, 100.0, 100.0).unwrap();
assert_eq!(cusum.target(), 200.0);
assert_eq!(cusum.slack_upper(), 10.0);
assert_eq!(cusum.slack_lower(), 10.0);
assert_eq!(cusum.threshold_upper(), 100.0);
assert_eq!(cusum.threshold_lower(), 100.0);
assert_eq!(cusum.upper(), upper_before);
assert_eq!(cusum.count(), count_before);
}
#[test]
fn reconfigure_validates() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert!(cusum.reconfigure(100.0, -1.0, 0.0, 1.0, 1.0).is_err());
assert!(cusum.reconfigure(100.0, 0.0, -1.0, 1.0, 1.0).is_err());
assert!(cusum.reconfigure(100.0, 0.0, 0.0, 0.0, 1.0).is_err());
assert!(cusum.reconfigure(100.0, 0.0, 0.0, 1.0, 0.0).is_err());
}
#[test]
fn i128_basic() {
let mut cusum = CusumI128::builder(100)
.slack(5)
.threshold(50)
.build()
.unwrap();
assert_eq!(cusum.update(100), Some(Direction::Neutral));
}
#[test]
fn integer_default_slack_floor() {
let cusum = CusumI64::builder(10).threshold(5).build().unwrap();
assert_eq!(cusum.slack_upper(), 1);
assert_eq!(cusum.slack_lower(), 1);
let cusum = CusumI64::builder(100).threshold(50).build().unwrap();
assert_eq!(cusum.slack_upper(), 5);
}
#[test]
fn rejects_nan_and_inf() {
let mut cusum = CusumF64::builder(100.0)
.slack(5.0)
.threshold(50.0)
.build()
.unwrap();
assert_eq!(
cusum.update(f64::NAN).unwrap_err(),
crate::DataError::NotANumber
);
assert_eq!(
cusum.update(f64::INFINITY).unwrap_err(),
crate::DataError::Infinite
);
assert_eq!(
cusum.update(f64::NEG_INFINITY).unwrap_err(),
crate::DataError::Infinite
);
assert_eq!(cusum.count(), 0);
}
}