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// ---------------------------------------------------------------------------
195// PSP-9 system 10: the realizability interface (Paper I §12)
196// ---------------------------------------------------------------------------
197
198/// How Assumption 6's bound is instantiated. Theorem 12.4's conclusion
199/// weakens to match, so the mode is recorded and a certificate cannot be read
200/// as deterministic when the bound was only in expectation.
201#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum BoundMode {
204    Deterministic,
205    InExpectation,
206    WithProbability(f64),
207}
208
209/// A certified numeric bound with its evidence.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct CertifiedBound {
212    pub value: f64,
213    /// Content-addressed references to the validation evidence.
214    pub evidence_refs: Vec<String>,
215}
216
217/// A domain's claim to Paper I's numeric realizability residual
218/// (Assumption 6). `delta_r` lives here and never in
219/// [`StabilityClaim::delta`], which is the Papers I–II exogenous disturbance
220/// parameter.
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222pub struct RealizabilityClaim {
223    pub coordinate_schema: String,
224    pub projection_id: String,
225    pub encoder_id: String,
226    pub norm_id: String,
227    /// Assumption 6's uniform bound — not a maximum fitted on this run.
228    pub delta_r_bound: CertifiedBound,
229    pub delta_r_mode: BoundMode,
230    pub analysis_domain: String,
231    pub invariant_domain_evidence: Vec<String>,
232}
233
234/// Optional domain extension activating the analytic Paper I path
235/// (Theorem 12.4 / Corollary 12.5). Coding returns `None` for this
236/// extension: a fraction of edits denied or rewritten is
237/// `projection_mismatch` telemetry, not `‖r_k‖`.
238pub trait ProxyGeometry: Send + Sync {
239    /// The proposed proxy vector `x̃_{k+1}`.
240    fn proposed_proxy(&self, candidate_id: &str) -> Result<Vec<f64>>;
241    /// The realized state re-embedded through the encoder `E`.
242    fn encode_realized(&self, candidate_id: &str) -> Result<Vec<f64>>;
243    /// `‖E(a,s) − x̃‖` in the declared norm.
244    fn residual_norm(&self, proposed: &[f64], realized: &[f64]) -> Result<f64>;
245    fn stability_claim(&self) -> StabilityClaim;
246    fn realizability_claim(&self) -> RealizabilityClaim;
247}
248
249/// Verdict of the analytic-path admission check (PSP-9 system 10).
250#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
251#[serde(rename_all = "snake_case")]
252pub enum AnalyticPathVerdict {
253    /// All hypotheses are represented; Theorem 12.4 / Corollary 12.5 may run.
254    Admitted,
255    /// A named hypothesis is missing; the analytic path is refused and the
256    /// measured Paper II gate remains the only certificate.
257    Refused { missing: String },
258}
259
260/// Admit or refuse the analytic path from a claim's own constants.
261///
262/// Theorem 12.4 inherits Theorem 12.1's step-size bounds
263/// `0 < η < min{2(α−β)/(L(α+β)²), 1/(2μ(α−β))}` — the hypothesis that makes
264/// `c > 0` and places the contraction factor in `(1/2, 1)`. The runtime
265/// refuses the analytic path when it fails rather than reporting a floor
266/// computed from an inadmissible step size.
267pub fn admit_analytic_path(
268    stability: &StabilityClaim,
269    realizability: &RealizabilityClaim,
270) -> AnalyticPathVerdict {
271    let constants = [
272        ("alpha", stability.alpha),
273        ("beta", stability.beta),
274        ("mu", stability.mu),
275        ("smoothness_l", stability.smoothness_l),
276        ("eta", stability.eta),
277    ];
278    for (name, value) in constants {
279        match value {
280            Some(v) if v.is_finite() => {}
281            _ => {
282                return AnalyticPathVerdict::Refused {
283                    missing: format!("StabilityClaim.{name} is not a validated finite constant"),
284                }
285            }
286        }
287    }
288    if stability.analysis_domain != realizability.analysis_domain {
289        return AnalyticPathVerdict::Refused {
290            missing: "stability and realizability claims cover different analysis domains".into(),
291        };
292    }
293    if !realizability.delta_r_bound.value.is_finite() || realizability.delta_r_bound.value < 0.0 {
294        return AnalyticPathVerdict::Refused {
295            missing: "RealizabilityClaim.delta_r_bound is not a finite non-negative bound".into(),
296        };
297    }
298    if realizability.delta_r_bound.evidence_refs.is_empty() {
299        return AnalyticPathVerdict::Refused {
300            missing: "delta_r bound carries no validation evidence".into(),
301        };
302    }
303    let (alpha, beta) = (stability.alpha.unwrap(), stability.beta.unwrap());
304    let (l, eta, mu) = (
305        stability.smoothness_l.unwrap(),
306        stability.eta.unwrap(),
307        stability.mu.unwrap(),
308    );
309    match validate_step_size(eta, alpha, beta, l, mu) {
310        Ok(true) => AnalyticPathVerdict::Admitted,
311        Ok(false) => AnalyticPathVerdict::Refused {
312            missing: format!(
313                "Theorem 12.1 step-size precondition failed: eta = {eta} exceeds the \
314                 admissible bound"
315            ),
316        },
317        Err(e) => AnalyticPathVerdict::Refused {
318            missing: format!("Theorem 12.1 step-size precondition failed: {e}"),
319        },
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn iss_floor_matches_formula() {
329        // delta=1, alpha=2, beta=1, mu=0.5 -> 1 / (2 * 1 * 0.5) = 1.0
330        let v = ultimate_floor(2.0, 1.0, 1.0, 0.5).unwrap();
331        assert!((v - 1.0).abs() < 1e-12);
332    }
333
334    #[test]
335    fn iss_floor_requires_alpha_gt_beta() {
336        assert!(ultimate_floor(1.0, 1.0, 1.0, 0.5).is_err());
337        assert!(ultimate_floor(1.0, 2.0, 1.0, 0.5).is_err());
338    }
339
340    #[test]
341    fn iss_floor_requires_positive_mu() {
342        assert!(ultimate_floor(2.0, 1.0, 1.0, 0.0).is_err());
343    }
344
345    #[test]
346    fn step_size_bound_is_min_of_two_terms() {
347        // alpha=2,beta=1,L=1,mu=10: smoothness=2/(1*9)=0.222; curvature=1/(2*10*1)=0.05
348        let bound = step_size_upper_bound(2.0, 1.0, 1.0, 10.0).unwrap();
349        assert!((bound - 0.05).abs() < 1e-12);
350        assert!(validate_step_size(0.04, 2.0, 1.0, 1.0, 10.0).unwrap());
351        assert!(!validate_step_size(0.06, 2.0, 1.0, 1.0, 10.0).unwrap());
352    }
353
354    #[test]
355    fn claim_resolves_floor() {
356        let mut claim = StabilityClaim::not_claimed("toy");
357        claim.alpha = Some(2.0);
358        claim.beta = Some(1.0);
359        claim.delta = Some(1.0);
360        claim.mu = Some(0.5);
361        assert!(claim.claims_floor());
362        let f = claim.resolve_floor().unwrap().unwrap();
363        assert!((f - 1.0).abs() < 1e-12);
364        assert_eq!(claim.ultimate_floor, Some(f));
365    }
366
367    #[test]
368    fn not_claimed_has_no_floor() {
369        let mut claim = StabilityClaim::not_claimed("coding");
370        assert!(!claim.claims_floor());
371        assert_eq!(claim.resolve_floor().unwrap(), None);
372    }
373
374    fn full_claims() -> (StabilityClaim, RealizabilityClaim) {
375        let stability = StabilityClaim {
376            claim_id: "c".into(),
377            analysis_domain: "unit-disk".into(),
378            alpha: Some(1.0),
379            beta: Some(0.2),
380            delta: None,
381            mu: Some(0.5),
382            smoothness_l: Some(1.0),
383            eta: Some(0.5),
384            ultimate_floor: None,
385            evidence_refs: vec!["ev1".into()],
386        };
387        let realizability = RealizabilityClaim {
388            coordinate_schema: "r1".into(),
389            projection_id: "round".into(),
390            encoder_id: "identity".into(),
391            norm_id: "l2".into(),
392            delta_r_bound: CertifiedBound {
393                value: 0.125,
394                evidence_refs: vec!["ev2".into()],
395            },
396            delta_r_mode: BoundMode::Deterministic,
397            analysis_domain: "unit-disk".into(),
398            invariant_domain_evidence: vec!["ev3".into()],
399        };
400        (stability, realizability)
401    }
402
403    #[test]
404    fn analytic_path_admits_only_with_every_hypothesis() {
405        let (stability, realizability) = full_claims();
406        assert_eq!(
407            admit_analytic_path(&stability, &realizability),
408            AnalyticPathVerdict::Admitted
409        );
410    }
411
412    #[test]
413    fn an_inadmissible_step_size_refuses_the_analytic_path() {
414        let (mut stability, realizability) = full_claims();
415        // eta far beyond min{2(a-b)/(L(a+b)^2), 1/(2 mu (a-b))}.
416        stability.eta = Some(100.0);
417        assert!(matches!(
418            admit_analytic_path(&stability, &realizability),
419            AnalyticPathVerdict::Refused { missing } if missing.contains("step-size")
420        ));
421    }
422
423    #[test]
424    fn a_not_claimed_constant_refuses_the_analytic_path() {
425        let (mut stability, realizability) = full_claims();
426        stability.mu = None;
427        assert!(matches!(
428            admit_analytic_path(&stability, &realizability),
429            AnalyticPathVerdict::Refused { missing } if missing.contains("mu")
430        ));
431    }
432
433    #[test]
434    fn mismatched_analysis_domains_refuse_the_analytic_path() {
435        let (stability, mut realizability) = full_claims();
436        realizability.analysis_domain = "other".into();
437        assert!(matches!(
438            admit_analytic_path(&stability, &realizability),
439            AnalyticPathVerdict::Refused { .. }
440        ));
441    }
442
443    #[test]
444    fn an_unevidenced_delta_r_bound_refuses_the_analytic_path() {
445        let (stability, mut realizability) = full_claims();
446        realizability.delta_r_bound.evidence_refs.clear();
447        assert!(matches!(
448            admit_analytic_path(&stability, &realizability),
449            AnalyticPathVerdict::Refused { missing } if missing.contains("evidence")
450        ));
451    }
452}