Skip to main content

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}