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 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
78pub 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#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct AcceptedTrajectory {
110 pub node_id: String,
111 pub generation: u32,
112 pub baseline_energy: f64,
113 pub best_accepted_energy: f64,
115 pub rho_gate: f64,
116 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 pub fn decision_bound(&self) -> Result<u64> {
146 finite_decision_bound(self.baseline_energy, self.rho_gate, self.rejection_budget)
147 }
148
149 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 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 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 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 assert!(traj.submit(false, 5.0).unwrap().is_accepted());
212 assert_eq!(traj.best_accepted_energy, 5.0);
213 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 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 let mut traj = AcceptedTrajectory::new("n1", 0, 5.0, 1.0, 3).unwrap();
236 let bound = traj.decision_bound().unwrap(); let mut decisions = 0u64;
238 let mut v: f64 = 5.0;
239 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}