Skip to main content

agent_block_core/knl/
budget.rs

1//! K4 — the budget: a quota an owner grants a scope.
2//!
3//! The quota is what makes a run stop: termination is undecidable, so the
4//! owner injects a resource that only decreases and the run ends when it
5//! runs out (`ulimit` / cgroup semantics).  It is drawn down two ways, and
6//! they are independent: [`super::Session::reserve`] asks whether `n` may be
7//! consumed and refuses when it may not, [`super::Session::spend`] deducts
8//! `n` without asking.  Neither holds anything for the other — there is no
9//! settlement — so a caller that uses both for one call deducts twice.
10//!
11//! # The balance is the ledger, and nothing else
12//!
13//! There is no counter.  Every move of the balance is an event first — a
14//! `budget_granted` / `budget_reserved` / `budget_spent`
15//! ([`super::event`]) — and the balance *is* [`fold_balance`] over them.
16//! A reservation is a command with an invariant, so it is decided inside
17//! the store against the ledger as it stands there
18//! ([`super::EventStore::append_if`]); a *read* of the balance
19//! ([`super::Session::remaining`]) folds that same ledger.  A number kept
20//! beside the log would be a second answer to one question, and on a stream
21//! two handles write to it would be the wrong one.
22//!
23//! It is not accounting.  What a run actually consumed is the `usage`
24//! projection over the fact-log ([`super::projection`]); the two are
25//! independent, and an estimate that missed does not make either wrong.
26//!
27//! The fold knows only numbers.  It has no idea what an `llm_response` is,
28//! what a beat is, or what unit the amount is in — the unit lives in the
29//! grant's `tag` ([`BudgetGrant`]), for whoever reads the log.
30//!
31//! v1 is single-axis.  Multiple axes (turns / cost / time) fit later as a
32//! map of named balances — `reserve(n)` / `spend(n)` gaining an `(axis, n)`
33//! form — so nothing here needs to be taken back to get there.
34
35use serde_json::Value;
36
37use super::event::{
38    data_field, FIELD_AMOUNT, FIELD_DESC, FIELD_TAG, KIND_BUDGET_GRANTED, KIND_BUDGET_RESERVED,
39    KIND_BUDGET_SPENT,
40};
41use super::event_store::Current;
42use super::{KnlError, KnlResult};
43
44/// A stored number as a whole one (`0` when absent or not numeric).
45///
46/// The reading the ledger fold takes its amounts through: a `data` field is
47/// whatever was written, and a fold over the log must total what is there
48/// rather than stop at what is not.
49fn whole(value: &Value) -> i64 {
50    value
51        .as_i64()
52        .or_else(|| value.as_f64().map(|f| f.trunc() as i64))
53        .unwrap_or(0)
54}
55
56/// Reject an amount the counter cannot take.
57///
58/// One rule for `reserve`, `spend` and a grant: the balance moves by whole
59/// non-negative steps, so a negative amount is a refund by another name and
60/// is refused before anything — the balance or the log — is touched.
61pub(super) fn check_amount(amount: i64) -> KnlResult<()> {
62    if amount < 0 {
63        return Err(KnlError::Validation(format!(
64            "amount must be a non-negative whole number, got {amount}"
65        )));
66    }
67    Ok(())
68}
69
70/// The balance a log implies: `granted − reserved − spent`, in seq order.
71///
72/// The balance itself, and the only definition of one.  `None` means no
73/// grant was ever recorded, which is not the same as a balance of zero: a
74/// run with no budget refuses nothing, a run whose balance reached zero
75/// refuses everything.
76///
77/// Applied in order, floored at zero at each step: a run that overspent past
78/// zero and was granted again starts from zero, not from a debt the floor
79/// had already forgiven.  `budget_refused` moves nothing, which is the point
80/// of recording it — the fact that a stop happened, with no effect on the
81/// balance.
82///
83/// It folds [`Current`] events — ones that came through the upcaster seam —
84/// so a balance can only be taken over the shape the fold was written for.
85/// A caller may hand it the whole stream or only the `budget_*` kinds
86/// ([`super::EventStore::read_kinds`]); the arithmetic is the same, because
87/// every other kind moves nothing.
88///
89/// The amounts are read from the event's `data`, where a kind's own fields
90/// live ([`super::event`]) — the envelope carries the log's vocabulary, not
91/// the ledger's.
92pub fn fold_balance(events: &[Current]) -> Option<i64> {
93    let mut balance: Option<i64> = None;
94    for event in events {
95        let amount = data_field(event, FIELD_AMOUNT).map_or(0, whole).max(0);
96        match event.kind() {
97            KIND_BUDGET_GRANTED => {
98                balance = Some(balance.unwrap_or(0).saturating_add(amount));
99            }
100            KIND_BUDGET_RESERVED | KIND_BUDGET_SPENT => {
101                balance = balance.map(|b| b.saturating_sub(amount).max(0));
102            }
103            _ => {}
104        }
105    }
106    balance
107}
108
109/// Recover the grant a log records, from its last `budget_granted`.
110///
111/// A resumed run keeps the words of the grant it is continuing (the `tag`
112/// a refusal reports), and — more than cosmetically — keeps *having* a
113/// budget: a session whose log says a quota was granted must go on
114/// recording its moves, whether or not the resuming caller granted again.
115pub fn last_grant(events: &[Current]) -> Option<BudgetGrant> {
116    events
117        .iter()
118        .rev()
119        .find(|event| event.kind() == KIND_BUDGET_GRANTED)
120        .map(|event| BudgetGrant {
121            amount: data_field(event, FIELD_AMOUNT).map_or(0, whole).max(0),
122            tag: string_field(event, FIELD_TAG),
123            desc: string_field(event, FIELD_DESC),
124        })
125}
126
127/// An optional string `data` field of a stored event.
128fn string_field(event: &Current, field: &str) -> Option<String> {
129    data_field(event, field)
130        .and_then(Value::as_str)
131        .map(str::to_string)
132}
133
134/// What an owner grants a scope: an amount, and the words for what it is.
135///
136/// The kernel reads `amount` and nothing else.  `tag` names the unit (the
137/// shell writes `"tokens"`), and `desc` says who allowed what and why;
138/// both ride onto the `budget_granted` event verbatim, so a log can be
139/// audited without asking the shell what it meant.  `tag` comes back with
140/// a refused [`super::Session::reserve`] at the call site, so a caller can
141/// say which allowance stopped it without reading the log at all.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct BudgetGrant {
144    /// The quota, in whatever unit `tag` names.  Non-negative.
145    pub amount: i64,
146    /// The unit / identity of the grant, kernel-uninterpreted.
147    pub tag: Option<String>,
148    /// Free-text audit note, kernel-uninterpreted.
149    pub desc: Option<String>,
150}
151
152impl BudgetGrant {
153    /// A grant of `amount` with no tag or description.
154    pub fn new(amount: i64) -> Self {
155        Self {
156            amount,
157            tag: None,
158            desc: None,
159        }
160    }
161}
162
163/// What a parent hands to a child: an amount out of its own balance, and
164/// optionally the unit the child's ledger names it by.
165///
166/// Not a [`BudgetGrant`], and the difference is where the units come from.  A
167/// grant is an owner *allowing* — it raises a balance out of nothing the
168/// kernel can see, and only an owner may write one.  An allocation moves
169/// units that already exist: the parent's balance falls by exactly what the
170/// child's rises by, in one transaction ([`super::Session::open_child`]), so
171/// no total is created and none is lost.
172///
173/// There is no `desc`.  What an allocation is *for* is a supervisor's
174/// vocabulary, and the two events it writes already say the whole of what the
175/// kernel knows: which parent, which child, how much.
176///
177/// `tag` defaults to the parent's — the units come out of that ledger, so
178/// they are counted in that unit unless the caller renames them for the
179/// child.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct Allocation {
182    /// How much of the parent's balance to move.  Non-negative.
183    pub amount: i64,
184    /// The unit the child's ledger names, or the parent's when absent.
185    pub tag: Option<String>,
186}
187
188impl Allocation {
189    /// An allocation of `amount`, counted in the parent's own unit.
190    pub fn new(amount: i64) -> Self {
191        Self { amount, tag: None }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    /// A fixture log, as events that have been through the seam.
200    ///
201    /// The fold only takes [`Current`]s, and a literal written here is in
202    /// today's shape by construction, so the tests say so rather than
203    /// building a store to read one back through.
204    fn log(events: Vec<Value>) -> Vec<Current> {
205        events.into_iter().map(Current::assume_current).collect()
206    }
207
208    /// A negative amount is a refund by another name, and is refused before
209    /// anything is touched — whether or not there is a budget, because the
210    /// rule is about the amount.
211    #[test]
212    fn a_negative_amount_is_refused() {
213        let err = check_amount(-1).expect_err("a negative amount");
214        assert!(err.reason().contains("non-negative"), "{err}");
215        assert_eq!(check_amount(0), Ok(()));
216        assert_eq!(check_amount(i64::MAX), Ok(()));
217    }
218
219    /// The fold is the balance, and the only definition of one: every kind
220    /// of move applied in order, floored at zero, with a refusal moving
221    /// nothing.
222    #[test]
223    fn the_fold_of_the_ledger_is_the_balance() {
224        use serde_json::json;
225
226        let mut ledger = log(vec![
227            json!({ "kind": "budget_granted", "data": { "amount": 100 } }),
228        ]);
229        assert_eq!(fold_balance(&ledger), Some(100));
230
231        // A reservation of 30 was decided in the store and recorded there.
232        ledger.extend(log(vec![
233            json!({ "kind": "budget_reserved", "data": { "amount": 30 } }),
234        ]));
235        assert_eq!(fold_balance(&ledger), Some(70), "after a reservation");
236
237        // A refusal is recorded and moves nothing.
238        ledger.extend(log(vec![
239            json!({ "kind": "budget_refused", "data": { "amount": 1000, "remaining": 70 } }),
240        ]));
241        assert_eq!(fold_balance(&ledger), Some(70), "after a refusal");
242
243        ledger.extend(log(vec![
244            json!({ "kind": "budget_spent", "data": { "amount": 20 } }),
245        ]));
246        assert_eq!(fold_balance(&ledger), Some(50), "after a spend");
247
248        // Overspending floors at zero rather than going into debt, and a
249        // huge amount cannot wrap it…
250        ledger.extend(log(vec![
251            json!({ "kind": "budget_spent", "data": { "amount": i64::MAX } }),
252        ]));
253        assert_eq!(fold_balance(&ledger), Some(0), "at the floor");
254
255        // …so a later grant starts from zero, not from a forgiven debt.
256        ledger.extend(log(vec![
257            json!({ "kind": "budget_granted", "data": { "amount": 10 } }),
258        ]));
259        assert_eq!(fold_balance(&ledger), Some(10), "after a re-grant");
260    }
261
262    /// The amounts are read from `data` and nowhere else: a number left at
263    /// the top level is not a ledger entry, which is what stops the envelope
264    /// and a kind's own fields from being read as one namespace.
265    #[test]
266    fn an_amount_outside_data_is_not_folded() {
267        use serde_json::json;
268
269        assert_eq!(
270            fold_balance(&log(vec![
271                json!({ "kind": "budget_granted", "amount": 100 })
272            ])),
273            Some(0),
274            "a grant whose amount is not under data grants nothing"
275        );
276    }
277
278    /// No grant in the log is no budget — which is not a balance of zero:
279    /// one refuses nothing, the other refuses everything.
280    #[test]
281    fn a_log_without_a_grant_folds_to_no_budget() {
282        use serde_json::json;
283
284        assert_eq!(fold_balance(&[]), None);
285        assert_eq!(
286            fold_balance(&log(vec![
287                json!({ "kind": "session_opened", "data": { "scope_id": "s", "owner": "anon" } }),
288                json!({ "kind": "llm_response", "data": { "usage": { "input_tokens": 500 } } }),
289            ])),
290            None,
291            "a provider response is not a budget move"
292        );
293        assert_eq!(
294            fold_balance(&log(vec![
295                json!({ "kind": "budget_granted", "data": { "amount": 0 } }),
296            ])),
297            Some(0),
298            "a grant of zero is a budget, and an empty one"
299        );
300    }
301
302    /// The tag a refusal reports survives a resume: it is read back off the
303    /// last grant the log recorded.
304    #[test]
305    fn the_last_grant_is_recovered_from_the_log() {
306        use serde_json::json;
307
308        assert_eq!(last_grant(&[]), None);
309
310        let ledger = log(vec![
311            json!({
312                "kind": "budget_granted",
313                "data": { "amount": 100, "tag": "tokens", "desc": "first" }
314            }),
315            json!({ "kind": "budget_reserved", "data": { "amount": 10 } }),
316            json!({
317                "kind": "budget_granted",
318                "data": { "amount": 50, "tag": "tokens", "desc": "second" }
319            }),
320        ]);
321        let grant = last_grant(&ledger).expect("a grant was recorded");
322        assert_eq!(grant.amount, 50, "the latest grant, not the first");
323        assert_eq!(grant.tag.as_deref(), Some("tokens"));
324        assert_eq!(grant.desc.as_deref(), Some("second"));
325
326        // A grant with no words comes back with none invented.
327        let bare = last_grant(&log(vec![
328            json!({ "kind": "budget_granted", "data": { "amount": 7 } }),
329        ]))
330        .expect("a grant was recorded");
331        assert_eq!(bare, BudgetGrant::new(7));
332    }
333
334    /// A grant is an amount plus words the kernel does not read.
335    #[test]
336    fn a_grant_carries_the_amount_and_its_words() {
337        let plain = BudgetGrant::new(100);
338        assert_eq!(plain.amount, 100);
339        assert_eq!(plain.tag, None);
340        assert_eq!(plain.desc, None);
341
342        let tagged = BudgetGrant {
343            amount: 10,
344            tag: Some("tokens".to_string()),
345            desc: Some("one turn's worth".to_string()),
346        };
347        assert_eq!(tagged.amount, 10);
348        assert_eq!(tagged.tag.as_deref(), Some("tokens"));
349        assert_eq!(tagged.desc.as_deref(), Some("one turn's worth"));
350    }
351}