use crate::errors::AlkahestError;
use std::cell::{Cell, RefCell};
use std::fmt;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
pub mod memory;
pub use memory::{gmp_live_bytes, install as install_memory_accounting};
#[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>,
max_bytes: Option<u64>,
bytes_at_entry: 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 {
enter_with_memory(budget, None)
}
pub fn enter_with_memory(budget: Budget, max_bytes: Option<u64>) -> BudgetGuard {
if max_bytes.is_some() {
memory::install();
}
let frame = Frame {
start: Instant::now(),
wall: budget.wall,
max_steps: budget.max_steps,
steps: Cell::new(0),
seed: budget.seed,
max_bytes,
bytes_at_entry: memory::gmp_live_bytes(),
};
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))
}
pub fn max_bytes() -> Option<u64> {
STACK.with(|s| s.borrow().last().and_then(|f| f.max_bytes))
}
pub fn bytes_used() -> u64 {
STACK.with(|s| {
s.borrow()
.last()
.map(|f| memory::gmp_live_bytes().saturating_sub(f.bytes_at_entry))
.unwrap_or(0)
})
}
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(())
})
}
pub const DEFAULT_MAX_GROWTH_UNITS: u64 = 1 << 26;
pub fn check_growth(units: u64) -> Result<(), BudgetError> {
check()?;
let limit = STACK
.with(|s| s.borrow().last().and_then(|f| f.max_steps))
.unwrap_or(DEFAULT_MAX_GROWTH_UNITS);
if units > limit {
return Err(BudgetError::Steps {
limit,
taken: units,
});
}
Ok(())
}
const PROBE_INTERVAL: u32 = 16;
const GROWTH_RESERVE_FACTOR: u64 = 4;
const GROWTH_WINDOW: Duration = Duration::from_secs(1);
fn address_space_reserve(limit: u64, used: u64, prev: Option<(u64, Instant)>, now: Instant) -> u64 {
let growth = match prev {
Some((prev_used, at)) if now.saturating_duration_since(at) <= GROWTH_WINDOW => {
used.saturating_sub(prev_used)
}
_ => 0,
};
memory::reserve_bytes(limit).max(growth.saturating_mul(GROWTH_RESERVE_FACTOR))
}
thread_local! {
static PROBE_TICK: Cell<u32> = const { Cell::new(0) };
static LAST_VSZ: Cell<Option<(u64, Instant)>> = const { Cell::new(None) };
static LAST_TRIP: Cell<Option<BudgetTrip>> = const { Cell::new(None) };
}
pub fn check_all() -> Result<(), BudgetTrip> {
check()?;
check_memory()
}
pub fn check_memory() -> Result<(), BudgetTrip> {
let framed = STACK.with(|s| {
let stack = s.borrow();
stack.last().and_then(|f| {
f.max_bytes.map(|limit| {
(
limit,
memory::gmp_live_bytes().saturating_sub(f.bytes_at_entry),
)
})
})
});
if let Some((limit, used)) = framed {
if used > limit {
return Err(BudgetTrip::Memory { limit, used });
}
}
if memory::address_space_limit().is_some() {
let probe = PROBE_TICK.with(|t| {
let n = t.get().wrapping_add(1);
t.set(n);
n % PROBE_INTERVAL == 1
});
if probe {
if let (Some(limit), Some(used)) =
(memory::address_space_limit(), memory::address_space_used())
{
let now = Instant::now();
let prev = LAST_VSZ.with(|c| c.replace(Some((used, now))));
let reserve = address_space_reserve(limit, used, prev, now);
if used.saturating_add(reserve) >= limit {
return Err(BudgetTrip::AddressSpace {
limit,
used,
reserve,
});
}
}
}
}
Ok(())
}
pub fn check_alloc(bytes: u64) -> Result<(), BudgetTrip> {
check()?;
check_alloc_memory(bytes)
}
fn check_alloc_memory(bytes: u64) -> Result<(), BudgetTrip> {
let framed = STACK.with(|s| {
let stack = s.borrow();
stack.last().and_then(|f| {
f.max_bytes.map(|limit| {
(
limit,
memory::gmp_live_bytes().saturating_sub(f.bytes_at_entry),
)
})
})
});
if let Some((limit, used)) = framed {
if used.saturating_add(bytes) > limit {
return Err(BudgetTrip::Memory {
limit,
used: used.saturating_add(bytes),
});
}
}
if let (Some(limit), Some(used)) = (memory::address_space_limit(), memory::address_space_used())
{
let projected = used.saturating_add(bytes);
let reserve = memory::reserve_bytes(limit);
if projected.saturating_add(reserve) >= limit {
return Err(BudgetTrip::AddressSpace {
limit,
used: projected,
reserve,
});
}
}
Ok(())
}
pub const GMP_MAX_BITS: u64 = (i32::MAX as u64) * 64;
pub const MAX_INTEGER_BITS: u64 = 1 << 31;
pub fn preflight_bytes(bytes: u64) -> Result<(), BudgetTrip> {
if let Some(ceiling) = memory::physical_memory() {
if bytes > ceiling {
return Err(BudgetTrip::Oversized {
requested: bytes,
ceiling,
});
}
}
check_alloc_memory(bytes)
}
pub fn preflight_bignum_bits(bits: u64, copies: u64) -> Result<(), BudgetTrip> {
if bits > MAX_INTEGER_BITS {
return Err(BudgetTrip::Oversized {
requested: bits.div_ceil(8),
ceiling: MAX_INTEGER_BITS / 8,
});
}
preflight_bytes(bits.div_ceil(8).saturating_mul(copies.max(1)))
}
pub fn record_trip(trip: BudgetTrip) {
LAST_TRIP.with(|c| c.set(Some(trip)));
}
pub fn take_trip() -> Option<BudgetTrip> {
LAST_TRIP.with(|c| c.take())
}
pub fn clear_trip() {
LAST_TRIP.with(|c| c.set(None));
}
#[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",
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum BudgetTrip {
Budget(BudgetError),
Memory {
limit: u64,
used: u64,
},
AddressSpace {
limit: u64,
used: u64,
reserve: u64,
},
Oversized {
requested: u64,
ceiling: u64,
},
}
impl From<BudgetError> for BudgetTrip {
fn from(e: BudgetError) -> Self {
BudgetTrip::Budget(e)
}
}
impl BudgetTrip {
pub fn budget_error(&self) -> Option<BudgetError> {
match self {
BudgetTrip::Budget(e) => Some(*e),
_ => None,
}
}
}
impl fmt::Display for BudgetTrip {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BudgetTrip::Budget(e) => e.fmt(f),
BudgetTrip::Memory { limit, used } => write!(
f,
"budget exceeded: exact-arithmetic memory limit {limit} bytes reached \
({used} bytes held live)"
),
BudgetTrip::AddressSpace {
limit,
used,
reserve,
} => write!(
f,
"budget exceeded: refusing before the process address-space limit \
({used} of {limit} bytes mapped, reserve {reserve} bytes) — the allocation \
that would follow cannot fail safely, GMP and the Rust allocator both abort"
),
BudgetTrip::Oversized { requested, ceiling } => write!(
f,
"refusing a result of about {requested} bytes: it is larger than the \
{ceiling}-byte ceiling (physical memory, or the largest single integer \
computed here), and the allocation cannot fail safely — GMP and FLINT abort"
),
}
}
}
impl std::error::Error for BudgetTrip {}
impl AlkahestError for BudgetTrip {
fn code(&self) -> &'static str {
match self {
BudgetTrip::Budget(e) => e.code(),
BudgetTrip::Memory { .. } => "E-BUDGET-004",
BudgetTrip::AddressSpace { .. } => "E-BUDGET-005",
BudgetTrip::Oversized { .. } => "E-BUDGET-006",
}
}
fn remediation(&self) -> Option<&'static str> {
match self {
BudgetTrip::Budget(e) => e.remediation(),
BudgetTrip::Memory { .. } => Some(
"raise Budget(max_bytes=...), or ask for a smaller problem (fewer unknowns, \
a lower order/degree) — the exact coefficients, not the shape of the system, \
are what grew",
),
BudgetTrip::AddressSpace { .. } => Some(
"raise the process address-space limit (ulimit -v, or the container/cgroup \
memory limit), or ask for a smaller problem — this refusal replaces the \
uncatchable abort that would otherwise follow",
),
BudgetTrip::Oversized { .. } => Some(
"ask for a smaller result (a smaller exponent, degree or shape) — no limit \
setting can make this one fit; this refusal replaces the uncatchable abort \
that would otherwise follow",
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
#[test]
fn address_space_growth_counts_only_across_a_recent_probe() {
const GB: u64 = 1 << 30;
let limit = 16 * GB;
let flat = memory::reserve_bytes(limit);
let now = Instant::now();
let recent = now - Duration::from_millis(5);
let stale = now - (GROWTH_WINDOW + Duration::from_secs(60));
assert_eq!(address_space_reserve(limit, 15 * GB, None, now), flat);
assert_eq!(
address_space_reserve(limit, 15 * GB, Some((14 * GB, recent)), now),
4 * GB
);
assert_eq!(
address_space_reserve(limit, 15 * GB, Some((7 * GB, stale)), now),
flat
);
assert_eq!(
address_space_reserve(limit, 7 * GB, Some((15 * GB, recent)), now),
flat
);
}
static TEST_SERIAL: Mutex<()> = Mutex::new(());
fn serial() -> MutexGuard<'static, ()> {
TEST_SERIAL.lock().unwrap_or_else(|e| e.into_inner())
}
#[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 growth_is_refused_without_an_active_budget() {
let _serial = serial();
assert!(!is_active());
assert!(check_growth(1_000).is_ok());
let err = check_growth(DEFAULT_MAX_GROWTH_UNITS + 1).unwrap_err();
assert_eq!(err.code(), "E-BUDGET-002");
assert_eq!(
err,
BudgetError::Steps {
limit: DEFAULT_MAX_GROWTH_UNITS,
taken: DEFAULT_MAX_GROWTH_UNITS + 1,
}
);
}
#[test]
fn max_steps_replaces_the_default_growth_ceiling() {
let _serial = serial();
{
let _guard = enter(Budget::new().with_max_steps(10));
assert!(check_growth(10).is_ok());
assert_eq!(
check_growth(11).unwrap_err(),
BudgetError::Steps {
limit: 10,
taken: 11
}
);
}
{
let _guard = enter(Budget::new().with_max_steps(DEFAULT_MAX_GROWTH_UNITS * 4));
assert!(check_growth(DEFAULT_MAX_GROWTH_UNITS + 1).is_ok());
}
}
#[test]
fn growth_checkpoint_still_reports_the_wall_clock() {
let _serial = serial();
let _guard = enter(Budget::new().with_wall(Duration::from_millis(10)));
assert!(check_growth(1).is_ok());
std::thread::sleep(Duration::from_millis(25));
assert_eq!(check_growth(1).unwrap_err().code(), "E-BUDGET-001");
}
#[test]
fn a_growth_refusal_does_not_poison_later_calls() {
let _serial = serial();
assert!(check_growth(DEFAULT_MAX_GROWTH_UNITS + 1).is_err());
assert!(check_growth(1).is_ok());
assert!(check().is_ok());
}
fn big_gmp_integer() -> rug::Integer {
let mut z = rug::Integer::from(1);
z <<= 64_000_000;
z
}
#[test]
fn memory_budget_trips_when_gmp_memory_passes_max_bytes() {
let _serial = serial();
let _guard = enter_with_memory(Budget::new(), Some(1 << 20));
assert!(
check_all().is_ok(),
"an empty frame must not trip before anything is allocated"
);
let z = big_gmp_integer();
let err = check_all().unwrap_err();
assert_eq!(err.code(), "E-BUDGET-004");
assert!(
matches!(err, BudgetTrip::Memory { limit, used } if limit == 1 << 20 && used > limit),
"{err:?}"
);
drop(z);
}
#[test]
fn a_generous_memory_budget_does_not_trip() {
let _serial = serial();
let _guard = enter_with_memory(Budget::new(), Some(1 << 40));
let z = big_gmp_integer();
assert!(check_all().is_ok());
drop(z);
}
#[test]
fn check_alloc_refuses_before_the_allocation_is_made() {
let _serial = serial();
let _guard = enter_with_memory(Budget::new(), Some(1 << 20));
let err = check_alloc(4 << 20).unwrap_err();
assert_eq!(err.code(), "E-BUDGET-004");
assert!(
matches!(err, BudgetTrip::Memory { used, .. } if used >= 4 << 20),
"{err:?}"
);
}
#[test]
fn max_bytes_is_visible_and_scoped_to_its_frame() {
let _serial = serial();
assert_eq!(max_bytes(), None);
{
let _guard = enter_with_memory(Budget::new(), Some(4096));
assert_eq!(max_bytes(), Some(4096));
let _inner = enter(Budget::new());
assert_eq!(max_bytes(), None);
}
assert_eq!(max_bytes(), None);
}
#[test]
fn trips_round_trip_through_the_out_of_band_carrier() {
let _serial = serial();
clear_trip();
assert_eq!(take_trip(), None);
record_trip(BudgetTrip::Memory { limit: 1, used: 2 });
let taken = take_trip().expect("recorded");
assert_eq!(taken.code(), "E-BUDGET-004");
assert_eq!(take_trip(), None);
}
#[test]
fn budget_trip_codes_have_remediation() {
for trip in [
BudgetTrip::Budget(BudgetError::Cancelled),
BudgetTrip::Memory { limit: 1, used: 2 },
BudgetTrip::AddressSpace {
limit: 3,
used: 2,
reserve: 1,
},
] {
assert!(trip.code().starts_with("E-BUDGET-"));
assert!(trip.remediation().is_some());
assert!(!trip.to_string().is_empty());
}
assert_eq!(
BudgetTrip::from(BudgetError::Cancelled).budget_error(),
Some(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());
}
}
}