Skip to main content

dsfb_rf/
sign.rs

1//! Residual sign tuple: (‖r‖, ṙ, r̈)
2//!
3//! The semiotic manifold coordinate. This is the primary inferential object
4//! from which all higher-level DSFB states derive.
5//!
6//! ## Mathematical Definition (paper §B.2)
7//!
8//! σ(k) = (‖r(k)‖, ṙ(k), r̈(k))
9//!
10//! ṙ(k) = (1/W) Σ_{j=k-W+1}^{k} (‖r(j)‖ - ‖r(j-1)‖)
11//! r̈(k) = ṙ(k) - ṙ(k-1)
12//!
13//! Sub-threshold observations (SNR < SNR_floor) contribute zero to
14//! drift and slew sums (missingness-aware signal validity).
15//!
16//! ## Key distinction from Luenberger/Kalman (paper §I-C, Table I)
17//!
18//! A linear observer gain matrix L maps r(k) → L·r(k), collapsing the
19//! manifold to a scalar. The sign tuple preserves all three coordinates:
20//! magnitude, drift direction, and trajectory curvature.
21
22use crate::platform::SnrFloor;
23
24/// The residual sign tuple σ(k) = (‖r‖, ṙ, r̈).
25///
26/// This is the coordinate on the semiotic manifold M_sem ⊂ ℝ³.
27/// All DSFB grammar states, motif classifications, and DSA scores
28/// are derived from this object alone.
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct SignTuple {
31    /// ‖r(k)‖ — instantaneous residual norm. What threshold detectors see.
32    pub norm: f32,
33    /// ṙ(k) — finite-difference drift rate. What threshold detectors discard.
34    pub drift: f32,
35    /// r̈(k) — slew (trajectory curvature). Abrupt regime change signal.
36    pub slew: f32,
37}
38
39impl SignTuple {
40    /// Construct a sign tuple directly from components.
41    #[inline]
42    pub const fn new(norm: f32, drift: f32, slew: f32) -> Self {
43        Self { norm, drift, slew }
44    }
45
46    /// Zero sign tuple — admissible baseline.
47    #[inline]
48    pub const fn zero() -> Self {
49        Self { norm: 0.0, drift: 0.0, slew: 0.0 }
50    }
51
52    /// Returns true if drift is persistently outward (drift > 0).
53    #[inline]
54    pub fn is_outward_drift(&self) -> bool {
55        self.drift > 0.0
56    }
57
58    /// Returns true if slew magnitude exceeds threshold δ_s.
59    #[inline]
60    pub fn is_abrupt_slew(&self, delta_s: f32) -> bool {
61        self.slew.abs() > delta_s
62    }
63}
64
65/// Fixed-capacity sliding window for computing sign tuples.
66///
67/// Generic parameter `W` is the window width. All storage is stack-allocated.
68/// No heap allocation, no std, no unsafe.
69pub struct SignWindow<const W: usize> {
70    /// Circular buffer of recent residual norms.
71    norms: [f32; W],
72    /// Previous drift estimate (for slew computation).
73    prev_drift: f32,
74    /// Write position in the circular buffer.
75    head: usize,
76    /// Number of valid observations inserted so far (saturates at W).
77    count: usize,
78}
79
80impl<const W: usize> SignWindow<W> {
81    /// Create a new empty sign window.
82    pub const fn new() -> Self {
83        Self {
84            norms: [0.0; W],
85            prev_drift: 0.0,
86            head: 0,
87            count: 0,
88        }
89    }
90
91    /// Push a new residual norm observation and return the current sign tuple.
92    ///
93    /// If `sub_threshold` is true (SNR below floor), the norm is stored as-is
94    /// but drift and slew are forced to zero per the missingness-aware signal
95    /// validity rule (paper §IX-C, §B.2).
96    pub fn push(&mut self, norm: f32, sub_threshold: bool, snr_floor: SnrFloor) -> SignTuple {
97        // snr_floor is consumed for type-safety at the API boundary; the
98        // sub-threshold decision has already been applied by the caller and
99        // is passed via the `sub_threshold` flag.
100        core::hint::black_box(snr_floor);
101
102        // Write norm into circular buffer
103        self.norms[self.head] = norm;
104        self.head = (self.head + 1) % W;
105        if self.count < W {
106            self.count += 1;
107        }
108
109        if sub_threshold || self.count < 2 {
110            // Sub-threshold: zero drift and slew per paper §B.2
111            self.prev_drift = 0.0;
112            return SignTuple::new(norm, 0.0, 0.0);
113        }
114
115        // Compute mean first-difference over filled portion of window
116        let filled = self.count.min(W);
117        let mut sum_diff = 0.0_f32;
118        let mut n_diffs = 0usize;
119
120        for i in 1..filled {
121            let cur_idx = (self.head + W - 1 - (i - 1)) % W;
122            let prev_idx = (self.head + W - 1 - i) % W;
123            sum_diff += self.norms[cur_idx] - self.norms[prev_idx];
124            n_diffs += 1;
125        }
126
127        let drift = if n_diffs > 0 {
128            sum_diff / n_diffs as f32
129        } else {
130            0.0
131        };
132        let slew = drift - self.prev_drift;
133        self.prev_drift = drift;
134
135        SignTuple::new(norm, drift, slew)
136    }
137
138    /// Reset the window (e.g., after a waveform transition suppression period).
139    pub fn reset(&mut self) {
140        self.norms = [0.0; W];
141        self.prev_drift = 0.0;
142        self.head = 0;
143        self.count = 0;
144    }
145}
146
147impl<const W: usize> Default for SignWindow<W> {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153// ---------------------------------------------------------------
154// Tests
155// ---------------------------------------------------------------
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::platform::SnrFloor;
160
161    #[test]
162    fn sign_tuple_zero_is_admissible() {
163        let s = SignTuple::zero();
164        assert_eq!(s.norm, 0.0);
165        assert!(!s.is_outward_drift());
166        assert!(!s.is_abrupt_slew(0.01));
167    }
168
169    #[test]
170    fn window_drift_monotone_increase() {
171        let mut w = SignWindow::<5>::new();
172        let floor = SnrFloor::default();
173        // Feed monotonically increasing norms — drift should be positive
174        let mut last_drift = -f32::INFINITY;
175        for i in 0..8u32 {
176            let norm = i as f32 * 0.01;
177            let sig = w.push(norm, false, floor);
178            if i >= 2 {
179                assert!(sig.drift >= 0.0, "drift should be non-negative for increasing norms");
180                let _ = last_drift; // suppress unused warning
181                last_drift = sig.drift;
182            }
183        }
184        let _ = last_drift;
185    }
186
187    #[test]
188    fn window_sub_threshold_forces_zero_drift() {
189        let mut w = SignWindow::<5>::new();
190        let floor = SnrFloor::default();
191        // Sub-threshold observations must not contribute drift
192        for i in 0..5u32 {
193            let norm = i as f32 * 0.05;
194            let sig = w.push(norm, true, floor);
195            assert_eq!(sig.drift, 0.0);
196            assert_eq!(sig.slew, 0.0);
197        }
198    }
199
200    #[test]
201    fn window_reset_clears_state() {
202        let mut w = SignWindow::<5>::new();
203        let floor = SnrFloor::default();
204        for i in 0..5u32 {
205            w.push(i as f32 * 0.1, false, floor);
206        }
207        w.reset();
208        let sig = w.push(0.05, false, floor);
209        // After reset, count=1, so drift/slew should be zero
210        assert_eq!(sig.drift, 0.0);
211    }
212}