Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Capacity & storage

α — How does a locus declare what it holds, and how does that commitment shape its lowering?

A locus’s params declare its baseline state — typed fields, mutable from any of its methods, alive for the locus’s lifetime. That’s enough for many loci. But once a locus needs to hold a collection — a queue of pending work, a hashmap of sessions, a recent-events ring buffer — params runs out. You need bounded storage with a discipline.

This chapter covers three layered concepts: capacity slots (the substrate-level storage primitives), projection classes (how a locus declares the resolution at which it serves observations of its children), and forms (the application-layer storage discipline annotations: @form(vec), @form(hashmap), @form(ring_buffer)).

The implicit Arena

Every locus has an implicit slot 0: its Arena. The Arena is a bump allocator for everything the locus’s body short-livedly allocates — string concatenations, struct literals constructed inside a method, transient values. Allocations into the Arena are freed wholesale when the locus dissolves; nothing else needs to track them.

You never write the Arena down. It’s there because it’s universal. When this chapter talks about capacity slots it means slots 1..N, the storage commitments above the implicit floor.

Slot kinds: pool and heap

A capacity { ... } block declares 1..N storage slots:

locus Matchmaker {
    capacity {
        heap waiting of Player;    // slot 1: growable, locus-bounded
        pool sessions of Cell;     // slot 2: fixed-shape, recyclable
    }
}

Two slot kinds, two commitments:

  • heap X of T;growable storage bounded by my own lifetime. Individual cells alloc and free during the locus’s life; the whole region frees wholesale at dissolve. This is the right shape for things whose retained size isn’t known at construction.
  • pool Y of T;bounded recyclable cells of a fixed shape. The population is bounded; individual values come and go, but the slot doesn’t grow indefinitely. Right for map-style buckets, fixed-shape registries, per-handler scratch frames.

The slot name is yours; idiomatic names are waiting, entries, bindings, routes, bytes. The cell type can be any value-shape: a primitive, a type struct, a generic parameter. Slots cannot hold locus references. Locus membership goes through accept(c: Child), not slots — slots are for values.

At this layer the user-facing API is method-shaped. A heap slot exposes alloc() and free(); a pool slot exposes acquire() and release():

let cell = self.entries.acquire();
// ... mutate cell ...
self.entries.release(cell);

This is fine for some uses, but verbose for most. The forms layer replaces it with method sets that match how you’d normally think about the storage.

Forms — the high-level annotation

A @form(...) annotation on a locus picks a high-level lowering for one of its capacity slots and synthesizes a matching method set. The user writes the locus once; the compiler emits a tight, hand-rolled-C-class implementation.

Three forms ship in v1:

@form(vec) — growable contiguous buffer

@form(vec)
locus PlayerQueue {
    capacity { heap items of Player; }
    // synthesized: push, get, pop, len, is_empty
}

fn main() {
    let q = PlayerQueue { };
    q.push(Player { id: "p1", name: "Anna" });
    q.push(Player { id: "p2", name: "Bo" });
    let first = q.get(0) or raise;
}

The Aperio analogue of Vec<T> / std::vector<T> / Go slices. Backed by a doubling-realloc buffer. push is amortized O(1). get and pop are fallible(IndexError) — see the next chapter on the failure channels for what or raise means.

@form(vec) requires exactly one heap slot. The slot’s cell type becomes the vec’s element type.

@form(hashmap) — intrusive open-addressing table

type CmdEntry { name: String; handler: Int; }

@form(hashmap)
locus CmdRegistry {
    capacity { pool entries of CmdEntry indexed_by name; }
    // synthesized: set, get, has, remove, len, is_empty
}

fn main() {
    let r = CmdRegistry { };
    r.set(CmdEntry { name: "spawn", handler: 1 });
    let entry = r.get("spawn") or raise;
}

The Aperio analogue of Map<K, V> / std::unordered_map. The key is intrusive — the cell type carries its own key as a named field declared via indexed_by. set(value) takes the whole value and extracts the key. This shape is structurally different from HashMap<K, V> (no separate K and V slots) and reflects how real keyed stores almost always look in practice: the key is one of the fields.

@form(hashmap) requires exactly one pool slot with an indexed_by FIELD clause. The slot’s cell type must be a user-declared struct; the indexed-by field must be Int or String.

@form(ring_buffer, cap = N) — fixed-capacity FIFO

@form(ring_buffer, cap = 64)
locus RecentCmds {
    capacity { pool history of CmdEntry; }
    // synthesized: push -> Bool, pop -> fallible(EmptyError),
    //              len, is_full
}

A bounded circular buffer. push returns Booltrue on success, false when the buffer is at capacity (so callers choose drop-vs-backpressure). pop is fallible-on-empty.

@form(ring_buffer) requires a pool slot and the annotation arg cap = N (positive integer literal).

Why forms instead of Vec<T>?

Two reasons.

The structural reason. A growable buffer is a storage discipline, not just a parameterized type. Vec<T> in Rust glues “contiguous memory, dynamic length, owning the cells” into one type. But in Aperio’s substrate, every one of those commitments is a separate decision: who owns the memory (the locus does), where it lives (in the locus’s slot), how it grows (doubling realloc), what happens on dissolve (region freed). The @form(vec) annotation makes those decisions explicit at the declaration site.

The pragmatic reason. Each form has a single canonical lowering tuned for the substrate. @form(vec)’s lowering is within a few percent of hand-written C for push-heavy workloads (verified by a microbench in bench/micro/). You don’t get a slow generic implementation that “works for any type”; you get a tight implementation specialized for your cell type via monomorphization.

The downside, in fairness: you can’t pass a @form(vec) of Player as an argument of type Vec<Player> to some library function expecting a generic collection. The forms are locus-shaped: each form is a locus type. If you want shared APIs across forms, you write an interface (see The locus on interface I { ... }).

Projection classes

Forms are about how a locus stores cells of a type. Projection classes are about something different: how a parent locus serves observations of its accepted children to the observer above it.

locus Pool : projection chunked {
    accept(w: Worker) { /* ... */ }
}

Three projection classes:

  • rich — fine-grained. The parent serves observations of named individual children. Typical N ≈ 4-10. Each child carries its own state worth observing in detail. Storage consequence: per-child arenas, low churn.
  • chunked — mid-grained. The parent serves observations over chunks or ranges of its children. Typical N ≈ 10-30. Storage consequence: per-coordinatee sub-regions inside the parent’s arena, freed on each child dissolution.
  • recognition — aggregate. The parent serves population-level views (“represent as a histogram”, “as a curve”, “as a count”). Typical N ≈ 100-500. Individual children are not addressed by name. Storage consequence: pre-allocated fixed pool sized at parent birth; cell stride derived from the accept-method type union.

The projection class affects allocator strategy, sub-region nesting, and the cost of iterating self.children. It does not affect the surface methods on the parent or the children — same code reads from a rich pool or a chunked pool. The annotation is a commitment about resolution; the compiler picks the allocator that makes that resolution cheap.

You rarely need to think about projection classes when writing ordinary application code. They become load-bearing when you’re designing a parent that genuinely has many children (workers, sessions, agents) and you want to commit to the observation resolution upfront.

Forms and projection classes are orthogonal

Both annotations can appear on the same locus:

@form(hashmap)
locus SessionPool : projection chunked {
    capacity { pool sessions of Session indexed_by id; }
    accept(w: Worker) { /* ... */ }
}

@form(hashmap) controls how sessions slot’s storage is laid out and what methods get synthesized. projection chunked controls how the parent serves observations of its accepted Worker children. The two operate on different slots of different shape and don’t interfere.

When to use what

You needReach for
One value per fieldparams
Growable list of T@form(vec)
Keyed store, key is a field of T@form(hashmap)
Bounded FIFO, drop-on-full@form(ring_buffer)
Parent holds many children, namedaccept + rich projection
Parent holds many children, chunkedaccept + chunked projection
Parent holds many children, aggregateaccept + recognition projection
Raw cell recycling with custom logicpool X of T directly

Next

The next chapter, The two failure channels, covers the two orthogonal failure mechanisms — closures / on_failure for structural failure, and fallible(E) / or-disposition for value-level errors — and the rule for which one to use where.