use serde_json::Value;
use super::event::{
data_field, FIELD_AMOUNT, FIELD_DESC, FIELD_TAG, KIND_BUDGET_GRANTED, KIND_BUDGET_RESERVED,
KIND_BUDGET_SPENT,
};
use super::event_store::Current;
use super::{KnlError, KnlResult};
fn whole(value: &Value) -> i64 {
value
.as_i64()
.or_else(|| value.as_f64().map(|f| f.trunc() as i64))
.unwrap_or(0)
}
pub(super) fn check_amount(amount: i64) -> KnlResult<()> {
if amount < 0 {
return Err(KnlError::Validation(format!(
"amount must be a non-negative whole number, got {amount}"
)));
}
Ok(())
}
pub fn fold_balance(events: &[Current]) -> Option<i64> {
let mut balance: Option<i64> = None;
for event in events {
let amount = data_field(event, FIELD_AMOUNT).map_or(0, whole).max(0);
match event.kind() {
KIND_BUDGET_GRANTED => {
balance = Some(balance.unwrap_or(0).saturating_add(amount));
}
KIND_BUDGET_RESERVED | KIND_BUDGET_SPENT => {
balance = balance.map(|b| b.saturating_sub(amount).max(0));
}
_ => {}
}
}
balance
}
pub fn last_grant(events: &[Current]) -> Option<BudgetGrant> {
events
.iter()
.rev()
.find(|event| event.kind() == KIND_BUDGET_GRANTED)
.map(|event| BudgetGrant {
amount: data_field(event, FIELD_AMOUNT).map_or(0, whole).max(0),
tag: string_field(event, FIELD_TAG),
desc: string_field(event, FIELD_DESC),
})
}
fn string_field(event: &Current, field: &str) -> Option<String> {
data_field(event, field)
.and_then(Value::as_str)
.map(str::to_string)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BudgetGrant {
pub amount: i64,
pub tag: Option<String>,
pub desc: Option<String>,
}
impl BudgetGrant {
pub fn new(amount: i64) -> Self {
Self {
amount,
tag: None,
desc: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Allocation {
pub amount: i64,
pub tag: Option<String>,
}
impl Allocation {
pub fn new(amount: i64) -> Self {
Self { amount, tag: None }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn log(events: Vec<Value>) -> Vec<Current> {
events.into_iter().map(Current::assume_current).collect()
}
#[test]
fn a_negative_amount_is_refused() {
let err = check_amount(-1).expect_err("a negative amount");
assert!(err.reason().contains("non-negative"), "{err}");
assert_eq!(check_amount(0), Ok(()));
assert_eq!(check_amount(i64::MAX), Ok(()));
}
#[test]
fn the_fold_of_the_ledger_is_the_balance() {
use serde_json::json;
let mut ledger = log(vec![
json!({ "kind": "budget_granted", "data": { "amount": 100 } }),
]);
assert_eq!(fold_balance(&ledger), Some(100));
ledger.extend(log(vec![
json!({ "kind": "budget_reserved", "data": { "amount": 30 } }),
]));
assert_eq!(fold_balance(&ledger), Some(70), "after a reservation");
ledger.extend(log(vec![
json!({ "kind": "budget_refused", "data": { "amount": 1000, "remaining": 70 } }),
]));
assert_eq!(fold_balance(&ledger), Some(70), "after a refusal");
ledger.extend(log(vec![
json!({ "kind": "budget_spent", "data": { "amount": 20 } }),
]));
assert_eq!(fold_balance(&ledger), Some(50), "after a spend");
ledger.extend(log(vec![
json!({ "kind": "budget_spent", "data": { "amount": i64::MAX } }),
]));
assert_eq!(fold_balance(&ledger), Some(0), "at the floor");
ledger.extend(log(vec![
json!({ "kind": "budget_granted", "data": { "amount": 10 } }),
]));
assert_eq!(fold_balance(&ledger), Some(10), "after a re-grant");
}
#[test]
fn an_amount_outside_data_is_not_folded() {
use serde_json::json;
assert_eq!(
fold_balance(&log(vec![
json!({ "kind": "budget_granted", "amount": 100 })
])),
Some(0),
"a grant whose amount is not under data grants nothing"
);
}
#[test]
fn a_log_without_a_grant_folds_to_no_budget() {
use serde_json::json;
assert_eq!(fold_balance(&[]), None);
assert_eq!(
fold_balance(&log(vec![
json!({ "kind": "session_opened", "data": { "scope_id": "s", "owner": "anon" } }),
json!({ "kind": "llm_response", "data": { "usage": { "input_tokens": 500 } } }),
])),
None,
"a provider response is not a budget move"
);
assert_eq!(
fold_balance(&log(vec![
json!({ "kind": "budget_granted", "data": { "amount": 0 } }),
])),
Some(0),
"a grant of zero is a budget, and an empty one"
);
}
#[test]
fn the_last_grant_is_recovered_from_the_log() {
use serde_json::json;
assert_eq!(last_grant(&[]), None);
let ledger = log(vec![
json!({
"kind": "budget_granted",
"data": { "amount": 100, "tag": "tokens", "desc": "first" }
}),
json!({ "kind": "budget_reserved", "data": { "amount": 10 } }),
json!({
"kind": "budget_granted",
"data": { "amount": 50, "tag": "tokens", "desc": "second" }
}),
]);
let grant = last_grant(&ledger).expect("a grant was recorded");
assert_eq!(grant.amount, 50, "the latest grant, not the first");
assert_eq!(grant.tag.as_deref(), Some("tokens"));
assert_eq!(grant.desc.as_deref(), Some("second"));
let bare = last_grant(&log(vec![
json!({ "kind": "budget_granted", "data": { "amount": 7 } }),
]))
.expect("a grant was recorded");
assert_eq!(bare, BudgetGrant::new(7));
}
#[test]
fn a_grant_carries_the_amount_and_its_words() {
let plain = BudgetGrant::new(100);
assert_eq!(plain.amount, 100);
assert_eq!(plain.tag, None);
assert_eq!(plain.desc, None);
let tagged = BudgetGrant {
amount: 10,
tag: Some("tokens".to_string()),
desc: Some("one turn's worth".to_string()),
};
assert_eq!(tagged.amount, 10);
assert_eq!(tagged.tag.as_deref(), Some("tokens"));
assert_eq!(tagged.desc.as_deref(), Some("one turn's worth"));
}
}