1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
64pub enum DetectabilityClass {
65 StructuralDetected,
68
69 StressDetected,
72
73 EarlyLowMarginCrossing,
76
77 NotDetected,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub enum SemanticStatus {
87 PersistentStructuralFault,
90
91 ClearStructuralDetection,
94
95 MarginalStructuralDegradation,
98
99 IsolatedStressEvent,
102
103 DegradedAmbiguous,
106
107 Ambiguous,
110
111 NotDetected,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
117#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
118pub enum DetectionStrengthBand {
119 Clear = 0,
121 Marginal = 1,
123 Degraded = 2,
125 Critical = 3,
127}
128
129impl DetectionStrengthBand {
130 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#[derive(Debug, Clone, Copy)]
143pub struct DetectabilityThresholds {
144 pub high_margin_threshold: f32,
147
148 pub low_margin_threshold: f32,
151
152 pub persistence_duration_threshold: u32,
155
156 pub persistence_fraction_threshold: f32,
159
160 pub early_window: u32,
164
165 pub kappa: f32,
169}
170
171impl DetectabilityThresholds {
172 pub const fn default_rf() -> Self {
174 Self {
175 high_margin_threshold: 0.20, low_margin_threshold: 0.02, 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#[derive(Debug, Clone, Copy, PartialEq)]
199pub struct DetectabilityBound {
200 pub delta_0: f32,
202 pub alpha: f32,
204 pub kappa: f32,
206 pub tau_upper: Option<f32>,
208 pub bound_satisfied: Option<bool>,
210}
211
212impl DetectabilityBound {
213 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 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#[derive(Debug, Clone, Copy)]
237pub struct DetectabilitySummary {
238 pub class: DetectabilityClass,
240 pub semantic: SemanticStatus,
242 pub band: DetectionStrengthBand,
244 pub bound: DetectabilityBound,
246 pub post_crossing_duration: u32,
249 pub post_crossing_fraction: f32,
251 pub peak_margin_after_crossing: f32,
253 pub boundary_proximate_crossing: bool,
255}
256
257pub struct DetectabilityTracker<const W: usize> {
261 post_crossing_duration: u32,
263 outside_buf: [bool; W],
265 head: usize,
267 count: usize,
269 peak_margin: f32,
271 first_crossing_sample: Option<u32>,
273 sample_idx: u32,
275 thresholds: DetectabilityThresholds,
277 cached_alpha: f32,
279 delta_0: f32,
281}
282
283impl<const W: usize> DetectabilityTracker<W> {
284 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 pub const fn default_rf() -> Self {
302 Self::new(DetectabilityThresholds::default_rf())
303 }
304
305 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 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 #[inline] pub fn post_crossing_duration(&self) -> u32 { self.post_crossing_duration }
435 #[inline] pub fn peak_margin(&self) -> f32 { self.peak_margin }
437}
438
439#[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 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 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 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 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 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 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 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}