Skip to main content

perspt_sdk/
gate.rs

1//! Measured acceptance gate and finite-decision bound (PSP-8 System 2).
2//!
3//! The gate is the harness paper's measured discrete contract and applies even
4//! when the continuous analytic constants are unavailable:
5//!
6//! ```text
7//! accept(y) <=> hard(y) OR V(y) <= V(x_best) - rho_gate.
8//! ```
9//!
10//! Descent is measured against the *best* accepted energy `V(x_best)`, not the
11//! most recent. There is a single descent tolerance `rho_gate > 0`. With
12//! `V >= 0`, baseline `V_0`, and rejection budget `B`, the run terminates
13//! within
14//!
15//! ```text
16//! N_gate <= floor(V_0 / rho_gate) + B + 1
17//! ```
18//!
19//! gate decisions.
20
21use serde::{Deserialize, Serialize};
22
23use crate::error::{check_positive_finite, Result, SdkError};
24
25/// Outcome of evaluating one candidate against the gate (PSP-8 `GateDecision`).
26#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27#[serde(tag = "kind", rename_all = "snake_case")]
28pub enum GateDecision {
29    /// All required verifiers and hard constraints passed.
30    HardPass,
31    /// Energy descended below the best accepted energy by at least `rho_gate`.
32    AcceptedByDescent { delta_v: f64 },
33    /// Candidate did not descend enough; retained as an observation only.
34    RejectedNonDescending { delta_v: f64 },
35    /// Stopped at a declared analytic floor.
36    StoppedAtDeclaredFloor,
37    /// Correction budget exhausted; a residual certificate was issued.
38    ExhaustedWithCertificate { certificate_id: String },
39}
40
41impl GateDecision {
42    /// Whether this decision admits the candidate into the accepted trajectory.
43    pub fn is_accepted(&self) -> bool {
44        matches!(
45            self,
46            GateDecision::HardPass | GateDecision::AcceptedByDescent { .. }
47        )
48    }
49}
50
51/// Evaluate the measured acceptance gate for a candidate.
52///
53/// * `hard_pass` — all required verifiers and hard policy constraints passed.
54/// * `candidate_v` — the candidate's total energy `V(y)`.
55/// * `best_accepted_v` — the best accepted energy `V(x_best)` so far.
56/// * `rho_gate` — the single descent tolerance (`> 0`).
57pub fn evaluate_gate(
58    hard_pass: bool,
59    candidate_v: f64,
60    best_accepted_v: f64,
61    rho_gate: f64,
62) -> Result<GateDecision> {
63    evaluate_gate_with_floor(hard_pass, candidate_v, best_accepted_v, rho_gate, None)
64}
65
66/// The full gate ordering (PSP-9 system 10): hard pass; candidate at or below
67/// the declared floor; descent by at least `rho_gate`; rejection.
68///
69/// A floor decision checkpoints the realized candidate as a *classified
70/// terminal state* but does not imply delivery, and it is never reported as
71/// verified success.
72pub fn evaluate_gate_with_floor(
73    hard_pass: bool,
74    candidate_v: f64,
75    best_accepted_v: f64,
76    rho_gate: f64,
77    declared_energy_floor: Option<f64>,
78) -> Result<GateDecision> {
79    check_positive_finite(rho_gate, "rho_gate")?;
80    crate::error::check_non_negative_finite(candidate_v, "candidate energy")?;
81    crate::error::check_non_negative_finite(best_accepted_v, "best accepted energy")?;
82    if let Some(floor) = declared_energy_floor {
83        crate::error::check_non_negative_finite(floor, "declared energy floor")?;
84    }
85
86    if hard_pass {
87        return Ok(GateDecision::HardPass);
88    }
89    if let Some(floor) = declared_energy_floor {
90        if candidate_v <= floor {
91            return Ok(GateDecision::StoppedAtDeclaredFloor);
92        }
93    }
94    let delta_v = best_accepted_v - candidate_v;
95    if candidate_v <= best_accepted_v - rho_gate {
96        Ok(GateDecision::AcceptedByDescent { delta_v })
97    } else {
98        Ok(GateDecision::RejectedNonDescending { delta_v })
99    }
100}
101
102/// Finite-decision bound `floor(V_0 / rho_gate) + B + 1` (PSP-8 System 2).
103pub fn finite_decision_bound(
104    baseline_energy: f64,
105    rho_gate: f64,
106    rejection_budget: u32,
107) -> Result<u64> {
108    check_positive_finite(rho_gate, "rho_gate")?;
109    crate::error::check_non_negative_finite(baseline_energy, "baseline energy")?;
110    let descents = (baseline_energy / rho_gate).floor();
111    if !descents.is_finite() {
112        return Err(SdkError::InvalidGate(
113            "finite-decision bound overflow".into(),
114        ));
115    }
116    Ok(descents as u64 + rejection_budget as u64 + 1)
117}
118
119/// A reference to a recorded gate decision (PSP-8 `GateDecisionRef`).
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121pub struct GateDecisionRef {
122    pub decision: GateDecision,
123    pub observed_energy: f64,
124    pub best_accepted_before: f64,
125}
126
127/// Accepted-trajectory record for one node generation (PSP-8 System 2 / 11).
128///
129/// The accepted trajectory contains only hard-pass or descent-gated states;
130/// observed candidates that fail the gate are retained as observations but
131/// never advance accepted progress.
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct AcceptedTrajectory {
134    pub node_id: String,
135    pub generation: u32,
136    pub baseline_energy: f64,
137    /// The best accepted energy `V(x_best)` so far.
138    pub best_accepted_energy: f64,
139    pub rho_gate: f64,
140    /// Finite rejection budget `B`.
141    pub rejection_budget: u32,
142    pub rejections_used: u32,
143    pub gate_decisions: Vec<GateDecisionRef>,
144}
145
146impl AcceptedTrajectory {
147    pub fn new(
148        node_id: impl Into<String>,
149        generation: u32,
150        baseline_energy: f64,
151        rho_gate: f64,
152        rejection_budget: u32,
153    ) -> Result<Self> {
154        check_positive_finite(rho_gate, "rho_gate")?;
155        crate::error::check_non_negative_finite(baseline_energy, "baseline energy")?;
156        Ok(Self {
157            node_id: node_id.into(),
158            generation,
159            baseline_energy,
160            best_accepted_energy: baseline_energy,
161            rho_gate,
162            rejection_budget,
163            rejections_used: 0,
164            gate_decisions: Vec::new(),
165        })
166    }
167
168    /// The finite-decision bound for this trajectory.
169    pub fn decision_bound(&self) -> Result<u64> {
170        finite_decision_bound(self.baseline_energy, self.rho_gate, self.rejection_budget)
171    }
172
173    /// Evaluate a candidate and fold the decision into the trajectory, updating
174    /// the best accepted energy on acceptance and the rejection count on
175    /// rejection. Returns the decision taken.
176    pub fn submit(&mut self, hard_pass: bool, candidate_v: f64) -> Result<GateDecision> {
177        self.submit_with_floor(hard_pass, candidate_v, None)
178    }
179
180    /// Submit a candidate under the full gate ordering, including the
181    /// domain's declared energy floor (PSP-9 system 10).
182    ///
183    /// The finite-decision bound is enforced, not advisory (PSP-10 Gate X):
184    /// the `(N_gate + 1)`-th submission is refused before any decision is
185    /// appended. Ordinary descent/rejection flows stay under the bound by
186    /// construction; this guard fails closed against callers that would
187    /// resubmit past a terminal hard pass or floor stop.
188    pub fn submit_with_floor(
189        &mut self,
190        hard_pass: bool,
191        candidate_v: f64,
192        declared_energy_floor: Option<f64>,
193    ) -> Result<GateDecision> {
194        let bound = self.decision_bound()?;
195        if self.gate_decisions.len() as u64 >= bound {
196            return Err(SdkError::InvalidGate(format!(
197                "finite-decision bound {bound} reached for {}:{}; submission refused",
198                self.node_id, self.generation
199            )));
200        }
201        let decision = evaluate_gate_with_floor(
202            hard_pass,
203            candidate_v,
204            self.best_accepted_energy,
205            self.rho_gate,
206            declared_energy_floor,
207        )?;
208        self.gate_decisions.push(GateDecisionRef {
209            decision: decision.clone(),
210            observed_energy: candidate_v,
211            best_accepted_before: self.best_accepted_energy,
212        });
213        let checkpoints =
214            decision.is_accepted() || matches!(decision, GateDecision::StoppedAtDeclaredFloor);
215        if checkpoints {
216            // A floor stop checkpoints the candidate as a classified terminal
217            // state; it consumes no rejection budget and is never reported as
218            // verified success.
219            if candidate_v < self.best_accepted_energy {
220                self.best_accepted_energy = candidate_v;
221            }
222        } else if matches!(decision, GateDecision::RejectedNonDescending { .. }) {
223            self.rejections_used += 1;
224        }
225        Ok(decision)
226    }
227
228    /// Whether the rejection budget is exhausted.
229    pub fn budget_exhausted(&self) -> bool {
230        self.rejections_used >= self.rejection_budget
231    }
232}
233
234/// The product-level terminal classification of one node generation (PSP-9
235/// system 10). The kernel's `srbn::Status` is recorded as evidence; this
236/// enum is what the product reports and the ledger stores.
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(rename_all = "snake_case", tag = "outcome")]
239pub enum NodeTerminalOutcome {
240    /// Realized candidate passed every hard predicate; eligible for delivery.
241    HardPass,
242    /// Best checkpoint reached the domain's measured tolerance without a
243    /// hard pass. Not delivered unless domain policy explicitly permits it,
244    /// and never reported as verified success.
245    DeclaredFloor { floor: f64 },
246    /// Budgets exhausted or recovery contained the node; a residual
247    /// certificate is issued.
248    Escalated { certificate_id: String },
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn hard_pass_is_accepted() {
257        let d = evaluate_gate(true, 100.0, 0.0, 0.5).unwrap();
258        assert_eq!(d, GateDecision::HardPass);
259        assert!(d.is_accepted());
260    }
261
262    #[test]
263    fn descent_below_best_minus_rho_accepts() {
264        // best=10, candidate=9.4, rho=0.5 -> 9.4 <= 9.5 accept
265        let d = evaluate_gate(false, 9.4, 10.0, 0.5).unwrap();
266        assert!(matches!(d, GateDecision::AcceptedByDescent { .. }));
267        assert!(d.is_accepted());
268    }
269
270    #[test]
271    fn insufficient_descent_rejected() {
272        // best=10, candidate=9.6, rho=0.5 -> 9.6 > 9.5 reject
273        let d = evaluate_gate(false, 9.6, 10.0, 0.5).unwrap();
274        assert!(matches!(d, GateDecision::RejectedNonDescending { .. }));
275        assert!(!d.is_accepted());
276    }
277
278    #[test]
279    fn descent_measured_against_best_not_latest() {
280        let mut traj = AcceptedTrajectory::new("n1", 0, 10.0, 0.5, 8).unwrap();
281        // Descend to 5.0 (accept), best = 5.0.
282        assert!(traj.submit(false, 5.0).unwrap().is_accepted());
283        assert_eq!(traj.best_accepted_energy, 5.0);
284        // Candidate 5.2 would descend vs *latest if latest were higher*, but
285        // best is 5.0 so 5.2 > 5.0 - 0.5 => rejected.
286        assert!(!traj.submit(false, 5.2).unwrap().is_accepted());
287        assert_eq!(traj.best_accepted_energy, 5.0);
288    }
289
290    #[test]
291    fn finite_decision_bound_formula() {
292        // floor(10 / 0.5) + 3 + 1 = 20 + 4 = 24
293        assert_eq!(finite_decision_bound(10.0, 0.5, 3).unwrap(), 24);
294    }
295
296    #[test]
297    fn rho_gate_must_be_positive() {
298        assert!(evaluate_gate(false, 1.0, 2.0, 0.0).is_err());
299        assert!(finite_decision_bound(10.0, 0.0, 1).is_err());
300    }
301
302    #[test]
303    fn trajectory_terminates_within_bound() {
304        // Worst case: a stream of non-descending candidates exhausts B, and a
305        // descending stream is bounded by floor(V0/rho)+1.
306        let mut traj = AcceptedTrajectory::new("n1", 0, 5.0, 1.0, 3).unwrap();
307        let bound = traj.decision_bound().unwrap(); // floor(5)+3+1 = 9
308        let mut decisions = 0u64;
309        let mut v: f64 = 5.0;
310        // Descend one rho per step until zero.
311        while v > 0.0 {
312            v = (v - 1.0).max(0.0);
313            traj.submit(false, v).unwrap();
314            decisions += 1;
315        }
316        assert!(decisions <= bound);
317    }
318
319    #[test]
320    fn floor_ordering_sits_between_hard_pass_and_descent() {
321        // Hard pass wins even below the floor.
322        let d = evaluate_gate_with_floor(true, 0.1, 10.0, 0.5, Some(1.0)).unwrap();
323        assert_eq!(d, GateDecision::HardPass);
324        // At or below the floor without hard pass: classified floor stop.
325        let d = evaluate_gate_with_floor(false, 0.9, 10.0, 0.5, Some(1.0)).unwrap();
326        assert_eq!(d, GateDecision::StoppedAtDeclaredFloor);
327        // Above the floor, ordinary descent applies.
328        let d = evaluate_gate_with_floor(false, 5.0, 10.0, 0.5, Some(1.0)).unwrap();
329        assert!(matches!(d, GateDecision::AcceptedByDescent { .. }));
330    }
331
332    #[test]
333    fn floor_stop_checkpoints_without_consuming_the_rejection_budget() {
334        let mut traj = AcceptedTrajectory::new("n1", 0, 10.0, 0.5, 3).unwrap();
335        let d = traj.submit_with_floor(false, 0.8, Some(1.0)).unwrap();
336        assert_eq!(d, GateDecision::StoppedAtDeclaredFloor);
337        assert_eq!(traj.best_accepted_energy, 0.8);
338        assert_eq!(traj.rejections_used, 0);
339    }
340
341    #[test]
342    fn without_a_floor_the_legacy_gate_is_unchanged() {
343        let d = evaluate_gate(false, 0.9, 10.0, 0.5).unwrap();
344        assert!(matches!(d, GateDecision::AcceptedByDescent { .. }));
345    }
346}