#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SignTuple {
pub norm: f64,
pub drift: f64,
pub slew: f64,
}
impl SignTuple {
#[inline]
#[must_use]
pub const fn new(norm: f64, drift: f64, slew: f64) -> Self {
debug_assert!(norm.is_finite() || norm.is_nan(), "norm must be finite or NaN");
Self { norm, drift, slew }
}
#[inline]
#[must_use]
pub const fn zero() -> Self {
Self { norm: 0.0, drift: 0.0, slew: 0.0 }
}
#[inline]
#[must_use]
pub fn is_outward_drift(&self) -> bool {
self.drift > 0.0
}
#[inline]
#[must_use]
pub fn is_abrupt_slew(&self, delta_s: f64) -> bool {
debug_assert!(delta_s >= 0.0, "delta_s must be non-negative");
crate::math::abs_f64(self.slew) > delta_s
}
}
impl Default for SignTuple {
fn default() -> Self {
Self::zero()
}
}
pub struct SignWindow<const W: usize> {
norms: [f64; W],
prev_drift: f64,
head: usize,
count: usize,
}
impl<const W: usize> SignWindow<W> {
#[must_use]
pub const fn new() -> Self {
Self { norms: [0.0; W], prev_drift: 0.0, head: 0, count: 0 }
}
pub fn push(&mut self, norm: f64, below_floor: bool) -> SignTuple {
debug_assert!(W > 0, "SignWindow<0> is degenerate — W must be ≥ 1");
debug_assert!(self.head < W.max(1), "head invariant violated");
if W == 0 {
return SignTuple::zero();
}
self.norms[self.head] = norm;
self.head = (self.head + 1) % W;
if self.count < W {
self.count += 1;
}
if below_floor || self.count < 2 {
self.prev_drift = 0.0;
return SignTuple::new(norm, 0.0, 0.0);
}
let filled = self.count.min(W);
let mut sum_diff = 0.0_f64;
let mut n_diffs = 0_usize;
let mut i = 1_usize;
while i < filled {
let cur = (self.head + W - 1 - (i - 1)) % W;
let prev = (self.head + W - 1 - i) % W;
sum_diff += self.norms[cur] - self.norms[prev];
n_diffs += 1;
i += 1;
}
let drift = if n_diffs > 0 { sum_diff / n_diffs as f64 } else { 0.0 };
let slew = drift - self.prev_drift;
self.prev_drift = drift;
SignTuple::new(norm, drift, slew)
}
pub fn reset(&mut self) {
self.norms = [0.0; W];
self.prev_drift = 0.0;
self.head = 0;
self.count = 0;
}
#[inline]
#[must_use]
pub fn count(&self) -> usize {
self.count
}
}
impl<const W: usize> Default for SignWindow<W> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zero_tuple_is_rest() {
let s = SignTuple::zero();
assert_eq!(s.norm, 0.0);
assert!(!s.is_outward_drift());
assert!(!s.is_abrupt_slew(0.01));
}
#[test]
fn outward_drift_is_positive_drift() {
assert!(SignTuple::new(0.1, 0.01, 0.0).is_outward_drift());
assert!(!SignTuple::new(0.1, 0.0, 0.0).is_outward_drift());
assert!(!SignTuple::new(0.1, -0.01, 0.0).is_outward_drift());
}
#[test]
fn abrupt_slew_threshold_is_absolute() {
assert!(SignTuple::new(0.1, 0.0, 0.1).is_abrupt_slew(0.05));
assert!(SignTuple::new(0.1, 0.0, -0.1).is_abrupt_slew(0.05));
assert!(!SignTuple::new(0.1, 0.0, 0.01).is_abrupt_slew(0.05));
}
#[test]
fn window_sub_floor_forces_zero_drift() {
let mut w = SignWindow::<5>::new();
for i in 0..5u32 {
let s = w.push(i as f64 * 0.1, true);
assert_eq!(s.drift, 0.0);
assert_eq!(s.slew, 0.0);
}
}
#[test]
fn window_monotone_increase_has_positive_drift() {
let mut w = SignWindow::<5>::new();
for i in 0..8u32 {
let s = w.push(i as f64 * 0.01, false);
if i >= 2 {
assert!(s.drift > 0.0, "expected positive drift, got {}", s.drift);
}
}
}
#[test]
fn window_constant_input_has_zero_drift() {
let mut w = SignWindow::<5>::new();
let mut last = None;
for _ in 0..8 {
last = Some(w.push(0.42, false));
}
let s = last.expect("pushed at least once");
assert!(crate::math::abs_f64(s.drift) < 1e-12, "drift = {}", s.drift);
}
#[test]
fn window_reset_clears_state() {
let mut w = SignWindow::<5>::new();
for i in 0..5u32 {
w.push(i as f64 * 0.1, false);
}
w.reset();
assert_eq!(w.count(), 0);
let s = w.push(0.5, false);
assert_eq!(s.drift, 0.0);
}
}