Memory Model
Kathryn2’s memory model can be stated in one sentence: the arena owns
everything, everyone else holds Copy handles. This page spells out the
rules that follow from it, and the one idiom — take/replace-back — that makes
the borrow checker happy without Rc, RefCell, or unsafe.
Ownership rules
Section titled “Ownership rules”- One owner per object: the arena. No model object stores another model
object by value or by
Box. Cross-references are always*Identhandles (Copy). See the Ident pattern. - No
Rc/Arc/RefCellfor model state. All mutability is mediated by&mut ModelArena. There is exactly one road to any object, so there is never a second borrow to fight. - No raw pointers. When porting C++ code, translate any
T*-style reference into aTIdenthandle.
Because ownership is centralised, “who frees this?” and “is this still alive?” have mechanical answers: the arena frees it, and the generation check tells you (see below).
The lifetime / re-borrow problem
Section titled “The lifetime / re-borrow problem”The single-owner design creates one recurring borrow conflict. A method that mutates an object and needs the arena to do its work would require two overlapping mutable borrows:
// ✗ does not compile: reg borrows arena mutably, then we pass arena againlet reg = arena.get_reg_mut(reg_i);reg.build_update_event(arena);The codebase’s answer is the take/replace-back idiom:
let mut reg = arena.take_reg(reg_i);reg.build_update_event(arena); // arena is freely usable herearena.replace_back_reg(reg);A slot moves through these states — a take reserves it (not a free), while free/reuse bumps the generation:
stateDiagram-v2
[*] --> Occupied : add / insert
Occupied --> TakenOut : take, slot holds T default
TakenOut --> Occupied : replace_back, same index and generation
Occupied --> Freed : free bumps generation
Freed --> Occupied : insert reuses slot, bumps generation again
note right of TakenOut
get / get_mut / free on a
taken slot is a debug_assert bug
end note
take_reg moves the value out of its slot, ending the borrow of the arena
entirely. While the value is out, &mut ModelArena is available to any inner
call. replace_back_reg moves it back. This replaces both the C++
raw-pointer anti-pattern and the Rc<RefCell<T>> workaround while keeping
the single-owner invariant and full borrow-checker visibility.
A production example — gen_asm_node in
src/model/hw_component/arena_impl_hwc.rs, which lets a destination HCP
build its own assignment node while the arena stays usable:
pub fn gen_asm_node(&mut self, des_i : HcpIdent , src_i : HcpIdent, des_slice: Option<Slice>, src_slice: Slice ,) -> (NcpIdent, AsmResize) { let (src_i, src_slice, resize) = self.sanitize_asm_src(des_i, src_i, des_slice, src_slice); let des = self.take_hcp(des_i); // move out let node_i = des.do_asm(src_i, des_slice, src_slice, self); // arena free self.replace_back_hcp(des); // move back (node_i, resize)}How take/replace-back works underneath
Section titled “How take/replace-back works underneath”ArenaNode<T> (src/common/arena_base.rs) implements the checked-out state:
pub fn temp_take(&mut self) -> T where T: Default { debug_assert!(!self.is_temp_taken, "ArenaNode::temp_take called on already taken slot"); self.is_temp_taken = true; std::mem::take(&mut self.value) // slot holds T::default() meanwhile}
pub fn temp_replace_back(&mut self, value: T) { debug_assert!(self.is_temp_taken, "ArenaNode::temp_replace_back called on a slot that was not taken"); self.is_temp_taken = false; self.value = value;}Consequences you must respect:
- Every arena-stored type needs
Default— the slot holdsT::default()while the value is out. - The slot stays reserved. A take is not a free; the handle remains
valid, and
replace_backrestores the original value at the same index and generation. - Double-take is a bug, caught by
debug_assert!viais_temp_taken, as is callingget/get_mut/freeon a taken slot. If code re-enters the arena for an object that is currently taken, restructure the call so the object is passed down or replaced back first. - Always replace back on every path. Take/replace-back pairs should
bracket a short, panic-free region; an early
returnbetween them leaves a default value in the slot.
Why typed get_mut was removed
Section titled “Why typed get_mut was removed”ModelArena deliberately has no get_<thing> /
get_<thing>_mut methods (a policy set in the
CRUD conventions):
- a typed
get_mutpins&mut ModelArenafor as long as the reference lives, so the very common “mutate X using the arena” shape cannot be written — take/replace-back can express everythingget_mutcan, but not vice versa; - one uniform access pattern across all object types keeps call sites predictable and greppable.
The read-only trait-object accessors (get_hcp_assign, get_ue_common, …)
are the narrow exception, covered in Dispatch.
Generation safety
Section titled “Generation safety”ArenaHandle carries a generation: u32. ArenaGroup::free bumps the
slot’s generation before pushing the index onto the free list, so every
handle taken earlier fails the generation assert in get/get_mut/take:
assert_eq!(node.generation(), handle.generation, "ArenaHandle generation mismatch");Slot reuse via free_slots is automatic, and the recycling insert bumps the
generation again — a stale handle can never alias a slot’s new occupant. The
default handle (generation = u32::MAX) never matches anything, so
uninitialised idents fail fast too. Details in
The Model Arena.
Reset — reusing the process for a new design
Section titled “Reset — reusing the process for a new design”ModelArena::reset (src/model/arena_impl.rs) re-initialises every
ArenaGroup and clears all build state (top_module, module_trace_stack,
hcp_pending_buffer, flow_block_init_stack). After a reset, every
previously issued ident is dead — its handle points into a rebuilt group and
will panic on use.
The Python session layer builds one process-wide arena at import and exposes
reset() for rebuilding from scratch; build_flow() itself is not
re-runnable on the same arena (it asserts a fresh top start node), so the
cycle is always reset → declare → gen_flow → build_flow.
flowchart LR
RESET["reset<br/>re-init every ArenaGroup + clear build state"] --> DECL["declare<br/>make_* / mk_*"]
DECL --> GEN["gen_flow"]
GEN --> BUILD["build_flow<br/>asserts a fresh top start node"]
BUILD -.->|"reuse process for a new design<br/>old idents now dead"| RESET
Global ID allocation
Section titled “Global ID allocation”Identity is decoupled from storage. IdentBase::global_id comes from a
process-wide atomic counter in src/model/common/identifier.rs:
static GLOBAL_MODEL_ID: AtomicU64 = AtomicU64::new(0);// in IdentBase::newglobal_id: GLOBAL_MODEL_ID.fetch_add(1, Ordering::Relaxed),It increments forever and never resets across reset() — so objects from
different designs in one process can never collide in name or identity
(unique names embed the ID, e.g. REG_counter_42). Treat the value as
opaque: it is fine for equality, hashing, and debugging, but nothing may
depend on its absolute value or density.
Ownership at the boundaries
Section titled “Ownership at the boundaries”The same single-owner discipline extends past the core:
- Python bindings —
PyModelArenais#[pyclass(unsendable)]: single-threaded by design, mirroring the arena being the sole owner. Python wrappers hold only idents; the arena never crosses into Python object graphs. See Python Layer. - Verilog backend —
BackendVerilogtakes the whole arena by value; the Python wrapper moves it out withstd::mem::replace(&mut arena.arena, ModelArena::new()), leaving the session arena empty. The backend is the model’s final consumer, so transferring ownership (rather than borrowing) makes that explicit. - Backend
impl ModelArenaextensions (e.g.src/backends/verilog/arena_ext_vb.rs) must compose the publictake_*/replace_back_*methods — thepub(super)groups are out of reach outsidesrc/model/, by design.