pub struct LyapunovEstimator<const W: usize> {
log_norms: [f32; W],
head: usize,
count: usize,
outside_buf: [bool; W],
outside_head: usize,
outside_count: usize,
post_crossing_duration: u32,
}
impl<const W: usize> LyapunovEstimator<W> {
pub const fn new() -> Self {
Self {
log_norms: [0.0; W],
head: 0,
count: 0,
outside_buf: [false; W],
outside_head: 0,
outside_count: 0,
post_crossing_duration: 0,
}
}
pub fn push(&mut self, norm: f32, rho: f32) -> LyapunovResult {
let ln_norm = ln_f32(norm.max(1e-12));
let oldest_ln = self.advance_log_ring(ln_norm);
let (post_crossing_fraction, separation_at_exit) =
self.advance_crossing_state(norm, rho);
if self.count < W {
return LyapunovResult {
lambda: 0.0,
stability: StabilityClass::InsufficientData,
time_to_exit: None,
post_crossing_duration: self.post_crossing_duration,
post_crossing_fraction,
separation_at_exit,
};
}
let lambda = (ln_norm - oldest_ln) / W as f32;
LyapunovResult {
lambda,
stability: StabilityClass::from_lambda(lambda),
time_to_exit: estimate_time_to_exit(norm, rho, lambda),
post_crossing_duration: self.post_crossing_duration,
post_crossing_fraction,
separation_at_exit,
}
}
fn advance_log_ring(&mut self, ln_norm: f32) -> f32 {
let oldest = self.log_norms[self.head];
self.log_norms[self.head] = ln_norm;
self.head = (self.head + 1) % W;
if self.count < W { self.count += 1; }
oldest
}
fn advance_crossing_state(&mut self, norm: f32, rho: f32) -> (f32, f32) {
let outside = norm > rho && rho > 1e-30;
if outside {
self.post_crossing_duration = self.post_crossing_duration.saturating_add(1);
} else {
self.post_crossing_duration = 0;
}
self.outside_buf[self.outside_head] = outside;
self.outside_head = (self.outside_head + 1) % W;
if self.outside_count < W { self.outside_count += 1; }
let outside_count = self.outside_buf[..self.outside_count]
.iter().filter(|&&b| b).count();
let frac = outside_count as f32 / self.outside_count.max(1) as f32;
let sep = if outside && rho > 1e-30 { (norm - rho) / rho } else { 0.0 };
(frac, sep)
}
pub fn reset(&mut self) {
self.log_norms = [0.0; W];
self.head = 0;
self.count = 0;
self.outside_buf = [false; W];
self.outside_head = 0;
self.outside_count = 0;
self.post_crossing_duration = 0;
}
}
impl<const W: usize> Default for LyapunovEstimator<W> {
fn default() -> Self {
Self::new()
}
}
fn estimate_time_to_exit(norm: f32, rho: f32, lambda: f32) -> Option<f32> {
if lambda > 1e-6 && norm < rho && norm > 1e-12 {
let t = ln_f32(rho / norm) / lambda;
if t > 0.0 && t < 1e6 { Some(t) } else { None }
} else {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LyapunovResult {
pub lambda: f32,
pub stability: StabilityClass,
pub time_to_exit: Option<f32>,
pub post_crossing_duration: u32,
pub post_crossing_fraction: f32,
pub separation_at_exit: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum StabilityClass {
ExponentialDivergence,
MarginalDivergence,
NeutralStability,
ExponentialConvergence,
InsufficientData,
}
impl StabilityClass {
const LAMBDA_CRIT: f32 = 0.01;
const EPSILON: f32 = 0.001;
pub fn from_lambda(lambda: f32) -> Self {
if lambda > Self::LAMBDA_CRIT {
StabilityClass::ExponentialDivergence
} else if lambda > Self::EPSILON {
StabilityClass::MarginalDivergence
} else if lambda > -Self::EPSILON {
StabilityClass::NeutralStability
} else {
StabilityClass::ExponentialConvergence
}
}
#[inline]
pub fn is_diverging(&self) -> bool {
matches!(
self,
StabilityClass::ExponentialDivergence | StabilityClass::MarginalDivergence
)
}
}
#[inline]
fn ln_f32(x: f32) -> f32 {
if x <= 0.0 {
return -23.0;
}
let bits = x.to_bits();
let exponent = ((bits >> 23) & 0xFF) as i32 - 127;
let mantissa_bits = (bits & 0x007F_FFFF) | 0x3F80_0000;
let m = f32::from_bits(mantissa_bits);
let t = m - 1.0;
let ln_m = t * (6.0 + t) / (6.0 + 4.0 * t);
ln_m + exponent as f32 * core::f32::consts::LN_2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ln_known_values() {
assert!((ln_f32(1.0)).abs() < 0.01, "ln(1)={}", ln_f32(1.0));
assert!((ln_f32(core::f32::consts::E) - 1.0).abs() < 0.05, "ln(e)={}", ln_f32(core::f32::consts::E));
assert!((ln_f32(0.5) - (-0.6931)).abs() < 0.05, "ln(0.5)={}", ln_f32(0.5));
assert!((ln_f32(10.0) - 2.3026).abs() < 0.1, "ln(10)={}", ln_f32(10.0));
}
#[test]
fn lyapunov_diverging_trajectory() {
let mut est = LyapunovEstimator::<5>::new();
let rho = 1.0;
let mut norm = 0.1_f32;
let mut last = est.push(norm, rho);
norm *= 1.5;
for _ in 0..9 {
last = est.push(norm, rho);
norm *= 1.5;
}
assert!(last.lambda > 0.0, "diverging trajectory must have λ>0, got {}", last.lambda);
assert!(last.stability.is_diverging());
}
#[test]
fn lyapunov_converging_trajectory() {
let mut est = LyapunovEstimator::<5>::new();
let rho = 1.0;
let mut norm = 0.5_f32;
let mut last = est.push(norm, rho);
norm *= 0.7;
for _ in 0..9 {
last = est.push(norm, rho);
norm *= 0.7; }
assert!(last.lambda < 0.0, "converging trajectory must have λ<0, got {}", last.lambda);
assert_eq!(last.stability, StabilityClass::ExponentialConvergence);
assert!(last.time_to_exit.is_none(), "converging trajectory has no exit time");
}
#[test]
fn lyapunov_stationary_trajectory() {
let mut est = LyapunovEstimator::<5>::new();
let rho = 1.0;
let mut last = est.push(0.1, rho);
for _ in 0..9 {
last = est.push(0.1, rho); }
assert!(last.lambda.abs() < 0.01, "stationary trajectory must have λ≈0, got {}", last.lambda);
assert_eq!(last.stability, StabilityClass::NeutralStability);
}
#[test]
fn lyapunov_time_to_exit_finite() {
let mut est = LyapunovEstimator::<5>::new();
let rho = 10.0; let mut norm = 0.1_f32;
let mut last = est.push(norm, rho);
norm *= 1.3;
for _ in 0..9 {
last = est.push(norm, rho);
norm *= 1.3; }
assert!(last.lambda > 0.0, "λ must be positive for growing norm, got {}", last.lambda);
assert!(last.time_to_exit.is_some(), "diverging below ρ must have finite exit time");
let t = last.time_to_exit.unwrap();
assert!(t > 0.0 && t < 1000.0, "exit time should be reasonable: {}", t);
}
#[test]
fn lyapunov_insufficient_data_before_window_full() {
let mut est = LyapunovEstimator::<10>::new();
for i in 0..9 {
let r = est.push(0.1 * (i as f32 + 1.0), 1.0);
assert_eq!(r.stability, StabilityClass::InsufficientData,
"should be InsufficientData until window fills at i={}", i);
}
}
#[test]
fn stability_class_from_lambda() {
assert_eq!(StabilityClass::from_lambda(0.05), StabilityClass::ExponentialDivergence);
assert_eq!(StabilityClass::from_lambda(0.005), StabilityClass::MarginalDivergence);
assert_eq!(StabilityClass::from_lambda(0.0), StabilityClass::NeutralStability);
assert_eq!(StabilityClass::from_lambda(-0.05), StabilityClass::ExponentialConvergence);
}
#[test]
fn reset_clears_state() {
let mut est = LyapunovEstimator::<5>::new();
for _ in 0..8 { est.push(0.1, 1.0); }
est.reset();
let r = est.push(0.1, 1.0);
assert_eq!(r.stability, StabilityClass::InsufficientData);
}
#[test]
fn post_crossing_duration_increments_when_outside() {
let mut est = LyapunovEstimator::<5>::new();
for i in 1..=5 {
let r = est.push(2.0, 1.0); assert_eq!(r.post_crossing_duration, i, "duration should be {i} at step {i}");
}
}
#[test]
fn post_crossing_duration_resets_on_recovery() {
let mut est = LyapunovEstimator::<5>::new();
for _ in 0..5 { est.push(2.0, 1.0); }
let r = est.push(0.5, 1.0);
assert_eq!(r.post_crossing_duration, 0, "duration must reset on recovery");
}
#[test]
fn post_crossing_fraction_zero_when_nominal() {
let mut est = LyapunovEstimator::<5>::new();
for _ in 0..10 {
let r = est.push(0.5, 1.0); assert!(r.post_crossing_fraction < 1e-6, "fraction should be zero when nominal");
}
}
#[test]
fn post_crossing_fraction_grows_when_outside() {
let mut est = LyapunovEstimator::<5>::new();
for _ in 0..5 {
let r = est.push(2.0, 1.0); assert!(r.post_crossing_fraction > 0.0, "fraction should grow");
}
let r = est.push(2.0, 1.0);
assert!((r.post_crossing_fraction - 1.0).abs() < 1e-5, "all samples outside → fraction=1.0");
}
#[test]
fn separation_at_exit_computed() {
let mut est = LyapunovEstimator::<5>::new();
let r = est.push(1.5, 1.0);
assert!((r.separation_at_exit - 0.5).abs() < 1e-5,
"separation_at_exit={}", r.separation_at_exit);
}
#[test]
fn separation_at_exit_zero_when_inside() {
let mut est = LyapunovEstimator::<5>::new();
let r = est.push(0.5, 1.0);
assert!((r.separation_at_exit).abs() < 1e-6, "inside envelope → separation=0");
}
}