lm compiles through four independent backends: C, WebAssembly, ARM64, and bytecode for a VM. There is no shared intermediate representation, because the language's control flow is if, while and a statement-only break, which stays structured enough to feed WebAssembly's blocks and loops directly and simple enough for a naive register-free ARM64 walk. All four consume the same typed AST and go their own way from there.
That arrangement gives the project its correctness claim: a program is right if all four backends produce the same output, byte for byte. Three implementations agreeing is decent evidence; one of them being an interpreter with completely different machinery is better evidence still.
Which makes the most important document in the project the list of places where agreement is not guaranteed. There are four, and they are worth reading as a set, because each one is a place where a language declined to specify something and the target's own semantics came through instead.
The four
Division by zero is unchecked in C and on ARM64, traps in WebAssembly, and throws in the VM.
Casting a float that is out of integer range is undefined in C, saturates on ARM64, traps in WebAssembly, and wraps in the VM. Four behaviours, four backends, and no two of them alike. Nothing about lm chose any of this; each backend simply lowered the cast to the instruction its target provides, and those four instructions disagree.
Running off the end of memory dies quietly under C and ARM64, traps in WebAssembly, and throws in the VM. Nothing bounds-checks, and alloc does not fail.
f64 printing relies on C's %.6f and JavaScript's toFixed(6) agreeing. They do for ordinary values, and not necessarily for infinities or NaN.
Some further limits are worth separating out, because they are not divergences and it would be easy to file them together. Nothing enforces alignment, so a *i32 at an odd offset works on all four backends here, but that is a property of these targets rather than a promise. ARM64 takes at most 8 arguments of each class per call. A function pointer captures nothing, so anything a callee needs must be passed in or reachable through memory. for counts up by one, with no step clause and no reverse range, and while covers the rest. All four backends behave identically on every one of those; they are limits on what you can write, not on what agreement means.
Why the list has to exist
An oracle that flags every disagreement as a bug is only useful if disagreement is rare and always wrong. Without this list, the four-way comparison degrades into noise the first time someone writes a test that divides by zero, and the usual response to a noisy check is to stop reading it.
With the list, the oracle stays sharp and the exceptions stay small. Four cases, all of them at the edges of defined behaviour, none of them reachable by a program that stays inside the language's guarantees. Everything else agreeing byte for byte is then a real signal rather than a hopeful one. This is the same reason the self-hosting fixpoint is worth running separately: each instrument is only trustworthy to the extent you have written down where it is entitled to fire.
It also makes the list a design document in disguise. Every entry is a decision not to pay for something. Bounds-checking every access, trapping on division by zero, and defining the out-of-range cast would each cost instructions in the inner loop of a language whose entire premise is machine-level semantics with no garbage collector. Writing them down as divergences is cheaper than implementing them, and considerably more honest than not mentioning them.
The one that would bite hardest
The float cast is the entry I would warn someone about, because it is the only one of the four that produces a plausible wrong number rather than a crash.
Divide by zero and you either trap or you get an obviously broken value. Run off the end of memory and you crash, or you read garbage that usually looks like garbage. Cast 1e300 to an i64, though, and ARM64 hands you the saturated maximum, the VM hands you a wrapped value, WebAssembly refuses, and C does whatever the C compiler felt like that day. Three of those four are numbers. Two of them are numbers a program will happily keep computing with.
That is the shape of defect this whole four-backend arrangement is best at catching, and it is also the one case where the arrangement is explicitly not catching it. Worth knowing which side of the line you are on.
What generalises
Differential testing gets recommended as though the hard part were running the same input through two implementations. The hard part is the exception list, and the exception list is not overhead: it is the specification, arrived at from the other direction. Every entry above is a sentence that could have gone in a language reference and did not, until two backends were caught disagreeing about it.
So if you are building this kind of oracle, budget for the list rather than treating each exception as a nuisance discovered late. Its length is a fair measure of how much of your language you have actually pinned down, and four is short only because lm is small.
The list is in the README, next to the guarantees it qualifies.
Top comments (0)