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 frames backwards.
  • A "stack overflow" in your language is a frames.len() > LIMIT check, 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:

  1. Mark — from every root, walk every edge, set a bit on each object you reach.
  2. 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:

BugSymptomCause
Missing rootRandom corruption; a table becomes nil mid-script; a "generation mismatch" errorYou 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 edgeSame symptoms, appearing only when a nested structure is involvedAn 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:

  1. 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 Engine registers as GC roots.
  2. 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.
  3. 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 nondeterminismEmber's decision
Hash map iteration orderInsertion-ordered hash part in tables. Diverges from Lua, where next order is unspecified. [ADR-008]
Address-based hashing of objectsHash object identity by a stable, monotonically assigned id, never by heap index or address
Floating-point differencesSame operations in the same order; no fast-math, no reassociation, no f32 intermediates
Wall clock / RNGNot in the default standard library at all. If a host wants them, the host injects them — and can inject a fixed seed
GC timingCollection must never be observable from a script. No finalizers in the default build
Iteration over host collectionsThe 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.md restating 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

  1. Name each representation between text and result, and state what it deletes and what it adds.
  2. Name the three things called "the stack" and give a bug caused by confusing two of them.
  3. Where does the name x exist at run time? Justify your answer with a command you can run.
  4. local a = {1}; local b = a; b[1] = 9. Explain, in terms of Value and the heap, why a[1] is 9.
  5. What are the five root sets, and what is the symptom of forgetting one?
  6. Why does a closure that outlives its frame require a heap allocation? What is closed when an upvalue is closed?
  7. What is a guard, and what must exist for a guard failure to be recoverable in a JIT?
  8. Give three sources of nondeterminism in a naive runtime and Ember's decision for each.
  9. Where in the dispatch loop does the instruction budget go, and why not anywhere else?
  10. 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.