1use crate::grammar::GrammarState;
25use crate::sign::SignTuple;
26
27#[derive(Debug, Clone, Copy, PartialEq)]
29pub struct DsaScore(pub f32);
30
31impl DsaScore {
32 pub const ZERO: Self = Self(0.0);
34
35 #[inline]
37 pub fn value(&self) -> f32 { self.0 }
38
39 #[inline]
41 pub fn meets_threshold(&self, tau: f32) -> bool { self.0 >= tau }
42}
43
44pub struct DsaWindow<const W: usize> {
49 boundary_flags: [bool; W],
51 drift_flags: [bool; W],
53 slew_flags: [bool; W],
55 ewma_flags: [bool; W],
57 motif_flags: [bool; W],
59 head: usize,
61 count: usize,
63 ewma_norm: f32,
65 lambda: f32,
67 ewma_threshold: f32,
69 weights: [f32; 5],
71 delta_s: f32,
73}
74
75impl<const W: usize> DsaWindow<W> {
76 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 pub fn push(
101 &mut self,
102 sign: &SignTuple,
103 grammar: GrammarState,
104 motif_fired: bool,
105 ) -> DsaScore {
106 self.ewma_norm = self.lambda * sign.norm + (1.0 - self.lambda) * self.ewma_norm;
108
109 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 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 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 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 pub fn calibrate_ewma_threshold(&mut self, healthy_norms: &[f32]) {
160 if healthy_norms.is_empty() {
161 return;
162 }
163 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; }
179}
180
181pub struct CorroborationAccumulator<const K: usize> {
187 counts: [u8; K],
189 head: usize,
190 filled: usize,
191 persistence_threshold: u8,
193}
194
195impl<const K: usize> CorroborationAccumulator<K> {
196 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 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 self.counts.iter().all(|&c| c >= self.persistence_threshold)
218 }
219
220 pub fn reset(&mut self) {
222 self.counts = [0; K];
223 self.head = 0;
224 self.filled = 0;
225 }
226}
227
228#[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 assert!(!acc.push(2));
263 assert!(!acc.push(2));
264 assert!(!acc.push(2));
265 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); 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}