Skip to main content

sublinear_solver/
budget.rs

1//! `PlanBudget` — cumulative complexity-class + operation-count
2//! accumulator for chained solves.
3//!
4//! ADR-001 roadmap item #4's phase-2 MCP gate refuses **one** solve
5//! that exceeds the caller's `max_complexity_class`. That's necessary
6//! but not sufficient: an agent that issues 10 000 SubLinear solves in
7//! a tight loop can still blow a real-world J/decision budget without
8//! ever tripping a single per-call gate.
9//!
10//! This module closes the gap with a budget accumulator that lives
11//! across the chain. Callers `try_consume(class)` once per solve;
12//! the budget refuses the call if either:
13//!
14//! 1. `class` exceeds `max_class` (per-call class gate, same semantics
15//!    as the existing MCP `enforceComplexityBudget`), or
16//! 2. the remaining operation count has dropped to zero.
17//!
18//! The "operation count" abstraction is *number of solves*, not
19//! number of inner-loop iterations — the unit a budget-aware agent
20//! actually reasons about. A 100-event reflex loop with a 100-op
21//! budget runs to completion; a 10 000-event one trips the budget
22//! at iteration 100 and short-circuits gracefully.
23//!
24//! ## Composition
25//!
26//! Pair this with the per-call class gate that's already wired into
27//! the MCP solve handlers. The MCP layer enforces *budget per call*;
28//! `PlanBudget` enforces *budget per plan*.
29//!
30//! ## Why not `Rc<RefCell<...>>`?
31//!
32//! The accumulator is intentionally `&mut self` — borrow-checker
33//! catches accidental shared mutation. Callers running planners on
34//! multiple threads should `Arc<Mutex<PlanBudget>>` or partition the
35//! budget per worker. The single-threaded planning case (the
36//! common one) stays zero-cost.
37
38use crate::complexity::ComplexityClass;
39use core::fmt;
40
41/// Cumulative budget for a chain of solves.
42///
43/// Tracks both the strictest per-call class allowance (`max_class`)
44/// and a count of remaining ops (`remaining_ops`). Each
45/// [`try_consume`] either succeeds (decrementing `remaining_ops`,
46/// updating `worst_seen`) or returns a [`BudgetExhausted`] error
47/// the caller can use to short-circuit the chain.
48#[derive(Debug, Clone)]
49pub struct PlanBudget {
50    /// Strictest per-call class allowed. A `try_consume(class)` with
51    /// `class > max_class` is rejected regardless of how many ops
52    /// remain.
53    max_class: ComplexityClass,
54    /// Remaining operation slots. Decremented per successful consume.
55    remaining_ops: usize,
56    /// Worst class consumed so far (highest rank). Useful for the
57    /// planner to report what bound *was* actually exercised.
58    worst_seen: Option<ComplexityClass>,
59}
60
61impl PlanBudget {
62    /// Construct a fresh budget with the given per-call class ceiling
63    /// and op-count allowance.
64    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    /// Attempt to consume one slot for an operation of the given
73    /// `class`. Returns `Ok(())` on success and decrements the
74    /// remaining-op count; returns `Err(BudgetExhausted)` if the
75    /// class exceeds the budget or no ops remain.
76    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    /// Number of op-slots still available.
97    pub const fn remaining_ops(&self) -> usize {
98        self.remaining_ops
99    }
100
101    /// Strictest per-call class allowed.
102    pub const fn max_class(&self) -> ComplexityClass {
103        self.max_class
104    }
105
106    /// Highest class consumed so far across the chain, or `None`
107    /// if no ops have been consumed yet.
108    pub const fn worst_seen(&self) -> Option<ComplexityClass> {
109        self.worst_seen
110    }
111
112    /// True iff the budget can still accept *at least* a `Logarithmic`
113    /// op — i.e. there are ops remaining (since `Logarithmic` is
114    /// the cheapest class and always within any sensible `max_class`).
115    /// Useful for "should I even try the next iteration?" loop guards.
116    pub const fn has_capacity(&self) -> bool {
117        self.remaining_ops > 0
118    }
119}
120
121/// Reason a `try_consume` was rejected.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub enum BudgetExhausted {
124    /// The attempted op's class is stronger than the budget's `max_class`
125    /// ceiling. The op was *not* counted against the remaining ops.
126    ClassExceeded {
127        attempted: ComplexityClass,
128        ceiling: ComplexityClass,
129    },
130    /// The remaining op-count dropped to zero. `worst_seen` reports
131    /// the highest class consumed before the budget ran out.
132    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        // Refusal must NOT decrement the remaining-op count.
193        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        // A later cheaper op must not lower worst_seen.
220        b.try_consume(ComplexityClass::Logarithmic).unwrap();
221        assert_eq!(b.worst_seen(), Some(ComplexityClass::SubLinear));
222
223        // A later more-expensive op must raise it.
224        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        // ComplexityClass::short_label() gives big-O notation,
255        // e.g. "O(n)" for Linear and "O(n^c), c<1" for SubLinear.
256        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}