#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DetectabilityClass {
StructuralDetected,
StressDetected,
EarlyLowMarginCrossing,
NotDetected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum SemanticStatus {
PersistentStructuralFault,
ClearStructuralDetection,
MarginalStructuralDegradation,
IsolatedStressEvent,
DegradedAmbiguous,
Ambiguous,
NotDetected,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DetectionStrengthBand {
Clear = 0,
Marginal = 1,
Degraded = 2,
Critical = 3,
}
impl DetectionStrengthBand {
pub fn from_class(class: DetectabilityClass) -> Self {
match class {
DetectabilityClass::NotDetected => Self::Clear,
DetectabilityClass::EarlyLowMarginCrossing => Self::Marginal,
DetectabilityClass::StressDetected => Self::Degraded,
DetectabilityClass::StructuralDetected => Self::Critical,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct DetectabilityThresholds {
pub high_margin_threshold: f32,
pub low_margin_threshold: f32,
pub persistence_duration_threshold: u32,
pub persistence_fraction_threshold: f32,
pub early_window: u32,
pub kappa: f32,
}
impl DetectabilityThresholds {
pub const fn default_rf() -> Self {
Self {
high_margin_threshold: 0.20, low_margin_threshold: 0.02, persistence_duration_threshold: 10,
persistence_fraction_threshold: 0.30,
early_window: 5,
kappa: 0.001,
}
}
}
impl Default for DetectabilityThresholds {
fn default() -> Self { Self::default_rf() }
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DetectabilityBound {
pub delta_0: f32,
pub alpha: f32,
pub kappa: f32,
pub tau_upper: Option<f32>,
pub bound_satisfied: Option<bool>,
}
impl DetectabilityBound {
pub fn compute(delta_0: f32, alpha: f32, kappa: f32) -> Self {
let tau_upper = if alpha > kappa + 1e-12 {
Some(delta_0 / (alpha - kappa))
} else {
None
};
Self { delta_0, alpha, kappa, tau_upper, bound_satisfied: None }
}
pub fn validate_crossing(&mut self, t_cross: f32, epsilon: f32) {
self.bound_satisfied = self.tau_upper.map(|tau| t_cross <= tau + epsilon);
}
}
#[derive(Debug, Clone, Copy)]
pub struct DetectabilitySummary {
pub class: DetectabilityClass,
pub semantic: SemanticStatus,
pub band: DetectionStrengthBand,
pub bound: DetectabilityBound,
pub post_crossing_duration: u32,
pub post_crossing_fraction: f32,
pub peak_margin_after_crossing: f32,
pub boundary_proximate_crossing: bool,
}
pub struct DetectabilityTracker<const W: usize> {
post_crossing_duration: u32,
outside_buf: [bool; W],
head: usize,
count: usize,
peak_margin: f32,
first_crossing_sample: Option<u32>,
sample_idx: u32,
thresholds: DetectabilityThresholds,
cached_alpha: f32,
delta_0: f32,
}
impl<const W: usize> DetectabilityTracker<W> {
pub const fn new(thresholds: DetectabilityThresholds) -> Self {
Self {
post_crossing_duration: 0,
outside_buf: [false; W],
head: 0,
count: 0,
peak_margin: 0.0,
first_crossing_sample: None,
sample_idx: 0,
thresholds,
cached_alpha: 0.0,
delta_0: 0.0,
}
}
pub const fn default_rf() -> Self {
Self::new(DetectabilityThresholds::default_rf())
}
pub fn update(&mut self, norm: f32, rho: f32, alpha: f32) -> DetectabilitySummary {
let outside = norm > rho && rho > 1e-30;
let normalised_excess = if rho > 1e-30 { ((norm - rho) / rho).max(0.0) } else { 0.0 };
self.cached_alpha = alpha;
self.update_crossing_state(outside, normalised_excess);
let post_crossing_fraction = self.update_outside_ring(outside);
let crossing_time = self.first_crossing_sample
.map(|s| self.sample_idx.saturating_sub(s) as f32)
.unwrap_or(0.0);
let early = self.first_crossing_sample
.map(|s| self.sample_idx.saturating_sub(s) < self.thresholds.early_window)
.unwrap_or(false);
let class = self.classify_detection(outside, normalised_excess);
let semantic = self.derive_semantic(class, post_crossing_fraction, early);
let bound = self.compute_bound(outside, alpha, crossing_time);
let band = DetectionStrengthBand::from_class(class);
self.sample_idx = self.sample_idx.wrapping_add(1);
DetectabilitySummary {
class,
semantic,
band,
bound,
post_crossing_duration: self.post_crossing_duration,
post_crossing_fraction,
peak_margin_after_crossing: self.peak_margin,
boundary_proximate_crossing: early,
}
}
fn update_crossing_state(&mut self, outside: bool, normalised_excess: f32) {
if outside && self.first_crossing_sample.is_none() {
self.first_crossing_sample = Some(self.sample_idx);
self.delta_0 = normalised_excess;
}
if outside {
self.post_crossing_duration = self.post_crossing_duration.saturating_add(1);
if normalised_excess > self.peak_margin {
self.peak_margin = normalised_excess;
}
} else {
self.post_crossing_duration = 0;
self.peak_margin = 0.0;
self.first_crossing_sample = None;
}
}
fn update_outside_ring(&mut self, outside: bool) -> f32 {
self.outside_buf[self.head] = outside;
self.head = (self.head + 1) % W;
if self.count < W { self.count += 1; }
let outside_count = self.outside_buf[..self.count].iter().filter(|&&b| b).count();
outside_count as f32 / self.count.max(1) as f32
}
fn classify_detection(&self, outside: bool, normalised_excess: f32) -> DetectabilityClass {
if !outside && self.post_crossing_duration == 0 {
DetectabilityClass::NotDetected
} else if normalised_excess > self.thresholds.high_margin_threshold {
DetectabilityClass::StructuralDetected
} else if normalised_excess > self.thresholds.low_margin_threshold {
DetectabilityClass::StressDetected
} else {
DetectabilityClass::EarlyLowMarginCrossing
}
}
fn derive_semantic(
&self,
class: DetectabilityClass,
post_crossing_fraction: f32,
early: bool,
) -> SemanticStatus {
match class {
DetectabilityClass::NotDetected => SemanticStatus::NotDetected,
DetectabilityClass::StructuralDetected => {
if self.post_crossing_duration >= self.thresholds.persistence_duration_threshold {
SemanticStatus::PersistentStructuralFault
} else {
SemanticStatus::ClearStructuralDetection
}
}
DetectabilityClass::StressDetected => {
if post_crossing_fraction >= self.thresholds.persistence_fraction_threshold {
SemanticStatus::MarginalStructuralDegradation
} else {
SemanticStatus::IsolatedStressEvent
}
}
DetectabilityClass::EarlyLowMarginCrossing => {
if early { SemanticStatus::Ambiguous } else { SemanticStatus::DegradedAmbiguous }
}
}
}
fn compute_bound(&self, outside: bool, alpha: f32, crossing_time: f32) -> DetectabilityBound {
let kappa = self.thresholds.kappa;
let mut bound = DetectabilityBound::compute(self.delta_0, alpha, kappa);
if outside {
bound.validate_crossing(crossing_time, 1.0);
}
bound
}
pub fn reset(&mut self) {
self.post_crossing_duration = 0;
self.outside_buf = [false; W];
self.head = 0;
self.count = 0;
self.peak_margin = 0.0;
self.first_crossing_sample = None;
self.sample_idx = 0;
self.cached_alpha = 0.0;
self.delta_0 = 0.0;
}
#[inline] pub fn post_crossing_duration(&self) -> u32 { self.post_crossing_duration }
#[inline] pub fn peak_margin(&self) -> f32 { self.peak_margin }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nominal_operation_is_not_detected() {
let mut tracker = DetectabilityTracker::<20>::default_rf();
for i in 0..50 {
let r = tracker.update(0.05, 0.10, 0.0);
assert_eq!(r.class, DetectabilityClass::NotDetected, "step {}", i);
assert_eq!(r.semantic, SemanticStatus::NotDetected);
assert_eq!(r.band, DetectionStrengthBand::Clear);
}
}
#[test]
fn large_crossing_structural_detected() {
let mut tracker = DetectabilityTracker::<20>::default_rf();
let r = tracker.update(0.13, 0.10, 0.01);
assert_eq!(r.class, DetectabilityClass::StructuralDetected);
assert_eq!(r.band, DetectionStrengthBand::Critical);
}
#[test]
fn small_crossing_stress_detected() {
let mut tracker = DetectabilityTracker::<20>::default_rf();
let r = tracker.update(0.105, 0.10, 0.005);
assert_eq!(r.class, DetectabilityClass::StressDetected);
assert_eq!(r.band, DetectionStrengthBand::Degraded);
}
#[test]
fn marginal_crossing_early_low_margin() {
let mut tracker = DetectabilityTracker::<20>::default_rf();
let r = tracker.update(0.1005, 0.10, 0.001);
assert_eq!(r.class, DetectabilityClass::EarlyLowMarginCrossing);
}
#[test]
fn persistent_structural_fault_after_threshold() {
let mut tracker = DetectabilityTracker::<20>::default_rf();
for i in 0..12 {
let r = tracker.update(0.15, 0.10, 0.01);
if i >= 10 {
assert_eq!(
r.semantic, SemanticStatus::PersistentStructuralFault,
"step {}: expected PersistentStructuralFault", i
);
}
}
}
#[test]
fn post_crossing_fraction_accumulates() {
let mut tracker = DetectabilityTracker::<20>::default_rf();
for _ in 0..10 { tracker.update(0.15, 0.10, 0.01); }
for _ in 0..10 {
let r = tracker.update(0.05, 0.10, 0.0);
assert_eq!(r.class, DetectabilityClass::NotDetected);
}
}
#[test]
fn tau_upper_bound_computed() {
let bound = DetectabilityBound::compute(0.05, 0.01, 0.001);
let tau = bound.tau_upper.expect("should have bound");
assert!((tau - 5.555_555).abs() < 1e-2, "tau={}", tau);
}
#[test]
fn tau_upper_none_when_alpha_le_kappa() {
let bound = DetectabilityBound::compute(0.05, 0.0005, 0.001);
assert!(bound.tau_upper.is_none(), "alpha <= kappa → no bound");
}
#[test]
fn detection_strength_band_ordering() {
assert!(DetectionStrengthBand::Clear < DetectionStrengthBand::Marginal);
assert!(DetectionStrengthBand::Marginal < DetectionStrengthBand::Degraded);
assert!(DetectionStrengthBand::Degraded < DetectionStrengthBand::Critical);
}
#[test]
fn reset_clears_all_state() {
let mut tracker = DetectabilityTracker::<20>::default_rf();
for _ in 0..20 { tracker.update(0.15, 0.10, 0.01); }
tracker.reset();
let r = tracker.update(0.05, 0.10, 0.0);
assert_eq!(r.class, DetectabilityClass::NotDetected);
assert_eq!(r.post_crossing_duration, 0);
}
}