Skip to main content

perspt_sdk/
stability.rs

1//! Analytic stability claims (PSP-8 System 2).
2//!
3//! Perspt's primary contract is the *measured* discrete gate ([`crate::gate`]).
4//! The continuous embedding-space constants `alpha` (correction strength),
5//! `beta` (drift), `delta` (disturbance), `L` (smoothness), and `eta` (step
6//! size) are optional: a domain registers them only when it can instrument a
7//! continuous correction coordinate. The coding domain exposes no such
8//! coordinate, so these remain `NotClaimed` for it.
9//!
10//! The energy-slope constant `mu` is the exception — it is combinatorial and is
11//! computed from the verification graph in [`crate::spectral`].
12//!
13//! When the constants are present and satisfy their preconditions, the SDK
14//! reports the input-to-state-stability floor
15//!
16//! ```text
17//! V_inf = delta^2 / (2 (alpha - beta)^2 mu),   alpha > beta, mu > 0,
18//! ```
19//!
20//! and the discrete step-size certificate.
21
22use serde::{Deserialize, Serialize};
23
24use crate::error::{Result, SdkError};
25
26/// A registered analytic stability claim (PSP-8 `StabilityClaim`). A missing
27/// constant is an explicit `NotClaimed` status, never a soft pass.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct StabilityClaim {
30    pub claim_id: String,
31    /// The sublevel set / analysis domain on which the constants are justified.
32    pub analysis_domain: String,
33    pub alpha: Option<f64>,
34    pub beta: Option<f64>,
35    pub delta: Option<f64>,
36    /// Spectral energy-slope constant; usually filled from [`crate::spectral`].
37    pub mu: Option<f64>,
38    pub smoothness_l: Option<f64>,
39    pub eta: Option<f64>,
40    /// Cached ISS floor `V_inf` if it could be computed.
41    pub ultimate_floor: Option<f64>,
42    pub evidence_refs: Vec<String>,
43}
44
45impl StabilityClaim {
46    pub fn not_claimed(analysis_domain: impl Into<String>) -> Self {
47        Self {
48            claim_id: uuid::Uuid::new_v4().to_string(),
49            analysis_domain: analysis_domain.into(),
50            alpha: None,
51            beta: None,
52            delta: None,
53            mu: None,
54            smoothness_l: None,
55            eta: None,
56            ultimate_floor: None,
57            evidence_refs: Vec::new(),
58        }
59    }
60
61    /// Whether enough constants are present to assert an analytic floor.
62    pub fn claims_floor(&self) -> bool {
63        self.alpha.is_some() && self.beta.is_some() && self.delta.is_some() && self.mu.is_some()
64    }
65
66    /// Compute and cache the ISS floor `V_inf`, returning it when the
67    /// preconditions `alpha > beta` and `mu > 0` hold.
68    pub fn resolve_floor(&mut self) -> Result<Option<f64>> {
69        let floor = match (self.alpha, self.beta, self.delta, self.mu) {
70            (Some(a), Some(b), Some(d), Some(m)) => Some(ultimate_floor(a, b, d, m)?),
71            _ => None,
72        };
73        self.ultimate_floor = floor;
74        Ok(floor)
75    }
76}
77
78/// ISS energy floor `V_inf = delta^2 / (2 (alpha - beta)^2 mu)`.
79///
80/// Requires `alpha > beta` and `mu > 0` (PSP-8 System 2).
81pub fn ultimate_floor(alpha: f64, beta: f64, delta: f64, mu: f64) -> Result<f64> {
82    for (name, v) in [
83        ("alpha", alpha),
84        ("beta", beta),
85        ("delta", delta),
86        ("mu", mu),
87    ] {
88        if !v.is_finite() {
89            return Err(SdkError::InvalidStability(format!("{name} is not finite")));
90        }
91    }
92    if alpha <= beta {
93        return Err(SdkError::InvalidStability(format!(
94            "ISS floor requires alpha > beta (got alpha={alpha}, beta={beta})"
95        )));
96    }
97    if mu <= 0.0 {
98        return Err(SdkError::InvalidStability(format!(
99            "ISS floor requires mu > 0 (got mu={mu})"
100        )));
101    }
102    let gap = alpha - beta;
103    Ok((delta * delta) / (2.0 * gap * gap * mu))
104}
105
106/// Sufficient discrete step-size upper bound (PSP-8 Theorem 12.1):
107///
108/// ```text
109/// eta < min{ 2(alpha-beta) / (L (alpha+beta)^2),  1 / (2 mu (alpha-beta)) }.
110/// ```
111pub fn step_size_upper_bound(alpha: f64, beta: f64, smoothness_l: f64, mu: f64) -> Result<f64> {
112    if alpha <= beta {
113        return Err(SdkError::InvalidStability(
114            "step-size bound requires alpha > beta".into(),
115        ));
116    }
117    if smoothness_l <= 0.0 || mu <= 0.0 {
118        return Err(SdkError::InvalidStability(
119            "step-size bound requires L > 0 and mu > 0".into(),
120        ));
121    }
122    let gap = alpha - beta;
123    let sum = alpha + beta;
124    let smoothness_term = (2.0 * gap) / (smoothness_l * sum * sum);
125    let curvature_term = 1.0 / (2.0 * mu * gap);
126    Ok(smoothness_term.min(curvature_term))
127}
128
129/// Geometric contraction coefficient `c(eta) = (alpha-beta) - L eta (alpha+beta)^2 / 2`.
130pub fn c_eta(alpha: f64, beta: f64, smoothness_l: f64, eta: f64) -> f64 {
131    let sum = alpha + beta;
132    (alpha - beta) - (smoothness_l * eta * sum * sum) / 2.0
133}
134
135/// Expected one-step geometric factor `1 - 2 eta c(eta) mu` from
136/// `V(x_k) <= (1 - 2 eta c(eta) mu)^k V(x_0)`.
137pub fn geometric_factor(eta: f64, c: f64, mu: f64) -> f64 {
138    1.0 - 2.0 * eta * c * mu
139}
140
141/// Check that a proposed step size respects the sufficient bound.
142pub fn validate_step_size(
143    eta: f64,
144    alpha: f64,
145    beta: f64,
146    smoothness_l: f64,
147    mu: f64,
148) -> Result<bool> {
149    if eta <= 0.0 {
150        return Ok(false);
151    }
152    let bound = step_size_upper_bound(alpha, beta, smoothness_l, mu)?;
153    Ok(eta < bound)
154}
155
156/// The kernel-facing stability parameters (PSP-8 `StabilityParameters`).
157#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
158pub struct StabilityParameters {
159    pub energy_tolerance: f64,
160    /// Single descent tolerance for the gate.
161    pub rho_gate: f64,
162    /// Advisory analytic decrease only; never relaxes `rho_gate`.
163    pub predicted_descent: Option<f64>,
164    /// Spectral, computed from the verification graph.
165    pub mu: Option<f64>,
166    pub alpha: Option<f64>,
167    pub beta: Option<f64>,
168    pub delta: Option<f64>,
169    pub smoothness_l: Option<f64>,
170    pub eta: Option<f64>,
171    /// Requires alpha, beta, delta, mu.
172    pub ultimate_floor: Option<f64>,
173}
174
175impl StabilityParameters {
176    /// Measured-only parameters: just `rho_gate` and `energy_tolerance`, with
177    /// all analytic constants `NotClaimed`. This is the coding-domain default.
178    pub fn measured(rho_gate: f64, energy_tolerance: f64) -> Self {
179        Self {
180            energy_tolerance,
181            rho_gate,
182            predicted_descent: None,
183            mu: None,
184            alpha: None,
185            beta: None,
186            delta: None,
187            smoothness_l: None,
188            eta: None,
189            ultimate_floor: None,
190        }
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn iss_floor_matches_formula() {
200        // delta=1, alpha=2, beta=1, mu=0.5 -> 1 / (2 * 1 * 0.5) = 1.0
201        let v = ultimate_floor(2.0, 1.0, 1.0, 0.5).unwrap();
202        assert!((v - 1.0).abs() < 1e-12);
203    }
204
205    #[test]
206    fn iss_floor_requires_alpha_gt_beta() {
207        assert!(ultimate_floor(1.0, 1.0, 1.0, 0.5).is_err());
208        assert!(ultimate_floor(1.0, 2.0, 1.0, 0.5).is_err());
209    }
210
211    #[test]
212    fn iss_floor_requires_positive_mu() {
213        assert!(ultimate_floor(2.0, 1.0, 1.0, 0.0).is_err());
214    }
215
216    #[test]
217    fn step_size_bound_is_min_of_two_terms() {
218        // alpha=2,beta=1,L=1,mu=10: smoothness=2/(1*9)=0.222; curvature=1/(2*10*1)=0.05
219        let bound = step_size_upper_bound(2.0, 1.0, 1.0, 10.0).unwrap();
220        assert!((bound - 0.05).abs() < 1e-12);
221        assert!(validate_step_size(0.04, 2.0, 1.0, 1.0, 10.0).unwrap());
222        assert!(!validate_step_size(0.06, 2.0, 1.0, 1.0, 10.0).unwrap());
223    }
224
225    #[test]
226    fn claim_resolves_floor() {
227        let mut claim = StabilityClaim::not_claimed("toy");
228        claim.alpha = Some(2.0);
229        claim.beta = Some(1.0);
230        claim.delta = Some(1.0);
231        claim.mu = Some(0.5);
232        assert!(claim.claims_floor());
233        let f = claim.resolve_floor().unwrap().unwrap();
234        assert!((f - 1.0).abs() < 1e-12);
235        assert_eq!(claim.ultimate_floor, Some(f));
236    }
237
238    #[test]
239    fn not_claimed_has_no_floor() {
240        let mut claim = StabilityClaim::not_claimed("coding");
241        assert!(!claim.claims_floor());
242        assert_eq!(claim.resolve_floor().unwrap(), None);
243    }
244}