ocas_core/fuel.rs
1//! Resource budget for long-running algorithms.
2//!
3//! [`Fuel`] is a shared, decrementing budget that algorithms consume as they
4//! work. When the budget is exhausted, callers observe an
5//! [`OcasError::OutOfFuel`](crate::error::OcasError::OutOfFuel) and can stop
6//! the computation deterministically.
7//!
8//! # Design
9//!
10//! `Fuel` wraps an [`AtomicUsize`] counter so that a single budget can be
11//! shared (cheaply cloned as a handle) across nested calls — for example a
12//! top-level `simplify` that drives many pattern matches. Consumption is
13//! infallible: once the counter hits zero every subsequent [`Fuel::consume`]
14//! or [`Fuel::check`] reports exhaustion. The budget is monotone
15//! non-increasing, never refilled except by constructing a new `Fuel`.
16//!
17//! Algorithms that already keep per-call limits (e.g. `simplify`'s
18//! `iter_limit`, `integrate`'s `MAX_DEPTH`) can opt into `Fuel` without
19//! breaking their existing API by adding a `*_with_fuel` entry point.
20//!
21//! # Example
22//!
23//! ```
24//! use ocas_core::fuel::Fuel;
25//!
26//! let fuel = Fuel::new(100);
27//! for _ in 0..50 {
28//! fuel.consume(1);
29//! }
30//! assert!(fuel.check().is_ok());
31//! fuel.consume(100);
32//! assert!(fuel.check().is_err());
33//! ```
34
35use std::sync::Arc;
36use std::sync::atomic::{AtomicUsize, Ordering};
37
38use crate::error::{OcasError, Result};
39
40/// A decrementing resource budget shared across algorithm invocations.
41///
42/// Clone produces a new handle to the **same** underlying counter; consumption
43/// on any clone is observed by all clones. Use this to thread a single budget
44/// through nested calls without passing `&Fuel` everywhere.
45#[derive(Debug, Clone)]
46pub struct Fuel {
47 remaining: Arc<AtomicUsize>,
48}
49
50impl Fuel {
51 /// Create a new budget with `budget` units.
52 pub fn new(budget: usize) -> Self {
53 Self {
54 remaining: Arc::new(AtomicUsize::new(budget)),
55 }
56 }
57
58 /// Consume `n` units. If the remaining budget would drop to zero or below,
59 /// the counter saturates at zero (subsequent [`Self::check`] fails). This
60 /// call is infallible — query the outcome with [`Self::check`] or read the
61 /// residual via [`Self::remaining`].
62 pub fn consume(&self, n: usize) {
63 // Saturating subtraction on the atomic. Relaxed ordering is fine: fuel
64 // is a best-effort cut-off, not a synchronisation primitive. We only
65 // need eventual visibility across threads, which Relaxed provides.
66 loop {
67 let cur = self.remaining.load(Ordering::Relaxed);
68 let next = cur.saturating_sub(n);
69 // Try to commit; if another thread beat us, retry with their value.
70 if self
71 .remaining
72 .compare_exchange_weak(cur, next, Ordering::Relaxed, Ordering::Relaxed)
73 .is_ok()
74 {
75 break;
76 }
77 }
78 }
79
80 /// Return `Ok(())` if budget remains, `Err(OutOfFuel)` otherwise. Cheap
81 /// hot-path probe; use between small units of work to avoid an explicit
82 /// [`Self::consume`] on every iteration.
83 pub fn check(&self) -> Result<()> {
84 if self.remaining.load(Ordering::Relaxed) == 0 {
85 Err(OcasError::OutOfFuel)
86 } else {
87 Ok(())
88 }
89 }
90
91 /// The number of units still available. `0` means exhausted.
92 pub fn remaining(&self) -> usize {
93 self.remaining.load(Ordering::Relaxed)
94 }
95}
96
97impl Default for Fuel {
98 fn default() -> Self {
99 // A generous default; algorithms that opt in may override.
100 Self::new(1_000_000)
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn consume_then_check() {
110 let f = Fuel::new(3);
111 assert!(f.check().is_ok());
112 f.consume(2);
113 assert!(f.check().is_ok());
114 f.consume(1);
115 assert!(f.check().is_err());
116 // Stays exhausted.
117 f.consume(10);
118 assert!(f.check().is_err());
119 }
120
121 #[test]
122 fn remaining_is_monotone() {
123 let f = Fuel::new(10);
124 assert_eq!(f.remaining(), 10);
125 f.consume(3);
126 assert_eq!(f.remaining(), 7);
127 f.consume(100); // saturates
128 assert_eq!(f.remaining(), 0);
129 }
130
131 #[test]
132 fn clone_shares_counter_decrements() {
133 let f = Fuel::new(5);
134 let g = f.clone();
135 f.consume(2);
136 // g observes the decrement made on f.
137 assert_eq!(g.remaining(), 3);
138 g.consume(3);
139 assert!(f.check().is_err());
140 }
141
142 #[test]
143 fn zero_budget_immediately_exhausted() {
144 let f = Fuel::new(0);
145 assert!(f.check().is_err());
146 assert_eq!(f.remaining(), 0);
147 }
148
149 #[test]
150 fn consume_zero_is_noop() {
151 let f = Fuel::new(5);
152 f.consume(0);
153 assert_eq!(f.remaining(), 5);
154 assert!(f.check().is_ok());
155 }
156
157 #[test]
158 fn default_has_generous_budget() {
159 let f = Fuel::default();
160 assert!(f.remaining() > 0);
161 assert!(f.check().is_ok());
162 }
163}