Lab 12: Differential Testing (Milestone 8)
Background
Eight lines of new test code. That is the whole lab.
Because Lab 8's harness is backend-agnostic,
adding a second Backend impl and one comparison test turns every test you have ever written, and
every test you will ever write, into a two-implementation test. This is the payoff for
ADR-003, written four weeks
ago, before you knew whether it would be worth it.
Then you build a program generator, find the divergences you did not think of, and write down precisely what this technique cannot catch.
Why This Lab Matters
- Every feature from here to the capstone is checked by two independent implementations. Closures, upvalues, GC, metatables, multiple returns — all of Section 4's hard parts land with this net already under them.
- It is the least glamorous lab and the highest-leverage one. Nothing new runs. What changes is the cost of every future bug, which drops from "find it by reading code" to "the test names the program".
- Knowing the blind spot is half the value. A technique you trust beyond its scope is worse than one you do not have.
Prerequisites
- Lab 11 complete: the VM runs the corpus.
- The Reference Implementation re-read — especially §8, the blind spot.
Predict First
- How many of your ~27 golden programs will disagree between the backends on the first run? Write a number.
- Which category will disagree first: arithmetic, scope, control flow, or functions?
print(1/0)— will the two backends print the same text? What aboutprint(0/0)?- Which of these bugs would differential testing catch: (a) a wrong jump target, (b) Lua's modulo
sign rule implemented incorrectly, (c)
local x = xbinding before evaluating in the compiler only, (d) the lexer mis-lexing..? - If both backends print the same wrong answer, what is the only thing that can tell you?
Step 1: The Eight Lines
#![allow(unused)] fn main() { // tests/differential.rs #[test] fn backends_agree_on_the_whole_corpus() { for case in corpus() { let a = TreeBackend::new().run(&case.src, &Limits::test_defaults()); let b = VmBackend::new().run(&case.src, &Limits::test_defaults()); match (a, b) { (Ok(x), Ok(y)) => assert_eq!(x, y, "OUTPUT differs in {}", case.name), (Err(x), Err(y)) => assert_eq!(x.kind, y.kind, "ERROR KIND differs in {}", case.name), (a, b) => panic!("{}: one backend succeeded and the other failed\n\ interp: {a:?}\nvm: {b:?}", case.name), } } } }
Three rules, each of which you would get wrong on the first attempt if it were not written down:
- Compare stdout, not internal state. The backends' internals are supposed to differ. The contract is observable behavior.
- Compare
ErrorKind, not messages. The VM's message knows an instruction pointer; the tree walker's knows an AST node. Requiring identical text would be pointless churn and would push you toward making the messages worse — the same distinction Section 5 makes for hosts, wherekindis the API and the message is presentation. - A one-sided failure is the loudest case. One backend succeeding while the other errors is almost always a real bug, and it deserves its own arm with both results printed.
Run it. Record the number of failures before you fix anything — that number is data about your
own error rate and you will want it in docs/learning/06-vm.md.
Step 2: Read the First Divergence Properly
When a case fails, do not immediately edit the VM. Follow the procedure, because which backend is wrong is genuinely not obvious:
# 1. Isolate. Shrink the program by hand until one line differs.
ember run --interp /tmp/min.ember
ember run /tmp/min.ember
# 2. Which representation? Look at the artifact between them.
ember ast /tmp/min.ember
ember disassemble /tmp/min.ember
ember trace /tmp/min.ember
# 3. Consult the SPECIFICATION, not your memory.
grep -n 'RULE' docs/architecture.md | grep -i <the-construct>
# 4. Ask the third oracle.
lua /tmp/min.ember
Step 3 is the one people skip and the one that matters. The question is not "which backend do I
prefer?" — it is "which one matches docs/architecture.md?" If neither does, you have found the most
valuable kind of bug: the specification is wrong or silent, and both implementations guessed.
Fix the document first, then both backends.
Write the outcome of your first three divergences in docs/learning/06-vm.md in this shape:
DIVERGENCE 1
program: local x = 1; local x = x + 1; return x
interp: 2
vm: nil
spec says: RULE:local-init-order — the initializer is evaluated before the binding exists
who was wrong: VM (the compiler declared the local before compiling the initializer)
fix: compiler.rs — move declare_local after compile_exprlist
invariant: a golden case now pins it in BOTH backends
That last line is the important one. Every divergence you fix must leave a corpus case behind, or you have fixed the instance and not the class.
Step 3: Generate Programs You Would Not Have Written
Hand-written cases test what you understood. A generator tests what you did not.
#![allow(unused)] fn main() { // tests/property.rs proptest! { #[test] fn generated_programs_agree(p in arb_program(4)) { let limits = Limits { instructions: 200_000, depth: 32, ..Limits::test_defaults() }; let a = TreeBackend::new().run(&p, &limits); let b = VmBackend::new().run(&p, &limits); match (a, b) { (Ok(x), Ok(y)) => prop_assert_eq!(x, y, "source:\n{}", p), (Err(x), Err(y)) => prop_assert_eq!(x.kind, y.kind, "source:\n{}", p), _ => prop_assert!(false, "one-sided failure on:\n{}", p), } } } }
The generator is the work. Build it in layers, and start at layer 1 — a generator that emits everything at once will produce programs that error immediately and test nothing:
| Layer | Emits | Finds |
|---|---|---|
| 1 | integer/float arithmetic expressions | coercion rules, ///% signs, overflow |
| 2 | + locals, shadowing, nested blocks | slot allocation, scope popping |
| 3 | + if/while/for/break | jump patching, loop bounds, break pops |
| 4 | + functions, calls, recursion | arity, frames, return adjustment |
| 5 | + and/or with side effects | short-circuit in both backends |
#![allow(unused)] fn main() { fn arb_program(layer: u8) -> impl Strategy<Value = String> { // Two properties matter more than expressiveness: // * TERMINATION — bound every loop with a literal count; use the budget // as a backstop, not as the primary guarantee. // * OBSERVABILITY — every program must end in `print(...)` of something, // or the comparison compares two empty strings and always passes. // A generator that produces silent programs is the most common way this // technique is deployed and does nothing. } }
Warning: The observability rule is not optional and it is easy to violate silently. Add a meta-test: assert that a sample of generated programs produce non-empty output. A property test that passes because both backends printed nothing is worse than no test — it reports confidence you have not earned.
Step 4: Write Down the Blind Spot
Differential testing cannot find a bug in code the two backends share. Ember's backends share:
lexer.rsandparser.rs— the entire front endvalue.rs—raw_eq, ordering, truthiness,type_namearith::*— every arithmetic and comparison helperstrings.rs,table.rs,heap.rs(from Section 4)error.rsand theSpan/SourceMapmachinery
So:
| Bug location | Caught? | What catches it instead |
|---|---|---|
| Compiler (jump target, slot number, constant index) | yes | |
| VM (stack effect, dispatch, frame handling) | yes | |
Compiler and tree walker disagreeing on a rule (local x = x) | yes | |
| Upvalue capture (Lab 14) — the backends capture differently | yes | |
Shared arith::mod_ implementing Lua's sign rule wrongly | no | the lua comparison; hand-written rule tests |
Shared raw_eq mishandling int-vs-float | no | same |
The lexer mis-lexing .. | no | parser tests, fuzzing |
docs/architecture.md specifying the wrong thing | no | the Lua manual; the warm-up experiments |
Put that table in docs/learning/06-vm.md. The difference between "we have differential testing"
and understanding what the sentence buys is exactly this table.
Then add the mitigation — the third oracle:
#![allow(unused)] fn main() { // tests/lua_compat.rs — behind `--features lua-compat`, since it needs `lua` installed. #[test] fn corpus_agrees_with_real_lua_where_we_do_not_diverge() { for case in corpus().iter().filter(|c| !c.diverges_from_lua) { let ours = VmBackend::new().run(&case.src, &Limits::none()).unwrap(); let theirs = run_reference_lua(&case.src).unwrap(); assert_eq!(ours, theirs, "{} differs from Lua 5.4", case.name); } } }
diverges_from_lua is a flag in the corpus case header, and every case that sets it must name
the entry in appendix/lua-differences.md that justifies it. That coupling is what keeps the
divergence list honest: you cannot skip a Lua comparison without documenting why.
The Trace
$ cargo test --test differential
running 1 test
test backends_agree_on_the_whole_corpus ... FAILED
---- backends_agree_on_the_whole_corpus stdout ----
OUTPUT differs in scope/shadowing
left: "1\n2\n3\n1\n"
right: "1\n2\nnil\n1\n"
Now the procedure:
$ cat > /tmp/min.ember <<'EOF'
do
local x = 2
local x = x + 1
print(x)
end
EOF
$ ember run --interp /tmp/min.ember # 3
$ ember run /tmp/min.ember # nil
$ lua /tmp/min.ember # 3
$ ember disassemble /tmp/min.ember
0000 2 LOAD_INT 2 ; x → slot 0
0001 3 GET_LOCAL 1 ; x ← slot 1?! It was declared before the initializer.
0002 | LOAD_INT 1
0003 | ADD
There it is. GET_LOCAL 1 reads the new, uninitialized x instead of the outer one — the
compiler called declare_local before compile_expr. The tree walker got it right because
Lab 5 put the ordering in the right place there,
and docs/architecture.md says which is correct.
One rule, two implementations, one of them wrong, found by a test nobody wrote for it. That is the entire argument for ADR-003, demonstrated on your own code.
After the fix:
$ cargo test --test differential
test backends_agree_on_the_whole_corpus ... ok
$ cargo test
test result: ok. NN passed; 0 failed
Expected Output
$ cargo test
test golden::tree_interpreter_matches_the_corpus ... ok
test golden::vm_matches_the_corpus ... ok
test differential::backends_agree_on_the_whole_corpus ... ok
test property::generated_programs_agree ... ok
test property::generated_programs_produce_output ... ok
$ cargo test --features lua-compat --test lua_compat
test corpus_agrees_with_real_lua_where_we_do_not_diverge ... ok
Debugging Steps
Zero failures on the first run
Suspicious, and usually one of three things: the corpus is too small, the programs produce no
output, or check is not actually comparing. Add a deliberate bug to the VM (change ADD to
SUB) and confirm the test fails. A test you have never seen fail is not a test.
The property test passes instantly and always
Your generator produces programs with no print. See the warning in Step 3.
1/0 prints differently between backends
Both call the same Display, so this cannot happen — unless one backend formats through a different
path. Check for a to_string in the VM's print that the tree walker's does not use. This is a
shared-code assumption you should verify rather than assume.
The property test fails with a 400-line program
proptest shrinks; look at the shrunk case, not the original. If shrinking is not producing
something small, your generator's shrink strategy is missing — write it, it is worth the hour.
Error kinds differ: Limit vs Runtime
The two backends account for recursion differently. The tree walker counts FrameInfo pushes; the
VM counts CallFrame pushes. If one counts native calls and the other does not, deep recursion hits
different limits. Make both count the same events and add a corpus case.
Both backends agree and both are wrong
The only thing that can tell you is an independent oracle: lua, the manual, or the warm-up
experiments. This is the blind spot, live.
Experiment
CLAIM. Differential testing finds bugs that hand-written tests do not, and the ratio is large.
METHOD. Count three things:
- How many bugs your hand-written unit tests found while building Labs 9–11.
- How many the golden corpus found once the VM ran it.
- How many the differential test and the generator found afterwards.
Then, for each bug in category 3, ask: would I have written a test for this? Be honest.
PREDICTION. Before tallying: what fraction of your VM bugs do you think category 3 caught?
RESULT. Put the tally in docs/learning/06-vm.md. It is the empirical justification for the
~1,000 lines of tree walker you are now maintaining forever, and it is the number to quote when
someone asks why you have two interpreters.
Test
#![allow(unused)] fn main() { #[test] fn the_differential_test_can_actually_fail() { // A meta-test. Run a program under a DELIBERATELY broken backend and assert // the comparison reports it. Without this, a comparison that silently does // nothing looks exactly like a comparison that passes. let mut broken = VmBackend::with_sabotage(Sabotage::AddIsSub); let case = Case::inline("print(2 + 3)", "5\n"); assert!(check(&mut broken, &case).is_some(), "the harness must detect a wrong result"); } #[test] fn generated_programs_produce_output() { // The property test is worthless if the programs print nothing. let sample: Vec<String> = sample_programs(50, 4); let silent = sample.iter().filter(|p| !p.contains("print")).count(); assert_eq!(silent, 0, "{silent}/50 generated programs produce no observable output"); } #[test] fn every_lua_divergence_is_documented() { // A corpus case may opt out of the Lua comparison ONLY by naming the // documented divergence that justifies it. This is what keeps // appendix/lua-differences.md from rotting. let documented = divergence_ids_in_appendix(); for case in corpus().iter().filter(|c| c.diverges_from_lua) { assert!(documented.contains(&case.divergence_id), "{} opts out of the Lua comparison with undocumented id {:?}", case.name, case.divergence_id); } } #[test] fn both_backends_reach_the_same_limit_kinds() { for src in ["while true do end", "local function f() return f() end return f()", "local t = {} for i=1,1e9 do t[i]=i end"] { let limits = Limits { instructions: 100_000, depth: 64, memory: 1 << 20 }; let a = TreeBackend::new().run(src, &limits).unwrap_err(); let b = VmBackend::new().run(src, &limits).unwrap_err(); assert_eq!(a.kind, b.kind, "limit kind differs for: {src}"); } } }
Challenge Extensions
- Fuzz the pair. A
cargo-fuzztarget that feeds arbitrary bytes as source to both backends and asserts they agree (including agreeing that it is a parse error). Cheap, and it finds front-end bugs the generator cannot express. - Shrink better. Write a custom
proptestshrinker for your program AST that removes statements, simplifies expressions, and reduces literals. A good shrinker turns a 300-line failure into a 3-line one, and that ratio is why property testing is worth the setup. - Metamorphic properties. Assert relations rather than values: wrapping any statement in
if true then ... endmust not change output;do ... endaround a block must not; adding an unused local must not. Each is a one-line property and each catches scope bugs directly. - Coverage-guided corpus growth. Run
cargo llvm-covover the corpus. Whichvm.rsarms and whichcompiler.rsbranches are never taken? Write cases until the VM's dispatch match is fully covered — the uncovered arms are exactly the opcodes nobody has tested. - A third backend. Once Section 7's JIT exists, add it to the same comparison. The harness needs zero changes, which is the entire reward for the design decision made in Lab 8.
Deliverables
-
tests/differential.rscompares the whole corpus under both backends: output for successes,ErrorKindfor failures, and a loud one-sided-failure arm. - The meta-test proving the harness can detect a wrong result.
- Every divergence found is fixed and left behind a corpus case.
-
The first three divergences written up in
docs/learning/06-vm.mdin the given shape. - A layered program generator, with the "produces output" meta-test.
-
tests/lua_compat.rsbehind a feature flag, withdiverges_from_luacases required to name a documented divergence. -
The blind-spot table written in
docs/learning/06-vm.md. - The bug-source tally from the experiment.
- Milestone 8 complete: two implementations, one answer.
Validation / Self-check
- Why compare stdout rather than internal state? Why
ErrorKindrather than the message? - Give the four-step procedure for investigating a divergence. Which step do people skip, and what does skipping it cost?
- What must every fixed divergence leave behind, and why?
- Name the two properties a program generator must have. What is the symptom of missing the second?
- List four bug locations differential testing catches and four it cannot. What covers the latter?
- Why must a corpus case that opts out of the Lua comparison name a documented divergence?
- What is a meta-test, and why does this lab need one?
- Both backends print the same wrong answer. What are your options, in order?
Next: Section 4 — Objects, the Heap, and the Collector. You now have two implementations that agree. Everything Section 4 builds lands with that net underneath it.