1pub struct LyapunovEstimator<const W: usize> {
55 log_norms: [f32; W],
57 head: usize,
59 count: usize,
61
62 outside_buf: [bool; W],
65 outside_head: usize,
67 outside_count: usize,
69 post_crossing_duration: u32,
71}
72
73impl<const W: usize> LyapunovEstimator<W> {
74 pub const fn new() -> Self {
76 Self {
77 log_norms: [0.0; W],
78 head: 0,
79 count: 0,
80 outside_buf: [false; W],
81 outside_head: 0,
82 outside_count: 0,
83 post_crossing_duration: 0,
84 }
85 }
86
87 pub fn push(&mut self, norm: f32, rho: f32) -> LyapunovResult {
94 let ln_norm = ln_f32(norm.max(1e-12));
95 let oldest_ln = self.advance_log_ring(ln_norm);
96 let (post_crossing_fraction, separation_at_exit) =
97 self.advance_crossing_state(norm, rho);
98
99 if self.count < W {
100 return LyapunovResult {
101 lambda: 0.0,
102 stability: StabilityClass::InsufficientData,
103 time_to_exit: None,
104 post_crossing_duration: self.post_crossing_duration,
105 post_crossing_fraction,
106 separation_at_exit,
107 };
108 }
109
110 let lambda = (ln_norm - oldest_ln) / W as f32;
111 LyapunovResult {
112 lambda,
113 stability: StabilityClass::from_lambda(lambda),
114 time_to_exit: estimate_time_to_exit(norm, rho, lambda),
115 post_crossing_duration: self.post_crossing_duration,
116 post_crossing_fraction,
117 separation_at_exit,
118 }
119 }
120
121 fn advance_log_ring(&mut self, ln_norm: f32) -> f32 {
122 let oldest = self.log_norms[self.head];
123 self.log_norms[self.head] = ln_norm;
124 self.head = (self.head + 1) % W;
125 if self.count < W { self.count += 1; }
126 oldest
127 }
128
129 fn advance_crossing_state(&mut self, norm: f32, rho: f32) -> (f32, f32) {
130 let outside = norm > rho && rho > 1e-30;
131 if outside {
132 self.post_crossing_duration = self.post_crossing_duration.saturating_add(1);
133 } else {
134 self.post_crossing_duration = 0;
135 }
136 self.outside_buf[self.outside_head] = outside;
137 self.outside_head = (self.outside_head + 1) % W;
138 if self.outside_count < W { self.outside_count += 1; }
139 let outside_count = self.outside_buf[..self.outside_count]
140 .iter().filter(|&&b| b).count();
141 let frac = outside_count as f32 / self.outside_count.max(1) as f32;
142 let sep = if outside && rho > 1e-30 { (norm - rho) / rho } else { 0.0 };
143 (frac, sep)
144 }
145
146 pub fn reset(&mut self) {
148 self.log_norms = [0.0; W];
149 self.head = 0;
150 self.count = 0;
151 self.outside_buf = [false; W];
152 self.outside_head = 0;
153 self.outside_count = 0;
154 self.post_crossing_duration = 0;
155 }
156}
157
158impl<const W: usize> Default for LyapunovEstimator<W> {
159 fn default() -> Self {
160 Self::new()
161 }
162}
163
164fn estimate_time_to_exit(norm: f32, rho: f32, lambda: f32) -> Option<f32> {
165 if lambda > 1e-6 && norm < rho && norm > 1e-12 {
166 let t = ln_f32(rho / norm) / lambda;
167 if t > 0.0 && t < 1e6 { Some(t) } else { None }
168 } else {
169 None
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq)]
175pub struct LyapunovResult {
176 pub lambda: f32,
182
183 pub stability: StabilityClass,
185
186 pub time_to_exit: Option<f32>,
194
195 pub post_crossing_duration: u32,
203
204 pub post_crossing_fraction: f32,
211
212 pub separation_at_exit: f32,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
226pub enum StabilityClass {
227 ExponentialDivergence,
230
231 MarginalDivergence,
234
235 NeutralStability,
238
239 ExponentialConvergence,
242
243 InsufficientData,
245}
246
247impl StabilityClass {
248 const LAMBDA_CRIT: f32 = 0.01;
251 const EPSILON: f32 = 0.001;
253
254 pub fn from_lambda(lambda: f32) -> Self {
256 if lambda > Self::LAMBDA_CRIT {
257 StabilityClass::ExponentialDivergence
258 } else if lambda > Self::EPSILON {
259 StabilityClass::MarginalDivergence
260 } else if lambda > -Self::EPSILON {
261 StabilityClass::NeutralStability
262 } else {
263 StabilityClass::ExponentialConvergence
264 }
265 }
266
267 #[inline]
269 pub fn is_diverging(&self) -> bool {
270 matches!(
271 self,
272 StabilityClass::ExponentialDivergence | StabilityClass::MarginalDivergence
273 )
274 }
275}
276
277#[inline]
287fn ln_f32(x: f32) -> f32 {
288 if x <= 0.0 {
289 return -23.0;
290 }
291
292 let bits = x.to_bits();
294 let exponent = ((bits >> 23) & 0xFF) as i32 - 127;
295 let mantissa_bits = (bits & 0x007F_FFFF) | 0x3F80_0000;
297 let m = f32::from_bits(mantissa_bits);
298
299 let t = m - 1.0;
302 let ln_m = t * (6.0 + t) / (6.0 + 4.0 * t);
303
304 ln_m + exponent as f32 * core::f32::consts::LN_2
305}
306
307#[cfg(test)]
310mod tests {
311 use super::*;
312
313 #[test]
314 fn ln_known_values() {
315 assert!((ln_f32(1.0)).abs() < 0.01, "ln(1)={}", ln_f32(1.0));
316 assert!((ln_f32(core::f32::consts::E) - 1.0).abs() < 0.05, "ln(e)={}", ln_f32(core::f32::consts::E));
317 assert!((ln_f32(0.5) - (-0.6931)).abs() < 0.05, "ln(0.5)={}", ln_f32(0.5));
318 assert!((ln_f32(10.0) - 2.3026).abs() < 0.1, "ln(10)={}", ln_f32(10.0));
319 }
320
321 #[test]
322 fn lyapunov_diverging_trajectory() {
323 let mut est = LyapunovEstimator::<5>::new();
324 let rho = 1.0;
325 let mut norm = 0.1_f32;
327 let mut last = est.push(norm, rho);
328 norm *= 1.5;
329 for _ in 0..9 {
330 last = est.push(norm, rho);
331 norm *= 1.5;
332 }
333 assert!(last.lambda > 0.0, "diverging trajectory must have λ>0, got {}", last.lambda);
334 assert!(last.stability.is_diverging());
335 }
336
337 #[test]
338 fn lyapunov_converging_trajectory() {
339 let mut est = LyapunovEstimator::<5>::new();
340 let rho = 1.0;
341 let mut norm = 0.5_f32;
342 let mut last = est.push(norm, rho);
343 norm *= 0.7;
344 for _ in 0..9 {
345 last = est.push(norm, rho);
346 norm *= 0.7; }
348 assert!(last.lambda < 0.0, "converging trajectory must have λ<0, got {}", last.lambda);
349 assert_eq!(last.stability, StabilityClass::ExponentialConvergence);
350 assert!(last.time_to_exit.is_none(), "converging trajectory has no exit time");
351 }
352
353 #[test]
354 fn lyapunov_stationary_trajectory() {
355 let mut est = LyapunovEstimator::<5>::new();
356 let rho = 1.0;
357 let mut last = est.push(0.1, rho);
358 for _ in 0..9 {
359 last = est.push(0.1, rho); }
361 assert!(last.lambda.abs() < 0.01, "stationary trajectory must have λ≈0, got {}", last.lambda);
362 assert_eq!(last.stability, StabilityClass::NeutralStability);
363 }
364
365 #[test]
366 fn lyapunov_time_to_exit_finite() {
367 let mut est = LyapunovEstimator::<5>::new();
368 let rho = 10.0; let mut norm = 0.1_f32;
370 let mut last = est.push(norm, rho);
371 norm *= 1.3;
372 for _ in 0..9 {
373 last = est.push(norm, rho);
374 norm *= 1.3; }
376 assert!(last.lambda > 0.0, "λ must be positive for growing norm, got {}", last.lambda);
377 assert!(last.time_to_exit.is_some(), "diverging below ρ must have finite exit time");
378 let t = last.time_to_exit.unwrap();
379 assert!(t > 0.0 && t < 1000.0, "exit time should be reasonable: {}", t);
380 }
381
382 #[test]
383 fn lyapunov_insufficient_data_before_window_full() {
384 let mut est = LyapunovEstimator::<10>::new();
385 for i in 0..9 {
386 let r = est.push(0.1 * (i as f32 + 1.0), 1.0);
387 assert_eq!(r.stability, StabilityClass::InsufficientData,
388 "should be InsufficientData until window fills at i={}", i);
389 }
390 }
391
392 #[test]
393 fn stability_class_from_lambda() {
394 assert_eq!(StabilityClass::from_lambda(0.05), StabilityClass::ExponentialDivergence);
395 assert_eq!(StabilityClass::from_lambda(0.005), StabilityClass::MarginalDivergence);
396 assert_eq!(StabilityClass::from_lambda(0.0), StabilityClass::NeutralStability);
397 assert_eq!(StabilityClass::from_lambda(-0.05), StabilityClass::ExponentialConvergence);
398 }
399
400 #[test]
401 fn reset_clears_state() {
402 let mut est = LyapunovEstimator::<5>::new();
403 for _ in 0..8 { est.push(0.1, 1.0); }
404 est.reset();
405 let r = est.push(0.1, 1.0);
406 assert_eq!(r.stability, StabilityClass::InsufficientData);
407 }
408
409 #[test]
410 fn post_crossing_duration_increments_when_outside() {
411 let mut est = LyapunovEstimator::<5>::new();
412 for i in 1..=5 {
414 let r = est.push(2.0, 1.0); assert_eq!(r.post_crossing_duration, i, "duration should be {i} at step {i}");
416 }
417 }
418
419 #[test]
420 fn post_crossing_duration_resets_on_recovery() {
421 let mut est = LyapunovEstimator::<5>::new();
422 for _ in 0..5 { est.push(2.0, 1.0); }
423 let r = est.push(0.5, 1.0);
425 assert_eq!(r.post_crossing_duration, 0, "duration must reset on recovery");
426 }
427
428 #[test]
429 fn post_crossing_fraction_zero_when_nominal() {
430 let mut est = LyapunovEstimator::<5>::new();
431 for _ in 0..10 {
432 let r = est.push(0.5, 1.0); assert!(r.post_crossing_fraction < 1e-6, "fraction should be zero when nominal");
434 }
435 }
436
437 #[test]
438 fn post_crossing_fraction_grows_when_outside() {
439 let mut est = LyapunovEstimator::<5>::new();
440 for _ in 0..5 {
441 let r = est.push(2.0, 1.0); assert!(r.post_crossing_fraction > 0.0, "fraction should grow");
443 }
444 let r = est.push(2.0, 1.0);
445 assert!((r.post_crossing_fraction - 1.0).abs() < 1e-5, "all samples outside → fraction=1.0");
446 }
447
448 #[test]
449 fn separation_at_exit_computed() {
450 let mut est = LyapunovEstimator::<5>::new();
451 let r = est.push(1.5, 1.0);
453 assert!((r.separation_at_exit - 0.5).abs() < 1e-5,
454 "separation_at_exit={}", r.separation_at_exit);
455 }
456
457 #[test]
458 fn separation_at_exit_zero_when_inside() {
459 let mut est = LyapunovEstimator::<5>::new();
460 let r = est.push(0.5, 1.0);
461 assert!((r.separation_at_exit).abs() < 1e-6, "inside envelope → separation=0");
462 }
463}