Skip to main content

dsfb_rf/
dsa.rs

1//! Deterministic Structural Accumulator (DSA) score.
2//!
3//! ## Mathematical Definition (paper §B.5)
4//!
5//! DSA(k) = w₁·b(k) + w₂·d(k) + w₃·s(k) + w₄·e(k) + w₅·μ(k)
6//!
7//! where:
8//!   b(k) = rolling boundary density (fraction of last W_dsa in Boundary)
9//!   d(k) = outward drift persistence (fraction with ṙ > 0)
10//!   s(k) = slew density (fraction with |r̈| > δ_s)
11//!   e(k) = normalised EWMA alarm occupancy over last W_dsa
12//!   μ(k) = motif recurrence frequency in last W_dsa
13//!
14//! Default weights: w_i = 1.0 (unit weights, paper Stage III config).
15//!
16//! Alert fires when DSA(k) ≥ τ for ≥ K consecutive observations
17//! AND ≥ m feature channels co-activate (corroboration, Lemma 6).
18//!
19//! ## Corroboration (paper Lemma 6)
20//!
21//! False-episode rate decreases monotonically with corroboration count c(k).
22//! The DSA score is a monotonically increasing function of c(k).
23
24use crate::grammar::GrammarState;
25use crate::sign::SignTuple;
26
27/// DSA score value — a dimensionless accumulation metric ∈ [0, 5.0].
28#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct DsaScore(pub f32);
30
31impl DsaScore {
32    /// Zero DSA score.
33    pub const ZERO: Self = Self(0.0);
34
35    /// Raw score value.
36    #[inline]
37    pub fn value(&self) -> f32 { self.0 }
38
39    /// Returns true if score meets or exceeds threshold τ.
40    #[inline]
41    pub fn meets_threshold(&self, tau: f32) -> bool { self.0 >= tau }
42}
43
44/// Fixed-capacity DSA window.
45///
46/// W_DSA = window width for accumulation (default 10, paper Stage III).
47/// K = persistence threshold (default 4).
48pub struct DsaWindow<const W: usize> {
49    /// Circular buffer: was this observation a boundary-approach?
50    boundary_flags: [bool; W],
51    /// Circular buffer: was drift outward?
52    drift_flags: [bool; W],
53    /// Circular buffer: was slew above threshold?
54    slew_flags: [bool; W],
55    /// Circular buffer: was EWMA above threshold?
56    ewma_flags: [bool; W],
57    /// Circular buffer: did a named motif fire?
58    motif_flags: [bool; W],
59    /// Write head.
60    head: usize,
61    /// Count of valid observations (saturates at W).
62    count: usize,
63    /// EWMA accumulator for residual norm.
64    ewma_norm: f32,
65    /// EWMA smoothing weight λ (default 0.20, paper Stage III).
66    lambda: f32,
67    /// EWMA alarm threshold = healthy_mean_ewma + 3σ_ewma.
68    ewma_threshold: f32,
69    /// DSA component weights [w1..w5].
70    weights: [f32; 5],
71    /// Slew threshold δ_s for slew density computation.
72    delta_s: f32,
73}
74
75impl<const W: usize> DsaWindow<W> {
76    /// Create a new DSA window with paper Stage III defaults.
77    ///
78    /// λ = 0.20, unit weights, δ_s = 0.05.
79    /// `ewma_threshold` should be set from healthy-window calibration.
80    pub const fn new(ewma_threshold: f32) -> Self {
81        Self {
82            boundary_flags: [false; W],
83            drift_flags: [false; W],
84            slew_flags: [false; W],
85            ewma_flags: [false; W],
86            motif_flags: [false; W],
87            head: 0,
88            count: 0,
89            ewma_norm: 0.0,
90            lambda: 0.20,
91            ewma_threshold,
92            weights: [1.0; 5],
93            delta_s: 0.05,
94        }
95    }
96
97    /// Push one observation and compute the DSA score.
98    ///
99    /// `motif_fired`: true if a named (non-Unknown) motif was identified.
100    pub fn push(
101        &mut self,
102        sign: &SignTuple,
103        grammar: GrammarState,
104        motif_fired: bool,
105    ) -> DsaScore {
106        // Update EWMA for norm
107        self.ewma_norm = self.lambda * sign.norm + (1.0 - self.lambda) * self.ewma_norm;
108
109        // Compute individual flags
110        let b = grammar.requires_attention();
111        let d = sign.drift > 0.0;
112        let s = sign.slew.abs() > self.delta_s;
113        let e = self.ewma_norm > self.ewma_threshold;
114        let mu = motif_fired;
115
116        // Write into circular buffers
117        let h = self.head;
118        self.boundary_flags[h] = b;
119        self.drift_flags[h] = d;
120        self.slew_flags[h] = s;
121        self.ewma_flags[h] = e;
122        self.motif_flags[h] = mu;
123
124        self.head = (self.head + 1) % W;
125        if self.count < W { self.count += 1; }
126
127        // Compute density scores over filled window
128        let n = self.count as f32;
129        let b_score = self.boundary_flags[..self.count].iter().filter(|&&x| x).count() as f32 / n;
130        let d_score = self.drift_flags[..self.count].iter().filter(|&&x| x).count() as f32 / n;
131        let s_score = self.slew_flags[..self.count].iter().filter(|&&x| x).count() as f32 / n;
132        let e_score = self.ewma_flags[..self.count].iter().filter(|&&x| x).count() as f32 / n;
133        let mu_score = self.motif_flags[..self.count].iter().filter(|&&x| x).count() as f32 / n;
134
135        let score = self.weights[0] * b_score
136            + self.weights[1] * d_score
137            + self.weights[2] * s_score
138            + self.weights[3] * e_score
139            + self.weights[4] * mu_score;
140
141        DsaScore(score)
142    }
143
144    /// Reset the DSA window (e.g., after post-transition guard expires).
145    pub fn reset(&mut self) {
146        self.boundary_flags = [false; W];
147        self.drift_flags = [false; W];
148        self.slew_flags = [false; W];
149        self.ewma_flags = [false; W];
150        self.motif_flags = [false; W];
151        self.head = 0;
152        self.count = 0;
153        self.ewma_norm = 0.0;
154    }
155
156    /// Calibrate the EWMA threshold from healthy-window observations.
157    ///
158    /// Sets ewma_threshold = mean_ewma_norm + 3 * std_ewma_norm over healthy window.
159    pub fn calibrate_ewma_threshold(&mut self, healthy_norms: &[f32]) {
160        if healthy_norms.is_empty() {
161            return;
162        }
163        // Run EWMA over healthy window to get steady-state distribution
164        let mut ewma = 0.0_f32;
165        let mut ewma_vals = [0.0_f32; 256];
166        let clip = healthy_norms.len().min(256);
167        for (i, &n) in healthy_norms[..clip].iter().enumerate() {
168            ewma = self.lambda * n + (1.0 - self.lambda) * ewma;
169            ewma_vals[i] = ewma;
170        }
171        let n = clip as f32;
172        let mean = ewma_vals[..clip].iter().sum::<f32>() / n;
173        let var = ewma_vals[..clip].iter()
174            .map(|&x| (x - mean) * (x - mean))
175            .sum::<f32>() / n;
176        self.ewma_threshold = mean + 3.0 * crate::math::sqrt_f32(var);
177        self.ewma_norm = 0.0; // reset accumulator after calibration
178    }
179}
180
181/// Multi-channel DSA corroboration accumulator.
182///
183/// Implements Lemma 6: false-episode rate decreases monotonically
184/// with corroboration count c(k) (number of channels simultaneously
185/// in Boundary/Violation grammar state).
186pub struct CorroborationAccumulator<const K: usize> {
187    /// Rolling buffer of corroboration counts c(k).
188    counts: [u8; K],
189    head: usize,
190    filled: usize,
191    /// Minimum consecutive K observations at or above threshold τ.
192    persistence_threshold: u8,
193}
194
195impl<const K: usize> CorroborationAccumulator<K> {
196    /// New accumulator. `persistence_threshold` = minimum c(k) to qualify.
197    pub const fn new(persistence_threshold: u8) -> Self {
198        Self {
199            counts: [0; K],
200            head: 0,
201            filled: 0,
202            persistence_threshold,
203        }
204    }
205
206    /// Push a new corroboration count and return whether the accumulator
207    /// fires (K consecutive observations ≥ persistence_threshold).
208    pub fn push(&mut self, count: u8) -> bool {
209        self.counts[self.head] = count;
210        self.head = (self.head + 1) % K;
211        if self.filled < K { self.filled += 1; }
212
213        if self.filled < K {
214            return false;
215        }
216        // All K slots must be ≥ persistence_threshold
217        self.counts.iter().all(|&c| c >= self.persistence_threshold)
218    }
219
220    /// Reset.
221    pub fn reset(&mut self) {
222        self.counts = [0; K];
223        self.head = 0;
224        self.filled = 0;
225    }
226}
227
228// ---------------------------------------------------------------
229// Tests
230// ---------------------------------------------------------------
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::grammar::{GrammarState, ReasonCode};
235
236    #[test]
237    fn dsa_zero_for_clean_signal() {
238        let mut w = DsaWindow::<10>::new(1.0);
239        let sign = SignTuple::new(0.01, 0.0, 0.0);
240        for _ in 0..10 {
241            let score = w.push(&sign, GrammarState::Admissible, false);
242            assert!(score.value() < 0.5, "clean signal DSA should be low");
243        }
244    }
245
246    #[test]
247    fn dsa_rises_for_sustained_boundary() {
248        let mut w = DsaWindow::<10>::new(0.05);
249        let sign = SignTuple::new(0.07, 0.005, 0.0);
250        let grammar = GrammarState::Boundary(ReasonCode::SustainedOutwardDrift);
251        let mut last = DsaScore::ZERO;
252        for _ in 0..10 {
253            last = w.push(&sign, grammar, true);
254        }
255        assert!(last.value() > 1.5, "sustained boundary DSA should be elevated: {}", last.value());
256    }
257
258    #[test]
259    fn corroboration_fires_after_k_consecutive() {
260        let mut acc = CorroborationAccumulator::<4>::new(1);
261        // First 3 pushes: not yet K filled
262        assert!(!acc.push(2));
263        assert!(!acc.push(2));
264        assert!(!acc.push(2));
265        // 4th push fills K
266        assert!(acc.push(2));
267    }
268
269    #[test]
270    fn corroboration_requires_all_k_above_threshold() {
271        let mut acc = CorroborationAccumulator::<4>::new(2);
272        acc.push(3); acc.push(3); acc.push(0); // one below threshold
273        let fires = acc.push(3);
274        assert!(!fires, "should not fire with one slot below threshold");
275    }
276
277    #[test]
278    fn dsa_threshold_check() {
279        let score = DsaScore(2.5);
280        assert!(score.meets_threshold(2.0));
281        assert!(!score.meets_threshold(3.0));
282    }
283}