1use crate::grammar::GrammarState;
24use crate::syntax::MotifClass;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31pub enum Provenance {
32 FrameworkDesign,
34 PublicDataObserved,
36 FieldValidated,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45pub enum SemanticDisposition {
46 PreTransitionCluster,
48 CorroboratingDrift,
50 TransientNoise,
52 RecurrentPattern,
54 AbruptOnsetEvent,
56 MaskApproach,
58 PhaseNoiseDegradation,
60 Unknown,
62 LnaGainInstability,
67 LoInstabilityPrecursor,
72}
73
74#[derive(Debug, Clone, Copy)]
76#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
77pub struct MotifEntry {
78 pub motif_class: MotifClass,
80 pub min_severity: u8,
83 pub disposition: SemanticDisposition,
85 pub provenance: Provenance,
87 pub description: &'static str,
89}
90
91impl MotifEntry {
92 #[inline]
94 pub fn matches(&self, motif: MotifClass, grammar: GrammarState) -> bool {
95 self.motif_class == motif && grammar.severity() >= self.min_severity
96 }
97}
98
99pub struct HeuristicsBank<const M: usize> {
105 entries: [Option<MotifEntry>; M],
106 count: usize,
107}
108
109impl<const M: usize> HeuristicsBank<M> {
110 pub const fn empty() -> Self {
112 Self {
113 entries: [None; M],
114 count: 0,
115 }
116 }
117
118 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 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 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 SemanticDisposition::Unknown
164 }
165
166 #[inline]
168 pub fn len(&self) -> usize {
169 self.count
170 }
171
172 #[inline]
174 pub fn is_empty(&self) -> bool {
175 self.count == 0
176 }
177
178 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum KnownClockClass {
214 TcxoSteadyState,
217 OcxoWarmup,
220 FreeRunXtal,
223 PllAcquisition,
226 LowNoiseOcxo,
229 Unknown,
231}
232
233impl KnownClockClass {
234 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 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
260pub 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#[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 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 #[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 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 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}