Skip to main content

dsfb_robotics/
sign.rs

1//! Residual sign tuple σ(k) = (‖r(k)‖, ṙ(k), r̈(k)) and its sliding-window
2//! estimator.
3//!
4//! The sign tuple is the coordinate of the **semiotic manifold** M_sem
5//! ⊂ ℝ³ — DSFB's primary inferential object. Incumbent robotics
6//! observers (Luenberger, Kalman, inverse-dynamics identification)
7//! collapse residuals to a scalar or a covariance-shaped likelihood and
8//! discard the trajectory. DSFB retains all three coordinates:
9//! magnitude (what threshold alarms see), drift (what they discard
10//! between alarms), and slew (the curvature signal for abrupt-onset
11//! regime changes such as collisions or payload steps).
12//!
13//! ## Definitions
14//!
15//! σ(k) = (‖r(k)‖, ṙ(k), r̈(k))
16//!
17//! ṙ(k) = (1/W) Σ_{j=k-W+1}^{k} (‖r(j)‖ − ‖r(j-1)‖)
18//!
19//! r̈(k) = ṙ(k) − ṙ(k-1)
20//!
21//! Below-nominal-floor samples (e.g. residual magnitude below the
22//! known sensor noise floor) contribute **zero** drift and slew, so
23//! DSFB does not attribute structural meaning to pure-noise windows.
24
25/// A single residual sign tuple.
26///
27/// All DSFB grammar states, motif classifications, and policy
28/// decisions derive from this object alone. The field names deliberately
29/// mirror dsfb-rf (`norm`, `drift`, `slew`) so cross-crate tooling can
30/// consume sign tuples uniformly.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct SignTuple {
33    /// ‖r(k)‖ — instantaneous residual norm. What threshold detectors see.
34    pub norm: f64,
35    /// ṙ(k) — mean first-difference over the drift window.
36    pub drift: f64,
37    /// r̈(k) — slew (drift curvature).
38    pub slew: f64,
39}
40
41impl SignTuple {
42    /// Construct a sign tuple from explicit components.
43    #[inline]
44    #[must_use]
45    pub const fn new(norm: f64, drift: f64, slew: f64) -> Self {
46        debug_assert!(norm.is_finite() || norm.is_nan(), "norm must be finite or NaN");
47        Self { norm, drift, slew }
48    }
49
50    /// The zero sign tuple — residual at rest, no drift, no curvature.
51    #[inline]
52    #[must_use]
53    pub const fn zero() -> Self {
54        Self { norm: 0.0, drift: 0.0, slew: 0.0 }
55    }
56
57    /// Returns `true` if drift is positive (outward motion relative to nominal).
58    #[inline]
59    #[must_use]
60    pub fn is_outward_drift(&self) -> bool {
61        self.drift > 0.0
62    }
63
64    /// Returns `true` if the slew magnitude exceeds the abrupt-slew
65    /// threshold `delta_s`.
66    #[inline]
67    #[must_use]
68    pub fn is_abrupt_slew(&self, delta_s: f64) -> bool {
69        debug_assert!(delta_s >= 0.0, "delta_s must be non-negative");
70        crate::math::abs_f64(self.slew) > delta_s
71    }
72}
73
74impl Default for SignTuple {
75    fn default() -> Self {
76        Self::zero()
77    }
78}
79
80/// Fixed-capacity sliding window for computing sign tuples from a
81/// streaming residual sequence.
82///
83/// Generic parameter `W` is the drift-window width. All storage is
84/// stack-allocated: no heap, no `unsafe`, no `std`.
85pub struct SignWindow<const W: usize> {
86    norms: [f64; W],
87    prev_drift: f64,
88    head: usize,
89    /// Saturates at `W` — we never need a larger count.
90    count: usize,
91}
92
93impl<const W: usize> SignWindow<W> {
94    /// Create an empty sliding window.
95    #[must_use]
96    pub const fn new() -> Self {
97        Self { norms: [0.0; W], prev_drift: 0.0, head: 0, count: 0 }
98    }
99
100    /// Insert the next residual norm and return the current sign tuple.
101    ///
102    /// When `below_floor` is `true`, the sample is stored (so future
103    /// diffs see it) but drift and slew are forced to zero for this
104    /// observation to avoid attributing structural meaning to
105    /// noise-floor samples.
106    pub fn push(&mut self, norm: f64, below_floor: bool) -> SignTuple {
107        debug_assert!(W > 0, "SignWindow<0> is degenerate — W must be ≥ 1");
108        debug_assert!(self.head < W.max(1), "head invariant violated");
109
110        if W == 0 {
111            // Degenerate configuration — return an all-zero tuple rather
112            // than reading or writing the zero-length array. Guarded by
113            // debug_assert above; release builds short-circuit safely.
114            return SignTuple::zero();
115        }
116
117        self.norms[self.head] = norm;
118        self.head = (self.head + 1) % W;
119        if self.count < W {
120            self.count += 1;
121        }
122
123        if below_floor || self.count < 2 {
124            self.prev_drift = 0.0;
125            return SignTuple::new(norm, 0.0, 0.0);
126        }
127
128        // Mean first-difference across the filled portion of the buffer.
129        let filled = self.count.min(W);
130        let mut sum_diff = 0.0_f64;
131        let mut n_diffs = 0_usize;
132        let mut i = 1_usize;
133        while i < filled {
134            let cur = (self.head + W - 1 - (i - 1)) % W;
135            let prev = (self.head + W - 1 - i) % W;
136            sum_diff += self.norms[cur] - self.norms[prev];
137            n_diffs += 1;
138            i += 1;
139        }
140
141        let drift = if n_diffs > 0 { sum_diff / n_diffs as f64 } else { 0.0 };
142        let slew = drift - self.prev_drift;
143        self.prev_drift = drift;
144        SignTuple::new(norm, drift, slew)
145    }
146
147    /// Reset the window (e.g. after a context transition suppression
148    /// period has ended).
149    pub fn reset(&mut self) {
150        self.norms = [0.0; W];
151        self.prev_drift = 0.0;
152        self.head = 0;
153        self.count = 0;
154    }
155
156    /// The number of samples accumulated so far (saturates at `W`).
157    #[inline]
158    #[must_use]
159    pub fn count(&self) -> usize {
160        self.count
161    }
162}
163
164impl<const W: usize> Default for SignWindow<W> {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn zero_tuple_is_rest() {
176        let s = SignTuple::zero();
177        assert_eq!(s.norm, 0.0);
178        assert!(!s.is_outward_drift());
179        assert!(!s.is_abrupt_slew(0.01));
180    }
181
182    #[test]
183    fn outward_drift_is_positive_drift() {
184        assert!(SignTuple::new(0.1, 0.01, 0.0).is_outward_drift());
185        assert!(!SignTuple::new(0.1, 0.0, 0.0).is_outward_drift());
186        assert!(!SignTuple::new(0.1, -0.01, 0.0).is_outward_drift());
187    }
188
189    #[test]
190    fn abrupt_slew_threshold_is_absolute() {
191        assert!(SignTuple::new(0.1, 0.0, 0.1).is_abrupt_slew(0.05));
192        assert!(SignTuple::new(0.1, 0.0, -0.1).is_abrupt_slew(0.05));
193        assert!(!SignTuple::new(0.1, 0.0, 0.01).is_abrupt_slew(0.05));
194    }
195
196    #[test]
197    fn window_sub_floor_forces_zero_drift() {
198        let mut w = SignWindow::<5>::new();
199        for i in 0..5u32 {
200            let s = w.push(i as f64 * 0.1, true);
201            assert_eq!(s.drift, 0.0);
202            assert_eq!(s.slew, 0.0);
203        }
204    }
205
206    #[test]
207    fn window_monotone_increase_has_positive_drift() {
208        let mut w = SignWindow::<5>::new();
209        for i in 0..8u32 {
210            let s = w.push(i as f64 * 0.01, false);
211            if i >= 2 {
212                assert!(s.drift > 0.0, "expected positive drift, got {}", s.drift);
213            }
214        }
215    }
216
217    #[test]
218    fn window_constant_input_has_zero_drift() {
219        let mut w = SignWindow::<5>::new();
220        let mut last = None;
221        for _ in 0..8 {
222            last = Some(w.push(0.42, false));
223        }
224        let s = last.expect("pushed at least once");
225        assert!(crate::math::abs_f64(s.drift) < 1e-12, "drift = {}", s.drift);
226    }
227
228    #[test]
229    fn window_reset_clears_state() {
230        let mut w = SignWindow::<5>::new();
231        for i in 0..5u32 {
232            w.push(i as f64 * 0.1, false);
233        }
234        w.reset();
235        assert_eq!(w.count(), 0);
236        let s = w.push(0.5, false);
237        assert_eq!(s.drift, 0.0);
238    }
239}