Milestone 0: The Runtime Mental Model
This is the most important chapter in the overview and the only milestone with no code. It gives you twelve claims about how language runtimes work. Each one is a place where beginners hold a subtly wrong model, and each wrong model produces a specific, predictable bug later.
Read it slowly. Argue with it. Then come back after Section 3 and read it again — half of it will have changed meaning.
Milestone 0 is complete when you can draw the map from memory and state all twelve claims.
The One-Sentence Model
A language runtime is a machine for turning a description of a computation into the computation, by moving information out of the description and into data structures, one representation at a time.
Every layer you build deletes something from the description and creates something in the machine. The lexer deletes whitespace and creates tokens. The parser deletes syntax and creates structure. The compiler deletes names and creates slot numbers. The VM deletes the structure entirely and creates values on a stack. Understanding a runtime means knowing, at every layer, what was just thrown away and what replaced it.
Claim 1: A program is a sequence of representations, and each one deliberately loses information
TEXT "local x = 10 + 20"
│ loses: nothing. This is the ground truth.
▼
TOKENS [Local, Ident("x"), Equal, Int(10), Plus, Int(20), Eof]
│ loses: whitespace, comments, exact formatting.
│ gains: classification, and a SPAN on each token so we can point back.
▼
AST LocalDecl { name: "x", init: Binary(Add, Int(10), Int(20)) }
│ loses: parentheses, token order, syntax.
│ gains: STRUCTURE — precedence is now shape, not a rule.
▼
BYTECODE LOAD_CONST 0; LOAD_CONST 1; ADD; SET_LOCAL 0
│ loses: the name "x", the tree, nesting.
│ gains: a linear order, and slot numbers.
▼
VM STATE stack=[30] → slots=[30]
loses: the program. Only values remain.
gains: the answer.
Why it matters. Every debugging session in this curriculum starts with "which representation is
wrong?" If the tokens are wrong, do not read the parser. If the AST is right and the bytecode is
wrong, the bug is in exactly one file. Ember gives you a command to print each level — ember tokens, ember ast, ember disassemble, ember trace — and using them is not optional.
The wrong model: "the interpreter reads my code and does what it says." No. Nothing at run time
has ever seen your code. By the time anything runs, the source text is a String used only for
error messages.
How to see it: luac -l -l on any Lua file, right now. The names are in a side table for the
debugger; the instructions use numbers.
Claim 2: "The stack" means three different things, and confusing them is the most common bug in Section 3
┌────────────────────────────────────────────────────────────────────┐
│ 1. THE RUST CALL STACK │
│ Real machine stack. Grows when vm.rs calls a function. │
│ A tree-walking interpreter uses this for the SCRIPT's recursion │
│ too — which is why a deep script overflows it and ABORTS. │
├────────────────────────────────────────────────────────────────────┤
│ 2. THE VALUE STACK Vec<Value> inside the VM │
│ Where operands live during expression evaluation, AND where │
│ local variables live. These are the same array. That surprises │
│ everyone once. │
├────────────────────────────────────────────────────────────────────┤
│ 3. THE FRAME STACK Vec<CallFrame> inside the VM │
│ One entry per active Ember call: which function, where its │
│ slots start in the value stack, and the return address (ip). │
└────────────────────────────────────────────────────────────────────┘
A bytecode VM's win is partly that the script's recursion no longer consumes the Rust stack — a
recursive Ember function pushes a CallFrame onto a Vec, not a Rust stack frame. That is why the
VM can enforce a recursion limit and return a clean error where the tree walker would abort the
process. It is also why the tree walker in Section 2 needs its own depth counter, added in Lab 7,
before it can be called safe.
The wrong model: "local variables live in a hash map called the environment." They do in Lab 5, for one lab, precisely so you can feel how slow and how wrong-shaped that is. Then Section 3 fixes it and you never go back.
Claim 3: Names are a compile-time concept; slots are the runtime concept
SOURCE COMPILER'S VIEW RUNTIME'S VIEW
------ --------------- --------------
local a = 1 scopes: [ {a→0} ] stack: [1]
local b = 2 scopes: [ {a→0, b→1} ] stack: [1, 2]
do scopes: [ {a→0,b→1}, {} ] stack: [1, 2]
local c = 3 scopes: [ {a→0,b→1}, {c→2} ] stack: [1, 2, 3]
print(a + c) emits GET_LOCAL 0; GET_LOCAL 2 stack: [1, 2, 3, 1, 3]
end scope popped; emits POP stack: [1, 2]
The compiler keeps a stack of scopes mapping names to slot indices. It resolves a to 0 once,
at compile time, and emits GET_LOCAL 0. At run time there is no name, no map, and no search — a
local variable read is an array index. That is the largest single performance difference between a
tree-walking interpreter and a bytecode VM, and it is not about "compiled vs interpreted" at all.
Why it matters. When you benchmark in Section 7 and find that locals are ~free while globals are
a hash lookup, this is why. It is also why Lua programmers write local sin = math.sin at the top
of a hot loop — they are converting a global lookup into a slot read, by hand.
The wrong model: "compiled languages are fast because machine code is fast." Partly. But a huge share of the win in any compilation step — even to bytecode — is that name resolution happened once instead of a million times.
Claim 4: Values are small and copied; objects are large and shared through handles
#![allow(unused)] fn main() { // 16 bytes. Copy. Lives in registers, on the stack, inside arrays, everywhere. enum Value { Nil, Boolean(bool), Integer(i64), Float(f64), Str(GcRef<EmberStr>), // ← 8-byte handle, not the string Table(GcRef<Table>), // ← 8-byte handle, not the table Closure(GcRef<Closure>), // ... } }
A Value is always the same size and always cheap to copy. When it refers to something big, it
holds a handle — an index plus a generation counter — into the heap's slot table. Copying a
Value copies the handle, not the object. Two Values holding the same handle are the same
table, and mutating through one is visible through the other.
Value::Table(GcRef { index: 7, gen: 2 })
│
heap.slots ────────────┼──────────────────────────────────
[0] EmberStr "name" │
[1] Closure │
... ▼
[7] Table { array: [...], hash: {...} } gen[7] == 2 ✔ live
Why it matters. Aliasing is now a thing your language has, and users will trip over it:
local a = {1, 2, 3}
local b = a
b[1] = 99
print(a[1]) -- 99. Same table.
Tables have identity; numbers and strings have value semantics. That distinction, and where
it lives in your Value enum, is Section 2's core lesson.
The wrong model: "I'll use Rc<RefCell<Table>> and it'll be fine." It will be fine until a
table refers to itself, which takes one line of Lua, and then it leaks forever. That is Claim 7.
Claim 5: A call frame is a window onto the value stack, not a container
value stack (one Vec<Value>):
index: 0 1 2 3 4 5 6 7 8
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ fn │ 10 │ 20 │ fn │ 5 │ tmp │ ... │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
└──────── frame 0 ───────┘└──── frame 1 ─────┘
base = 0 base = 3
frames: [ CallFrame { closure: main, base: 0, ip: 42 },
CallFrame { closure: add, base: 3, ip: 7 } ]
A frame is three numbers: which function is running, where its slots begin, and where its
instruction pointer is. GET_LOCAL 1 inside frame 1 reads stack[frame.base + 1]. Calling pushes a
frame; returning pops one, truncates the stack back to base, and pushes the return value.
Why it matters. This is why:
- Recursion is cheap — a frame is 3 words, not a heap allocation.
- A stack trace is possible — walk
framesbackwards. - A "stack overflow" in your language is a
frames.len() > LIMITcheck, and can return an error instead of crashing. - Returning a reference to a local is meaningless — the slots are about to be truncated. Which sets up Claim 6.
Claim 6: Closures break stack discipline, and the heap is what you break it with
function make_counter()
local count = 0 -- a slot in make_counter's frame
return function() count = count + 1; return count end
end -- frame dies here. `count` does not.
The whole point of a stack is that frames die in reverse order of creation. A closure that outlives its defining frame violates that. Something must move.
BEFORE RETURN AFTER RETURN
value stack value stack
┌──────────┐ ┌──────────┐
│ count=0 │◀── OPEN upvalue │ (gone) │
└──────────┘ points at slot 1 └──────────┘
▲
│ heap
closure ─────┐ ┌──────────────────┐
└──────────────────────▶ │ Upvalue::Closed( │
│ Integer(1)) │◀── closure points here
└──────────────────┘
An open upvalue points at a live stack slot — so the closure and the still-running function see
the same variable, which is why the two closures in Warm-Up Experiment 3 shared count. When the
frame dies, the upvalue is closed: the value is copied into the heap cell, and everyone pointing
at the cell keeps working.
That is Lua's design, and Ember copies it. The alternative — heap-allocate every captured variable eagerly — is simpler and slower, and you will implement that first, in Lab 14, before replacing it, so you can benchmark the difference.
The wrong model: "the closure copies the variable." If it did, the two closures in the warm-up
would not have shared state, and get() would have returned 0.
Claim 7: The heap is a graph, and garbage collection is graph reachability
ROOTS (the VM's own state — if you forget one, you free live memory)
┌──────────────┬──────────────┬──────────┬────────────────┬──────────────┐
│ value stack │ call frames │ globals │ open upvalues │ host handles │
└──────┬───────┴──────┬───────┴────┬─────┴────────┬───────┴──────┬───────┘
▼ ▼ ▼ ▼ ▼
┌────────┐ ┌─────────┐ ┌────────┐ ┌──────────┐ ┌──────────┐
│ Table │───▶│ Closure │─▶│Upvalue │ │ Table │ │ UserData │
│ A │ └─────────┘ └────────┘ │ B │ └──────────┘
└───┬────┘ └────┬─────┘
│ │
▼ ▼
┌────────┐ ┌──────────┐
│ Table │◀──────────────────────────────│ Table │
│ C │──────────────────────────────▶│ D │ ← C and D are a CYCLE
└────────┘ └──────────┘
┌────────┐
│ Table │ ← unreachable from any root. GARBAGE, even though C↔D
│ E │ have nonzero "reference counts".
└────────┘
Collection is two passes:
- Mark — from every root, walk every edge, set a bit on each object you reach.
- Sweep — walk the whole slot table; free anything unmarked; clear the marks.
That is it. The cycle C↔D is collected if and only if nothing reachable points into it, which is exactly the property reference counting cannot compute locally.
Why it matters. Two bug classes, and you will produce both:
| Bug | Symptom | Cause |
|---|---|---|
| Missing root | Random corruption; a table becomes nil mid-script; a "generation mismatch" error | You forgot to trace some VM state — usually the temporaries on the value stack, or an object held only by a native function that is mid-call |
| Missing edge | Same symptoms, appearing only when a nested structure is involved | An object's trace method does not visit all its children — usually the table's keys, or a closure's upvalues |
Ember's generation-checked handles turn both of these from undefined behavior into a clean, loud runtime error with a message. That is the entire justification for the handle design, and it is ADR-006.
Claim 8: Dynamic typing means every operation carries its type checks to run time
#![allow(unused)] fn main() { // The static-language version, in Rust: zero run-time work. fn add(a: i64, b: i64) -> i64 { a + b } // The dynamic-language version: this is what `+` actually costs. fn add(a: Value, b: Value) -> Result<Value> { match (a, b) { (Value::Integer(x), Value::Integer(y)) => Ok(Value::Integer(x.wrapping_add(y))), (Value::Float(x), Value::Float(y)) => Ok(Value::Float(x + y)), (Value::Integer(x), Value::Float(y)) => Ok(Value::Float(x as f64 + y)), (Value::Float(x), Value::Integer(y)) => Ok(Value::Float(x + y as f64)), (a, b) => self.try_metamethod_add(a, b), // __add, or a typed error } } }
Every + in a dynamic language is a small decision tree. Every field access is a hash lookup. Every
call is a check that the callee is callable. The types did not disappear; the checks moved.
Everything in Section 7 is an attempt to delete checks that a measurement proves are almost always taking the same branch — while keeping one cheap check (a guard) so that correctness survives the rare case. That is the whole idea of speculative optimization, in one sentence, and you now have it.
Claim 9: Making a dynamic language fast means deleting indirections and guarding the deletion
SLOW FAST GUARD
──── ──── ─────
hash lookup of "score" → read slot 3 → is this still shape #17?
generic ADD dispatch → integer add → are both still integers?
call through a closure → inlined body → is this still the same function?
a Value on the heap → a raw f64 in a register → did anything escape?
The left column is what a naive runtime does. The right column is what a fast one does most of the time. The third column is what makes the second column legal.
When a guard fails, you must be able to get back. In an interpreter, that is easy — fall through to the slow path. In a JIT, it means reconstructing the entire interpreter state from optimized machine code, which is deoptimization, and it is why JITs are hard.
The wrong model: "a JIT compiles my code to machine code, so it's like C now." It compiles a speculative version of your code, valid only while its assumptions hold, plus the machinery to undo it. The undo machinery is most of the engineering.
Claim 10: The host boundary is an ownership boundary
RUST HOST │ EMBER RUNTIME
─────────── │ ─────────────
owns: the Engine │ owns: the heap, the stack, the globals
owns: real data (Article, User) │ owns: every Value
owns: the budget and the clock │
│
passes IN: data, by marshaling │ never sees a raw Rust pointer
gets OUT: values, by marshaling │ never outlives the Engine
registers: functions, which the │ calls back INTO Rust, re-entrantly
script may call │
Three rules follow, and they shape the entire API in Section 5:
- Scripts never hold a raw pointer to host memory, and the host never holds a raw pointer to a
collectable object. Lua enforces this with its stack-based C API; Ember enforces it with
handles that the
Engineregisters as GC roots. - Re-entrancy is real. A registered Rust function can be called from a script, and it can call
back into the script. Your VM must survive being re-entered, which constrains where you can hold
&mut self. This is the single hardest Rust problem in the curriculum and it is Lab 19. - Capabilities are granted, never assumed. The script can do exactly what the host registered and nothing else. There is no ambient filesystem, no ambient clock, no ambient network — not because it is hard, but because a default of "nothing" is the only default you can defend.
Claim 11: Errors are values with locations, and locations must be designed in from line one
You cannot retrofit good diagnostics. The chain that produces this —
policy.ember:12:17: error: attempt to multiply nil by number
12 │ score = article.boost * 2
│ ^^^^^^^^^^^^^ this is nil
stack traceback:
in function 'score' policy.ember:12
in function 'rank' policy.ember:22
in <host>
— requires a Span on every token (Lab 1), a Span on every AST node (Lab 2), a line table in
every Chunk mapping instruction offsets back to spans (Lab 9), a frame stack that knows each
frame's current instruction (Lab 11), and debug names for locals (Lab 10). Miss any one and the
message degrades to "runtime error".
That is why Ember's Token has a span before Ember can do arithmetic. The cost is one u32 pair
per token; the benefit is that every later layer can point at source.
Claim 12: Determinism is a design decision, not a property you get for free
For a policy engine — the capstone — two runs of the same script on the same inputs must produce the same output, or you cannot A/B test, cache, replay, or debug from a log. That does not happen by accident. It requires:
| Source of nondeterminism | Ember's decision |
|---|---|
| Hash map iteration order | Insertion-ordered hash part in tables. Diverges from Lua, where next order is unspecified. [ADR-008] |
| Address-based hashing of objects | Hash object identity by a stable, monotonically assigned id, never by heap index or address |
| Floating-point differences | Same operations in the same order; no fast-math, no reassociation, no f32 intermediates |
| Wall clock / RNG | Not in the default standard library at all. If a host wants them, the host injects them — and can inject a fixed seed |
| GC timing | Collection must never be observable from a script. No finalizers in the default build |
| Iteration over host collections | The host's marshaling code must impose an order |
Why it matters. "Deterministic" is a feature you build, and every one of those rows is a place where the obvious implementation is nondeterministic. The last row in particular is the kind of thing that ships, works for a year, and then produces a ranking that differs between two replicas of the same service.
The Two Loops
Everything in this curriculum is one of two loops. If you can write both from memory, you understand the architecture.
The compile loop — runs once per chunk, over a tree:
#![allow(unused)] fn main() { fn compile_stmt(&mut self, stmt: &Stmt) -> Result<()> { match stmt { Stmt::Local { name, init, span } => { self.compile_expr(init)?; // leaves one value on the stack let slot = self.declare_local(name)?; // NAME → NUMBER, right here self.emit(Op::SetLocal(slot), *span); } Stmt::If { cond, then, els, .. } => { self.compile_expr(cond)?; let jump = self.emit_jump(Op::JumpIfFalse(0)); // placeholder… self.compile_block(then)?; self.patch_jump(jump)?; // …patched once we know } // ... } Ok(()) } }
The dispatch loop — runs once per instruction, forever:
#![allow(unused)] fn main() { loop { self.budget.tick()?; // sandboxing lives HERE let op = self.fetch(); // read at ip, advance ip match op { // decode Op::LoadConst(i) => self.push(self.chunk().constants[i as usize]), Op::GetLocal(s) => self.push(self.stack[self.frame().base + s as usize]), Op::Add => self.binary_add()?, Op::Jump(off) => self.frame_mut().ip = off as usize, Op::Call(argc) => self.call_value(argc)?, Op::Return => if self.pop_frame() { return Ok(()) }, // ... } } }
Notice: budget.tick() is in the fetch position, not scattered through the opcodes. That is the
only place a sandbox can be both complete and cheap, and it is a decision you make in Section 3 —
long before Section 5 needs it.
The Map You Should Be Able to Draw
From memory, on a whiteboard, in under three minutes:
text → [LEXER] → tokens → [PARSER] → AST ─┬─▶ [TREE INTERP] ─┐
│ ├─▶ Value
└─▶ [COMPILER] → Chunk → [VM] ─┘
│ │
constants ─┘ ├─ value stack
line table ├─ frame stack
└─ heap ─ [GC]
│
ENGINE (host boundary)
with these labels attached:
- On the compiler arrow: "names become slots"
- On the VM box: "fetch, decode, execute"
- On the heap box: "handles, not pointers"
- On the GC: "mark from roots, sweep the rest"
- On the Engine: "capabilities and budgets"
- Across the two paths into
Value: "these must agree — that is the differential test"
Deliverables
-
docs/learning/00-mental-model.mdrestating all twelve claims in your own words, one paragraph each. - The map above, drawn from memory, photographed or redrawn into that file.
- For each of Claims 3, 6, 7, and 9: write down the wrong model you personally held before reading this, if you held one. If you held none, write down which claim you least believe and why. You will check it at the capstone.
Validation / Self-check
- Name each representation between text and result, and state what it deletes and what it adds.
- Name the three things called "the stack" and give a bug caused by confusing two of them.
- Where does the name
xexist at run time? Justify your answer with a command you can run. local a = {1}; local b = a; b[1] = 9. Explain, in terms ofValueand the heap, whya[1]is 9.- What are the five root sets, and what is the symptom of forgetting one?
- Why does a closure that outlives its frame require a heap allocation? What is closed when an upvalue is closed?
- What is a guard, and what must exist for a guard failure to be recoverable in a JIT?
- Give three sources of nondeterminism in a naive runtime and Ember's decision for each.
- Where in the dispatch loop does the instruction budget go, and why not anywhere else?
- Write both loops — compile and dispatch — from memory. Compare with this chapter.
Next: The Rust Crate Design — where all of this becomes a module tree.