Skip to main content

perspt_sdk/
recovery.rs

1//! The recovery lattice (PSP-9 system 11, Paper III Definition 8.1 and
2//! Theorem 6).
3//!
4//! Rejection is the steady state of a stochastic proposer, so recovery must
5//! terminate *by construction*. Definition 8.1's well-formedness conditions
6//! are adopted literally, because the `b + k` count is a consequence of them:
7//!
8//! * **One pool, every level.** `b` is a single non-replenishing budget
9//!   shared across the whole cascade. *Every* horizontal repetition consumes
10//!   one unit — a second fallback route, a second refinement, and a second
11//!   escalation each cost one unit exactly as a re-proposal does. A per-level
12//!   budget (including a separate "refinement depth" counter) would give
13//!   `Σ_λ b_λ + k`, not `b + k`; refinement depth is a *cap* on level-2
14//!   repetitions within the shared pool, never its own pool.
15//! * **Exhaustion forbids repetition.** With zero remaining budget below the
16//!   top level, the next non-terminal control MUST strictly escalate.
17//! * **The top level is unconditionally terminating.**
18
19use serde::{Deserialize, Serialize};
20
21/// The escalation chain. `k` is the number of strict escalations available
22/// from level 0, i.e. `Contain as u8`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum CascadeLevel {
26    /// Re-propose after a directed correction.
27    Retry = 0,
28    /// Switch route: alternate tool, alternate provider or family, or bundle
29    /// mode. Where multi-provider pays for itself operationally.
30    Fallback = 1,
31    /// Topological Escalation: split or revise the node through the mutable
32    /// work graph. Without certified proxy geometry this is empirical
33    /// recovery and carries no convergence claim (Paper I Remark 12.3).
34    Refine = 2,
35    /// `ask_user`, or hand the node to a higher-capability route.
36    Escalate = 3,
37    /// Revoke mutating capabilities, freeze the node, restore best accepted
38    /// state, issue the residual certificate. Unconditionally terminating.
39    Contain = 4,
40}
41
42impl CascadeLevel {
43    fn next(self) -> CascadeLevel {
44        match self {
45            CascadeLevel::Retry => CascadeLevel::Fallback,
46            CascadeLevel::Fallback => CascadeLevel::Refine,
47            CascadeLevel::Refine => CascadeLevel::Escalate,
48            CascadeLevel::Escalate | CascadeLevel::Contain => CascadeLevel::Contain,
49        }
50    }
51}
52
53/// A failure observation's deterministic classification. The classifier is
54/// total: unknown classes map to [`CascadeLevel::Contain`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum FailureKind {
58    GateRejection,
59    ToolFailure,
60    ProviderTransport,
61    ProviderRateLimit,
62    CapabilityMismatch,
63    MeasuredPlateau,
64    ScopeTooBroad,
65    NeedsApproval,
66    NeedsCapability,
67    Unknown,
68}
69
70/// Map every failure class to its entry control (PSP-9 system 11). Total by
71/// construction — the compiler enforces it.
72pub fn classify_failure(kind: FailureKind) -> CascadeLevel {
73    match kind {
74        FailureKind::GateRejection | FailureKind::ToolFailure => CascadeLevel::Retry,
75        FailureKind::ProviderTransport
76        | FailureKind::ProviderRateLimit
77        | FailureKind::CapabilityMismatch => CascadeLevel::Fallback,
78        FailureKind::MeasuredPlateau | FailureKind::ScopeTooBroad => CascadeLevel::Refine,
79        FailureKind::NeedsApproval | FailureKind::NeedsCapability => CascadeLevel::Escalate,
80        FailureKind::Unknown => CascadeLevel::Contain,
81    }
82}
83
84/// Theorem 6's four terminal classes. Recorded alongside — not in place of —
85/// the node's Paper II `NodeTerminalOutcome`.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum CascadeClass {
89    /// A recovery control succeeded and the node re-entered the gate.
90    Committed,
91    /// An irreversible external effect was rolled back through its
92    /// compensation record (R5). Compensations are declared forward actions
93    /// and consume the same `b`.
94    Compensated,
95    /// Level 3 handed the node to the user, whose decision terminated it.
96    HumanEscalation,
97    /// Level 4: capabilities revoked, node frozen, best state restored.
98    Contained,
99}
100
101/// What the cascade granted for one requested control.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub struct GrantedControl {
104    pub level: CascadeLevel,
105    /// Whether this control is unconditionally terminating.
106    pub terminal: bool,
107    /// Whether the request was forced upward by budget exhaustion.
108    pub forced_escalation: bool,
109}
110
111/// The shared-pool cascade for one node (Definition 8.1).
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct RecoveryCascade {
114    /// The non-replenishing shared budget `b`.
115    pub budget: u32,
116    pub spent: u32,
117    pub current_level: CascadeLevel,
118    pub escalations_used: u32,
119    pub steps: u32,
120}
121
122impl RecoveryCascade {
123    pub fn new(budget: u32) -> Self {
124        Self {
125            budget,
126            spent: 0,
127            current_level: CascadeLevel::Retry,
128            escalations_used: 0,
129            steps: 0,
130        }
131    }
132
133    /// The escalation depth `k`: strict escalations available from level 0.
134    pub const K: u32 = 4;
135
136    /// The Theorem 6 bound: every cascade terminates within `b + k` steps.
137    pub fn step_bound(&self) -> u32 {
138        self.budget + Self::K
139    }
140
141    /// Request a control. A request at or below the current level is a
142    /// horizontal repetition and consumes one unit of the shared pool; a
143    /// request above it is a strict escalation and consumes a level. When
144    /// the pool is exhausted below the top, the grant is forced strictly
145    /// upward (Definition 8.1's second condition).
146    pub fn grant(&mut self, requested: CascadeLevel) -> GrantedControl {
147        if self.current_level == CascadeLevel::Contain {
148            return GrantedControl {
149                level: CascadeLevel::Contain,
150                terminal: true,
151                forced_escalation: false,
152            };
153        }
154        self.steps += 1;
155        if requested > self.current_level {
156            // Strict escalation: consumes a level, never the pool.
157            self.current_level = requested;
158            self.escalations_used += 1;
159            return GrantedControl {
160                level: requested,
161                terminal: requested == CascadeLevel::Contain,
162                forced_escalation: false,
163            };
164        }
165        // Horizontal repetition at or below the current level.
166        if self.spent < self.budget {
167            self.spent += 1;
168            return GrantedControl {
169                level: requested,
170                terminal: false,
171                forced_escalation: false,
172            };
173        }
174        // Exhaustion forbids repetition: force a strict escalation.
175        let forced = self.current_level.next();
176        self.current_level = forced;
177        self.escalations_used += 1;
178        GrantedControl {
179            level: forced,
180            terminal: forced == CascadeLevel::Contain,
181            forced_escalation: true,
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    /// MC-N core: an always-failing adversary terminates within `b + k`.
191    #[test]
192    fn always_failing_adversary_terminates_within_b_plus_k() {
193        let b = 5;
194        let mut cascade = RecoveryCascade::new(b);
195        let mut steps = 0;
196        loop {
197            let granted = cascade.grant(CascadeLevel::Retry);
198            steps += 1;
199            if granted.terminal {
200                break;
201            }
202            assert!(steps <= cascade.step_bound(), "exceeded b + k");
203        }
204        assert!(steps <= b + RecoveryCascade::K);
205        assert_eq!(cascade.current_level, CascadeLevel::Contain);
206    }
207
208    /// MC-N companion: `b` horizontal steps distributed across levels
209    /// exhaust the pool exactly as `b` retries at level 0 do.
210    #[test]
211    fn horizontal_repetitions_at_any_level_draw_on_the_one_pool() {
212        let b = 6;
213        // Distributed: two retries, escalate, two fallbacks, escalate, two
214        // refinements = six horizontal steps across three levels.
215        let mut distributed = RecoveryCascade::new(b);
216        distributed.grant(CascadeLevel::Retry);
217        distributed.grant(CascadeLevel::Retry);
218        distributed.grant(CascadeLevel::Fallback); // escalation (free)
219        distributed.grant(CascadeLevel::Fallback); // horizontal
220        distributed.grant(CascadeLevel::Fallback); // horizontal
221        distributed.grant(CascadeLevel::Refine); // escalation (free)
222        distributed.grant(CascadeLevel::Refine); // horizontal
223        assert_eq!(distributed.spent, 5);
224        let granted = distributed.grant(CascadeLevel::Refine); // horizontal: last unit
225        assert_eq!(distributed.spent, 6);
226        assert!(!granted.forced_escalation);
227
228        // Level 0 only: six retries exhaust identically.
229        let mut level0 = RecoveryCascade::new(b);
230        for _ in 0..b {
231            level0.grant(CascadeLevel::Retry);
232        }
233        assert_eq!(level0.spent, distributed.spent);
234
235        // Both now force strict escalation on the next horizontal request.
236        let forced = distributed.grant(CascadeLevel::Retry);
237        assert!(forced.forced_escalation);
238        assert!(forced.level > CascadeLevel::Refine);
239        let forced0 = level0.grant(CascadeLevel::Retry);
240        assert!(forced0.forced_escalation);
241    }
242
243    /// Exhausting `b` below level `k` forces strict escalation, never a
244    /// stall and never a replenish.
245    #[test]
246    fn exhaustion_forces_strict_escalation() {
247        let mut cascade = RecoveryCascade::new(1);
248        cascade.grant(CascadeLevel::Retry); // spends the only unit
249        let g1 = cascade.grant(CascadeLevel::Retry);
250        assert!(g1.forced_escalation);
251        assert_eq!(g1.level, CascadeLevel::Fallback);
252        let g2 = cascade.grant(CascadeLevel::Retry);
253        assert!(g2.forced_escalation);
254        assert_eq!(g2.level, CascadeLevel::Refine);
255        let g3 = cascade.grant(CascadeLevel::Fallback);
256        assert_eq!(g3.level, CascadeLevel::Escalate);
257        let g4 = cascade.grant(CascadeLevel::Retry);
258        assert_eq!(g4.level, CascadeLevel::Contain);
259        assert!(g4.terminal);
260    }
261
262    /// The top level never returns control to the loop.
263    #[test]
264    fn contain_is_unconditionally_terminating() {
265        let mut cascade = RecoveryCascade::new(3);
266        cascade.grant(CascadeLevel::Contain);
267        for _ in 0..5 {
268            let granted = cascade.grant(CascadeLevel::Retry);
269            assert!(granted.terminal);
270            assert_eq!(granted.level, CascadeLevel::Contain);
271        }
272    }
273
274    /// The failure classifier is total; provider-transport failures map to
275    /// fallback (failover is recovery, not retry) and unknown classes
276    /// contain.
277    #[test]
278    fn every_failure_class_maps_to_a_control() {
279        assert_eq!(
280            classify_failure(FailureKind::ProviderTransport),
281            CascadeLevel::Fallback
282        );
283        assert_eq!(
284            classify_failure(FailureKind::MeasuredPlateau),
285            CascadeLevel::Refine
286        );
287        assert_eq!(
288            classify_failure(FailureKind::Unknown),
289            CascadeLevel::Contain
290        );
291    }
292}