budgetkernel 0.1.0

A small, auditable, deterministic, zero-allocation budget accounting kernel.
Documentation
# budgetkernel design


`budgetkernel` is a small, deterministic budget accounting kernel.

It answers one question:

> Given a declared budget and a sequence of charges, should the caller continue, degrade, or stop?

The crate is intentionally narrow. It does not measure time, read clocks, perform I/O, persist state, coordinate across machines, or refill budgets automatically. The caller owns those policies. The kernel owns only bounded accounting.

## Core guarantees


The design is built around these guarantees:

- **No heap allocation on the hot path.** Budget state is stored in fixed-size arrays keyed by `Dim`.
- **No clocks, no I/O, no syscalls.** The caller supplies elapsed time, token counts, byte counts, call counts, or any other measured value.
- **Deterministic behavior.** Same inputs and same starting state produce the same outputs.
- **No caller-triggerable panics.** Valid API usage returns `Result` or `Verdict`; it does not panic on overflow, missing dimensions, or exhausted budgets.
- **Saturating arithmetic.** Charge accumulation uses `u64::saturating_add`; remaining budget uses `u64::saturating_sub`.
- **Bounded work.** Public operations are `O(MAX_DIMS)` or better, with `MAX_DIMS = 8`.
- **`no_std` compatibility.** The core crate works without `std`; the `std` feature adds `std::error::Error` implementations.

`debug_assert!` is allowed for internal invariant checks. The no-panic guarantee means no caller-triggerable panics from valid API usage. A `debug_assert!` firing indicates a bug inside the kernel, not an expected runtime outcome.

## Dimensions


Dimensions are fixed:

```rust
pub enum Dim {
	Tokens,
	Millis,
	Bytes,
	Calls,
	Memory,
	Custom0,
	Custom1,
	Custom2,
}
```

There are exactly eight dimensions.

This is deliberate. Earlier designs considered generic or user-defined dimensions, but that would require user-provided discriminants or hashing. User-provided discriminants can alias accidentally, and hashing introduces unnecessary machinery. A fixed enum gives us:

dense array indexing
compiler-owned discriminants
no hashing
no allocation
easy auditability
stable behavior across feature configurations

The three Custom dimensions are the escape hatch for domain-specific units. They are still fixed slots; callers assign meaning in their own adapter code.

## Budget construction


Budgets are built with `Budget::builder()`:

```rust
let budget = Budget::builder()
	.limit_with_warn(Dim::Tokens, 10_000, 8_000)
	.limit(Dim::Calls, 50)
	.build()?;
```

The builder validates structural mistakes:

- duplicate dimensions
- zero limits
- warn thresholds greater than or equal to limits
- empty budgets

Duplicate declarations are rejected. After a duplicate is recorded, the builder's internal stored values are unspecified because `build()` will fail regardless. Callers must not rely on which duplicate declaration is retained internally.

## Mutable Budget in v0.1


The original design considered a split between an immutable policy and a mutable ledger:

```rust
let policy = BudgetPolicy::builder().build()?;
let mut ledger = policy.ledger();
```

v0.1 intentionally collapses this into one mutable `Budget`:

```rust
let mut budget = Budget::builder().build()?;
budget.charge(Dim::Tokens, 100)?;
```

This is simpler for the target use case: one request, one budget, one mutable accounting state.

One plausible future path is to introduce an immutable policy plus mutable ledger for shared-policy or multi-tenant use cases. That can be done without breaking v0.1 users by preserving `Budget::builder()` and making `Budget` a compatibility alias or wrapper around the mutable ledger shape.

## Charging


The core operation is:

```rust
pub fn charge(&mut self, dim: Dim, amount: u64) -> Result<Verdict, ChargeError>
```

The algorithm is:

1. Look up the declared limit for `dim`.
2. Return `ChargeError::UnknownDimension(dim)` if the dimension was not declared.
3. Read current spent, defaulting to zero.
4. Compute `new_spent = current.saturating_add(amount)`.
5. Store `new_spent`.
6. Return `Exhausted(dim)` if `new_spent > limit`.
7. Return `Warn(dim)` if a warn threshold exists and `new_spent > warn`.
8. Otherwise return `Continue`.

Spent is updated even when the budget becomes exhausted. This preserves diagnostic information about how far the caller attempted to go.

## Inclusive limits


Limits are inclusive.

If the limit is 100, then reaching exactly 100 is allowed:

```text
spent == limit     => Continue or Warn
spent > limit      => Exhausted
```

This is why exhaustion checks use `>` rather than `>=`.

## Warn semantics


Warn thresholds are strict and repeated.

If the warn threshold is 80, then:

```text
spent <= 80          => Continue
80 < spent <= limit  => Warn
spent > limit        => Exhausted
```

Warn is not one-shot. It fires on every charge where the current accounting state is above the warn threshold but not exhausted.

This avoids hidden suppression state. If callers want one-shot logging, they track that in their adapter.

## Exhaustion priority


Exhaustion wins over warning.

If one charge crosses both the warn threshold and the limit, the verdict is `Exhausted`, not `Warn`.

## Zero-amount charges


Charging zero is valid.

```rust
budget.charge(dim, 0)?;
```

A zero charge reports the verdict for the current state without increasing the reported spent value. It can be used as a state poll.

Because `Warn` is based on current state, a zero charge can return `Warn` or `Exhausted` if the budget was already warning or exhausted.

## `warn = 0`


A warn threshold of zero is valid.

It means any positive spend enters the warning state:

```text
spent == 0         => Continue
spent > 0          => Warn, unless exhausted
```

This is useful when callers want to observe the first nonzero spend.

## `Verdict::worst`


v0.1 does not provide atomic batch charging. Callers who want to charge multiple dimensions at one checkpoint perform several sequential `charge()` calls.

`Verdict::worst` helps reduce those sequential verdicts:

```rust
let mut verdict = Verdict::Continue;

verdict = verdict.worst(budget.charge(Dim::Tokens, tokens)?);
verdict = verdict.worst(budget.charge(Dim::Millis, millis)?);
verdict = verdict.worst(budget.charge(Dim::Calls, 1)?);
```

Severity order is:

- Exhausted
- Warn
- Continue

For equal severity, `worst` is left-biased. This keeps reduction deterministic.

This does not make multiple charges atomic. Batch charging remains a possible future API.

## Manual reset


`Budget::reset()` clears spent counters and preserves limits and warn thresholds.

This does not conflict with the non-goal of automatic refill. The distinction is:

- `reset()` is explicit, synchronous, caller-triggered, and clock-free.
- Automatic refill is time-based policy and belongs in a rate limiter or adapter.

The kernel provides the primitive. The caller decides when a budget period ends.

## Internal storage


Internally, `Budget` uses three fixed maps:

- limits
- warn thresholds
- spent counters

The fixed map is array-backed and keyed by `Dim::index()`.

The default implementation uses `MaybeUninit<u64>` plus presence bits. The `safe-map` feature uses fully initialized `[u64; MAX_DIMS]` storage instead.

Both variants expose the same internal API and pass the same tests.

## `safe-map` feature


`safe-map` removes unsafe code from the internal map implementation.

The default map avoids eager slot initialization. The `safe-map` variant initializes every slot to zero. In current local microbenchmarks, both variants have similar hot-path behavior. Do not rely on one feature configuration being universally faster than the other.

Use `safe-map` when you want a fully safe implementation for audit, policy, or confidence reasons.

## Non-goals


`budgetkernel` does not provide:

- automatic time-based refill
- rate limiting
- background tasks
- persistence
- distributed coordination
- async APIs
- dynamic dimension registration
- pricing tables
- clocks
- I/O
- logging

Adapters can build those behaviors around the kernel.