use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Spend {
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub tokens: u64,
#[serde(default, skip_serializing_if = "is_zero_u64")]
pub minor_units: u64,
}
impl Spend {
#[must_use]
pub const fn is_zero(&self) -> bool {
self.tokens == 0 && self.minor_units == 0
}
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero_u64(v: &u64) -> bool {
*v == 0
}
impl Spend {
#[must_use]
pub const fn tokens(n: u64) -> Self {
Self {
tokens: n,
minor_units: 0,
}
}
#[must_use]
pub const fn money(minor_units: u64) -> Self {
Self {
tokens: 0,
minor_units,
}
}
#[allow(clippy::trivially_copy_pass_by_ref)]
#[must_use]
pub const fn is_free_ref(v: &Self) -> bool {
v.is_free()
}
#[must_use]
pub const fn is_free(self) -> bool {
self.tokens == 0 && self.minor_units == 0
}
#[must_use]
pub fn plus(self, other: Self) -> Self {
Self {
tokens: self.tokens.saturating_add(other.tokens),
minor_units: self.minor_units.saturating_add(other.minor_units),
}
}
}
impl std::ops::AddAssign for Spend {
fn add_assign(&mut self, rhs: Self) {
*self = self.plus(rhs);
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Budget {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_steps: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_effects: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_minor_units: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_replans: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_wallclock_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_denials: Option<u32>,
}
impl Budget {
#[must_use]
pub const fn bricked_ceiling(&self) -> Option<&'static str> {
if matches!(self.max_steps, Some(0)) {
return Some("max_steps");
}
if matches!(self.max_effects, Some(0)) {
return Some("max_effects");
}
if matches!(self.max_tokens, Some(0)) {
return Some("max_tokens");
}
if matches!(self.max_minor_units, Some(0)) {
return Some("max_minor_units");
}
if matches!(self.max_wallclock_secs, Some(0)) {
return Some("max_wallclock_secs");
}
None
}
#[must_use]
pub const fn unlimited() -> Self {
Self {
max_steps: None,
max_effects: None,
max_tokens: None,
max_minor_units: None,
max_replans: None,
max_wallclock_secs: None,
max_denials: None,
}
}
#[must_use]
pub const fn steps(mut self, n: usize) -> Self {
self.max_steps = Some(n);
self
}
#[must_use]
pub const fn effects(mut self, n: usize) -> Self {
self.max_effects = Some(n);
self
}
#[must_use]
pub const fn tokens(mut self, n: u64) -> Self {
self.max_tokens = Some(n);
self
}
#[must_use]
pub const fn minor_units(mut self, n: u64) -> Self {
self.max_minor_units = Some(n);
self
}
#[must_use]
pub const fn replans(mut self, n: u32) -> Self {
self.max_replans = Some(n);
self
}
#[must_use]
pub const fn denials(mut self, n: u32) -> Self {
self.max_denials = Some(n);
self
}
#[must_use]
pub const fn wallclock_secs(mut self, n: u64) -> Self {
self.max_wallclock_secs = Some(n);
self
}
#[must_use]
pub const fn tracks_wallclock(&self) -> bool {
self.max_wallclock_secs.is_some()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Consumed {
pub steps: usize,
pub effects: usize,
pub spend: Spend,
pub elapsed_secs: u64,
#[serde(default)]
pub denials: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum BudgetExceeded {
#[error("step budget exhausted: {allowed} step(s) permitted")]
Steps { allowed: usize },
#[error("effect budget exhausted: {allowed} operation(s) permitted, {used} performed")]
Effects { allowed: usize, used: usize },
#[error("replan budget exhausted: {allowed} replan(s) permitted")]
Replans { allowed: u32 },
#[error("denial budget exhausted: {allowed} refusal(s) permitted")]
Denials { allowed: u32 },
#[error("{limit} (recorded: {used})")]
Recorded { limit: String, used: String },
#[error("token budget exhausted: {allowed} permitted, {used} consumed")]
Tokens { allowed: u64, used: u64 },
#[error("cost budget exhausted: {allowed} minor units permitted, {used} spent")]
Money { allowed: u64, used: u64 },
#[error("time budget exhausted: {allowed}s permitted, {used}s elapsed")]
Wallclock { allowed: u64, used: u64 },
}
impl BudgetExceeded {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Steps { .. } => "steps",
Self::Effects { .. } => "effects",
Self::Replans { .. } => "replans",
Self::Denials { .. } => "denials",
Self::Recorded { .. } => "recorded",
Self::Tokens { .. } => "tokens",
Self::Money { .. } => "money",
Self::Wallclock { .. } => "wallclock",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Ledger {
budget: Budget,
consumed: Consumed,
live: Spend,
}
impl Ledger {
#[must_use]
pub const fn new(budget: Budget) -> Self {
Self {
budget,
consumed: Consumed {
steps: 0,
effects: 0,
spend: Spend {
tokens: 0,
minor_units: 0,
},
elapsed_secs: 0,
denials: 0,
},
live: Spend {
tokens: 0,
minor_units: 0,
},
}
}
#[must_use]
pub const fn budget(&self) -> Budget {
self.budget
}
#[must_use]
pub const fn live_spend(&self) -> Spend {
self.live
}
#[must_use]
pub const fn consumed(&self) -> Consumed {
self.consumed
}
#[must_use]
pub const fn tracks_wallclock(&self) -> bool {
self.budget.tracks_wallclock()
}
pub const fn observe_elapsed(&mut self, secs: u64) {
self.consumed.elapsed_secs = secs;
}
pub fn admit_step(&self) -> Result<(), BudgetExceeded> {
if let Some(max) = self.budget.max_steps
&& self.consumed.steps >= max
{
return Err(BudgetExceeded::Steps { allowed: max });
}
self.check_denials()?;
self.check_time()
}
pub const fn record_step(&mut self) {
self.consumed.steps += 1;
}
pub fn admit_effect(&self) -> Result<(), BudgetExceeded> {
self.check_denials()?;
if let Some(max) = self.budget.max_effects
&& self.consumed.effects >= max
{
return Err(BudgetExceeded::Effects {
allowed: max,
used: self.consumed.effects,
});
}
if let Some(max) = self.budget.max_tokens
&& self.consumed.spend.tokens >= max
{
return Err(BudgetExceeded::Tokens {
allowed: max,
used: self.consumed.spend.tokens,
});
}
if let Some(max) = self.budget.max_minor_units
&& self.consumed.spend.minor_units >= max
{
return Err(BudgetExceeded::Money {
allowed: max,
used: self.consumed.spend.minor_units,
});
}
self.check_time()
}
pub fn record_effect(&mut self, spend: Spend) {
self.consumed.effects += 1;
self.consumed.spend += spend;
}
pub fn record_live_effect(&mut self, spend: Spend) {
self.record_effect(spend);
self.live += spend;
}
pub fn record_denial(&mut self) -> Result<(), BudgetExceeded> {
self.consumed.denials += 1;
if let Some(max) = self.budget.max_denials
&& self.consumed.denials > max
{
return Err(BudgetExceeded::Denials { allowed: max });
}
Ok(())
}
pub fn admit_policy_check(&self) -> Result<(), BudgetExceeded> {
self.check_denials()
}
fn check_denials(&self) -> Result<(), BudgetExceeded> {
if let Some(max) = self.budget.max_denials
&& self.consumed.denials > max
{
return Err(BudgetExceeded::Denials { allowed: max });
}
Ok(())
}
fn check_time(&self) -> Result<(), BudgetExceeded> {
if let Some(max) = self.budget.max_wallclock_secs
&& self.consumed.elapsed_secs >= max
{
return Err(BudgetExceeded::Wallclock {
allowed: max,
used: self.consumed.elapsed_secs,
});
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unlimited_budget_admits_everything() {
let mut l = Ledger::new(Budget::unlimited());
for _ in 0..1000 {
l.admit_effect().unwrap();
l.record_effect(Spend::tokens(10_000));
}
l.admit_step().unwrap();
}
#[test]
fn the_step_limit_stops_the_next_step() {
let mut l = Ledger::new(Budget::default().steps(2));
l.admit_step().unwrap();
l.record_step();
l.admit_step().unwrap();
l.record_step();
assert!(matches!(
l.admit_step().unwrap_err(),
BudgetExceeded::Steps { allowed: 2 }
));
}
#[test]
fn the_effect_limit_stops_a_runaway_loop_of_free_operations() {
let mut l = Ledger::new(Budget::default().effects(3));
for _ in 0..3 {
l.admit_effect().unwrap();
l.record_effect(Spend::default());
}
assert!(matches!(
l.admit_effect().unwrap_err(),
BudgetExceeded::Effects {
allowed: 3,
used: 3
}
));
}
#[test]
fn the_token_limit_reports_where_it_stood() {
let mut l = Ledger::new(Budget::default().tokens(100));
l.admit_effect().unwrap();
l.record_effect(Spend::tokens(150));
match l.admit_effect().unwrap_err() {
BudgetExceeded::Tokens { allowed, used } => {
assert_eq!(
(allowed, used),
(100, 150),
"raise-the-limit needs the numbers"
);
}
other => panic!("got {other:?}"),
}
}
#[test]
fn the_cost_limit_uses_integers() {
let mut l = Ledger::new(Budget::default().minor_units(500));
l.admit_effect().unwrap();
l.record_effect(Spend::money(499));
l.admit_effect().expect("still under");
l.record_effect(Spend::money(2));
assert!(matches!(
l.admit_effect().unwrap_err(),
BudgetExceeded::Money {
allowed: 500,
used: 501
}
));
}
#[test]
fn elapsed_time_is_only_checked_when_asked_for() {
let mut unbounded = Ledger::new(Budget::default().steps(10));
assert!(!unbounded.tracks_wallclock());
unbounded.observe_elapsed(99_999);
unbounded.admit_step().expect("no wall-clock limit was set");
let mut bounded = Ledger::new(Budget::default().wallclock_secs(60));
assert!(bounded.tracks_wallclock());
bounded.observe_elapsed(61);
assert!(matches!(
bounded.admit_step().unwrap_err(),
BudgetExceeded::Wallclock {
allowed: 60,
used: 61
}
));
}
#[test]
fn a_negative_amount_does_not_deserialize() {
serde_json::from_str::<Spend>(r#"{"minor_units":-1}"#)
.expect_err("a negative spend un-spends every ceiling comparing against it");
serde_json::from_str::<Budget>(r#"{"max_minor_units":-1}"#)
.expect_err("a negative ceiling is not a ceiling");
assert_eq!(
serde_json::from_str::<Spend>(r#"{"minor_units":250}"#).expect("an ordinary spend"),
Spend::money(250)
);
assert_eq!(
serde_json::from_str::<Budget>(r#"{"max_minor_units":250}"#)
.expect("an ordinary ceiling")
.max_minor_units,
Some(250)
);
}
#[test]
fn spend_saturates_rather_than_wrapping() {
let huge = Spend {
tokens: u64::MAX,
minor_units: u64::MAX,
};
let sum = huge.plus(Spend::tokens(10)).plus(Spend::money(10));
assert_eq!(sum.tokens, u64::MAX);
assert_eq!(sum.minor_units, u64::MAX);
}
#[test]
fn the_same_figures_produce_the_same_verdict() {
let replay = |spends: &[Spend]| {
let mut l = Ledger::new(Budget::default().tokens(100));
let mut stopped_at = None;
for (i, s) in spends.iter().enumerate() {
if l.admit_effect().is_err() {
stopped_at = Some(i);
break;
}
l.record_effect(*s);
}
stopped_at
};
let spends = [Spend::tokens(40); 5];
assert_eq!(
replay(&spends),
replay(&spends),
"same figures, same verdict"
);
assert_eq!(replay(&spends), Some(3));
}
#[test]
fn a_metered_budget_overshoots_by_at_most_one_operation() {
let mut l = Ledger::new(Budget::default().tokens(100));
l.admit_effect().unwrap();
l.record_effect(Spend::tokens(99));
l.admit_effect().expect("99 has not reached 100");
l.record_effect(Spend::tokens(1_000_000));
assert!(l.admit_effect().is_err(), "but nothing further starts");
assert_eq!(l.consumed().spend.tokens, 1_000_099);
}
#[test]
fn an_effect_count_budget_is_exact() {
let mut l = Ledger::new(Budget::default().effects(2));
l.admit_effect().unwrap();
l.record_effect(Spend::tokens(1));
l.admit_effect().unwrap();
l.record_effect(Spend::tokens(1));
assert!(l.admit_effect().is_err());
assert_eq!(l.consumed().effects, 2, "never more than asked for");
}
}
#[cfg(test)]
mod denial_tests {
use super::*;
#[test]
fn the_denial_ceiling_admits_what_it_says_and_no_more() {
let mut ledger = Ledger::new(Budget::unlimited().denials(3));
for i in 1..=3 {
assert!(
ledger.record_denial().is_ok(),
"refusal {i} is within a ceiling of 3"
);
}
assert!(matches!(
ledger.record_denial(),
Err(BudgetExceeded::Denials { allowed: 3 })
));
}
#[test]
fn past_the_ceiling_no_further_effect_is_admitted() {
let mut ledger = Ledger::new(Budget::unlimited().denials(1));
assert!(ledger.admit_effect().is_ok());
let _ = ledger.record_denial();
assert!(
ledger.admit_effect().is_ok(),
"one refusal is within a ceiling of one"
);
let _ = ledger.record_denial();
assert!(
matches!(
ledger.admit_effect(),
Err(BudgetExceeded::Denials { allowed: 1 })
),
"past the ceiling the next attempt must be refused before it is \
performed, or the loop keeps learning"
);
assert!(
matches!(
ledger.admit_step(),
Err(BudgetExceeded::Denials { allowed: 1 })
),
"and the run must not be admitted to a further step"
);
}
#[test]
fn an_unset_denial_ceiling_does_not_bind() {
let mut ledger = Ledger::new(Budget::unlimited());
for _ in 0..1_000 {
assert!(ledger.record_denial().is_ok());
}
assert!(ledger.admit_effect().is_ok());
}
}