hanki

22. Implementation status

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

The execution model has two tiers. A single front end, lex, parse, name-resolve, type, effect-check and 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. The LLVM backend's optimization policy is selected when the toolchain is built: Rust debug assertions select the development policy, LLVM O1 with the O1 optimizing backend; without debug assertions it uses the release policy, O2 with the default optimizing backend. Both verify generated IR and eliminate unreachable code. HANKI_TIMINGS=1 identifies the selected pipeline, and the native object cache includes the complete policy in its key. Build the toolchain with cargo build --release and use its release runtime archives for application throughput measurements. HANKI_RUNTIME_DIR selects the linked runtime independently of the backend policy.

Planned typed value ABI: small scalar structs must support allocation-free construction, copying, and transport through direct calls, generic functions, trait dispatch, and stored callbacks on both tiers, independently of inlining. The initial class is a finite product of at most four raw machine-word lanes after generic substitution and recursive field flattening; fixed-width integers, bools and floats each use one lane, and unit uses zero. Opaque products retain their privacy and constructor checks. Larger, recursive and managed-field products have separate representations. Eligible scalar fields are inline in enclosing storage. Closure environments, containers and actor envelopes may allocate their own storage, with no additional scalar box.

The same planned layout model supports an explicit discriminant and inline payload for Option<T> over every T. Construction and matching add no wrapper allocation; managed payloads retain their own representation and ownership. Nested options remain distinct, and recursive nominal payloads retain finite indirect storage. General tagged-union layouts support later Result work, while the selected migration covers Option. Value semantics, actor isolation, resource restrictions, float bits and bytecode allocation metering remain enforced. These are implementation targets: native execution currently uses one word per value and only local scalar stack promotion is implemented. The staged ABI must update every producer, consumer, observer and artifact format before activation.

Rust applications can also use the supported bytecode-only full-program embedding boundary in hanki_runtime::embed. A host explicitly supplies a capability ceiling, finite whole-program step and cumulative-allocation limits, a deterministic seed, owned user-effect provider callbacks, string inputs and a cancellation token; compilation prints nothing and returns structured diagnostics, while execution returns a structured outcome. Defaults grant no authority and use finite bounds, and an in-program provide block cannot shadow an installed callback. A compiled program is opaque, process-local, and tied to the Host (or one of its clones) that proved its authority and provider routes. Every outcome terminates and joins remaining actors before returning. Provider callbacks are trusted Rust outside VM metering, and cancellation waits for an in-progress callback to return. Reachable dbg! and dynamic source loading are refused. This is an implementation API and no new language syntax or semantics; the complete SemVer, threading, ownership, and v1-scope contract is in docs/design/full-program-embedding.md.

Tier parity is a hard requirement. The two tiers are two production execution modes, and no dev tier and release tier. Bytecode, hanki run, is the portable, instant-start, fuel-bounded mode, for scripts, embedding, latency-sensitive work, and any host with no LLVM toolchain. AOT, hanki build, is the maximum-throughput mode. A program must be observationally indistinguishable between them, with the same stdout, the same errors, the same effects and the 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-against-run parity harness (repl_parity.rs), and the OS-capability harness (aot_sys_io.rs), which pins the fs, net, process and stdin (io.read_line!) seam; those touch the filesystem, network and stdin, and they therefore run in isolated tempdirs outside the example corpus. Any intentional divergence must be justified here, and is driven toward zero.

In a correct program there is no language-level behavioural divergence. Each case where the tiers could otherwise differ is closed, and the one that is not yet closed is diagnostic text and no behaviour. It is listed last:

The registry in one place, each code paired with the hanki-rtcore constant that defines it (crates/hanki-rtcore/src/exit.rs). The prose above is the explanation, and this table is what a test checks the constants against, which leaves the two cannot drift:

CodeConstantFault
254UNCAUGHT_THROW_EXIT_CODEan uncaught throw propagating past main!
253UNSUPERVISED_DEATH_EXIT_CODEan actor death reaching the root with no supervisor
252ROOT_CRASH_EXIT_CODEa crash! reaching the root, in main! itself
251DETERMINISTIC_DEADLOCK_EXIT_CODEa deadlock detected under --deterministic
250RESOURCE_EXHAUSTED_EXIT_CODEa whole-program resource bound exceeded (bytecode tier only)
1RUNTIME_FAULT_EXIT_CODEan OS-level runtime fault: a failed stdout/stderr write, or a spawn the OS refuses (no descriptors for the actor's wakeup pipe, no thread)

Dynamic module loading and hot reload behave identically on both tiers. The surface itself, module.load!, unload! and reload!, the source-file rule, and the annotation that supplies T, is specified in §14. The bytecode tier compiles the source to bytecode and loads or swaps it; the AOT tier turns it into running code through a backend that is statically linked into the binary, and a load!-using build therefore either links successfully or fails at build time, and backend availability is never a runtime surprise the caller must handle, load!'s ModuleLoadError remaining about the module and not the environment. That backend is linked in only where the program uses load! or reload!, which the build knows statically, and a program with neither retains 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, and a loaded source is therefore compiled to bytecode and run on the same interpreter as the hanki run tier. It is an interpreter and no in-process LLVM JIT: it adds on the order of 8 MB to a load!-using binary and no runtime dependency, where a JIT must either static-link LLVM, around 170 MB, roughly ten times the baseline binary, or load libLLVM.so dynamically and thereby reintroduce the 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 being no throughput goal, and code that needs native speed is compiled into the main build in place of loaded.

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, a measured release size delta of about 3 MB and zero for programs that do not load!, the binary remaining LLVM-free. reload! of a function-mode handle, re-pointing the vtable at the recompiled code with no actor to coordinate, works on both tiers, and an AOT host can therefore hot-swap everything it can load. The actor-aware reload! coordination and loaded-actor hot-reload, at the actor's next safe point, carrying or migrate-ing its state, are bytecode-tier today. The capability gate at the load boundary is on both tiers (§23): an AOT host has its own declared surface baked in and refuses a loaded module that exceeds it, as hanki run does with no flags. The resource metering of loaded code, --max-steps, --max-bytes and with_budget, remains bytecode-tier, the AOT tier having no step counter.

Not yet implemented: actor operations, spawn, send and await, on the AOT-embedded loader; loaded actors on the AOT-embedded loader, which the bytecode tier spawns today; host-to-loaded actor supervision, where 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, and piped output therefore never has 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 offers the plain name completion instead. 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, and a receiver expression's method docs are therefore one call away. A session-defined help shadows the special form.

Other current limitations worth noting: