hanki

22. Implementation status

The compiler is in Rust (workspace at crates/).

Execution model - two tiers. A single front end (lex → parse → name-resolve → type → effect-check → desugar) lowers to a stack-based bytecode IR (crates/hanki-compiler/src/bytecode.rs) consumed by two backends:

Both backends consume the same bytecode IR.

Tier parity is a hard requirement. The two tiers are two production execution modes, not a dev tier and a release tier: bytecode (hanki run) is the portable, instant-start, fuel-sandboxable mode - scripts, embedding, latency-sensitive work, and any host with no LLVM toolchain - and AOT (hanki build) is the maximum-throughput mode. A program must be observationally indistinguishable between them - same stdout, same errors, same effects, same exit code - differing only in time and memory. The alignment is enforced by three cross-tier harnesses: the differential corpus (crates/hanki-cli/tests/differential.rs) over every examples/ program, the REPL-vs-run parity harness (repl_parity.rs), and the OS-capability harness (aot_sys_io.rs) that pins the fs / net / process / stdin (io.read_line!) seam - those touch the filesystem, network, and stdin, so they run in isolated tempdirs outside the example corpus. Any intentional divergence must be justified here and is deliberately driven toward zero.

In a correct program there is no language-level behavioural divergence. Each case where the tiers could otherwise differ is closed:

Dynamic module loading and hot reload are one surface that behaves identically on both tiers - module.load!<T: trait>(path) / module.unload! / module.reload!<T> - and the path is a Hanki source file, never a pre-built artifact: the user never manages .sos. The bytecode tier compiles the source to bytecode and loads/swaps it; the AOT tier turns it into running code via a backend that is statically linked into the binary - so a load!-using build either links successfully or fails at build time, and backend availability is never a runtime surprise the caller must handle (load!'s ModuleLoadError stays about the module, not the environment). That backend is linked in only when the program uses load!/reload! (the build knows statically): a program with neither keeps the lean, LLVM-free AOT baseline and pays nothing.

It is an embedded bytecode interpreter: a load!-using build statically links the LLVM-free compile-to-bytecode pipeline plus the VM, so a loaded source is compiled to bytecode and run on the same interpreter as the hanki run tier. An interpreter rather than an in-process LLVM JIT: it adds on the order of 8 MB to a load!-using binary and no runtime dependency, whereas a JIT must either static-link LLVM (~170 MB, roughly ten times the baseline binary) or load libLLVM.so dynamically and so reintroduce the very backend-availability failure a static link exists to remove. The budgets are fixed: at most a 12 MB binary-size delta to enable load! (zero for a program that uses neither), at most 0.5 ms added cold-start, and loaded-code throughput explicitly ungated - the load path is not a throughput goal, so code that needs native speed is compiled into the main build rather than loaded.

The boundary between an AOT host and an interpreted loaded module is observationally transparent: calling into a loaded module is indistinguishable from single-tier execution save in time/memory and via explicit, opt-in reflection - the same parity bar the two tiers hold to each other, and nearly free here because both tiers are codegen strategies over one shared runtime core (hanki-rtcore: heap, reference-counted memory management, scheduler, mailbox, RtValue, the numeric algebra, exit/death). A mixed-tier differential test - an AOT host plus an interpreted module, checked against the all-bytecode run - enforces it; passing opaque non-data values (closures, futures, resource handles) across the boundary is a documented scoped limitation (these non-inspectable handles are not marshallable data). Runtime-resolved backends (a JIT shipped as a sidecar .so, or shelling out to the toolchain) are disfavoured: they would turn "is the backend present?" into a per-call error every dynamic-module program must handle, and break fully-static deployment.

A per-actor migrate(old: T) hook drives state migration; the actor-aware coordination - the in-flight handler finishing under the old code, every subsequent message under the new, plus migration (or a clean StateMigrationRequired) on a layout change - works on the bytecode tier through module.reload!<T> (§14). reload! of a function-mode handle - the ADR's degenerate case, re-pointing the vtable at the recompiled code with no actor to coordinate - works on both tiers, so an AOT host can hot-swap everything it can load; actor-mode reload! under an AOT host waits on loaded-actor support there, a single unified cross-tier surface is not yet implemented.

What works today. Both tiers load source modules: the bytecode VM directly, and the AOT tier by linking the embedded interpreter into a load!-using binary (measured release size delta ~3 MB, zero for programs that do not load!; the binary stays LLVM-free).

Data crosses the boundary by deep-copy marshalling, for every data type: scalars, strings, the arbitrary-precision numerics and bytes (through their RootKind), and every struct/sum aggregate, including the applied-type generics List/Map/Option/Result and user generics - a sum/struct heap object carries its own layout, so the self-describing pointer walk reconstructs it with no type information. The bytecode tier's arguments are already RtValues and need no marshalling at all. Opaque non-data values - closures, futures, resource handles, and a Module<T> handle passed as an argument - are the documented scoped limitation.

An effectful loaded method behaves identically on both tiers. On the bytecode tier it performs io/fs/net and actor sends, its yields serviced by the host scheduler exactly as inline code's are; the AOT-embedded loader services the same OS-seam host-calls (io/fs/net/env/process, clock reads, random draws) through the same intrinsic table, and a mixed-tier differential test pins an io.print!-ing loaded method's output and exit code across the two. A loaded method's actor sends are the exception: bytecode tier only.

A loaded module may declare the actor it exposes. When the host trait T's ! methods name-and-signature match a loaded actor's on handlers, module.load!<T>(path) spawns that actor and the returned Module<T> is its reference: m.method!(args) is a blocking send, the actor stays live and stateful between load! and unload!, and unload! stops it. Each dispatch resolves the actor's handlers against its own loaded module, which is what module.reload!<T> swaps over - on the bytecode tier a loaded actor hot-reloads from recompiled source at its next safe point, carrying or migrate-ing its state (§14).

A throw escaping a loaded method is reconciled into the host's throw-type space by type name on both tiers (the per-module tags do not agree across the boundary) and re-raised at the call site, so a loaded def! export's throws E surfaces at the host's matching catch e: E; the AOT-embedded loader recovers the host's tag from the same throw-type table the host registers at startup. A loaded actor handler that throws instead goes down like any actor - its throws E does not cross the send (§15) - and the host observes the death as actor.SendFailed::Died. The error type must be shared or structurally identical across the two modules: the thrown value's variant tags are type-local and so already agree, but they are not remapped, which makes a divergent redefinition that reorders the type's variants the documented unsupported case.

Not yet implemented: actor operations (spawn/send/await) on the AOT-embedded loader; loaded actors on the AOT-embedded loader, which the bytecode tier spawns today; host-to-loaded actor supervision, so a loaded entry actor's uncaught death escalates to root; more than one exposed entry actor per module; actor-mode reload! under an AOT host, which waits on loaded actors there; and a single unified cross-tier reload!.

The REPL is analyzer-backed. hanki repl evaluates through the same typed session pipeline as hanki run (string interpolation included), and its per-keystroke UX queries the live checker: Tab completion is type-aware - xs.<Tab> lists the receiver's actual members, working for literal receivers, chained calls, module names, and type names alike - shown as an aligned list whose signature column renders from the typed signatures (dimmed when colours are on; plain under NO_COLOR); inline ghost hints after the cursor show a complete line's inferred type ([1,2]list.List<int>) or a trailing member's receiver-instantiated signature ([1,2].map(f: (int) -> U) -> list.List<U>), while a prop hints its type. :hints on|off toggles the ambient hints (default on; HANKI_REPL_HINTS=0 starts a session off; hints are automatically absent when styling is off, so piped output never carries hint text). The queries are check-only against a cached session assembly (warm p95 under 7 ms on the reference dev box) and never print - an unresolvable receiver simply offers the plain name completion. help(target) (and its command spelling :doc target) looks documentation up in the live session: a session-defined item renders its own doc comment, a module name (help(list)) its prose and item index, a type its docs and members, and the dotted forms - help(json.parse), help(List.empty), help([1,2].map) - resolve through the same analyzer the completion uses, so a receiver expression's method docs are one call away. A session-defined help shadows the special form.

Other current limitations worth noting: