Skip to main content

dsfb_rf/
heuristics.rs

1//! Heuristics bank H: fixed-capacity typed RF motif library.
2//!
3//! The heuristics bank is what makes DSFB diagnostically superior to a
4//! Luenberger observer for operator-facing applications. A Luenberger
5//! observer has no memory of what structural pattern it is observing —
6//! it sees only "error." The heuristics bank accumulates typed,
7//! provenance-aware motif entries that distinguish classes of structural
8//! behavior (paper §V-F, Table I).
9//!
10//! ## Design
11//!
12//! Fixed-capacity array `[MotifEntry; M]` — no heap, no alloc.
13//! Generic parameter M = max entries (default 32 in the engine).
14//! Linear scan for lookup: O(M) per observe() call, M ≤ 32 → negligible.
15//!
16//! ## Non-Attribution Policy (paper §V-F)
17//!
18//! Motif entries carry *candidate mechanism hypotheses*, not attributions.
19//! No physical mechanism is attributed from public datasets (RadioML, ORACLE).
20//! The `provenance` field records whether the entry is framework-designed,
21//! empirically observed, or field-validated.
22
23use crate::grammar::GrammarState;
24use crate::syntax::MotifClass;
25
26/// Provenance of a heuristics bank entry.
27///
28/// Records the epistemic status of a motif entry — how it was derived.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub enum Provenance {
32    /// Derived from framework design principles.
33    FrameworkDesign,
34    /// Observed in public dataset (RadioML or ORACLE). No physical attribution.
35    PublicDataObserved,
36    /// Observed in deployment; field-validated by domain expert.
37    FieldValidated,
38}
39
40/// Semantic disposition returned by heuristics bank lookup.
41///
42/// This is what the operator sees as the final semantic label.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45pub enum SemanticDisposition {
46    /// Structurally confirmed pre-transition cluster.
47    PreTransitionCluster,
48    /// Corroborating drift — secondary support for escalation.
49    CorroboratingDrift,
50    /// Transient noise spike — no escalation warranted.
51    TransientNoise,
52    /// Recurrent structural pattern — watch and monitor.
53    RecurrentPattern,
54    /// Abrupt onset event — immediate escalation warranted.
55    AbruptOnsetEvent,
56    /// Spectral mask approach — proactive monitoring warranted.
57    MaskApproach,
58    /// Phase noise degradation — link quality monitoring.
59    PhaseNoiseDegradation,
60    /// Endoductive: no named interpretation. Returns full σ(k) to operator.
61    Unknown,
62    /// LNA gain instability: progressive gain collapse detected.
63    /// Signature: linear norm ramp below 30% ρ, near-zero r̈.
64    /// Recommended action: flag node telemetry for operator review.
65    /// Do NOT abort mission or reset radio — this is read-only observer output.
66    LnaGainInstability,
67    /// LO oscillator instability precursor detected.
68    /// Signature: `RecurrentBoundaryGrazing` with oscillatory slew.
69    /// Consistent with phase-noise excursion (OcxoWarmup or FreeRunXtal class).
70    /// Recommended action: tag geolocation/timing data with advisory.
71    LoInstabilityPrecursor,
72}
73
74/// A single entry in the heuristics bank.
75#[derive(Debug, Clone, Copy)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77pub struct MotifEntry {
78    /// The motif class this entry matches.
79    pub motif_class: MotifClass,
80    /// Minimum grammar severity required to activate this entry.
81    /// 0 = any, 1 = Boundary or above, 2 = Violation only.
82    pub min_severity: u8,
83    /// Semantic disposition returned when this entry matches.
84    pub disposition: SemanticDisposition,
85    /// Provenance of this entry.
86    pub provenance: Provenance,
87    /// Human-readable description (fixed-length str for no_std).
88    pub description: &'static str,
89}
90
91impl MotifEntry {
92    /// Returns true if this entry matches the given motif class and grammar severity.
93    #[inline]
94    pub fn matches(&self, motif: MotifClass, grammar: GrammarState) -> bool {
95        self.motif_class == motif && grammar.severity() >= self.min_severity
96    }
97}
98
99/// Fixed-capacity heuristics bank.
100///
101/// M = maximum number of entries. Populated at construction with the
102/// framework-designed RF motif library. Additional entries can be
103/// registered at runtime (up to M capacity) for endoductive learning.
104pub struct HeuristicsBank<const M: usize> {
105    entries: [Option<MotifEntry>; M],
106    count: usize,
107}
108
109impl<const M: usize> HeuristicsBank<M> {
110    /// Create an empty heuristics bank.
111    pub const fn empty() -> Self {
112        Self {
113            entries: [None; M],
114            count: 0,
115        }
116    }
117
118    /// Create a heuristics bank pre-populated with the RF framework motif library.
119    ///
120    /// Populates with the 7 canonical RF motifs from paper §V-F.
121    /// These are framework-designed entries (Provenance::FrameworkDesign).
122    pub fn default_rf() -> Self {
123        const MOTIFS: [MotifEntry; 9] = [
124            MotifEntry { motif_class: MotifClass::PreFailureSlowDrift,       min_severity: 1, disposition: SemanticDisposition::PreTransitionCluster,    provenance: Provenance::FrameworkDesign, description: "Persistent outward drift toward boundary" },
125            MotifEntry { motif_class: MotifClass::TransientExcursion,        min_severity: 2, disposition: SemanticDisposition::TransientNoise,          provenance: Provenance::FrameworkDesign, description: "Brief violation with rapid recovery" },
126            MotifEntry { motif_class: MotifClass::RecurrentBoundaryApproach, min_severity: 1, disposition: SemanticDisposition::RecurrentPattern,        provenance: Provenance::FrameworkDesign, description: "Repeated near-boundary excursions" },
127            MotifEntry { motif_class: MotifClass::AbruptOnset,               min_severity: 2, disposition: SemanticDisposition::AbruptOnsetEvent,        provenance: Provenance::FrameworkDesign, description: "Abrupt large slew" },
128            MotifEntry { motif_class: MotifClass::SpectralMaskApproach,      min_severity: 1, disposition: SemanticDisposition::MaskApproach,            provenance: Provenance::FrameworkDesign, description: "Monotone outward drift toward mask edge" },
129            MotifEntry { motif_class: MotifClass::PhaseNoiseExcursion,       min_severity: 1, disposition: SemanticDisposition::PhaseNoiseDegradation,   provenance: Provenance::FrameworkDesign, description: "Oscillatory slew with growing amplitude" },
130            MotifEntry { motif_class: MotifClass::FreqHopTransition,         min_severity: 1, disposition: SemanticDisposition::TransientNoise,          provenance: Provenance::FrameworkDesign, description: "FHSS waveform transition (suppressible)" },
131            MotifEntry { motif_class: MotifClass::LnaGainInstability,        min_severity: 1, disposition: SemanticDisposition::LnaGainInstability,      provenance: Provenance::FrameworkDesign, description: "Linear gain ramp, near-zero second derivative" },
132            MotifEntry { motif_class: MotifClass::LoInstabilityPrecursor,    min_severity: 1, disposition: SemanticDisposition::LoInstabilityPrecursor,  provenance: Provenance::FrameworkDesign, description: "Recurrent boundary grazing with oscillatory slew" },
133        ];
134        let mut bank = Self::empty();
135        for entry in MOTIFS {
136            bank.register(entry);
137        }
138        bank
139    }
140
141    /// Register a new motif entry. Returns false if bank is full.
142    pub fn register(&mut self, entry: MotifEntry) -> bool {
143        if self.count >= M {
144            return false;
145        }
146        self.entries[self.count] = Some(entry);
147        self.count += 1;
148        true
149    }
150
151    /// Look up a motif in the bank. Returns `Unknown` if no entry matches.
152    ///
153    /// Linear scan O(M). For M≤32 this is negligible per-observation cost.
154    pub fn lookup(&self, motif: MotifClass, grammar: GrammarState) -> SemanticDisposition {
155        for i in 0..self.count {
156            if let Some(ref entry) = self.entries[i] {
157                if entry.matches(motif, grammar) {
158                    return entry.disposition;
159                }
160            }
161        }
162        // No match: endoductive Unknown
163        SemanticDisposition::Unknown
164    }
165
166    /// Number of registered entries.
167    #[inline]
168    pub fn len(&self) -> usize {
169        self.count
170    }
171
172    /// Returns true if the bank is empty.
173    #[inline]
174    pub fn is_empty(&self) -> bool {
175        self.count == 0
176    }
177
178    /// Returns an iterator over populated entries.
179    pub fn entries(&self) -> impl Iterator<Item = &MotifEntry> {
180        self.entries[..self.count]
181            .iter()
182            .filter_map(|e| e.as_ref())
183    }
184}
185
186// ---------------------------------------------------------------
187// Clock Stability Library
188// ---------------------------------------------------------------
189// Allan deviation σ_y(τ) slope classification for oscillator-based
190// internal-cause heuristics (paper §10.3 "Clock and LO Instability").
191//
192// When the grammar trips at Boundary/Violation AND the Physics model
193// returns ArrheniusModel with low activation energy, a parallel check
194// of the Allan deviation signature can distinguish:
195//   – TCXO first-warmup (τ^{-1} slope → white FM, settling within ~60 s)
196//   – OCXO warmup (τ^{-3/2} slope → flicker FM, oven thermal lag)
197//   – Free-run crystal (τ^{+1/2} slope → random walk, no oven)
198//   – PLL acquisition (transient oscillation in σ_y at τ ≈ 1/f_bw)
199//
200// These are classified by fitting a log-log slope α to σ_y(τ) ∝ τ^α
201// and matching against the IEEE Std 1139-2008 Table 1 canonical slopes.
202//
203// Reference:
204//   IEEE Std 1139-2008, "Characterization of Clocks and Oscillators".
205//   Allan (1966), Proc. IEEE 54(2):221.
206
207/// Canonical clock / oscillator instability classes matched by Allan slope.
208///
209/// Classified by fitting log-log slope α to σ_y(τ) ∝ τ^α across the
210/// provided τ window.  α ≈ –1 → white FM; α ≈ –3/2 → flicker FM (OCXO);
211/// α ≈ +1/2 → random walk FM; α ≈ 0 → flicker phase noise.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum KnownClockClass {
214    /// White frequency modulation (α ≈ –1.0).
215    /// Typical of a TCXO in steady-state or immediately post-warmup.
216    TcxoSteadyState,
217    /// Flicker frequency modulation (α ≈ –0.5, strong τ^0 floor).
218    /// Typical of OCXO during oven thermal equilibration (~1–10 min).
219    OcxoWarmup,
220    /// Random walk frequency modulation (α ≈ +0.5).
221    /// Typical of a free-run crystal without temperature compensation.
222    FreeRunXtal,
223    /// Transient oscillation around a fast-crossing τ value.
224    /// Indicates an active PLL mid-acquisition or a loop bandwidth mismatch.
225    PllAcquisition,
226    /// Flicker phase noise (α ≈ –1.5), adjacent to carrier.
227    /// Typical of a low-noise OCXO or Rb oscillator in steady-state.
228    LowNoiseOcxo,
229    /// Slope could not be determined (too few τ points or noisy data).
230    Unknown,
231}
232
233impl KnownClockClass {
234    /// Human-readable label for SigMF annotation or log emission.
235    pub const fn label(self) -> &'static str {
236        match self {
237            KnownClockClass::TcxoSteadyState  => "TcxoSteadyState",
238            KnownClockClass::OcxoWarmup        => "OcxoWarmup",
239            KnownClockClass::FreeRunXtal       => "FreeRunXtal",
240            KnownClockClass::PllAcquisition    => "PllAcquisition",
241            KnownClockClass::LowNoiseOcxo      => "LowNoiseOcxo",
242            KnownClockClass::Unknown           => "Unknown",
243        }
244    }
245
246    /// Whether this class indicates an *internal* cause (clock/LO issue)
247    /// rather than an *external* cause (channel interference).
248    pub const fn is_internal_cause(self) -> bool {
249        matches!(
250            self,
251            KnownClockClass::TcxoSteadyState
252            | KnownClockClass::OcxoWarmup
253            | KnownClockClass::FreeRunXtal
254            | KnownClockClass::PllAcquisition
255            | KnownClockClass::LowNoiseOcxo
256        )
257    }
258}
259
260/// Classify an oscillator's instability class from its Allan deviation curve.
261///
262/// # Arguments
263/// - `sigma_y`  — array of Allan deviation values, one per τ point
264/// - `taus`     — corresponding integration times τ (seconds), monotone increasing
265///
266/// Both slices must have the same length ≥ 3.  Shorter inputs return
267/// `KnownClockClass::Unknown`.
268///
269/// ## Algorithm
270///
271/// Fits log-log slope α = Δ log(σ_y) / Δ log(τ) via least-squares over
272/// all provided τ points, then maps the slope to a canonical class:
273///
274/// | α range          | Class              |
275/// |------------------|--------------------|
276/// | (−∞, −1.2)       | `LowNoiseOcxo`     |
277/// | [−1.2, −0.7]     | `TcxoSteadyState`  |
278/// | (−0.7, −0.1)     | `OcxoWarmup`       |
279/// | [−0.1, +0.2)     | `PllAcquisition`   |
280/// | [+0.2, ∞)        | `FreeRunXtal`      |
281pub fn classify_clock_instability(sigma_y: &[f32], taus: &[f32]) -> KnownClockClass {
282    if sigma_y.len() < 3 || taus.len() < 3 || sigma_y.len() != taus.len() {
283        return KnownClockClass::Unknown;
284    }
285    let (sum_x, sum_y, sum_xx, sum_xy, m) = accumulate_log_sums(sigma_y, taus);
286    if m < 3 {
287        return KnownClockClass::Unknown;
288    }
289    let mf = m as f32;
290    let denom = mf * sum_xx - sum_x * sum_x;
291    if denom.abs() < 1e-9 {
292        return KnownClockClass::Unknown;
293    }
294    let alpha = (mf * sum_xy - sum_x * sum_y) / denom;
295    classify_slope(alpha)
296}
297
298fn accumulate_log_sums(sigma_y: &[f32], taus: &[f32]) -> (f32, f32, f32, f32, u32) {
299    let log = |v: f32| -> f32 { crate::math::ln_f32(v.max(1e-38)) };
300    let n = sigma_y.len().min(taus.len());
301    let mut sum_x  = 0.0_f32;
302    let mut sum_y  = 0.0_f32;
303    let mut sum_xx = 0.0_f32;
304    let mut sum_xy = 0.0_f32;
305    let mut m = 0u32;
306    for i in 0..n {
307        if taus[i] > 0.0 && sigma_y[i] > 0.0 {
308            let lx = log(taus[i]);
309            let ly = log(sigma_y[i]);
310            sum_x  += lx;
311            sum_y  += ly;
312            sum_xx += lx * lx;
313            sum_xy += lx * ly;
314            m += 1;
315        }
316    }
317    (sum_x, sum_y, sum_xx, sum_xy, m)
318}
319
320fn classify_slope(alpha: f32) -> KnownClockClass {
321    if alpha < -1.2 {
322        KnownClockClass::LowNoiseOcxo
323    } else if alpha <= -0.7 {
324        KnownClockClass::TcxoSteadyState
325    } else if alpha < -0.1 {
326        KnownClockClass::OcxoWarmup
327    } else if alpha < 0.2 {
328        KnownClockClass::PllAcquisition
329    } else {
330        KnownClockClass::FreeRunXtal
331    }
332}
333
334// ---------------------------------------------------------------
335// Tests
336// ---------------------------------------------------------------
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use crate::grammar::GrammarState;
341
342    #[test]
343    fn default_bank_has_nine_entries() {
344        let bank = HeuristicsBank::<32>::default_rf();
345        assert_eq!(bank.len(), 9);
346    }
347
348    #[test]
349    fn slow_drift_lookup_returns_pre_transition() {
350        let bank = HeuristicsBank::<32>::default_rf();
351        let disp = bank.lookup(
352            MotifClass::PreFailureSlowDrift,
353            GrammarState::Boundary(crate::grammar::ReasonCode::SustainedOutwardDrift),
354        );
355        assert_eq!(disp, SemanticDisposition::PreTransitionCluster);
356    }
357
358    #[test]
359    fn unknown_motif_returns_unknown() {
360        let bank = HeuristicsBank::<32>::default_rf();
361        let disp = bank.lookup(MotifClass::Unknown, GrammarState::Admissible);
362        assert_eq!(disp, SemanticDisposition::Unknown);
363    }
364
365    #[test]
366    fn abrupt_onset_lookup() {
367        let bank = HeuristicsBank::<32>::default_rf();
368        let disp = bank.lookup(
369            MotifClass::AbruptOnset,
370            GrammarState::Violation,
371        );
372        assert_eq!(disp, SemanticDisposition::AbruptOnsetEvent);
373    }
374
375    #[test]
376    fn bank_register_beyond_capacity_returns_false() {
377        let mut bank = HeuristicsBank::<2>::empty();
378        let entry = MotifEntry {
379            motif_class: MotifClass::Unknown,
380            min_severity: 0,
381            disposition: SemanticDisposition::Unknown,
382            provenance: Provenance::FrameworkDesign,
383            description: "test",
384        };
385        assert!(bank.register(entry));
386        assert!(bank.register(entry));
387        assert!(!bank.register(entry), "should be full at M=2");
388    }
389
390    #[test]
391    fn transient_excursion_requires_violation_severity() {
392        let bank = HeuristicsBank::<32>::default_rf();
393        // min_severity=2 (Violation), so Boundary should return Unknown
394        let disp = bank.lookup(
395            MotifClass::TransientExcursion,
396            GrammarState::Boundary(crate::grammar::ReasonCode::SustainedOutwardDrift),
397        );
398        assert_eq!(disp, SemanticDisposition::Unknown,
399            "TransientExcursion requires Violation severity");
400    }
401
402    // ── Clock stability library tests ────────────────────────────────────
403
404    #[test]
405    fn clock_labels_are_correct() {
406        assert_eq!(KnownClockClass::TcxoSteadyState.label(), "TcxoSteadyState");
407        assert_eq!(KnownClockClass::OcxoWarmup.label(),       "OcxoWarmup");
408        assert_eq!(KnownClockClass::FreeRunXtal.label(),      "FreeRunXtal");
409        assert_eq!(KnownClockClass::PllAcquisition.label(),   "PllAcquisition");
410        assert_eq!(KnownClockClass::LowNoiseOcxo.label(),     "LowNoiseOcxo");
411    }
412
413    #[test]
414    fn clock_all_are_internal() {
415        for &cls in &[
416            KnownClockClass::TcxoSteadyState,
417            KnownClockClass::OcxoWarmup,
418            KnownClockClass::FreeRunXtal,
419            KnownClockClass::PllAcquisition,
420            KnownClockClass::LowNoiseOcxo,
421        ] {
422            assert!(cls.is_internal_cause(), "{:?} should be internal", cls);
423        }
424        assert!(!KnownClockClass::Unknown.is_internal_cause());
425    }
426
427    #[test]
428    fn classify_tcxo_steady_state_slope_minus_one() {
429        // σ_y ∝ τ^{-1}: σ_y(τ) = 1e-11 / τ
430        let taus:    [f32; 5] = [1.0, 2.0, 4.0, 8.0, 16.0];
431        let sigma_y: [f32; 5] = [1e-11, 0.5e-11, 0.25e-11, 0.125e-11, 0.0625e-11];
432        let cls = classify_clock_instability(&sigma_y, &taus);
433        assert_eq!(cls, KnownClockClass::TcxoSteadyState, "α≈-1 slope: {:?}", cls);
434    }
435
436    #[test]
437    fn classify_freerun_xtal_slope_plus_half() {
438        // σ_y ∝ τ^{+0.5}: σ_y(τ) = 1e-11 * sqrt(τ)
439        let taus:    [f32; 5] = [1.0, 4.0, 9.0, 16.0, 25.0];
440        let sigma_y: [f32; 5] = [1e-11, 2e-11, 3e-11, 4e-11, 5e-11];
441        let cls = classify_clock_instability(&sigma_y, &taus);
442        assert_eq!(cls, KnownClockClass::FreeRunXtal, "α≈+0.5 slope: {:?}", cls);
443    }
444
445    #[test]
446    fn classify_too_few_points_returns_unknown() {
447        let taus:    [f32; 2] = [1.0, 2.0];
448        let sigma_y: [f32; 2] = [1e-11, 0.5e-11];
449        assert_eq!(classify_clock_instability(&sigma_y, &taus), KnownClockClass::Unknown);
450    }
451}