21. A Host trait decouples native functions from the execution engine
- Status: Accepted
- Date: 2026-09-04
Context
Milestone 3 adds a second execution engine: a bytecode compiler and virtual machine (ADR 0004 always planned for one). The tree-walking interpreter is not being replaced. It stays permanently, as the oracle a differential test suite compares the VM against, which means Korrin will have two engines running the same programs indefinitely.
Everything written in Rust rather than Korrin sits between them. The builtins
(crates/korrin/src/builtins.rs), the methods on str / list / map
(crates/korrin/src/interpreter/methods.rs), and the standard library
(crates/korrin/src/stdlib/) were all written against one concrete type:
pub type NativeFnPtr = fn(&mut Interpreter, &[Value], Span) -> Exec<Value>;
Left alone, that signature forces one of two bad outcomes: a second copy of
every builtin written against &mut Vm, or a VM that constructs an
Interpreter it does not otherwise need just to satisfy a type. The first
guarantees the two copies drift, which is precisely the drift the differential
suite exists to detect, so the suite would be testing our copy-paste discipline
instead of the VM. The second is a lie in the type system.
What the natives actually need from an engine turns out to be very little. An
audit of all 8 builtins, all 30 built-in methods, and all 11 standard-library
functions found exactly three uses: print and input write output, io.args()
reads the command-line arguments, and input reads a line of stdin (which it
did by calling std::io::stdin() directly, bypassing the interpreter
altogether). Every other native touches nothing but its own arguments. A fourth
capability, calling back into Korrin, is used by nothing today but is the whole
point of a higher-order native such as a sort that takes a comparison
function.
Decision
Native functions receive &mut dyn Host, never a concrete engine.
pub trait Host {
fn emit_output(&mut self, text: &str);
fn script_args(&self) -> &[String];
fn read_line(&mut self) -> Option<String>;
fn call_value(&mut self, callee: Value, args: Vec<Value>, span: Span) -> Exec<Value>;
}
NativeFnPtrbecomesfn(&mut dyn Host, &[Value], Span) -> Exec<Value>andNativeMethodFnbecomesfn(&mut dyn Host, &Value, &[Value], Span) -> Exec<Value>.- The trait lives in
crates/korrin/src/interpreter/host.rsand is implemented by every engine.Interpreterimplements it; the VM will. - These four methods are the complete list of what native code can reach outside its own arguments. Adding a fifth is a decision, not a detail: it widens what every engine must provide and what a builtin is able to do.
- The
inputbuiltin now reads throughHost::read_linerather than touchingstd::io::stdin()itself, so the last piece of native code that reached past its engine no longer does. - Errors need nothing from the host. A native builds them from free functions
(
runtime_error,Signal::error), and the only variant it originates isSignal::Error.
Consequences
- One implementation of every builtin, method, and standard-library function
serves both engines. When the VM lands,
mathandioneed no VM-specific work at all. crates/korrin/src/builtins.rsandcrates/korrin/src/stdlib/no longer name any engine, and no longer import one. They should not know which engines exist, and now they cannot.- The trait is the audit surface. "What can a builtin do?" is answered by reading four method signatures, which is also where a sandbox would attach if Korrin ever grows one.
- Method resolution moved:
emit_output,script_argsandcall_valueare no longer inherent methods onInterpreter, so a caller needsHostin scope. Inside the crate that is a one-line import. It does slightly widen the public API, since these are now reachable through a public trait rather than beingpub(crate). call_valueis on the trait despite having no caller today. This is deliberate: adding it later would break every implementation of the trait, and the first higher-order native (list.sort(by:)is the obvious candidate) will need it. The cost of carrying it unused is one method body per engine.- Native calls now go through a vtable rather than a direct call. This is one indirect jump at the boundary of a function that was about to do real work, and it is not on the hot path of either engine's inner loop.
Interpreterkeeps its own private machinery (call_function,instantiate,import_module, the module registry) off the trait. Nothing native ever needed it, and a VM's equivalents will not have the same shapes.
Alternatives considered
- An
enum Engine<'a> { Tree(&'a mut Interpreter), Vm(&'a mut Vm) }passed to natives. Avoids the vtable, but makesbuiltins.rsandstdlib/name and import every engine that exists, and every added engine edits the enum and every match on it. The dependency points the wrong way: the generic code would depend on the specific. - Generic natives,
fn<H: Host>(&mut H, ...). Monomorphised and so marginally faster, but a generic function has no single function-pointer type, and the builtin tables (BUILTINS,STR_METHODS, and friends) are arrays of function pointers. It would mean boxing closures or a registry per engine, for a saving that does not exist at this granularity. - Duplicating the natives per engine. Rejected outright. Two
implementations of
str.splitthat must agree forever is exactly the failure the differential test suite is built to catch, and it would be catching a problem we chose to create. - Passing the capabilities as separate arguments (an output sink, an args slice, a callback). The signature grows with every capability, every native pays for what it does not use, and there is no longer one name for "what an engine offers".