The Roadmap: Fifteen Milestones
Fifteen milestones, M0 through M14, delivered by thirty labs across seven sections. Each milestone has a definition of done that is observable — something you can run and look at, not "I understand it now."
Track them in docs/learning/progress.md. Milestones are the unit you tell other people about; labs
are the unit you work in.
The Whole Thing on One Page
flowchart TD
M0["M0 · Mental model"] --> M1["M1 · Arithmetic end to end"]
M1 --> M2["M2 · Values and type errors"]
M2 --> M3["M3 · Variables and scope"]
M3 --> M4["M4 · Control flow"]
M4 --> M5["M5 · Functions, frames, recursion — REFERENCE INTERPRETER DONE"]
M5 --> M6["M6 · Bytecode and disassembler"]
M6 --> M7["M7 · The compiler"]
M7 --> M8["M8 · The VM — and it agrees with M5"]
M8 --> M9["M9 · Tables"]
M9 --> M10["M10 · Closures and upvalues"]
M10 --> M11["M11 · Garbage collection and strings"]
M11 --> M12["M12 · Multiple returns, varargs, metatables — LANGUAGE DONE"]
M12 --> M13["M13 · Embedding, host objects, stdlib, modules"]
M13 --> M14["M14 · Sandboxing, diagnostics, REPL, observability, performance, JIT"]
M14 --> C["Capstone · Recommendation Policy Engine + end-to-end trace"]
Note: M5 and M8 are the two checkpoints that matter most. M5 means you have a complete, correct language you can trust. M8 means you have a second implementation that provably agrees with the first. Everything after M8 is built on that agreement — which is why Lab 12, the least glamorous lab in the curriculum, is also the highest-leverage one.
Section 1 — From Characters to Trees
M1 · Arithmetic, end to end
Labs 1–3. Lexer, Pratt parser, tree-walking evaluator.
$ echo '10 + 20 * 3' > t.ember && ember run t.ember
70
Done when:
-
ember tokens,ember ast, andember runall work on10 + 20 * 3. -
Precedence and associativity are correct for
+ - * / % ^and unary minus, with a test per rule.2 ^ 3 ^ 2is512, not64—^is right-associative. -
Every token and every AST node carries a
Span. - A syntax error prints the source line with a caret under the offending span.
-
docs/learning/01-lexer.md,02-parser.md,03-ast.mdwritten.
M2 · Values and honest type errors
Lab 4. nil, booleans, integers, floats, strings; comparison; truthiness; the coercion table.
There are no function calls until M5, so print does not exist yet — ember run -e prints the
value of the returned expression, and --types shows its type and numeric subtype.
$ ember run --types -e 'return 1 == 1.0'
true (boolean)
$ ember run --types -e 'return 3 // 2'
1 (number: integer)
$ ember run -e 'return "a" .. "b"'
ab
$ ember run -e 'return "x" * 2'
<argv>:1:8: error: attempt to multiply a string value
1 │ return "x" * 2
│ ^^^
Done when:
-
Valueis defined, isCopy, andsize_of::<Value>()is asserted in a test. - The integer/float rules match the warm-up's observations, with a test table covering every operator/subtype pair.
-
Integer/float equality is exact —
i64::MAX == i64::MAX + 0.0isfalse. - Type errors name the operator's verb and the offending type, and their span covers the offending operand, not the whole expression.
-
docs/adr/ADR-004-value-representation.md,ADR-005-integer-float-split.md, andADR-009-bytewise-string-ordering.mdwritten.
Section 2 — The Reference Interpreter
M3 · Variables and scope
Lab 5. local, globals, assignment, blocks, shadowing.
Done when:
-
Shadowing works: an inner
local xdoes not disturb the outer one, and the inner is visible only inside its block. -
local x = xreads the outerx— the initializer is evaluated before the new binding exists. (Get this wrong and you have written JavaScript's temporal dead zone by accident.) -
Reading an undefined global yields
nil; reading an undefined local is a compile error, because there is no such thing. -
You can state, in writing, why the environment being a
HashMapis wrong, before Section 3 tells you.
M4 · Control flow
Lab 6. if/elseif/else, while, numeric for, generic for (deferred to M9), break,
return.
Done when:
-
Only
nilandfalseare falsy.0and""are true. Tested explicitly, because every language disagrees here and yours must be documented. -
and/orshort-circuit and return operands, not booleans:nil or 5is5. -
A numeric
forcreates a fresh binding per iteration (warm-up Experiment 3). -
breakexits only the innermost loop; nested-loop test present.
M5 · Functions, frames, recursion — the reference interpreter is complete
Labs 7–8. Function declarations, calls, parameters, return, recursion, and a depth limit.
$ ember run --interp fib.ember # fib(25) by naive recursion
75025
$ ember run --interp deep.ember # 100_000-deep recursion
error: stack overflow (call depth limit 200 exceeded)
in function 'recurse' deep.ember:2
Done when:
-
Recursion works, mutual recursion works, and a runaway recursion returns an
ErrorKind::Limiterror rather than aborting the process. -
The golden corpus exists (
tests/golden/) with at least 25 programs, each with expected output. -
cargo testis green and the corpus is the thing that proves it. -
docs/adr/ADR-003-keep-the-tree-walker.mdwritten — before you build the VM, so it is a prediction rather than a justification.
This is the checkpoint. You have a working language. It is slow and it has no tables, but its semantics are defined, tested, and — critically — frozen as a reference. Everything from here is a second implementation that must agree with this one.
Section 3 — Bytecode and the Virtual Machine
M6 · Bytecode and a disassembler
Lab 9. The Op enum, the Chunk, the constant pool, the line table, and the disassembler.
$ ember disassemble t.ember
== chunk: t.ember ==
constants: [0] 10 [1] 20 [2] 3
offs line op operands comment
0000 1 LOAD_CONST 0 ; 10
0002 1 LOAD_CONST 1 ; 20
0004 1 LOAD_CONST 2 ; 3
0006 1 MUL
0007 1 ADD
0008 1 RETURN
Done when:
- Every opcode is documented in the opcode reference with operands, stack before, stack after, and possible errors.
- The disassembler is written before the VM, and used to check the compiler's output.
-
A
Chunkround-trips through the disassembler for every golden program without panicking.
M7 · The compiler
Lab 10. AST → Chunk. Scopes, slot allocation, jump emission and patching, constant pooling.
Done when:
- Every golden program compiles without error.
-
Jump patching is correct for
if/elseif/else,while, andbreak— verified by reading the disassembly, by hand, for at least three programs. Do this once manually; it is worth an hour. -
Local slots are reused after a block ends (check with
ember disassembleon nested blocks). -
Constants are deduplicated:
1 + 1has one constant, not two.
M8 · The VM — and it agrees with the tree walker
Labs 11–12. The dispatch loop, frames, the value stack, ember trace, and differential testing.
$ ember trace t.ember
ip op stack before stack after
0000 LOAD_CONST 0 [] [10]
0002 LOAD_CONST 1 [10] [10, 20]
...
$ cargo test --test differential
running 47 tests ... ok
Done when:
-
Every golden program produces byte-identical output under
--interpand the VM. - The differential test runs automatically over the entire corpus, so every future test is a differential test.
-
A
proptestgenerator emits random valid programs and asserts backend agreement. -
ember traceprints ip, opcode, and the stack before and after each instruction. -
The instruction budget check lives in the fetch position and has a test proving a
while true do endterminates withErrorKind::Limit. -
docs/adr/ADR-002-stack-vm.mdwritten.
Section 4 — Objects, the Heap, and the Collector
M9 · Tables
Lab 13. The hybrid table: array part plus insertion-ordered hash part. Constructors, t.k,
t[k], #t, pairs, ipairs.
Done when:
-
t.nameandt["name"]take the same path and are indistinguishable. - The array part is used for dense integer keys, the hash part for everything else, and a test shows the transition on rehash.
-
A float key with an exact integer value normalizes:
t[1.0]andt[1]are the same slot. -
pairsiterates in insertion order, deterministically, and there is a test that would fail under a randomly-seeded hasher. -
Table identity works:
{} ~= {}, anda = bmakes them alias. -
docs/adr/ADR-008-deterministic-iteration.mdwritten.
M10 · Closures and upvalues
Lab 14. Closures, capture analysis in the compiler, open/closed upvalues in the VM.
Done when:
- Warm-up Experiment 3 produces identical results in Ember and Lua.
-
The compiler resolves each free variable to a local, an upvalue, or a global, and the
disassembly shows
GET_UPVAL/SET_UPVALwhere you expect them. - Open upvalues are shared while the frame lives, and closed when it returns — with a test that creates two closures over one variable and asserts they still share it after the return.
-
ember trace --upvaluesshows the open-upvalue list and the moment each one closes.
M11 · Garbage collection and strings
Labs 15–16. The slot-table heap, handles, mark and sweep, allocation accounting, thresholds; then string interning, benchmarked.
$ ember run --trace-gc alloc.ember
gc: begin heap=2.4MB objects=51203
gc: marked roots=1841 reachable=1205
gc: swept freed=50000 heap=0.4MB in 3.1ms
Done when:
- A cycle is collected. This is the test that justifies the whole subsystem.
- The "forgot a root" bug is deliberately introduced, observed, and fixed — and the fix is a root-set enumeration that lives in one function so it cannot drift.
- Allocation accounting drives the collection threshold, and the threshold policy is documented.
-
collectgarbage("count")and a heap census by object type are available to scripts and toember --stats. -
Interning is added after a benchmark showed string comparison or hashing mattered, and the
benchmark delta is recorded in
docs/learning/14-performance.md. -
docs/adr/ADR-006-tracing-gc-with-handles.mdandADR-007-string-interning.mdwritten.
M12 · Multiple returns, varargs, metatables — the language is done
Labs 17–18.
Done when:
-
Every case in warm-up Experiment 4
matches Lua exactly, including
(f())truncation andselect('#', ...). -
__index(table and function forms),__newindex,__call,__tostring,__eq,__lt, and the arithmetic metamethods work, with a metamethod-lookup depth limit that prevents an__indexchain from hanging the VM. -
An
Account-style object-oriented example from the warm-up runs unchanged. - Differential tests still pass across the whole corpus. (They will break. That is the point: multiple returns are where the two backends are most likely to diverge.)
Section 5 — The Host Boundary
M13 · Embedding, host objects, the standard library, modules
Labs 19–22.
#![allow(unused)] fn main() { let mut engine = Engine::new(); engine.register_function("log", |_ctx, args| { println!("{args:?}"); Ok(Value::Nil) })?; engine.execute(POLICY_SOURCE)?; let score: f64 = engine.call("score", (user, article))?; }
Done when:
-
Engine::new/execute/call/set_global/get_global/register_functionall work and are documented with#[doc]examples that run undercargo test --doc. -
A registered Rust function can call back into Ember (re-entrancy) without a borrow-checker
workaround that leaks
unsafe. -
Host values are marshaled both ways via a
ToValue/FromValuepair, with a clear error when a conversion fails. - A Rust struct is exposed as userdata with field access from script, and the GC traces it correctly.
-
print,type,assert,error,pcall,tostring,tonumber,ipairs,pairs, andmath.*,string.*,table.*exist, and no filesystem, network, process, or environment access is reachable by default. -
requireworks through a host-supplied resolver, with a cache and a cyclic-import error. -
docs/adr/ADR-010-host-controlled-modules.mdandADR-011-send-sync.mdwritten.
Section 6 & 7 — Production and Performance
M14 · Sandboxing, diagnostics, tooling, observability, performance, JIT
Labs 23–30. This is the largest milestone and it splits naturally into two halves.
Half one — production (Labs 23–27), done when:
- Every threat in the model has a countermeasure or a written "we do not defend against this": infinite loops, infinite recursion, huge allocations, huge strings, error amplification, expensive host callbacks.
- Diagnostics render source, span, caret, and a traceback — for compile and runtime errors.
-
ember replworks, with multi-line input,.help,.disasm,.stats, and no way to panic the process. - Fuzz targets for lexer, parser, compiler, VM, and bytecode validation each run 10 minutes clean, corpus committed.
- No panic from any script input. A fuzz-found panic is a bug, not a curiosity.
-
engine.stats()reports instructions executed, allocations, live objects, GC runs, GC pause total, call count, and peak depth. -
docs/limitations.mdand the stated production profile are written and honest.
Half two — performance (Labs 28–30), done when:
-
benches/has a baseline recorded before any optimization, in the repo. - Each optimization is documented as: baseline → hypothesis → change → measurement → tradeoff. An optimization with no measured win is reverted, and the reversion is recorded too.
- An inline cache for table field access exists, with monomorphic/polymorphic/megamorphic states and a correctness test for cache invalidation on table shape change.
-
At least one specialized opcode (
ADD_INTor similar) exists with type-feedback-driven rewriting and a guard. -
A Cranelift JIT compiles at least
function add(a, b) return a + b endto native code, with a guard and a working deoptimization path. - Three-way benchmark recorded: tree interpreter vs. VM vs. JIT, on the same programs, with the command that reproduces it.
-
docs/adr/ADR-014-cranelift.mdwritten.
The Capstone
The Recommendation Policy Engine. Rust owns candidate retrieval, user and article data, metrics, execution limits, logging, and policy loading. Ember owns ranking, boosts, penalties, business rules, and experiments.
Plus the mandatory end-to-end trace: one non-trivial script, followed through tokens, AST, bytecode, constants, stack, frames, table accesses, closure captures, allocations, GC roots, and the return into Rust. If you cannot produce that document, you have not finished, regardless of what runs.
And the honest chapter: when not to embed a language — the cases where TOML is enough, or where plain Rust is better. Being able to argue against the thing you just spent four months building is the mark of an engineer rather than an enthusiast.
Lab Index
| Lab | Title | § | M |
|---|---|---|---|
| 1 | The Lexer | 1 | M1 |
| 2 | The Pratt Parser | 1 | M1 |
| 3 | The First Evaluator | 1 | M1 |
| 4 | Values and Type Errors | 1 | M2 |
| 5 | Variables and Scope | 2 | M3 |
| 6 | Control Flow | 2 | M4 |
| 7 | Functions and Recursion | 2 | M5 |
| 8 | The Reference Interpreter | 2 | M5 |
| 9 | Bytecode and the Disassembler | 3 | M6 |
| 10 | The Compiler | 3 | M7 |
| 11 | The Virtual Machine | 3 | M8 |
| 12 | Differential Testing | 3 | M8 |
| 13 | Tables | 4 | M9 |
| 14 | Closures and Upvalues | 4 | M10 |
| 15 | Mark and Sweep | 4 | M11 |
| 16 | Strings and Interning | 4 | M11 |
| 17 | Multiple Returns and Varargs | 4 | M12 |
| 18 | Metatables | 4 | M12 |
| 19 | The Engine API | 5 | M13 |
| 20 | Host Objects | 5 | M13 |
| 21 | The Standard Library | 5 | M13 |
| 22 | Modules | 5 | M13 |
| 23 | Limits and Sandboxing | 5 | M14 |
| 24 | Diagnostics | 6 | M14 |
| 25 | The CLI and REPL | 6 | M14 |
| 26 | The Test Matrix | 6 | M14 |
| 27 | Benchmarks | 6 | M14 |
| 28 | Inline Caches | 7 | M14 |
| 29 | Specialized Opcodes | 7 | M14 |
| 30 | A Cranelift JIT | 7 | M14 |
Validation / Self-check
- Which two milestones are the checkpoints, and what property does each one establish?
- Why must ADR-003 be written before the VM exists?
- What makes M8's definition of done stronger than "the VM runs my test programs"?
- In M11, why is interning added only after a benchmark?
- Which milestone would you cut if you had half the time, and what would you lose?
- What is the mandatory deliverable that is not code, and why is it mandatory?
Next: The Weekly Learning Plan.