Skip to main content

core.mem — Memory management

The implementation of CBGR (Capability-Based Generational References), the three-tier reference model, and the allocator stack. User code typically interacts with Heap, Shared, and references via base; this page documents the full mem API for systems programmers.

Module status​

Each stdlib module declares its maturity via the @arch_module(..., lifecycle: Lifecycle.X("vN.M")) directive at the top of its source. The four lifecycle states are:

StatusMeaningAPI stability
🟢 Theorem(v0.1+)Implementation complete, mechanised proof attached, tier-aligned test suite green on both --interp and --aot.Stable — additions only, no breaking changes inside the major version.
🟡 Conjecture(v0.1)Implementation complete; proof in progress; tests passing in at least one tier.Stable in spirit — may receive bug-fix breakage if a defect surface forces a rename.
🟠 Draft(vX)Implementation incomplete or partially gated behind feature flags; API may still shift.Unstable — pin a specific Verum version.
⚫ DeprecatedSuperseded by another module; kept for source-level compatibility until next major version.Will be removed — migrate per the deprecation note.

Orthogonal coverage axes:

AxisSymbolsMeaning
Tier--interp ✓ / --aot ✓Both tiers exercise the module's public surface; --interp is mandatory, --aot is "tier-aligned" gate.
Test coverage🟢 full / 🟡 partial / 🔴 none"Full" requires core-tests/<x>/<y>/{unit,property,integration,regression}_test.vr + audit.md.

core.mem per-module status​

The table reflects @arch_module(... lifecycle: ...) declared in each source file PLUS the test-coverage state in core-tests/mem/.

FileLifecycleTierTestsNotes
capability.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/capability/ — 4 files + audit
header.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/header/ — 4 files + audit
size_class.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/size_class/ — 4 files + audit; uncovered clz_u64 → ctlz + PAGE_HEADER_SIZE drift defects (both closed)
thin_ref.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/thin_ref/ — constructor field round-trips, .message() and .eq() are covered under the interpreter. Field lookup uses the field name across string-interning namespaces; see audit §8.
fat_ref.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/fat_ref/ — 4 files + audit (static-shape only)
hazard.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/hazard/ — green under the interpreter (the whole module used to SIGSEGV): live hazard_stats() / force_reclaim_all() / cleanup_thread_hazards() all pass after TYPE-NAME-INFERENCE-1 + PROTOCOL-ITER-1 + CALLSYNC-R0-CLOBBER-1 (audit §8)
epoch.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/epoch/ — core-tests/mem/epoch/ — the read and write surface is covered. Epoch advance goes through the same scalar cell and atomic operations that current_epoch and the reset helper use, so a test-driven advance and a real one cannot diverge.
allocator.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/allocator/ — static-shape + live cbgr_alloc round-trip via public Heap<T> / Shared<T> (audit §A closed); §B realloc-cross-boundary + §C ctx-allocator + §D protocol-impls + §E AllocStats + §F AOT sweep open
arena.vr🟢 Theorem (v0.1)partial🟢 fullcore-tests/mem/arena/ — core-tests/mem/arena/ — the constants, the ArenaConfig constructors (default, fixed, custom), a disjointness sweep over the four ArenaError variants and the message payload of each are all covered, as is the live allocation lifecycle.
segment.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/segment/ — Mimalloc-style 32 MiB chunks
heap.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/heap/ — thread-local fast path; live heap_alloc lifted via public Heap.new (audit §B closed); HeapError 7-variant + HeapStats 8-field surface exhausted + From<SegmentError> lift covered (audit §D closed)
diagnostics.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/diagnostics/ — read-only observer surface
cap_audit.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/cap_audit/ — capability transition events
cap_audit_ring.vr🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/cap_audit_ring/ — lock-free SPMC ring
mem_raw.vr (in core.intrinsics.runtime)🟢 Theorem (v0.1)--interp ✓ · --aot ✓🟢 fullmemcpy_addr/memmove_addr/memset_addr/memcmp_addr/strlen/strcmp — see core-tests/intrinsics/
mod.vr (module root)🟢 Theorem (v0.1)--interp ✓ · --aot pending🟢 fullcore-tests/mem/mod/ — module-root types (UseAfterFreeError, RevocationError, CbgrTier), execution-tier accessors and umbrella re-exports through mount core.mem.{Name} have interpreter coverage. The audit records the field-layout and capability-binding checks.

The dedicated-suite-pending modules are tracked in core-tests/INVENTORY.md; new modules graduate to --interp ✓ · --aot ✓ once all four test files land and the audit deferrals all close on both tiers.

Coverage boundaries​

A focused iterator control has matching interpreter and native results:

let mut s: Int = 0;
for x in xs.iter() { s = s + *x; }

This does not establish every iterator-adaptor or constructor path. The reference tiers, Heap<T>, arena and epoch surfaces have interpreter conformance suites; native coverage must be established for the specific API being used. See the linked per-module audits for their test scope.

File-by-file API surface​

FileWhat's in it
allocator.vrAllocator protocol, cbgr_alloc/cbgr_dealloc/cbgr_realloc, Layout, AllocError
header.vrAllocationHeader (32-byte CBGR metadata), MemValidationError / ValidationError alias, FLAG_* bits
thin_ref.vrThinRef<T> (16 bytes)
fat_ref.vrFatRef<T> (32 bytes)
hazard.vrHazardGuard<T> — concurrent-safe deref protection
epoch.vrEpochManager — generation wraparound safety
capability.vrCapability bits — read/write/execute/delegate/revoke/borrowed/mutable/no-escape; pack_epoch_caps / unpack_*
arena.vrGenerationalArena<T> — O(1) mass invalidation
segment.vrSegment — 32 MiB virtual regions, mimalloc-style
size_class.vr73-bin size class table (Mimalloc-style); size_to_bin / bin_to_size / aligned_size
heap.vrLocalHeap — thread-local allocation
diagnostics.vrRead-only MemHeaderView observer surface; live_allocations
cap_audit.vrCapEvent capability-transition event type
cap_audit_ring.vrLock-free SPMC ring buffer for CapEvent; record_revoke / record_attenuate / record_ref_* / record_gen_bump
mem_raw.vr (in core.intrinsics.runtime, not core.mem)memcpy_addr, memmove_addr, memset_addr, memcmp_addr, strlen, strcmp — all address-taking

References — three tiers​

ThinRef<T> — 16 bytes​

@repr(C, size(16), align(8))
public type ThinRef<T> is {
ptr: &unsafe T,
generation: UInt32,
epoch_and_caps: UInt32, // bits 0-15: epoch; bits 16-31: capability flags
};

Used for &T when T: Sized. The generation and epoch_and_caps are fixed at reference creation; the CBGR check compares them against the allocation's AllocationHeader on every deref.

The packed halves are epoch low, capabilities high — pack_epoch_caps (capability.vr) is ((caps as UInt32) << 16) | (epoch as UInt32). Read them with unpack_epoch / unpack_caps, or with the .epoch() / .capabilities() accessors on the reference, rather than shifting by hand.

FatRef<T> — 32 bytes​

@repr(C, size(32), align(8))
public type FatRef<T> is {
ptr: &unsafe Byte, // erased to Byte; the element type stays in `T`
generation: UInt32,
epoch_and_caps: UInt32, // bits 0-15: epoch; bits 16-31: capabilities
metadata: Int, // slice length, dyn-protocol vtable pointer, etc.
offset_from_base: UInt32, // non-zero for interior references
reserved: UInt32, // padding + room for future fields
};

Used when T is unsized — slices ([T]) and protocol objects (dyn P) — and for interior references that need an offset into a larger allocation.

AllocationHeader — 32 bytes, cache-aligned​

@repr(C, align(32))
public type AllocationHeader is {
size: UInt32, // offset 0 — payload bytes, header excluded
alignment: UInt16, // offset 4 — requested align (validated <= 4096)
base_offset: UInt16, // offset 6 — header address - malloc base
generation: UInt32, // offset 8 — atomic; bumped on free (HOT)
epoch_and_caps: UInt32, // offset 12 — atomic; epoch low, caps high
type_id: UInt32, // offset 16
flags: UInt32, // offset 20
ref_count: UInt32, // offset 24 — atomic; an allocation starts at 1
total: UInt32, // offset 28 — front slack + header + payload
};

Exactly 32 bytes, 32-byte aligned, and the field order IS the byte order — ALLOCATION_HEADER_EPOCH_OFFSET (12) and _CAPABILITIES_OFFSET (14) index into epoch_and_caps directly on a little-endian target.

Prepended to every CBGR-tracked allocation. The header lives in the same cache line as (or adjacent to) the object, so the CBGR check is typically a hot L1 hit.

CBGR check sequence (conceptual)​

fn deref<T>(r: ThinRef<T>) -> &T {
let hdr = header_of(r.ptr);
if hdr.generation != r.generation {
handle_use_after_free(&r, &hdr);
}
if unpack_epoch(r.epoch_and_caps) != unpack_epoch(hdr.epoch_and_caps) {
handle_epoch_mismatch(&r, &hdr);
}
unsafe { &*r.ptr }
}

Measured: 1.2–1.7 ns on the production_targets bench (x86_64 release build), well under the ≤ 15 ns design target.


Heap<T> — unique owned allocation​

Heap<T> and Shared<T> are declared in core.base.memory, not in core.mem — mount them from core.base (mount core.base.{Heap, Shared};). They are documented here because their layout is the CBGR triple (ptr, generation, epoch) and every operation on them goes through core.mem's allocator.

Heap.new(value) -> Heap<T> // panics on OOM
Heap.new_default() -> Heap<T> // T: Default
Heap.new_zeroed() -> Heap<T>
Heap.try_new(value) -> Result<Heap<T>, AllocError>
Heap.from_raw(ptr) -> Heap<T> // unsafe

Introspection​

h.as_ref() -> &T h.as_mut() -> &mut T
h.into_inner() -> T h.into_raw() -> &unsafe T (leaks)
h.leak() -> &mut T // leaks; returns static-lifetime mut ref
h.generation() -> UInt32
h.epoch() -> UInt16
h.capabilities() -> UInt16
h.is_valid() -> Bool
h.is_allocated() / h.is_freed() -> Bool
h.header_generation() / h.header_epoch() / h.header_size()
h.current_epoch() -> UInt16 // the header's epoch, not the reference's

Implements​

Deref, DerefMut, Drop, Clone (if T: Clone), Debug, Display (both if T is), Eq, Ord, Hash, Default (if T: Default).

The declared Heap<T>.clone() implementation allocates a new Heap containing the result of T.clone(). The new allocation does not make that result recursively independent: nested references and Shared handles retain the sharing specified by their own clone contracts. See copy depth; T: Clone alone does not promise a deeply isolated snapshot or support in every backend.


Shared<T> — atomically ref-counted​

The counts and the unwrap are methods on the value, not associated functions taking a reference — there is no Shared.strong_count(&s).

Shared.new(value) -> Shared<T>
Shared.try_new(value) -> Result<Shared<T>, AllocError>
Shared.new_default() -> Shared<T> // T: Default

s.clone() -> Shared<T> // bumps refcount
s.downgrade() -> Weak<T> // does not bump the strong count
s.strong_count() -> Int
s.weak_count() -> Int
s.is_unique() -> Bool // strong == 1; says NOTHING about weak
s.get_mut() -> Maybe<&mut T> // Some when strong == 1 AND weak == 0
s.make_unique() -> &mut T // clone-on-write; T: Clone
s.try_unwrap() -> Result<T, Shared<T>> // Ok when unique
s.ptr_eq(&other) -> Bool // same allocation, not same value
s.borrow() -> &T s.borrow_mut() -> &mut T
s.generation() -> UInt32 s.epoch() -> UInt16

The two conditions are not the same one: after a single downgrade(), is_unique() is still true while get_mut() answers None. Checked — weak=0->1 with unique=true in the same line.

Weak<T> carries the same triple and does not keep the value alive:

Weak.from(&shared) -> Weak<T>
w.upgrade() -> Maybe<Shared<T>> // Some while the target is live
w.is_alive() -> Bool
w.strong_count() -> Int w.weak_count() -> Int

Allocator protocol​

type Allocator is protocol {
fn alloc(&self, size: Int, align: Int)
-> Result<(&unsafe Byte, UInt32, UInt16), AllocError>;
fn alloc_zeroed(&self, size: Int, align: Int)
-> Result<(&unsafe Byte, UInt32, UInt16), AllocError>;
fn dealloc(&self, ptr: &unsafe Byte) -> Result<(), AllocError>;
fn realloc(&self, ptr: &unsafe Byte, old_size: Int, new_size: Int,
align: Int)
-> Result<(&unsafe Byte, UInt32, UInt16), AllocError>;
};

Two things about that signature are load-bearing:

  • the protocol takes size and align separately, not a Layout — Layout is a helper type used by container code, not part of this protocol;
  • alloc returns a triple (ptr, generation, capabilities), not a bare pointer. Those are exactly the three fields a ThinRef needs, so a caller builds a reference from the allocator's own answer instead of reading the header back.

GlobalAllocator is the default implementation; it forwards to cbgr_alloc and friends below.

Layout​

type Layout is {
size_: Int, // bytes
align_: Int, // bytes (power of 2)
};

implement Layout {
fn new<T>() -> Layout // T.size, T.alignment
fn from_size_align(size: Int, align: Int) -> Layout // panics on bad input
fn try_from_size_align(size: Int, align: Int)
-> Result<Layout, AllocError> // fallible
fn from_size(size: Int) -> Layout // natural alignment
fn size(&self) -> Int
fn align(&self) -> Int
fn repeat(&self, n: Int) -> Layout // [T; N] layout
fn try_repeat(&self, n: Int) -> Result<Layout, AllocError>
fn extend(&self, other: Layout) -> Layout // sequential layout
fn try_extend(&self, other: Layout) -> Result<Layout, AllocError>
}

from_size_align panics on invalid alignment (non-positive or non-power-of-2) or negative size — use try_from_size_align at any trust boundary (FFI, deserialised input).

AllocError​

type AllocError is
| OutOfMemory { requested: Int }
| InvalidSize { size: Int }
| InvalidAlignment { alignment: Int }
| MmapFailed { code: Int }
| MunmapFailed { code: Int }
| PageExhausted
| InvalidPointer
| CapacityOverflow { requested: Int }
| UnsupportedOs { op: Text }
;

e.message() -> Text // human-readable

Implements Display (routes via .message()), Debug, and Eq (per-variant; payload-bearing variants compare payloads).

Default allocator — cbgr_alloc​

fn cbgr_alloc(size: Int, align: Int)
-> Result<(&unsafe Byte, UInt32, UInt16), AllocError>
fn cbgr_alloc_zeroed(size: Int, align: Int)
-> Result<(&unsafe Byte, UInt32, UInt16), AllocError>
fn cbgr_dealloc(ptr: &unsafe Byte) -> Result<(), AllocError>
fn cbgr_realloc(ptr: &unsafe Byte, old_size: Int, new_size: Int, align: Int)
-> Result<(&unsafe Byte, UInt32, UInt16), AllocError>

cbgr_dealloc takes only the pointer — the size it needs is in the header it is about to invalidate, so there is no "free with the same layout you allocated with" obligation to get wrong.

Context-scoped allocator​

get_allocator() -> &dyn Allocator // context slot 1, else GlobalAllocator
set_context_allocator(allocator: &dyn Allocator)

ctx_alloc(size: Int, align: Int)
-> Result<(&unsafe Byte, UInt32, UInt16), AllocError>
ctx_alloc_zeroed(size: Int, align: Int)
-> Result<(&unsafe Byte, UInt32, UInt16), AllocError>
ctx_dealloc(ptr: &unsafe Byte) -> Result<(), AllocError>
ctx_realloc(ptr: &unsafe Byte, old_size: Int, new_size: Int, align: Int)
-> Result<(&unsafe Byte, UInt32, UInt16), AllocError>

Each ctx_* is a one-line forward to get_allocator(), which reads CONTEXT_SLOT_ALLOCATOR and falls back to GlobalAllocator.

Using a bump allocator for a task tree — MemStackAllocator is the Allocator implementor for that shape (GenerationalArena is a handle table, not an Allocator; the four implementors are GlobalAllocator, TieredAllocator, SimpleAllocator and MemStackAllocator):

let mut arena = MemStackAllocator.init(1 << 20)?;
provide Allocator = arena in {
build_parse_tree(source).await
};
arena.reset(); // O(1) — rewinds the bump offset for the next tree

Alignment​

fn align_up(value: Int, align: Int) -> Int // core.mem.allocator
fn align_down(value: Int, align: Int) -> Int // core.mem.allocator
fn aligned_size(size: Int, align: Int) -> Int // core.mem.size_class

There is no is_aligned(value, align) — test it as align_down(value, align) == value. The pointer-level predicates live in core.intrinsics.memory (ptr_is_aligned, ptr_is_aligned_to).


Hazard pointers​

type HazardGuard is { ... };
acquire_hazard(ptr: &unsafe Byte) -> HazardGuard
guard.release() // explicit drop also works
force_reclaim_all() // scan + reclaim retired nodes
cleanup_thread_hazards() // called on thread exit

Used internally to keep reads safe against a concurrent free. A reader installs its target in a hazard slot before the CBGR check; a freer scans all hazard slots before returning memory to the pool.

Observability — HazardStats​

type HazardStats is {
protected_count: Int, // currently protected pointers across all threads
retired_count: Int, // retired nodes awaiting reclamation
thread_count: Int, // registered threads
};

fn hazard_stats() -> HazardStats;

implement HazardStats {
fn needs_reclaim(&self) -> Bool {
self.retired_count >= RETIRED_THRESHOLD
}
fn estimated_retired_bytes(&self, avg_size: Int) -> Int {
self.retired_count * avg_size
}
}

Constants​

const HAZARD_POINTERS_PER_THREAD: Int = 8; // per-thread slot count
const RETIRED_THRESHOLD: Int = 64; // amortises scan cost
const MAX_THREADS: Int = 256; // global cap

Epoch manager​

One global manager, reached through the GLOBAL_EPOCH static rather than through a global() constructor:

static mut GLOBAL_EPOCH: EpochManager;

implement EpochManager {
fn current_epoch(&self) -> UInt64;
fn increment_epoch(&mut self) -> UInt64;
fn wraparound_count(&self) -> Int;
fn register_callback(&mut self, callback: EpochCallback) -> Int;
fn unregister_callback(&mut self, id: Int) -> Bool;
fn register_revocation_callback(&mut self, cb: RevocationCallback) -> Int;
fn unregister_revocation_callback(&mut self, id: Int) -> Bool;
}

// Module-level wrappers over the same static — prefer these.
fn current_epoch() -> UInt64
fn wraparound_count() -> Int
fn register_epoch_callback(callback: EpochCallback) -> Int
fn unregister_epoch_callback(id: Int) -> Bool
fn register_revocation_callback(callback: RevocationCallback) -> Int
fn unregister_revocation_callback(id: Int) -> Bool

type EpochCallback is fn(UInt64);
type RevocationCallback is fn(&unsafe Byte, UInt32, UInt32, UInt16);

Epochs are the safety net for 32-bit generation wraparound: a reference with a stale epoch fails the check even if the generation field collided. Two details a reader has to get right:

  • the counter is UInt64, but only its low 16 bits reach a reference — that is the EPOCH_MAX: UInt16 = 0xFFFF half of epoch_and_caps;
  • it advances when something calls increment_epoch(), not on a timer. DEFAULT_SYNC_INTERVAL = 1000 is a count of checks between reloads in the per-thread EpochCache, not milliseconds.
type EpochCache is { cached_epoch: UInt64, checks_since_sync: Int,
sync_interval: Int };

fn get_thread_epoch_cache() -> &mut EpochCache;
fn cached_epoch() -> UInt64;
fn invalidate_epoch_cache();

Capabilities​

// The raw bitflags, one per capability.
const CAP_READ: UInt16 = 1 << 0;
const CAP_WRITE: UInt16 = 1 << 1;
const CAP_EXECUTE: UInt16 = 1 << 2;
const CAP_DELEGATE: UInt16 = 1 << 3;
const CAP_REVOKE: UInt16 = 1 << 4;
const CAP_BORROWED: UInt16 = 1 << 5;
const CAP_MUTABLE: UInt16 = 1 << 6;
const CAP_NO_ESCAPE: UInt16 = 1 << 7;
const CAP_ALL: UInt16 = 0x00FF; // bits 8..15 are unassigned

// Named combinations, defined in terms of the bits above.
const CAP_READ_ONLY: UInt16 = CAP_READ;
const CAP_READ_WRITE: UInt16 = CAP_READ | CAP_WRITE;
const CAP_OWNED: UInt16 = CAP_READ | CAP_WRITE | CAP_DELEGATE
| CAP_REVOKE | CAP_MUTABLE;
const CAP_BORROW_SHARED: UInt16 = CAP_READ | CAP_BORROWED;
const CAP_BORROW_MUT: UInt16 = CAP_READ | CAP_WRITE | CAP_BORROWED
| CAP_MUTABLE;
const CAP_EXECUTABLE: UInt16 = CAP_READ | CAP_EXECUTE;

// And the type-safe wrapper over the same eight bits.
type Capability is
| Read | Write | Execute | Delegate
| Revoke | Borrowed | Mutable | NoEscape
;

implement Capability {
fn to_bit(&self) -> UInt16;
}

Embedded in the high 16 bits of the epoch_and_caps field of references — unpack_caps is (epoch_and_caps >> 16) as UInt16. Database with [Read] compiles to a reference with only CAP_READ set; attempts to call a write method hit a compile-time check against the method's required capability set.


GenerationalArena​

A bump arena over bytes, not a slotmap: it is not generic, it hands back offsets rather than typed handles, and there is no ArenaHandle, insert, get or remove. Nothing is freed individually — reset() bumps one shared generation and invalidates every outstanding reference into the arena at once.

type GenerationalArena is {
buffer: Int, capacity: Int, used: Int,
generation: Int, alloc_count: Int, reset_count: Int,
config: ArenaConfig,
};

GenerationalArena.new(capacity: Int) -> GenerationalArena
GenerationalArena.with_config(config: ArenaConfig) -> GenerationalArena

a.alloc(size: Int) -> Int // address; 0 on failure
a.alloc_aligned(size: Int, alignment: Int) -> Int
a.reset() // O(1) mass invalidation via a generation bump
a.destroy()

a.generation() -> Int a.used() -> Int a.capacity() -> Int
a.remaining() -> Int a.alloc_count() -> Int a.reset_count() -> Int
a.is_destroyed() -> Bool a.contains_ptr(ptr: Int) -> Bool

// Save and roll back a bump position within one generation.
type ArenaSnapshot is { used: Int, alloc_count: Int, generation: Int };
a.snapshot() -> ArenaSnapshot
a.restore(snap: ArenaSnapshot) -> Bool // false if reset() intervened

restore rolls the bump pointer back without bumping the generation, so references into the rolled-back span become address-invalid but stay generation-valid — the CBGR check passes and reads bytes the arena has since re-handed out. reset() is the one that makes a stale reference detectable. GenerationalArena also has no Drop impl: the buffer goes back on an explicit destroy(), never by going out of scope.

GenerationalArena does not implement Allocator — to make one the ambient allocator for a scope, use MemStackAllocator (see the context-scoped allocator).

Arenas are the idiomatic choice for:

  • AST trees (parser lifetimes)
  • Game engine objects (frame-scoped)
  • Request-scoped data (web-server tasks)

Configuration — ArenaConfig​

type ArenaConfig is {
initial_capacity: Int, // bytes
max_capacity: Int, // bytes; 0 = no limit
growth_factor: Int, // percentage; 200 = double on growth
};
constructorinitial / max / growth
ArenaConfig.default()64 KiB / 256 MiB / 2×
ArenaConfig.fixed(capacity: Int)capacity / capacity / no-grow (100%)
ArenaConfig.custom(initial, max, growth)

Errors — ArenaError​

type ArenaError is
| OutOfMemory { requested: Int, available: Int }
| ExceedsMaxCapacity { requested: Int, max: Int }
| InvalidAlignment { alignment: Int }
| AlreadyDestroyed
;

e.message() -> Text // human-readable formatter

Implements Display (routes via .message()) and Debug (renders the variant + payload braces).

Constants​

const DEFAULT_ARENA_CAPACITY: Int = 65_536; // 64 KiB
const MAX_ARENA_CAPACITY: Int = 268_435_456; // 256 MiB
const DEFAULT_GROWTH_FACTOR: Int = 200; // 200% = double
const ARENA_ALIGNMENT: Int = 8; // 64-bit word
const ARENA_GEN_INITIAL: Int = 1; // matches CBGR GEN_INITIAL

Segment allocator (internal)​

// Partitioning constants
const SEGMENT_SIZE: Int = 32 * 1024 * 1024; // 32 MiB
const SLICE_SIZE: Int = 64 * 1024; // 64 KiB
const SLICES_PER_SEGMENT: Int = 512; // 32 MiB ÷ 64 KiB
const SEGMENT_ALIGN: Int = SEGMENT_SIZE;
const SMALL_PAGE_SIZE: Int = SLICE_SIZE;
const MEDIUM_PAGE_SIZE: Int = 8 * SLICE_SIZE; // 512 KiB
const LARGE_PAGE_THRESHOLD: Int = MEDIUM_PAGE_SIZE;

// Slice state bytes
const SLICE_FREE: UInt8 = 0;
const SLICE_USED: UInt8 = 1;
const SLICE_SPAN_START: UInt8 = 2;
const SLICE_SPAN_CONTINUE: UInt8 = 3;

// Segment kinds
const SEGMENT_NORMAL: UInt8 = 0;
const SEGMENT_HUGE: UInt8 = 1;

type Segment is MemSegment; // alias
type SegmentError is
| MmapFailed { code: Int }
| MunmapFailed { code: Int }
| OutOfMemory
| UnsupportedOs { op: Text }
;

fn segment_alloc(thread_id: UInt64) -> Result<&mut MemSegment, SegmentError>;
fn segment_free(seg: &mut MemSegment);
fn segment_abandon(seg: &mut MemSegment);
fn ptr_to_segment(ptr: &unsafe Byte) -> &MemSegment;

Allocations are grouped into 73 size classes spaced at ~12.5% intervals (see size_class.vr). Small objects come from thread-local segments via segment_alloc; medium / large allocations are bookkept separately.


LocalHeap​

type LocalHeap is { ... };

// Lifecycle
init_thread_heap() -> Result<(), HeapError>
shutdown_thread_heap()
get_heap() -> &mut LocalHeap // lazy-init on first call

// Allocation (Tier-0: through CBGR `cbgr_alloc` rather than directly)
heap_alloc(size: Int) -> Result<(&unsafe Byte, UInt32, UInt16), HeapError>
heap_alloc_zeroed(size: Int) -> Result<(&unsafe Byte, UInt32, UInt16), HeapError>
heap_free(ptr: &unsafe Byte) -> Result<(), HeapError>
heap_free_validated(ptr: &unsafe Byte, gen: UInt32, caps: UInt16)
-> Result<(), HeapError>

// Observability
get_heap_stats() -> HeapStats

heap_alloc returns (ptr, generation, capabilities) — the generation is the CBGR header.generation at allocation time, and the capabilities are the bitflags the allocator deemed safe for the slot. Callers typically don't invoke this directly: the public Heap<T> / Shared<T> constructors are the user-facing API.

HeapStats​

type HeapStats is {
alloc_count: UInt64,
dealloc_count: UInt64,
bytes_allocated: UInt64,
bytes_freed: UInt64,
live_count: UInt64,
live_bytes: UInt64,
pages_in_use: UInt32,
segments_owned: UInt32,
};

HeapStats.new() -> HeapStats // every field zero — bootstrap initialiser

Errors — HeapError​

type HeapError is
| OutOfMemory
| PageExhausted
| InvalidPointer
| InvalidSize { size: Int }
| InvalidAlignment { alignment: Int }
| SegmentError { inner: SegmentError } // From<SegmentError>
| UseAfterFree
;
e.message() -> Texthuman-readable
HeapError.from(seg_err: SegmentError) -> HeapErrorFrom impl

Implements Display (routes via .message()), Debug, and Eq (per-variant; payload-bearing variants compare payloads).

Constants​

const DIRECT_LOOKUP_SIZE: Int = 129; // wsize 0..128 (lock-free fast path)
const PAGE_HEADER_SIZE: Int = 128; // declared in size_class.vr,
// where blocks_per_page uses it

const PAGE_FLAG_IN_FULL_QUEUE: UInt16 = 0x0001;
const PAGE_FLAG_HAS_ALIGNED: UInt16 = 0x0002;
const PAGE_FLAG_ZERO_INIT: UInt16 = 0x0004;

Thread-local heap. Lock-free fast path; spills into the global heap for cross-thread frees.


CBGR error types​

The reference-validation surface returns two error sum types:

UseAfterFreeError — 5-field record​

type UseAfterFreeError is {
expected_gen: UInt32,
actual_gen: UInt32,
expected_epoch: UInt16,
actual_epoch: UInt16,
type_name: Text,
};

implement UseAfterFreeError {
fn new(eg: UInt32, ag: UInt32, ee: UInt16, ae: UInt16,
tn: Text) -> UseAfterFreeError;
// `null_pointer` sets both gens to GEN_UNALLOCATED — `.message()`
// routes through the "null pointer" branch.
fn null_pointer(type_name: Text) -> UseAfterFreeError;
fn capability_violation(capability: Text, type_name: Text)
-> UseAfterFreeError;
fn message(&self) -> Text; // null-pointer / use-after-free branches
}

Implements Display (routes via .message()), Debug, and Eq (field-by-field compare).

RevocationError — 4-variant sum​

type RevocationError is
| NullPointer { type_name: Text }
| CapabilityViolation { type_name: Text }
| AlreadyRevoked { type_name: Text }
| Internal { type_name: Text, reason: Text }
;

implement RevocationError {
fn null_pointer(type_name: Text) -> RevocationError;
fn capability_violation(type_name: Text) -> RevocationError;
fn already_revoked(type_name: Text) -> RevocationError;
fn internal_error(type_name: Text, reason: Text) -> RevocationError;
}

Capability audit ring​

The CBGR system records every capability-state transition as a CapEvent and commits it into a lock-free single-producer / multi- consumer ring (cap_audit_ring.vr). Observers — panic post-mortem handlers, runtime monitors, future debugger UIs — read recent events via recent(n).

CapEventKind — 6-variant tag​

type CapEventKind is
| Revoke // capability revoked (e.g., write→read)
| Attenuate // capability narrowed (subset retained)
| RefIncr // reference count increment
| RefDecr // reference count decrement
| GenBump // generation field bumped (free path)
| EpochAdvance // wraparound-safety epoch bump
;

CapEvent — 8-field record​

type CapEvent is {
kind: CapEventKind,
seq: UInt64, // ring-assigned on commit; 0 = un-committed
ptr_id: UInt64, // CBGR user-pointer address, as a stable id
generation_before: UInt32,
generation_after: UInt32, // equal when the event does not bump it
capabilities_before: UInt16,
capabilities_after: UInt16, // equal when the event does not touch caps
timestamp_ns: UInt64, // monotonic clock; 0 when unsupported
};

The last field is a timestamp, not an epoch — the ring records when a transition happened, and the epoch is not part of the event.

CapEvent.new(kind, ptr_id, gen_before, gen_after, caps_before, caps_after, timestamp_ns) -> CapEventreturns seq=0
event.bumped_generation() -> Booltrue for Revoke + GenBump

bumped_generation() is kind-driven, not diff-driven: it returns true iff the kind is Revoke or GenBump, regardless of whether the before/after generation values happen to differ. This intent-based semantics matches the doc-comment contract and pinned in the audit suite.

Ring API​

const CAP_AUDIT_RING_CAPACITY: Int = 256; // power of 2 for efficient mod

is_enabled() -> Bool
enable() // idempotent
disable()
count() -> UInt64 // total commits since enable
recent(n: Int) -> List<CapEvent> // bounded by min(n, ring fill)

// Each writer takes only the fields its own kind can change, and each
// returns the commit sequence (0 when the ring is disabled).
record_revoke(ptr_id: UInt64, gen_before: UInt32, gen_after: UInt32,
caps: UInt16, timestamp_ns: UInt64) -> UInt64
record_attenuate(ptr_id: UInt64, generation: UInt32,
caps_before: UInt16, caps_after: UInt16,
timestamp_ns: UInt64) -> UInt64
record_ref_incr(ptr_id: UInt64, generation: UInt32, caps: UInt16,
timestamp_ns: UInt64) -> UInt64
record_ref_decr(ptr_id: UInt64, generation: UInt32, caps: UInt16,
timestamp_ns: UInt64) -> UInt64
record_gen_bump(ptr_id: UInt64, gen_before: UInt32, gen_after: UInt32,
caps: UInt16, timestamp_ns: UInt64) -> UInt64
record_epoch_advance(epoch_before: UInt16, epoch_after: UInt16,
timestamp_ns: UInt64) -> UInt64 // global; ptr_id = 0

commit(event: CapEvent) -> UInt64 // the writers' shared tail

When the ring is disabled, every record_* writer is a short-circuit no-op — count() does not advance, no allocation, no atomic. This keeps audit-disabled production builds zero-overhead.


Read-only diagnostics​

core.mem.diagnostics is the introspection surface — no mutating operations. Used by panic post-mortem handlers, runtime monitors, and future debugger integrations.

MemHeaderView — snapshot of an allocation header​

type MemHeaderView is {
generation: UInt32,
epoch: UInt16, // low 16 bits of the header's epoch_and_caps
capabilities: UInt16, // high 16 bits of the same word
size: UInt32,
alignment: UInt32,
type_id: UInt32,
flags: UInt32,
ref_count: UInt32,
};

MemHeaderView.from_header(header: &AllocationHeader) -> MemHeaderView

CallFrame — stack-trace entry​

type CallFrame is {
function: Text, // empty when symbolication failed
file: Text, // empty when the source map missed
line: Int, // 1-indexed; 0 = unknown
column: Int, // 1-indexed; 0 = unknown
instruction_pointer: UInt64, // 0 is the "no frame" sentinel
};

implement CallFrame {
fn from_ip(ip: UInt64) -> CallFrame; // unsymbolicated frame
}

Functions​

live_allocations() -> List<MemHeaderView>
live_allocation_count() -> Int
current_call_stack(skip: Int) -> List<CallFrame>

The producer-side wiring (writing MemHeaderView snapshots from the allocator on each cbgr_alloc, populating the call-stack from VBC debug info) is owned by the CBGR allocator and the interpreter debugger — those are tested separately.


Raw memory operations​

There are two families, and they are not interchangeable.

core.intrinsics.memory — reference-taking, lowered to the LLVM intrinsic of the same name:

fn memcpy(dst: &mut Byte, src: &Byte, count: USize)
fn memmove(dst: &mut Byte, src: &Byte, count: USize) // overlap-safe
fn memset(dst: &mut Byte, val: Byte, count: USize)
fn memcmp(a: &Byte, b: &Byte, count: USize) -> Int

core.intrinsics.runtime.mem_raw — address-taking, with an interpreter fallback written in Verum, for code that holds a raw address rather than a reference:

fn memcpy_addr(dst: Int, src: Int, n: Int) -> Int
fn memmove_addr(dst: Int, src: Int, n: Int) -> Int // overlap-safe
fn memset_addr(dst: Int, value: Int, n: Int) -> Int
fn memcmp_addr(a: Int, b: Int, n: Int) -> Int
fn strlen(s: Int) -> Int // NUL-terminated
fn strcmp(a: Int, b: Int) -> Int

Both bypass CBGR. Use only in allocator implementations, FFI boundaries, or when you can prove safety by other means.


Constants​

const GEN_INITIAL: UInt32 = 1;
const GEN_MAX: UInt32 = 0xFFFF_FFFE;
const GEN_UNALLOCATED: UInt32 = 0;

const EPOCH_MAX: UInt16 = 0xFFFF; // core.mem.epoch
const DEFAULT_SYNC_INTERVAL: Int = 1000; // epoch-cache resync period

const SSO_CAPACITY: Int = 23; // core.text — inline capacity
const PAGE_SIZE: Int = 65536; // core.mem.allocator — 64 KiB,
// i.e. 16 OS pages of 4 KiB

PAGE_SIZE here is the allocator's page, not the OS page; the 4096-byte one is core.sys.common.PAGE_SIZE (a USize).


Errors​

The three error types are declared once each, above: UseAfterFreeError and RevocationError under CBGR error types, AllocError under the allocator protocol.

On a CBGR violation, the runtime:

  1. Constructs a UseAfterFreeError with full diagnostic context.
  2. Invokes the installed panic handler (default: abort with stack trace).

CBGR execution tiers​

core.mem's own selector is CbgrTier — four variants, not two, and there is no ExecutionMode or current_mode():

type CbgrTier is
| Interpreter // full validation
| BaselineJit
| OptimizingJit
| Aot // minimal validation
;

fn get_execution_tier() -> CbgrTier;
fn set_execution_tier(tier: CbgrTier);

The runtime probes live in core.intrinsics.runtime.tier:

fn is_interpreted() -> Bool // true only under the VBC interpreter
fn get_tier() -> UInt8 // 0 = VBC, 1 = JIT baseline,
// 2 = JIT optimized, 3 = AOT

Under AOT both are compile-time constants. The tier affects how the CBGR check is produced, not whether it runs:

  • Interpreter: software check every deref, via the VBC Deref / DerefMut opcodes — the safe-by-default path, because the interpreter validates every reference regardless of the reference's static CBGR tier.
  • AOT: each CBGR tier lowers to a distinct code sequence in LLVM IR. Tier-0 references emit the load-compare-branch pattern; tier-1 references proven safe by escape analysis are elided to a direct load (0 ns); tier-2 &unsafe T references compile to a direct load with no check.

There is no JIT tier in between; a Verum program runs either in the interpreter or as AOT-compiled native code. CbgrTier.BaselineJit, CbgrTier.OptimizingJit and the 1 / 2 of get_tier are reserved names that nothing selects or returns today — treat a non-zero tier as "compiled", not as "JIT-compiled".


Cross-references​