1use serde::{Deserialize, Serialize};
22
23use crate::error::{check_positive_finite, Result, SdkError};
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
27#[serde(tag = "kind", rename_all = "snake_case")]
28pub enum GateDecision {
29 HardPass,
31 AcceptedByDescent { delta_v: f64 },
33 RejectedNonDescending { delta_v: f64 },
35 StoppedAtDeclaredFloor,
37 ExhaustedWithCertificate { certificate_id: String },
39}
40
41impl GateDecision {
42 pub fn is_accepted(&self) -> bool {
44 matches!(
45 self,
46 GateDecision::HardPass | GateDecision::AcceptedByDescent { .. }
47 )
48 }
49}
50
51pub 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
66pub 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
102pub 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133pub struct AcceptedTrajectory {
134 pub node_id: String,
135 pub generation: u32,
136 pub baseline_energy: f64,
137 pub best_accepted_energy: f64,
139 pub rho_gate: f64,
140 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 pub fn decision_bound(&self) -> Result<u64> {
170 finite_decision_bound(self.baseline_energy, self.rho_gate, self.rejection_budget)
171 }
172
173 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 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 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 pub fn budget_exhausted(&self) -> bool {
230 self.rejections_used >= self.rejection_budget
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(rename_all = "snake_case", tag = "outcome")]
239pub enum NodeTerminalOutcome {
240 HardPass,
242 DeclaredFloor { floor: f64 },
246 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 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 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 assert!(traj.submit(false, 5.0).unwrap().is_accepted());
283 assert_eq!(traj.best_accepted_energy, 5.0);
284 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 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 let mut traj = AcceptedTrajectory::new("n1", 0, 5.0, 1.0, 3).unwrap();
307 let bound = traj.decision_bound().unwrap(); let mut decisions = 0u64;
309 let mut v: f64 = 5.0;
310 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 let d = evaluate_gate_with_floor(true, 0.1, 10.0, 0.5, Some(1.0)).unwrap();
323 assert_eq!(d, GateDecision::HardPass);
324 let d = evaluate_gate_with_floor(false, 0.9, 10.0, 0.5, Some(1.0)).unwrap();
326 assert_eq!(d, GateDecision::StoppedAtDeclaredFloor);
327 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}