1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
//! # Weighted Small-Gain Theorem: independent numeric verification
//!
//! `allocator::allocate`'s `gd_ok` check tests, at runtime, that the constants in
//! [`crate::generated::stability_profile::PROFILE`] satisfy one specific weighted-diagonal-
//! dominance inequality (for each row $i$: $\sum_j G_{ij} d_j \le (1-\delta) d_i$). That
//! inequality is the *hypothesis* of the Weighted Small-Gain Theorem the profile is named
//! after; it is not itself a check that the theorem's *conclusion* -- $\rho(G) \le 1 - \delta$,
//! where $\rho$ is the spectral radius -- actually holds for these numbers. This module
//! computes that conclusion independently, from scratch, via power iteration, rather than
//! trusting that the row-wise hypothesis and the spectral-radius conclusion agree.
//!
//! It also independently evaluates the companion average-dwell-time bound
//! $$ \tau_D > \frac{\ln(\chi_{\max})}{-\ln(1-\delta)} $$
//! against the profile's declared `minimum_dwell_rounds`.
//!
//! ## No-`std` note
//! This crate is `no_std` by default (see `lib.rs`). `core::f64` does not expose `sqrt`,
//! `ln`, or `powi` (verified: those require linking `std`/libm, unlike `+ - * /` and `.abs()`,
//! which compile to native FP instructions). Adding an external `libm` dependency is out of
//! scope here, so this module carries its own small `ln` (bit-decomposition + atanh series)
//! and avoids `sqrt` entirely by normalizing the power-iteration vector with the infinity
//! norm instead of the Euclidean norm -- valid because `GAIN_MATRIX` is entrywise
//! non-negative, so Perron-Frobenius power iteration converges under any vector norm.
use crateNonNegativeFixed;
/// Bounded iteration count for the power-iteration spectral-radius estimate.
pub const SPECTRAL_RADIUS_MAX_ITERATIONS: u32 = 200;
/// Power iteration stops early once successive infinity-norm estimates differ by less
/// than this amount.
pub const SPECTRAL_RADIUS_CONVERGENCE_TOLERANCE: f64 = 1e-12;
/// Number of atanh-series terms used by the local `ln` approximation. The series argument
/// is bounded by `1/3` (see [`ln_f64`]), so this many terms is far past the precision a
/// stability margin comparison needs.
const LN_SERIES_TERMS: u32 = 24;
const LN_2: f64 = LN_2;
/// Converts a `generated::stability_profile::NonNegativeFixed` to `f64`.
///
/// No existing fixed-point-to-`f64` conversion exists on either `NonNegativeFixed` type in
/// this crate (checked `fixed.rs`: it defines `to_bits`/`to_num`/`log2` on the Q16.16
/// `crate::fixed::NonNegativeFixed`, but no `f64` path, and that is a different type from
/// this module's `crate::generated::stability_profile::NonNegativeFixed` anyway). This
/// mirrors the raw/1e9 scale `allocator::allocate`'s `gd_ok` block already uses directly on
/// `.raw` for these same constants.
/// Natural log for `x > 0`, implemented without `std`/libm.
///
/// Decomposes the IEEE-754 bit pattern into exponent `e` and mantissa `m` in `[1, 2)` (pure
/// bit manipulation via `to_bits`/`from_bits`, both available in `core`), then evaluates
/// `ln(m)` with the atanh series `ln(m) = 2*atanh((m-1)/(m+1))`, whose argument is at most
/// `1/3` for `m` in `[1, 2)`, so it converges quickly. `ln(x) = e * ln(2) + ln(m)`.
///
/// Only handles normal (non-subnormal) finite positive `f64`, which covers every value this
/// module is called with (proportions and ratios, never near the subnormal range).
/// Estimates the spectral radius (Perron root) of a 5x5 non-negative gain matrix via power
/// iteration, normalizing with the infinity norm at each step to avoid needing `sqrt`.
///
/// Runs for at most [`SPECTRAL_RADIUS_MAX_ITERATIONS`], stopping early once successive
/// infinity-norm estimates differ by less than [`SPECTRAL_RADIUS_CONVERGENCE_TOLERANCE`].
/// Theoretically-required minimum average dwell time between mode switches:
/// $$ \tau_D > \frac{\ln(\chi_{\max})}{-\ln(1-\delta)} $$
/// where `delta = contraction_margin` (as `f64`) and `chi_max` is the worst-case ratio by
/// which a mode switch can grow the certificate/Lyapunov function.
///
/// `chi_max` is a required caller-supplied parameter rather than being read off
/// [`crate::generated::stability_profile::StabilityProfile`]: none of that struct's fields
/// clearly represent it. `mode_jump_bound` (0.2 in the real profile) and
/// `total_homeostatic_radius` (0.12) were checked -- both are dimensionally *additive radii*
/// in the same normalized envelope as `certified_switching_radius` and
/// `certified_noise_radius` (all in [0, 1]), not a *multiplicative* growth ratio. A `chi_max`
/// that actually explains the profile's own `minimum_dwell_rounds = 461` at `delta = 0.01`
/// would need to be about 103 (solved from this same formula), which no field is anywhere
/// near. Treating `mode_jump_bound` as `chi_max` anyway would make this check pass trivially
/// (`ln(0.2) < 0`, so any `tau_D >= 0` satisfies it) without actually exercising the bound --
/// worse than an honest parameter. See `stability_profile_is_consistent` for how this is
/// wired against the real profile.
/// Independently checks both halves of the Weighted Small-Gain / average-dwell-time
/// certificate against the real [`crate::generated::stability_profile::PROFILE`] constants:
///
/// - `.0`: `spectral_radius(PROFILE.gain_matrix) <= 1.0 - delta`, i.e. the theorem's
/// *conclusion* actually holds for these numbers (not just the per-row hypothesis
/// `allocator::allocate`'s `gd_ok` checks at runtime).
/// - `.1`: the dwell-time bound computed by [`minimum_dwell_rounds`] for the given `chi_max`
/// is at or below `PROFILE.minimum_dwell_rounds`, i.e. the profile's declared dwell floor
/// is actually sufficient for that `chi_max`.
///
/// `chi_max` is threaded through from the caller rather than defaulted or fabricated here,
/// for the same reason documented on [`minimum_dwell_rounds`]: no profile field represents
/// it, and inventing a value would defeat the point of an independent check.