Skip to main content

dsfb_rf/
detectability.rs

1//! Rich deterministic detectability taxonomy for DSFB-RF.
2//!
3//! ## Theoretical Basis
4//!
5//! A key contribution of the DSFB framework is the derivation of a
6//! **deterministic upper bound on detection latency** (DSFB-Lattice §V,
7//! DSFB-Semiotics-Calculus §III).  Unlike Pd/Pfa quantities — which require
8//! calibrated probabilistic models and are sensitive to signal model mis-match
9//! — the DSFB detectability bound is purely algebraic:
10//!
11//! ```text
12//! τ_upper = δ₀ / (α − κ)   provided α > κ
13//! ```
14//!
15//! where:
16//! - δ₀ = initial residual offset from the nominal
17//! - α  = divergence rate (from the Lyapunov exponent λ or slew rate)
18//! - κ  = noise-floor rate (minimum observable drift, derived from σ₀)
19//!
20//! The bound asserts: *if a structural change is occurring at rate α, the
21//! grammar layer will detect it within τ_upper sample periods*.
22//!
23//! ## Interpretation taxonomy (DSFB-Lattice)
24//!
25//! Beyond the raw bound, the lattice framework defines a full hierarchy of
26//! semantic interpretation classes, making operator-facing output actionable
27//! rather than merely numeric:
28//!
29//! | InterpretationClass | Meaning |
30//! |---|---|
31//! | StructuralDetected | Envelope crossing confirmed with margin; clear fault |
32//! | StressDetected | Crossing detected with low post-crossing margin; degradation likely |
33//! | EarlyLowMarginCrossing | Crossing detected early but barely; watch state |
34//! | NotDetected | No crossing; nominal operation |
35//!
36//! The `SemanticStatus` refines this further with temporal persistence
37//! qualities, and `DetectionStrengthBand` provides a coarse ordinal for
38//! dashboard display.
39//!
40//! ## Post-crossing persistence
41//!
42//! For SIGINT / EW applications, it is important to know not just *when*
43//! a crossing occurs but **how long** the trajectory remains outside the
44//! envelope.  Long post-crossing persistence indicates a persistent structural
45//! fault (e.g., hardware failure, sustained jamming) rather than a transient
46//! spike.  This module tracks:
47//!
48//! - `post_crossing_duration`: number of samples outside envelope since first crossing
49//! - `post_crossing_fraction`: fraction of recent W samples spent outside envelope
50//! - `peak_margin_after_crossing`: maximum normalised excess (‖r‖ − ρ) / ρ since crossing
51//!
52//! ## Design
53//!
54//! - `no_std`, `no_alloc`, zero `unsafe`
55//! - O(1) per `update()` call (circular buffer for fraction tracking)
56//! - All types `Clone + Copy` for zero-cost passing through audit chain
57
58/// Coarse detection interpretation class.
59///
60/// Semantically richer than a binary "detected / not-detected" flag.
61/// Directly maps to VITA 49.2 context packet severity codes.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
64pub enum DetectabilityClass {
65    /// Envelope crossing confirmed with post-crossing margin > high_threshold.
66    /// Clear structural fault.  Escalate to operator.
67    StructuralDetected,
68
69    /// Envelope crossing with post-crossing margin in (low_threshold, high_threshold].
70    /// Stress / degradation detected with reduced confidence.
71    StressDetected,
72
73    /// Crossing detected very quickly (early) but margin is low.
74    /// Could indicate a brief transient or the early onset of a fault.
75    EarlyLowMarginCrossing,
76
77    /// No envelope crossing; trajectory within nominal bounds.
78    NotDetected,
79}
80
81/// Fine-grained semantic status combining class and temporal context.
82///
83/// Used for dashboard labelling and VITA 49.2 context packet annotation.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub enum SemanticStatus {
87    /// StructuralDetected + post-crossing duration ≥ duration_threshold.
88    /// Persistent structural fault; recommend hardware inspection.
89    PersistentStructuralFault,
90
91    /// StructuralDetected + duration < duration_threshold.
92    /// Clear structural detection (single-event or early onset).
93    ClearStructuralDetection,
94
95    /// StressDetected + post-crossing fraction ≥ fraction_threshold.
96    /// Marginal but sustained degradation; link quality watch.
97    MarginalStructuralDegradation,
98
99    /// StressDetected + fraction < fraction_threshold.
100    /// Isolated stress event; transient interference likely.
101    IsolatedStressEvent,
102
103    /// EarlyLowMarginCrossing sustained over multiple windows.
104    /// Ambiguous: could be noise or nascent fault — heightened watch.
105    DegradedAmbiguous,
106
107    /// EarlyLowMarginCrossing single occurrence.
108    /// Ambiguous transient — monitor only.
109    Ambiguous,
110
111    /// NotDetected; nominal operation.
112    NotDetected,
113}
114
115/// Operator-facing coarse strength band (for dashboard colour coding).
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
117#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
118pub enum DetectionStrengthBand {
119    /// No detection — green.
120    Clear = 0,
121    /// Ambiguous / low-margin — yellow.
122    Marginal = 1,
123    /// Stress / degradation detected — amber.
124    Degraded = 2,
125    /// Clear structural fault — red.
126    Critical = 3,
127}
128
129impl DetectionStrengthBand {
130    /// Derive from a `DetectabilityClass`.
131    pub fn from_class(class: DetectabilityClass) -> Self {
132        match class {
133            DetectabilityClass::NotDetected => Self::Clear,
134            DetectabilityClass::EarlyLowMarginCrossing => Self::Marginal,
135            DetectabilityClass::StressDetected => Self::Degraded,
136            DetectabilityClass::StructuralDetected => Self::Critical,
137        }
138    }
139}
140
141/// Thresholds governing the detectability taxonomy.
142#[derive(Debug, Clone, Copy)]
143pub struct DetectabilityThresholds {
144    /// Normalised excess (‖r‖ − ρ) / ρ above which a crossing is
145    /// classified `StructuralDetected` rather than `StressDetected`.
146    pub high_margin_threshold: f32,
147
148    /// Normalised excess below `high_margin_threshold` classified as
149    /// `StressDetected` (must be > 0).
150    pub low_margin_threshold: f32,
151
152    /// Post-crossing sample count above which a `StructuralDetected` event
153    /// is tagged as `PersistentStructuralFault`.
154    pub persistence_duration_threshold: u32,
155
156    /// Post-crossing fraction (0..=1) above which a `StressDetected` event
157    /// is tagged as `MarginalStructuralDegradation`.
158    pub persistence_fraction_threshold: f32,
159
160    /// Number of samples defining the "early" window.
161    /// Crossings occurring within `early_window` samples of the episode
162    /// start are tagged `EarlyLowMarginCrossing`.
163    pub early_window: u32,
164
165    /// Divergence rate threshold κ used in the τ_upper bound.
166    ///
167    /// Represents the minimum meaningful drift rate above the noise floor.
168    pub kappa: f32,
169}
170
171impl DetectabilityThresholds {
172    /// Conservative defaults suitable for most RF receiver applications.
173    pub const fn default_rf() -> Self {
174        Self {
175            high_margin_threshold: 0.20,    // 20 % excess → structural
176            low_margin_threshold: 0.02,     // 2 % → stress
177            persistence_duration_threshold: 10,
178            persistence_fraction_threshold: 0.30,
179            early_window: 5,
180            kappa: 0.001,
181        }
182    }
183}
184
185impl Default for DetectabilityThresholds {
186    fn default() -> Self { Self::default_rf() }
187}
188
189/// Deterministic detectability upper bound τ_upper.
190///
191/// Computes the latency upper bound from DSFB-Lattice Theorem 1:
192///
193/// ```text
194/// τ_upper = δ₀ / (α − κ)   iff α > κ
195/// ```
196///
197/// Returns `None` if α ≤ κ (divergence rate does not exceed noise floor).
198#[derive(Debug, Clone, Copy, PartialEq)]
199pub struct DetectabilityBound {
200    /// Initial offset δ₀ = ‖r_initial‖ from nominal.
201    pub delta_0: f32,
202    /// Observed divergence rate α (from Lyapunov λ or empirical slew rate).
203    pub alpha: f32,
204    /// Noise-floor rate κ.
205    pub kappa: f32,
206    /// Computed bound τ_upper (sample periods).  `None` if α ≤ κ.
207    pub tau_upper: Option<f32>,
208    /// Whether observed crossing time t_cross ≤ τ_upper + ε.
209    pub bound_satisfied: Option<bool>,
210}
211
212impl DetectabilityBound {
213    /// Compute the τ_upper bound given δ₀, α, κ.
214    pub fn compute(delta_0: f32, alpha: f32, kappa: f32) -> Self {
215        let tau_upper = if alpha > kappa + 1e-12 {
216            Some(delta_0 / (alpha - kappa))
217        } else {
218            None
219        };
220        Self { delta_0, alpha, kappa, tau_upper, bound_satisfied: None }
221    }
222
223    /// Validate whether the observed crossing time satisfies the bound.
224    ///
225    /// `t_cross` = number of samples from episode start to envelope crossing.
226    /// `epsilon` = tolerance for sample-period quantisation (default: 1.0).
227    pub fn validate_crossing(&mut self, t_cross: f32, epsilon: f32) {
228        self.bound_satisfied = self.tau_upper.map(|tau| t_cross <= tau + epsilon);
229    }
230}
231
232/// Full detectability summary for one observation window.
233///
234/// All fields are deterministically computed from the residual norm history.
235/// No probability model is required or assumed.
236#[derive(Debug, Clone, Copy)]
237pub struct DetectabilitySummary {
238    /// Coarse interpretation class.
239    pub class: DetectabilityClass,
240    /// Fine semantic status.
241    pub semantic: SemanticStatus,
242    /// Dashboard strength band.
243    pub band: DetectionStrengthBand,
244    /// Deterministic τ_upper bound (if computable).
245    pub bound: DetectabilityBound,
246    /// Number of samples spent outside envelope since first crossing.
247    /// Zero if no crossing has occurred.
248    pub post_crossing_duration: u32,
249    /// Fraction of recent `W` samples spent outside envelope (0..=1).
250    pub post_crossing_fraction: f32,
251    /// Maximum normalised excess (‖r‖ − ρ) / ρ observed since first crossing.
252    pub peak_margin_after_crossing: f32,
253    /// True if the crossing occurred within the "early window."
254    pub boundary_proximate_crossing: bool,
255}
256
257/// Running detectability tracker with O(1) per-sample update.
258///
259/// Generic `W` = window size for fraction tracking.
260pub struct DetectabilityTracker<const W: usize> {
261    /// Running count of consecutive outside-envelope samples.
262    post_crossing_duration: u32,
263    /// Circular buffer of outside-envelope flags for fraction computation.
264    outside_buf: [bool; W],
265    /// Write head for circular buffer.
266    head: usize,
267    /// Total samples pushed (saturates at W for fraction purposes).
268    count: usize,
269    /// Peak normalised excess since crossing.
270    peak_margin: f32,
271    /// Sample index at which the first crossing occurred (if any).
272    first_crossing_sample: Option<u32>,
273    /// Current sample index.
274    sample_idx: u32,
275    /// Thresholds.
276    thresholds: DetectabilityThresholds,
277    /// Cached divergence rate α (from last Lyapunov update).
278    cached_alpha: f32,
279    /// Initial offset δ₀ (set at first crossing).
280    delta_0: f32,
281}
282
283impl<const W: usize> DetectabilityTracker<W> {
284    /// Create a new tracker with the given thresholds.
285    pub const fn new(thresholds: DetectabilityThresholds) -> Self {
286        Self {
287            post_crossing_duration: 0,
288            outside_buf: [false; W],
289            head: 0,
290            count: 0,
291            peak_margin: 0.0,
292            first_crossing_sample: None,
293            sample_idx: 0,
294            thresholds,
295            cached_alpha: 0.0,
296            delta_0: 0.0,
297        }
298    }
299
300    /// Create with default RF thresholds.
301    pub const fn default_rf() -> Self {
302        Self::new(DetectabilityThresholds::default_rf())
303    }
304
305    /// Update the tracker with one residual norm observation.
306    ///
307    /// - `norm`:  current ‖r(k)‖
308    /// - `rho`:   current admissibility envelope radius ρ(k)
309    /// - `alpha`: divergence rate (Lyapunov λ or empirical slew; pass 0.0 if unknown)
310    ///
311    /// Returns a complete `DetectabilitySummary` for this observation.
312    pub fn update(&mut self, norm: f32, rho: f32, alpha: f32) -> DetectabilitySummary {
313        let outside = norm > rho && rho > 1e-30;
314        let normalised_excess = if rho > 1e-30 { ((norm - rho) / rho).max(0.0) } else { 0.0 };
315        self.cached_alpha = alpha;
316
317        self.update_crossing_state(outside, normalised_excess);
318        let post_crossing_fraction = self.update_outside_ring(outside);
319
320        let crossing_time = self.first_crossing_sample
321            .map(|s| self.sample_idx.saturating_sub(s) as f32)
322            .unwrap_or(0.0);
323        let early = self.first_crossing_sample
324            .map(|s| self.sample_idx.saturating_sub(s) < self.thresholds.early_window)
325            .unwrap_or(false);
326
327        let class = self.classify_detection(outside, normalised_excess);
328        let semantic = self.derive_semantic(class, post_crossing_fraction, early);
329        let bound = self.compute_bound(outside, alpha, crossing_time);
330        let band = DetectionStrengthBand::from_class(class);
331
332        self.sample_idx = self.sample_idx.wrapping_add(1);
333
334        DetectabilitySummary {
335            class,
336            semantic,
337            band,
338            bound,
339            post_crossing_duration: self.post_crossing_duration,
340            post_crossing_fraction,
341            peak_margin_after_crossing: self.peak_margin,
342            boundary_proximate_crossing: early,
343        }
344    }
345
346    fn update_crossing_state(&mut self, outside: bool, normalised_excess: f32) {
347        if outside && self.first_crossing_sample.is_none() {
348            self.first_crossing_sample = Some(self.sample_idx);
349            self.delta_0 = normalised_excess;
350        }
351        if outside {
352            self.post_crossing_duration = self.post_crossing_duration.saturating_add(1);
353            if normalised_excess > self.peak_margin {
354                self.peak_margin = normalised_excess;
355            }
356        } else {
357            self.post_crossing_duration = 0;
358            self.peak_margin = 0.0;
359            self.first_crossing_sample = None;
360        }
361    }
362
363    fn update_outside_ring(&mut self, outside: bool) -> f32 {
364        self.outside_buf[self.head] = outside;
365        self.head = (self.head + 1) % W;
366        if self.count < W { self.count += 1; }
367        let outside_count = self.outside_buf[..self.count].iter().filter(|&&b| b).count();
368        outside_count as f32 / self.count.max(1) as f32
369    }
370
371    fn classify_detection(&self, outside: bool, normalised_excess: f32) -> DetectabilityClass {
372        if !outside && self.post_crossing_duration == 0 {
373            DetectabilityClass::NotDetected
374        } else if normalised_excess > self.thresholds.high_margin_threshold {
375            DetectabilityClass::StructuralDetected
376        } else if normalised_excess > self.thresholds.low_margin_threshold {
377            DetectabilityClass::StressDetected
378        } else {
379            DetectabilityClass::EarlyLowMarginCrossing
380        }
381    }
382
383    fn derive_semantic(
384        &self,
385        class: DetectabilityClass,
386        post_crossing_fraction: f32,
387        early: bool,
388    ) -> SemanticStatus {
389        match class {
390            DetectabilityClass::NotDetected => SemanticStatus::NotDetected,
391            DetectabilityClass::StructuralDetected => {
392                if self.post_crossing_duration >= self.thresholds.persistence_duration_threshold {
393                    SemanticStatus::PersistentStructuralFault
394                } else {
395                    SemanticStatus::ClearStructuralDetection
396                }
397            }
398            DetectabilityClass::StressDetected => {
399                if post_crossing_fraction >= self.thresholds.persistence_fraction_threshold {
400                    SemanticStatus::MarginalStructuralDegradation
401                } else {
402                    SemanticStatus::IsolatedStressEvent
403                }
404            }
405            DetectabilityClass::EarlyLowMarginCrossing => {
406                if early { SemanticStatus::Ambiguous } else { SemanticStatus::DegradedAmbiguous }
407            }
408        }
409    }
410
411    fn compute_bound(&self, outside: bool, alpha: f32, crossing_time: f32) -> DetectabilityBound {
412        let kappa = self.thresholds.kappa;
413        let mut bound = DetectabilityBound::compute(self.delta_0, alpha, kappa);
414        if outside {
415            bound.validate_crossing(crossing_time, 1.0);
416        }
417        bound
418    }
419
420    /// Reset all state.
421    pub fn reset(&mut self) {
422        self.post_crossing_duration = 0;
423        self.outside_buf = [false; W];
424        self.head = 0;
425        self.count = 0;
426        self.peak_margin = 0.0;
427        self.first_crossing_sample = None;
428        self.sample_idx = 0;
429        self.cached_alpha = 0.0;
430        self.delta_0 = 0.0;
431    }
432
433    /// Access current post-crossing duration.
434    #[inline] pub fn post_crossing_duration(&self) -> u32 { self.post_crossing_duration }
435    /// Access peak margin since crossing.
436    #[inline] pub fn peak_margin(&self) -> f32 { self.peak_margin }
437}
438
439// ---------------------------------------------------------------
440// Tests
441// ---------------------------------------------------------------
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn nominal_operation_is_not_detected() {
448        let mut tracker = DetectabilityTracker::<20>::default_rf();
449        for i in 0..50 {
450            let r = tracker.update(0.05, 0.10, 0.0);
451            assert_eq!(r.class, DetectabilityClass::NotDetected, "step {}", i);
452            assert_eq!(r.semantic, SemanticStatus::NotDetected);
453            assert_eq!(r.band, DetectionStrengthBand::Clear);
454        }
455    }
456
457    #[test]
458    fn large_crossing_structural_detected() {
459        let mut tracker = DetectabilityTracker::<20>::default_rf();
460        // norm = 0.13, rho = 0.10 → excess = 30 % > 20 % threshold
461        let r = tracker.update(0.13, 0.10, 0.01);
462        assert_eq!(r.class, DetectabilityClass::StructuralDetected);
463        assert_eq!(r.band, DetectionStrengthBand::Critical);
464    }
465
466    #[test]
467    fn small_crossing_stress_detected() {
468        let mut tracker = DetectabilityTracker::<20>::default_rf();
469        // norm = 0.105, rho = 0.10 → excess = 5 % > 2 % but < 20 %
470        let r = tracker.update(0.105, 0.10, 0.005);
471        assert_eq!(r.class, DetectabilityClass::StressDetected);
472        assert_eq!(r.band, DetectionStrengthBand::Degraded);
473    }
474
475    #[test]
476    fn marginal_crossing_early_low_margin() {
477        let mut tracker = DetectabilityTracker::<20>::default_rf();
478        // norm = 0.1005, rho = 0.10 → excess = 0.5 % < 2 %
479        let r = tracker.update(0.1005, 0.10, 0.001);
480        assert_eq!(r.class, DetectabilityClass::EarlyLowMarginCrossing);
481    }
482
483    #[test]
484    fn persistent_structural_fault_after_threshold() {
485        let mut tracker = DetectabilityTracker::<20>::default_rf();
486        // persistence_duration_threshold = 10
487        for i in 0..12 {
488            let r = tracker.update(0.15, 0.10, 0.01);
489            if i >= 10 {
490                assert_eq!(
491                    r.semantic, SemanticStatus::PersistentStructuralFault,
492                    "step {}: expected PersistentStructuralFault", i
493                );
494            }
495        }
496    }
497
498    #[test]
499    fn post_crossing_fraction_accumulates() {
500        let mut tracker = DetectabilityTracker::<20>::default_rf();
501        // 10 outside, 10 inside
502        for _ in 0..10 { tracker.update(0.15, 0.10, 0.01); }
503        for _ in 0..10 {
504            let r = tracker.update(0.05, 0.10, 0.0);
505            // After return to nominal, class should be not detected
506            assert_eq!(r.class, DetectabilityClass::NotDetected);
507        }
508    }
509
510    #[test]
511    fn tau_upper_bound_computed() {
512        let bound = DetectabilityBound::compute(0.05, 0.01, 0.001);
513        // tau_upper = 0.05 / (0.01 - 0.001) = 0.05 / 0.009 ≈ 5.56
514        let tau = bound.tau_upper.expect("should have bound");
515        assert!((tau - 5.555_555).abs() < 1e-2, "tau={}", tau);
516    }
517
518    #[test]
519    fn tau_upper_none_when_alpha_le_kappa() {
520        let bound = DetectabilityBound::compute(0.05, 0.0005, 0.001);
521        assert!(bound.tau_upper.is_none(), "alpha <= kappa → no bound");
522    }
523
524    #[test]
525    fn detection_strength_band_ordering() {
526        assert!(DetectionStrengthBand::Clear < DetectionStrengthBand::Marginal);
527        assert!(DetectionStrengthBand::Marginal < DetectionStrengthBand::Degraded);
528        assert!(DetectionStrengthBand::Degraded < DetectionStrengthBand::Critical);
529    }
530
531    #[test]
532    fn reset_clears_all_state() {
533        let mut tracker = DetectabilityTracker::<20>::default_rf();
534        for _ in 0..20 { tracker.update(0.15, 0.10, 0.01); }
535        tracker.reset();
536        let r = tracker.update(0.05, 0.10, 0.0);
537        assert_eq!(r.class, DetectabilityClass::NotDetected);
538        assert_eq!(r.post_crossing_duration, 0);
539    }
540}