sublinear_solver/
budget.rs1use crate::complexity::ComplexityClass;
39use core::fmt;
40
41#[derive(Debug, Clone)]
49pub struct PlanBudget {
50 max_class: ComplexityClass,
54 remaining_ops: usize,
56 worst_seen: Option<ComplexityClass>,
59}
60
61impl PlanBudget {
62 pub fn new(max_class: ComplexityClass, max_ops: usize) -> Self {
65 Self {
66 max_class,
67 remaining_ops: max_ops,
68 worst_seen: None,
69 }
70 }
71
72 pub fn try_consume(&mut self, class: ComplexityClass) -> Result<(), BudgetExhausted> {
77 if class > self.max_class {
78 return Err(BudgetExhausted::ClassExceeded {
79 attempted: class,
80 ceiling: self.max_class,
81 });
82 }
83 if self.remaining_ops == 0 {
84 return Err(BudgetExhausted::OpsExhausted {
85 worst_seen: self.worst_seen,
86 });
87 }
88 self.remaining_ops -= 1;
89 self.worst_seen = Some(match self.worst_seen {
90 Some(prev) if prev >= class => prev,
91 _ => class,
92 });
93 Ok(())
94 }
95
96 pub const fn remaining_ops(&self) -> usize {
98 self.remaining_ops
99 }
100
101 pub const fn max_class(&self) -> ComplexityClass {
103 self.max_class
104 }
105
106 pub const fn worst_seen(&self) -> Option<ComplexityClass> {
109 self.worst_seen
110 }
111
112 pub const fn has_capacity(&self) -> bool {
117 self.remaining_ops > 0
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum BudgetExhausted {
124 ClassExceeded {
127 attempted: ComplexityClass,
128 ceiling: ComplexityClass,
129 },
130 OpsExhausted { worst_seen: Option<ComplexityClass> },
133}
134
135impl fmt::Display for BudgetExhausted {
136 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137 match self {
138 BudgetExhausted::ClassExceeded { attempted, ceiling } => write!(
139 f,
140 "PlanBudget: attempted class {} exceeds ceiling {}",
141 attempted.short_label(),
142 ceiling.short_label(),
143 ),
144 BudgetExhausted::OpsExhausted { worst_seen } => match worst_seen {
145 Some(c) => write!(
146 f,
147 "PlanBudget: ops exhausted (worst seen: {})",
148 c.short_label()
149 ),
150 None => write!(f, "PlanBudget: ops exhausted (none consumed)"),
151 },
152 }
153 }
154}
155
156#[cfg(any(feature = "std", test))]
157impl std::error::Error for BudgetExhausted {}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::complexity::ComplexityClass;
163
164 #[test]
165 fn fresh_budget_has_capacity_and_no_worst_seen() {
166 let b = PlanBudget::new(ComplexityClass::SubLinear, 10);
167 assert_eq!(b.remaining_ops(), 10);
168 assert!(b.has_capacity());
169 assert_eq!(b.worst_seen(), None);
170 }
171
172 #[test]
173 fn consume_within_class_and_count_succeeds() {
174 let mut b = PlanBudget::new(ComplexityClass::SubLinear, 3);
175 assert!(b.try_consume(ComplexityClass::Logarithmic).is_ok());
176 assert!(b.try_consume(ComplexityClass::SubLinear).is_ok());
177 assert_eq!(b.remaining_ops(), 1);
178 assert_eq!(b.worst_seen(), Some(ComplexityClass::SubLinear));
179 }
180
181 #[test]
182 fn consume_above_ceiling_refuses_without_counting() {
183 let mut b = PlanBudget::new(ComplexityClass::SubLinear, 5);
184 let err = b.try_consume(ComplexityClass::Linear).unwrap_err();
185 match err {
186 BudgetExhausted::ClassExceeded { attempted, ceiling } => {
187 assert_eq!(attempted, ComplexityClass::Linear);
188 assert_eq!(ceiling, ComplexityClass::SubLinear);
189 }
190 _ => panic!("expected ClassExceeded"),
191 }
192 assert_eq!(b.remaining_ops(), 5);
194 assert_eq!(b.worst_seen(), None);
195 }
196
197 #[test]
198 fn consume_after_ops_exhausted_refuses() {
199 let mut b = PlanBudget::new(ComplexityClass::SubLinear, 2);
200 assert!(b.try_consume(ComplexityClass::Logarithmic).is_ok());
201 assert!(b.try_consume(ComplexityClass::SubLinear).is_ok());
202 assert!(!b.has_capacity());
203
204 let err = b.try_consume(ComplexityClass::Logarithmic).unwrap_err();
205 match err {
206 BudgetExhausted::OpsExhausted { worst_seen } => {
207 assert_eq!(worst_seen, Some(ComplexityClass::SubLinear));
208 }
209 _ => panic!("expected OpsExhausted"),
210 }
211 }
212
213 #[test]
214 fn worst_seen_is_monotone() {
215 let mut b = PlanBudget::new(ComplexityClass::Linear, 10);
216 b.try_consume(ComplexityClass::SubLinear).unwrap();
217 assert_eq!(b.worst_seen(), Some(ComplexityClass::SubLinear));
218
219 b.try_consume(ComplexityClass::Logarithmic).unwrap();
221 assert_eq!(b.worst_seen(), Some(ComplexityClass::SubLinear));
222
223 b.try_consume(ComplexityClass::Linear).unwrap();
225 assert_eq!(b.worst_seen(), Some(ComplexityClass::Linear));
226 }
227
228 #[test]
229 fn ops_exhausted_carries_worst_seen() {
230 let mut b = PlanBudget::new(ComplexityClass::Linear, 1);
231 b.try_consume(ComplexityClass::Linear).unwrap();
232 let err = b.try_consume(ComplexityClass::Logarithmic).unwrap_err();
233 match err {
234 BudgetExhausted::OpsExhausted { worst_seen } => {
235 assert_eq!(worst_seen, Some(ComplexityClass::Linear));
236 }
237 _ => panic!("expected OpsExhausted"),
238 }
239 }
240
241 #[test]
242 fn zero_ops_budget_refuses_first_consume() {
243 let mut b = PlanBudget::new(ComplexityClass::Linear, 0);
244 assert!(!b.has_capacity());
245 let err = b.try_consume(ComplexityClass::Logarithmic).unwrap_err();
246 assert!(matches!(
247 err,
248 BudgetExhausted::OpsExhausted { worst_seen: None }
249 ));
250 }
251
252 #[test]
253 fn budget_exhausted_display_strings() {
254 let class_err = BudgetExhausted::ClassExceeded {
257 attempted: ComplexityClass::Linear,
258 ceiling: ComplexityClass::SubLinear,
259 };
260 let s = format!("{class_err}");
261 assert!(s.contains("O(n)"), "expected O(n) in {s:?}");
262 assert!(s.contains("c<1"), "expected c<1 in {s:?}");
263 assert!(s.contains("exceeds"));
264
265 let ops_err = BudgetExhausted::OpsExhausted { worst_seen: None };
266 let s = format!("{ops_err}");
267 assert!(s.contains("exhausted"));
268 }
269}