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}