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}