use serde::Serialize;
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct ResilienceTimeline {
pub detect_s: f64,
pub react_s: f64,
pub recover_s: f64,
pub loss_duration_s: f64,
pub bounded: bool,
}
fn final_window_start_index(n: usize) -> usize {
n.saturating_sub((n / 4).max(2).min(n))
}
pub fn bounded_verdict(series: &[(f64, f64)], threshold: f64, horizon: f64) -> bool {
let s: Vec<(f64, f64)> = series
.iter()
.copied()
.filter(|&(t, _)| t <= horizon)
.collect();
if s.is_empty() {
return true;
}
let maxabs = s.iter().map(|&(_, e)| e.abs()).fold(0.0_f64, f64::max);
if maxabs <= threshold {
return true; }
let last_abs = s.last().unwrap().1.abs();
if last_abs <= threshold {
return true; }
let start = final_window_start_index(s.len());
let start_abs = s[start].1.abs();
let rising = last_abs > start_abs * (1.0 + 1e-9);
!rising
}
pub fn timeline(
series: &[(f64, f64)],
threshold: f64,
react_lag: f64,
horizon: f64,
) -> ResilienceTimeline {
let first_breach = series
.iter()
.find(|&&(_, e)| e.abs() > threshold)
.map(|&(t, _)| t);
let Some(detect_s) = first_breach else {
return ResilienceTimeline {
detect_s: 0.0,
react_s: 0.0,
recover_s: 0.0,
loss_duration_s: 0.0,
bounded: true,
};
};
let recover = series
.iter()
.find(|&&(t, e)| t > detect_s && e.abs() <= threshold)
.map(|&(t, _)| t);
let bounded = bounded_verdict(series, threshold, horizon);
let (recover_s, loss_duration_s) = match recover {
Some(r) => (r, r - detect_s),
None => (horizon, (horizon - detect_s).max(0.0)),
};
ResilienceTimeline {
detect_s,
react_s: detect_s + react_lag,
recover_s,
loss_duration_s,
bounded,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn breach_then_recovery_durations() {
let mut series = Vec::new();
for t in 0..=100 {
let t = t as f64;
let err = if (10.0..40.0).contains(&t) { 5.0 } else { 0.5 };
series.push((t, err));
}
let tl = timeline(&series, 1.0, 2.0, 100.0);
assert_eq!(tl.detect_s, 10.0);
assert_eq!(tl.react_s, 12.0);
assert_eq!(tl.recover_s, 40.0);
assert_eq!(tl.loss_duration_s, 30.0);
assert!(tl.bounded);
}
#[test]
fn monotone_growth_is_unbounded() {
let series: Vec<(f64, f64)> = (0..=100).map(|t| (t as f64, t as f64)).collect();
let tl = timeline(&series, 1.0, 0.0, 100.0);
assert_eq!(tl.detect_s, 2.0); assert_eq!(tl.recover_s, 100.0); assert!(!tl.bounded);
}
#[test]
fn never_breaching_is_trivially_bounded() {
let series: Vec<(f64, f64)> = (0..=50).map(|t| (t as f64, 0.1)).collect();
let tl = timeline(&series, 1.0, 3.0, 50.0);
assert_eq!(tl.detect_s, 0.0);
assert_eq!(tl.loss_duration_s, 0.0);
assert!(tl.bounded);
}
#[test]
fn plateau_above_threshold_is_bounded() {
let series: Vec<(f64, f64)> = (0..=100)
.map(|t| (t as f64, if t < 10 { 0.2 } else { 8.0 }))
.collect();
assert!(bounded_verdict(&series, 1.0, 100.0));
}
}