Skip to main content

ruvector_solver/
budget.rs

1//! Compute budget enforcement for solver operations.
2//!
3//! [`BudgetEnforcer`] tracks wall-clock time, iteration count, and memory
4//! allocation against a [`ComputeBudget`]. Solvers call
5//! [`check_iteration`](BudgetEnforcer::check_iteration) at the top of each
6//! iteration loop and
7//! [`check_memory`](BudgetEnforcer::check_memory) before any allocation that
8//! could exceed the memory ceiling.
9//!
10//! Budget violations are reported as [`SolverError::BudgetExhausted`] with a
11//! human-readable reason describing which limit was hit.
12
13use std::time::Instant;
14
15use crate::error::SolverError;
16use crate::types::ComputeBudget;
17
18/// Default memory ceiling when none is specified (256 MiB).
19const DEFAULT_MEMORY_LIMIT: usize = 256 * 1024 * 1024;
20
21/// Enforces wall-time, iteration, and memory budgets during a solve.
22///
23/// Create one at the start of a solve and call the `check_*` methods at each
24/// iteration or before allocating scratch space. The enforcer is intentionally
25/// non-`Clone` so that each solve owns exactly one.
26///
27/// # Example
28///
29/// ```
30/// use ruvector_solver::budget::BudgetEnforcer;
31/// use ruvector_solver::types::ComputeBudget;
32///
33/// let budget = ComputeBudget::default();
34/// let mut enforcer = BudgetEnforcer::new(budget);
35///
36/// // At the top of each solver iteration:
37/// enforcer.check_iteration().unwrap();
38///
39/// // Before allocating scratch memory:
40/// enforcer.check_memory(1024).unwrap();
41/// ```
42pub struct BudgetEnforcer {
43    /// Monotonic clock snapshot taken when the enforcer was created.
44    start_time: Instant,
45
46    /// The budget limits to enforce.
47    budget: ComputeBudget,
48
49    /// Number of iterations consumed so far.
50    iterations_used: usize,
51
52    /// Cumulative memory allocated (tracked by the caller, not measured).
53    memory_used: usize,
54
55    /// Maximum memory allowed. Defaults to [`DEFAULT_MEMORY_LIMIT`] if
56    /// the `ComputeBudget` does not carry a memory field.
57    memory_limit: usize,
58}
59
60impl BudgetEnforcer {
61    /// Create a new enforcer with the given budget.
62    ///
63    /// The wall-clock timer starts immediately.
64    pub fn new(budget: ComputeBudget) -> Self {
65        Self {
66            start_time: Instant::now(),
67            budget,
68            iterations_used: 0,
69            memory_used: 0,
70            memory_limit: DEFAULT_MEMORY_LIMIT,
71        }
72    }
73
74    /// Create an enforcer with a custom memory ceiling.
75    ///
76    /// Use this when the caller knows the available memory and wants to
77    /// enforce a tighter or looser bound than the default 256 MiB.
78    pub fn with_memory_limit(budget: ComputeBudget, memory_limit: usize) -> Self {
79        Self {
80            start_time: Instant::now(),
81            budget,
82            iterations_used: 0,
83            memory_used: 0,
84            memory_limit,
85        }
86    }
87
88    /// Check whether the next iteration is within budget.
89    ///
90    /// Must be called **once per iteration**, at the top of the loop body.
91    /// Increments the internal iteration counter and checks both the iteration
92    /// limit and the wall-clock time limit.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`SolverError::BudgetExhausted`] if either the iteration count
97    /// or wall-clock time has been exceeded.
98    pub fn check_iteration(&mut self) -> Result<(), SolverError> {
99        self.iterations_used += 1;
100
101        // Iteration budget
102        if self.iterations_used > self.budget.max_iterations {
103            return Err(SolverError::BudgetExhausted {
104                reason: format!(
105                    "iteration limit reached ({} > {})",
106                    self.iterations_used, self.budget.max_iterations,
107                ),
108                elapsed: self.start_time.elapsed(),
109            });
110        }
111
112        // Wall-clock budget
113        let elapsed = self.start_time.elapsed();
114        if elapsed > self.budget.max_time {
115            return Err(SolverError::BudgetExhausted {
116                reason: format!(
117                    "wall-clock time limit reached ({:.2?} > {:.2?})",
118                    elapsed, self.budget.max_time,
119                ),
120                elapsed,
121            });
122        }
123
124        Ok(())
125    }
126
127    /// Check whether an additional memory allocation is within budget.
128    ///
129    /// Call this **before** performing the allocation. The `additional` parameter
130    /// is the number of bytes the caller intends to allocate. If the allocation
131    /// would push cumulative usage over the memory ceiling, the call fails
132    /// without modifying the internal counter.
133    ///
134    /// On success the internal counter is incremented by `additional`.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`SolverError::BudgetExhausted`] if the allocation would exceed
139    /// the memory limit.
140    pub fn check_memory(&mut self, additional: usize) -> Result<(), SolverError> {
141        let new_total = self.memory_used.saturating_add(additional);
142        if new_total > self.memory_limit {
143            return Err(SolverError::BudgetExhausted {
144                reason: format!(
145                    "memory limit reached ({} + {} = {} > {} bytes)",
146                    self.memory_used, additional, new_total, self.memory_limit,
147                ),
148                elapsed: self.start_time.elapsed(),
149            });
150        }
151        self.memory_used = new_total;
152        Ok(())
153    }
154
155    /// Wall-clock microseconds elapsed since the enforcer was created.
156    #[inline]
157    pub fn elapsed_us(&self) -> u64 {
158        self.start_time.elapsed().as_micros() as u64
159    }
160
161    /// Wall-clock duration elapsed since the enforcer was created.
162    #[inline]
163    pub fn elapsed(&self) -> std::time::Duration {
164        self.start_time.elapsed()
165    }
166
167    /// Number of iterations consumed so far.
168    #[inline]
169    pub fn iterations_used(&self) -> usize {
170        self.iterations_used
171    }
172
173    /// Cumulative memory tracked so far (in bytes).
174    #[inline]
175    pub fn memory_used(&self) -> usize {
176        self.memory_used
177    }
178
179    /// The tolerance target from the budget (convenience accessor).
180    #[inline]
181    pub fn tolerance(&self) -> f64 {
182        self.budget.tolerance
183    }
184
185    /// A reference to the underlying budget configuration.
186    #[inline]
187    pub fn budget(&self) -> &ComputeBudget {
188        &self.budget
189    }
190}
191
192// ---------------------------------------------------------------------------
193// Tests
194// ---------------------------------------------------------------------------
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::types::ComputeBudget;
200    use std::time::Duration;
201
202    fn tiny_budget() -> ComputeBudget {
203        ComputeBudget {
204            max_time: Duration::from_secs(60),
205            max_iterations: 5,
206            tolerance: 1e-6,
207        }
208    }
209
210    #[test]
211    fn iterations_within_budget() {
212        let mut enforcer = BudgetEnforcer::new(tiny_budget());
213        for _ in 0..5 {
214            enforcer.check_iteration().unwrap();
215        }
216        assert_eq!(enforcer.iterations_used(), 5);
217    }
218
219    #[test]
220    fn iteration_limit_exceeded() {
221        let mut enforcer = BudgetEnforcer::new(tiny_budget());
222        for _ in 0..5 {
223            enforcer.check_iteration().unwrap();
224        }
225        // 6th iteration should fail
226        let err = enforcer.check_iteration().unwrap_err();
227        match err {
228            SolverError::BudgetExhausted { ref reason, .. } => {
229                assert!(reason.contains("iteration"), "reason: {reason}");
230            }
231            other => panic!("expected BudgetExhausted, got {other:?}"),
232        }
233    }
234
235    #[test]
236    fn wall_clock_limit_exceeded() {
237        let budget = ComputeBudget {
238            max_time: Duration::from_nanos(1), // Impossibly short
239            max_iterations: 1_000_000,
240            tolerance: 1e-6,
241        };
242        let mut enforcer = BudgetEnforcer::new(budget);
243
244        // Burn a tiny bit of time so Instant::now() moves forward
245        std::thread::sleep(Duration::from_micros(10));
246
247        let err = enforcer.check_iteration().unwrap_err();
248        match err {
249            SolverError::BudgetExhausted { ref reason, .. } => {
250                assert!(reason.contains("wall-clock"), "reason: {reason}");
251            }
252            other => panic!("expected BudgetExhausted for time, got {other:?}"),
253        }
254    }
255
256    #[test]
257    fn memory_within_budget() {
258        let mut enforcer = BudgetEnforcer::with_memory_limit(tiny_budget(), 1024);
259        enforcer.check_memory(512).unwrap();
260        enforcer.check_memory(512).unwrap();
261        assert_eq!(enforcer.memory_used(), 1024);
262    }
263
264    #[test]
265    fn memory_limit_exceeded() {
266        let mut enforcer = BudgetEnforcer::with_memory_limit(tiny_budget(), 1024);
267        enforcer.check_memory(800).unwrap();
268
269        let err = enforcer.check_memory(300).unwrap_err();
270        match err {
271            SolverError::BudgetExhausted { ref reason, .. } => {
272                assert!(reason.contains("memory"), "reason: {reason}");
273            }
274            other => panic!("expected BudgetExhausted for memory, got {other:?}"),
275        }
276        // Memory should not have been incremented on failure
277        assert_eq!(enforcer.memory_used(), 800);
278    }
279
280    #[test]
281    fn memory_saturating_add_no_panic() {
282        // Use a limit smaller than usize::MAX so that saturation triggers an error.
283        let limit = usize::MAX / 2;
284        let mut enforcer = BudgetEnforcer::with_memory_limit(tiny_budget(), limit);
285        enforcer.check_memory(limit - 1).unwrap();
286        // Adding another large amount should saturate to usize::MAX which exceeds the limit.
287        let err = enforcer.check_memory(usize::MAX).unwrap_err();
288        assert!(matches!(err, SolverError::BudgetExhausted { .. }));
289    }
290
291    #[test]
292    fn elapsed_us_positive() {
293        let enforcer = BudgetEnforcer::new(tiny_budget());
294        // Just ensure it does not panic; the value may be 0 on fast machines.
295        let _ = enforcer.elapsed_us();
296    }
297
298    #[test]
299    fn tolerance_accessor() {
300        let enforcer = BudgetEnforcer::new(tiny_budget());
301        assert!((enforcer.tolerance() - 1e-6).abs() < f64::EPSILON);
302    }
303
304    #[test]
305    fn budget_accessor() {
306        let budget = tiny_budget();
307        let enforcer = BudgetEnforcer::new(budget.clone());
308        assert_eq!(enforcer.budget().max_iterations, 5);
309    }
310}