Skip to main content

dsfb_rf/
lyapunov.rs

1//! Lyapunov stability analysis for residual trajectory divergence.
2//!
3//! ## Theoretical Basis
4//!
5//! The DSFB grammar state "Boundary" corresponds to a trajectory on the
6//! semiotic manifold M_sem that is diverging from the nominal attractor.
7//! This module formalizes that divergence using finite-time Lyapunov
8//! exponents (FTLE), providing a stability-theoretic quantification of
9//! *how fast* the residual trajectory is leaving the admissible region.
10//!
11//! ## Mathematical Definition
12//!
13//! The finite-time Lyapunov exponent over window W at observation k is:
14//!
15//! λ(k) = (1/W) · ln(‖r(k)‖ / ‖r(k−W)‖)
16//!
17//! - λ > 0: exponential divergence (trajectory leaving nominal attractor)
18//! - λ ≈ 0: neutral stability (stationary residual)
19//! - λ < 0: exponential convergence (trajectory returning to nominal)
20//!
21//! ## Relationship to Grammar States
22//!
23//! | λ regime          | Grammar state interpretation            |
24//! |-------------------|-----------------------------------------|
25//! | λ > λ_crit        | Boundary[SustainedOutwardDrift]         |
26//! | λ > 0, < λ_crit   | Boundary approach — watch               |
27//! | λ ≈ 0             | Admissible — stationary residual        |
28//! | λ < 0             | Admissible — converging, healthy        |
29//!
30//! ## Distinction from Luenberger Observer
31//!
32//! A Luenberger observer drives ‖r(k)‖ → 0 via feedback gain L.
33//! It is structurally blind to the *rate* of divergence λ(k), because
34//! it projects r(k) onto a scalar correction term L·r(k).
35//! DSFB computes λ(k) explicitly and uses it as a first-class coordinate
36//! on the semiotic manifold alongside (‖r‖, ṙ, r̈).
37//!
38//! ## Design
39//!
40//! - `no_std`, `no_alloc`, zero `unsafe`
41//! - Fixed-capacity circular buffer `[f32; W]`
42//! - O(1) per observation (no window scan — uses head/tail of circular buffer)
43//! - Uses Newton-Raphson `ln` approximation (no `libm` dependency)
44
45/// Finite-Time Lyapunov Exponent (FTLE) estimator.
46///
47/// Computes λ(k) = (1/W) · ln(‖r(k)‖ / ‖r(k−W)‖) over a sliding window.
48///
49/// Generic parameter `W` = window width. All storage is stack-allocated.
50///
51/// Also tracks **post-crossing persistence** metrics (DSFB-Lattice §IV):
52/// the duration and fraction of samples that reside outside the admissibility
53/// envelope, enabling distinction between transient spikes and sustained faults.
54pub struct LyapunovEstimator<const W: usize> {
55    /// Circular buffer of log-norms for efficient ratio computation.
56    log_norms: [f32; W],
57    /// Write head in circular buffer.
58    head: usize,
59    /// Number of valid observations (saturates at W).
60    count: usize,
61
62    // ── Post-crossing persistence tracking ──────────────────────────────
63    /// Circular buffer of outside-envelope flags for fraction computation.
64    outside_buf: [bool; W],
65    /// Write head for outside_buf (separate from log_norms head).
66    outside_head: usize,
67    /// Number of valid entries in outside_buf (saturates at W).
68    outside_count: usize,
69    /// Consecutive samples outside the envelope since the last crossing.
70    post_crossing_duration: u32,
71}
72
73impl<const W: usize> LyapunovEstimator<W> {
74    /// Create a new estimator.
75    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    /// Push a new residual norm and return the FTLE estimate.
88    ///
89    /// Returns `LyapunovResult` containing λ(k), stability classification,
90    /// estimated time-to-envelope-exit, and post-crossing persistence metrics.
91    ///
92    /// If the window is not yet full, returns λ = 0.0 (insufficient data).
93    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    /// Reset the estimator.
147    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/// Result of a Lyapunov exponent computation.
174#[derive(Debug, Clone, Copy, PartialEq)]
175pub struct LyapunovResult {
176    /// Finite-time Lyapunov exponent λ(k).
177    ///
178    /// - λ > 0: exponential divergence from nominal
179    /// - λ ≈ 0: neutral (stationary)
180    /// - λ < 0: converging toward nominal
181    pub lambda: f32,
182
183    /// Stability classification derived from λ.
184    pub stability: StabilityClass,
185
186    /// Estimated observations until envelope exit under current divergence rate.
187    ///
188    /// `Some(t)` if λ > 0 and norm < ρ (trajectory is diverging but inside envelope).
189    /// `None` if λ ≤ 0, norm ≥ ρ, or insufficient data.
190    ///
191    /// This is the exponential analogue of Theorem 1's linear bound k* ≤ ρ/α.
192    /// Under exponential divergence: k* = ln(ρ/‖r‖) / λ.
193    pub time_to_exit: Option<f32>,
194
195    /// Number of consecutive samples spent outside the admissibility envelope.
196    ///
197    /// Resets to 0 as soon as the trajectory returns within ρ.
198    /// Provides the "post-crossing persistence duration" from DSFB-Lattice §IV.
199    ///
200    /// - 0: currently inside envelope (nominal or just recovered)
201    /// - 1..N: N consecutive samples outside — persistent structural fault candidate
202    pub post_crossing_duration: u32,
203
204    /// Fraction of the last W samples that were outside the admissibility envelope.
205    ///
206    /// Value ∈ [0, 1].  Close to 1 → trajectory mostly outside envelope (persistent
207    /// fault).  Close to 0 → transient or nominal.
208    ///
209    /// This is the "post-crossing fraction" from DSFB-Lattice §IV.
210    pub post_crossing_fraction: f32,
211
212    /// Normalised excess (‖r‖ − ρ) / ρ at the current sample.
213    ///
214    /// Positive when outside the envelope (violation / drift above ρ).
215    /// Zero when inside.  This is the "separation at exit" metric used to
216    /// classify the DetectabilityClass in the `detectability` module.
217    pub separation_at_exit: f32,
218}
219
220/// Stability classification from the Lyapunov exponent.
221///
222/// Maps λ onto qualitative stability regimes that inform grammar state
223/// assignment and operator advisory output.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
226pub enum StabilityClass {
227    /// λ > λ_crit (default 0.01): exponential divergence.
228    /// Corroborates Boundary[SustainedOutwardDrift] grammar state.
229    ExponentialDivergence,
230
231    /// 0 < λ ≤ λ_crit: marginal divergence.
232    /// Supports Boundary approach — structural monitoring warranted.
233    MarginalDivergence,
234
235    /// −ε < λ < ε: neutral stability (stationary residual).
236    /// Corroborates Admissible grammar state.
237    NeutralStability,
238
239    /// λ < −ε: exponential convergence toward nominal.
240    /// Strongly corroborates Admissible.
241    ExponentialConvergence,
242
243    /// Insufficient data to compute λ (window not full).
244    InsufficientData,
245}
246
247impl StabilityClass {
248    /// Critical Lyapunov exponent threshold.
249    /// Above this: exponential divergence regime.
250    const LAMBDA_CRIT: f32 = 0.01;
251    /// Neutral band half-width.
252    const EPSILON: f32 = 0.001;
253
254    /// Classify from a raw λ value.
255    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    /// Returns true if the trajectory is diverging (λ > ε).
268    #[inline]
269    pub fn is_diverging(&self) -> bool {
270        matches!(
271            self,
272            StabilityClass::ExponentialDivergence | StabilityClass::MarginalDivergence
273        )
274    }
275}
276
277// ── no_std natural logarithm ───────────────────────────────────────────────
278
279/// Fast natural logarithm, no_std safe, no libm dependency.
280///
281/// Uses IEEE 754 float decomposition x = m · 2^e, then a Padé-style
282/// rational approximation for ln(m) on [1.0, 2.0).
283///
284/// Accurate to ~5e-4 relative error for x ∈ [1e-10, 1e10].
285/// Returns −23.0 for x ≤ 0.
286#[inline]
287fn ln_f32(x: f32) -> f32 {
288    if x <= 0.0 {
289        return -23.0;
290    }
291
292    // Decompose via IEEE 754 bits: x = m · 2^e, m ∈ [1.0, 2.0)
293    let bits = x.to_bits();
294    let exponent = ((bits >> 23) & 0xFF) as i32 - 127;
295    // Force exponent to 0 (bias 127) so mantissa_bits represents m ∈ [1.0, 2.0)
296    let mantissa_bits = (bits & 0x007F_FFFF) | 0x3F80_0000;
297    let m = f32::from_bits(mantissa_bits);
298
299    // ln(m) for m ∈ [1.0, 2.0) via rational approximation around m=1
300    // ln(1+t) ≈ t(6+t) / (6+4t) for t ∈ [0, 1) — Padé [1,1] of ln(1+t)
301    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// ── Tests ──────────────────────────────────────────────────────────────────
308
309#[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        // Exponentially growing norms: 0.1, 0.15, 0.225, ...
326        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; // decaying
347        }
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); // constant norm
360        }
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; // large ρ so norm stays below it
369        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; // ends at ~1.38 < ρ=10
375        }
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        // norm > rho → outside
413        for i in 1..=5 {
414            let r = est.push(2.0, 1.0); // norm=2, rho=1 → outside
415            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        // Return to nominal
424        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); // always inside
433            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); // always outside
442            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        // norm=1.5, rho=1.0 → excess = 0.5/1.0 = 0.5
452        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}