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    check_positive_finite(rho_gate, "rho_gate")?;
64    crate::error::check_non_negative_finite(candidate_v, "candidate energy")?;
65    crate::error::check_non_negative_finite(best_accepted_v, "best accepted energy")?;
66
67    if hard_pass {
68        return Ok(GateDecision::HardPass);
69    }
70    let delta_v = best_accepted_v - candidate_v;
71    if candidate_v <= best_accepted_v - rho_gate {
72        Ok(GateDecision::AcceptedByDescent { delta_v })
73    } else {
74        Ok(GateDecision::RejectedNonDescending { delta_v })
75    }
76}
77
78/// Finite-decision bound `floor(V_0 / rho_gate) + B + 1` (PSP-8 System 2).
79pub fn finite_decision_bound(
80    baseline_energy: f64,
81    rho_gate: f64,
82    rejection_budget: u32,
83) -> Result<u64> {
84    check_positive_finite(rho_gate, "rho_gate")?;
85    crate::error::check_non_negative_finite(baseline_energy, "baseline energy")?;
86    let descents = (baseline_energy / rho_gate).floor();
87    if !descents.is_finite() {
88        return Err(SdkError::InvalidGate(
89            "finite-decision bound overflow".into(),
90        ));
91    }
92    Ok(descents as u64 + rejection_budget as u64 + 1)
93}
94
95/// A reference to a recorded gate decision (PSP-8 `GateDecisionRef`).
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct GateDecisionRef {
98    pub decision: GateDecision,
99    pub observed_energy: f64,
100    pub best_accepted_before: f64,
101}
102
103/// Accepted-trajectory record for one node generation (PSP-8 System 2 / 11).
104///
105/// The accepted trajectory contains only hard-pass or descent-gated states;
106/// observed candidates that fail the gate are retained as observations but
107/// never advance accepted progress.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct AcceptedTrajectory {
110    pub node_id: String,
111    pub generation: u32,
112    pub baseline_energy: f64,
113    /// The best accepted energy `V(x_best)` so far.
114    pub best_accepted_energy: f64,
115    pub rho_gate: f64,
116    /// Finite rejection budget `B`.
117    pub rejection_budget: u32,
118    pub rejections_used: u32,
119    pub gate_decisions: Vec<GateDecisionRef>,
120}
121
122impl AcceptedTrajectory {
123    pub fn new(
124        node_id: impl Into<String>,
125        generation: u32,
126        baseline_energy: f64,
127        rho_gate: f64,
128        rejection_budget: u32,
129    ) -> Result<Self> {
130        check_positive_finite(rho_gate, "rho_gate")?;
131        crate::error::check_non_negative_finite(baseline_energy, "baseline energy")?;
132        Ok(Self {
133            node_id: node_id.into(),
134            generation,
135            baseline_energy,
136            best_accepted_energy: baseline_energy,
137            rho_gate,
138            rejection_budget,
139            rejections_used: 0,
140            gate_decisions: Vec::new(),
141        })
142    }
143
144    /// The finite-decision bound for this trajectory.
145    pub fn decision_bound(&self) -> Result<u64> {
146        finite_decision_bound(self.baseline_energy, self.rho_gate, self.rejection_budget)
147    }
148
149    /// Evaluate a candidate and fold the decision into the trajectory, updating
150    /// the best accepted energy on acceptance and the rejection count on
151    /// rejection. Returns the decision taken.
152    pub fn submit(&mut self, hard_pass: bool, candidate_v: f64) -> Result<GateDecision> {
153        let decision = evaluate_gate(
154            hard_pass,
155            candidate_v,
156            self.best_accepted_energy,
157            self.rho_gate,
158        )?;
159        self.gate_decisions.push(GateDecisionRef {
160            decision: decision.clone(),
161            observed_energy: candidate_v,
162            best_accepted_before: self.best_accepted_energy,
163        });
164        if decision.is_accepted() {
165            if candidate_v < self.best_accepted_energy {
166                self.best_accepted_energy = candidate_v;
167            }
168        } else if matches!(decision, GateDecision::RejectedNonDescending { .. }) {
169            self.rejections_used += 1;
170        }
171        Ok(decision)
172    }
173
174    /// Whether the rejection budget is exhausted.
175    pub fn budget_exhausted(&self) -> bool {
176        self.rejections_used >= self.rejection_budget
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn hard_pass_is_accepted() {
186        let d = evaluate_gate(true, 100.0, 0.0, 0.5).unwrap();
187        assert_eq!(d, GateDecision::HardPass);
188        assert!(d.is_accepted());
189    }
190
191    #[test]
192    fn descent_below_best_minus_rho_accepts() {
193        // best=10, candidate=9.4, rho=0.5 -> 9.4 <= 9.5 accept
194        let d = evaluate_gate(false, 9.4, 10.0, 0.5).unwrap();
195        assert!(matches!(d, GateDecision::AcceptedByDescent { .. }));
196        assert!(d.is_accepted());
197    }
198
199    #[test]
200    fn insufficient_descent_rejected() {
201        // best=10, candidate=9.6, rho=0.5 -> 9.6 > 9.5 reject
202        let d = evaluate_gate(false, 9.6, 10.0, 0.5).unwrap();
203        assert!(matches!(d, GateDecision::RejectedNonDescending { .. }));
204        assert!(!d.is_accepted());
205    }
206
207    #[test]
208    fn descent_measured_against_best_not_latest() {
209        let mut traj = AcceptedTrajectory::new("n1", 0, 10.0, 0.5, 8).unwrap();
210        // Descend to 5.0 (accept), best = 5.0.
211        assert!(traj.submit(false, 5.0).unwrap().is_accepted());
212        assert_eq!(traj.best_accepted_energy, 5.0);
213        // Candidate 5.2 would descend vs *latest if latest were higher*, but
214        // best is 5.0 so 5.2 > 5.0 - 0.5 => rejected.
215        assert!(!traj.submit(false, 5.2).unwrap().is_accepted());
216        assert_eq!(traj.best_accepted_energy, 5.0);
217    }
218
219    #[test]
220    fn finite_decision_bound_formula() {
221        // floor(10 / 0.5) + 3 + 1 = 20 + 4 = 24
222        assert_eq!(finite_decision_bound(10.0, 0.5, 3).unwrap(), 24);
223    }
224
225    #[test]
226    fn rho_gate_must_be_positive() {
227        assert!(evaluate_gate(false, 1.0, 2.0, 0.0).is_err());
228        assert!(finite_decision_bound(10.0, 0.0, 1).is_err());
229    }
230
231    #[test]
232    fn trajectory_terminates_within_bound() {
233        // Worst case: a stream of non-descending candidates exhausts B, and a
234        // descending stream is bounded by floor(V0/rho)+1.
235        let mut traj = AcceptedTrajectory::new("n1", 0, 5.0, 1.0, 3).unwrap();
236        let bound = traj.decision_bound().unwrap(); // floor(5)+3+1 = 9
237        let mut decisions = 0u64;
238        let mut v: f64 = 5.0;
239        // Descend one rho per step until zero.
240        while v > 0.0 {
241            v = (v - 1.0).max(0.0);
242            traj.submit(false, v).unwrap();
243            decisions += 1;
244        }
245        assert!(decisions <= bound);
246    }
247}