Backends: Interpreter vs VM
An engine parses a script once and runs it on one of two backends that are held to identical behavior. The rules:
backend_type::vmis the default and the one a shipped host runs: a bytecode VM.backend_type::auto_selectnames the default and selects it. The engine never switches backends on its own.backend_type::interpreteris the reference: a tree-walking interpreter the VM is checked against. Only a program that linksjaiscript_interpreterinstead ofjaiscriptcarries it (The interpreter, for checking parity).- The choice is made per engine, before its first execute.
- Same values, same printed output, same error text on both, with one exception: recursion depth.
std::string script = "int s = 0; for (int i = 1; i <= 100; ++i) { s += i; } s";
auto oracle = jai::engine::make();
oracle->set_backend(jai::backend_type::interpreter); // BEFORE the first execute
oracle->execute(script).as_int(); // 5050 — same answer as the VMset_backend must happen before the first execute
Functions and classes capture the backend that defined them, so set_backend throws jai::runtime_error once a script has run. A build without the interpreter refuses it whenever it is asked:
set_backend after execute() is unsupported: defined functions and classes capture the executing backend
set_backend(interpreter) is unsupported: this build has no interpreter (link jaiscript_interpreter)The backend is built lazily: making an engine or choosing a type costs nothing until a script runs.
The standalone runner takes the backend as a flag and, unlike an engine, starts with no execution budget:
--backend=vm|interp|vm-reference execution backend (default vm; interp = tree-walking interpreter; vm-reference =
the vm with every optimization off, engine::vm_reference_mode)
--budget=N execution budget in seconds per execute (default 0 = unlimited)Checking is strict and the default backend is the VM in both the runner and a host, so a script that behaves differently under jaiscript file.jai than inside your host differs in what the host registers or sets.
The parity contract
The library's test suite runs on both backends, and a differential fuzzer fails on any difference in output or error text between them. Values, signals and callbacks do not depend on the backend. The one exception is recursion depth:
| Interpreter | VM | |
|---|---|---|
Default call-depth cap (engine::max_call_depth(backend, depth) sets either) | 256 active calls | 250,000 |
| Past the cap | catchable Maximum recursion depth exceeded - possible infinite recursion | |
| Native stack | a headroom check can fire before the cap (about 150 to 200 levels on a 1 MB thread): Native stack exhausted before the call-depth cap: the host thread's stack bounds this recursion | — |
A bare self tail call (return f(args) outside any try) | counts toward the cap | reuses its frame and runs to any depth; the execution budget stops a runaway one, like while (true) |
The VM reference mode
engine::vm_reference_mode(true) runs the VM on its plain general path with every optimization off. It is a correctness tool: every program gives the optimized VM's values, output and error text, so a disagreement between the two is a bug in an optimized path.
auto reference = jai::engine::make();
reference->vm_reference_mode(true); // BEFORE the first execute
reference->execute(script).as_int(); // 5050 — the optimized VM's answer- Compile time: no superinstructions, no typed or dynamic slot rows, no closed regions, no grafted callee, no counted-loop opcodes, no return fusion, no tail call, and no compile-time builtin dispatch index.
- Run time: no inline caches and no scoped-const folding. The engine switches
type_proofs,ir_passes,parameter_alias,local_aliasandaggregate_loweringanswer off while it is on. - Chosen before the first execute, like the backend. Afterwards the call throws
vm_reference_mode after execute() is unsupported: defined functions and classes capture the executing backend. - Allowed differences: where the specification leaves an outcome open, and recursion. With no tail call, a deep bare self tail call raises the depth error here.
- An engine without the mode runs exactly the code it would run if the mode did not exist. The runner spells it
--backend=vm-reference.
Seeing what the VM compiled
engine::disassembly_sink(fn) hands every chunk the VM compiles from then on (a program, a function at its definition, a standalone expression or declaration) to fn(listing, manifest) in compile order: the readable listing, and the chunk's manifest with every field and exact constant bits. The bytecode is the same with or without a sink. The runner's --disasm=FILE and --manifest=FILE write the two.
engine::cells_miss_every(n) is a testing lever for a build configured with JAISCRIPT_MISS_INJECTION: every nth guarded cell instruction misses before it runs, as a failed guard would, so the fallback path runs at every such position (the runner's --miss-every=N). 0, the default, is off. engine::miss_injection_available() reports whether the build carries it; without it a nonzero n throws std::logic_error.
Which to run
Run the VM. Keep the interpreter as a flag of a verification build (the runner's --backend=interp is one), so a suspected behavior difference can be compared against the reference at once. There is no tiered or automatic promotion between the two: the engine's 64-entry source cache and jaibites already make repeated scripts cheap. Performance has the measured cost of each backend.