use crate::errors::AlkahestError;
use std::cell::{Cell, RefCell};
use std::fmt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Budget {
pub wall: Option<Duration>,
pub max_steps: Option<u64>,
pub seed: Option<u64>,
}
impl Budget {
pub fn new() -> Self {
Self::default()
}
pub fn with_wall(mut self, wall: Duration) -> Self {
self.wall = Some(wall);
self
}
pub fn with_max_steps(mut self, max_steps: u64) -> Self {
self.max_steps = Some(max_steps);
self
}
pub fn with_seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
}
struct Frame {
start: Instant,
wall: Option<Duration>,
max_steps: Option<u64>,
steps: Cell<u64>,
seed: Option<u64>,
}
thread_local! {
static STACK: RefCell<Vec<Frame>> = const { RefCell::new(Vec::new()) };
}
pub struct BudgetGuard {
_not_send: std::marker::PhantomData<*const ()>,
}
impl Drop for BudgetGuard {
fn drop(&mut self) {
STACK.with(|s| {
s.borrow_mut().pop();
});
}
}
pub fn enter(budget: Budget) -> BudgetGuard {
let frame = Frame {
start: Instant::now(),
wall: budget.wall,
max_steps: budget.max_steps,
steps: Cell::new(0),
seed: budget.seed,
};
STACK.with(|s| s.borrow_mut().push(frame));
BudgetGuard {
_not_send: std::marker::PhantomData,
}
}
pub fn is_active() -> bool {
STACK.with(|s| !s.borrow().is_empty())
}
pub fn seed() -> Option<u64> {
STACK.with(|s| s.borrow().last().and_then(|f| f.seed))
}
static CANCELLED: AtomicBool = AtomicBool::new(false);
pub fn request_cancel() {
CANCELLED.store(true, Ordering::SeqCst);
}
pub fn clear_cancel() {
CANCELLED.store(false, Ordering::SeqCst);
}
pub fn is_cancelled() -> bool {
CANCELLED.load(Ordering::SeqCst)
}
pub fn check() -> Result<(), BudgetError> {
if is_cancelled() {
return Err(BudgetError::Cancelled);
}
STACK.with(|s| {
let stack = s.borrow();
let Some(frame) = stack.last() else {
return Ok(());
};
if let Some(wall) = frame.wall {
let elapsed = frame.start.elapsed();
if elapsed >= wall {
return Err(BudgetError::WallClock {
limit: wall,
elapsed,
});
}
}
if let Some(max_steps) = frame.max_steps {
let taken = frame.steps.get() + 1;
frame.steps.set(taken);
if taken > max_steps {
return Err(BudgetError::Steps {
limit: max_steps,
taken,
});
}
}
Ok(())
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BudgetError {
WallClock { limit: Duration, elapsed: Duration },
Steps { limit: u64, taken: u64 },
Cancelled,
}
impl fmt::Display for BudgetError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BudgetError::WallClock { limit, elapsed } => write!(
f,
"budget exceeded: wall-clock limit {limit:?} elapsed ({elapsed:?} elapsed)"
),
BudgetError::Steps { limit, taken } => write!(
f,
"budget exceeded: step limit {limit} reached ({taken} steps taken)"
),
BudgetError::Cancelled => write!(f, "budget: operation was cancelled"),
}
}
}
impl std::error::Error for BudgetError {}
impl AlkahestError for BudgetError {
fn code(&self) -> &'static str {
match self {
BudgetError::WallClock { .. } => "E-BUDGET-001",
BudgetError::Steps { .. } => "E-BUDGET-002",
BudgetError::Cancelled => "E-BUDGET-003",
}
}
fn remediation(&self) -> Option<&'static str> {
match self {
BudgetError::WallClock { .. } => Some(
"raise Budget(wall_ms=...), or accept a heuristic/numeric result for this \
candidate instead of an exact one",
),
BudgetError::Steps { .. } => Some(
"raise Budget(max_steps=...), or accept a partial/heuristic result for this \
candidate instead of an exact one",
),
BudgetError::Cancelled => Some(
"call alkahest.clear_cancel() (Python) or budget::clear_cancel() (Rust) before \
starting the next candidate",
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
static TEST_SERIAL: Mutex<()> = Mutex::new(());
fn serial() -> MutexGuard<'static, ()> {
TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner())
}
struct CancelGuard;
impl Drop for CancelGuard {
fn drop(&mut self) {
clear_cancel();
}
}
#[test]
fn no_budget_active_never_trips() {
let _serial = serial();
assert!(!is_active());
assert_eq!(seed(), None);
for _ in 0..1000 {
assert!(check().is_ok());
}
}
#[test]
fn step_budget_trips_after_limit() {
let _serial = serial();
let _guard = enter(Budget::new().with_max_steps(3));
assert!(check().is_ok());
assert!(check().is_ok());
assert!(check().is_ok());
let err = check().unwrap_err();
assert_eq!(err.code(), "E-BUDGET-002");
assert_eq!(err, BudgetError::Steps { limit: 3, taken: 4 });
}
#[test]
fn wall_budget_trips_after_elapsed() {
let _serial = serial();
let _guard = enter(Budget::new().with_wall(Duration::from_millis(10)));
assert!(check().is_ok());
std::thread::sleep(Duration::from_millis(25));
let err = check().unwrap_err();
assert_eq!(err.code(), "E-BUDGET-001");
assert!(matches!(err, BudgetError::WallClock { .. }));
}
#[test]
fn seed_round_trips_through_active_budget() {
let _serial = serial();
assert_eq!(seed(), None);
{
let _guard = enter(Budget::new().with_seed(7));
assert_eq!(seed(), Some(7));
}
assert_eq!(seed(), None);
}
#[test]
fn nested_budgets_shadow_not_merge() {
let _serial = serial();
let _outer = enter(Budget::new().with_seed(1).with_max_steps(1000));
assert_eq!(seed(), Some(1));
{
let _inner = enter(Budget::new().with_max_steps(2));
assert_eq!(seed(), None);
assert!(check().is_ok());
assert!(check().is_ok());
assert_eq!(check().unwrap_err().code(), "E-BUDGET-002");
}
assert_eq!(seed(), Some(1));
assert!(check().is_ok());
}
#[test]
fn guard_pops_on_early_return_via_question_mark() {
let _serial = serial();
fn inner() -> Result<(), BudgetError> {
let _guard = enter(Budget::new().with_max_steps(1));
check()?;
check()?; unreachable!();
}
assert!(inner().is_err());
assert!(!is_active());
}
#[test]
fn cancel_flag_trips_check_and_clears() {
let _serial = serial();
let _cancel_guard = CancelGuard;
assert!(!is_cancelled());
request_cancel();
assert!(is_cancelled());
let err = check().unwrap_err();
assert_eq!(err.code(), "E-BUDGET-003");
assert_eq!(err, BudgetError::Cancelled);
clear_cancel();
assert!(!is_cancelled());
assert!(check().is_ok());
}
#[test]
fn cancel_trips_even_with_a_generous_budget_active() {
let _serial = serial();
let _cancel_guard = CancelGuard;
let _guard = enter(Budget::new().with_max_steps(1_000_000));
request_cancel();
assert_eq!(check().unwrap_err(), BudgetError::Cancelled);
}
#[test]
fn error_codes_have_remediation() {
for err in [
BudgetError::WallClock {
limit: Duration::from_secs(1),
elapsed: Duration::from_secs(2),
},
BudgetError::Steps { limit: 1, taken: 2 },
BudgetError::Cancelled,
] {
assert!(err.code().starts_with("E-BUDGET-"));
assert!(err.remediation().is_some());
assert!(!err.to_string().is_empty());
}
}
}