7. Interpreting

The explicit-stack engine: tail calls, handlers, resumable continuations, and the host boundary.

Status first, honestly: the engine (packages/kernel-rs/src/eval.rs) is real and runs in three places: native Rust tests, the same code compiled to wasm and driven from Node, and the same wasm inside the editor page. It replays all 684 recorded UCM results (packages/compiler/test/eval-wasm.test.ts) and agrees with every one. The editor’s Run button runs your file in this engine first, in the page, so it works offline; if the server is reachable it then runs the same file through UCM and compares. It displays every value exactly as UCM does (records, variants, lists, dicts, floats, text escapes; only function values print as (not shown)), and it can yield every N reductions so the editor’s Stop button works. What it does not do yet: run code that refers to definitions stored outside the open buffer (those go to the server). It does not typecheck, so the editor only calls it on code the checker has accepted.

Why not just recurse?

A recursive host interpreter overflows its stack on deep or looping Clay code, and cannot capture a continuation (needed for resume). So the engine keeps its own stack on the heap, as a list of frames, and runs one loop:

loop:
  match state:
    Eval(code, env)  -> push a Frame describing what to do with the result; descend into a sub-expression
    Return(value)    -> pop a Frame; continue with the value

A 1,000,000-iteration tail loop and a 1,000,000-deep recursion both run (about 5 s in a debug build). Tail calls reuse the frame instead of pushing one. A step limit (50M) and a stack limit (10M) turn runaway programs into Failed{resource-exhaustion}.

Frames

Each frame is “what to do when the value in hand is ready”:

enum Frame {
    ArgOf   { arg: Code, env: Env },      // evaluated the function, now evaluate the argument
    CallWith{ f: Value },                 // evaluated the argument, now apply f
    Let     { name, body: Code, env },
    If      { t: Code, e: Code, env },
    And { r, env }, Or { r, env },
    Scrut   { cases, env },               // evaluated a `switch` scrutinee, now pick a case
    HandleInit { body, env },             // handler value being computed
    Handle  { handler, handled },         // a marker: "abilities in `handled` stop here"
}

Handlers and resume

handle body with clauses pushes a Handle marker and runs body. When body performs an ability operation:

perform(ability, op, args):
  walk the frame list from the top, looking for a Handle whose `handled` contains `ability`
  captured = frames above that marker            # a delimited continuation; frames are immutable
  pop down to (and including) the marker
  run the matching clause with (args..., resume = Cont(captured))

Applying resume(v) re-pushes the captured frames and returns v into them. Because the frame list is immutable data, a continuation can be resumed zero, one or many times; the tests cover all three (calling resume twice really does run the rest of the body twice). Handlers are shallow: after resuming, a handler must be reinstalled by the clause itself if wanted, matching Unison. If the walk reaches the bottom with no handler, the outcome is the host’s problem (below) or Failed{unhandled-ability}.

Semantics to copy from UCM

Never trust intuition here; these were checked against UCM: Int is 64-bit two’s complement with wraparound, division floors, Int.mod’s sign follows floor division, divide-by-zero fails, text compares by code point.

The host boundary

The engine never touches the world. It reports a step and the caller answers:

enum Outcome {
    Done(Value),
    Failed { class, message },
    Host { op: String, args: Vec<Value> },                    // "host, please do this" (e.g. read the clock)
    Perform { ability: String, op: u64, args: Vec<Value> },   // a user ability nobody handled
}
// engine.start(program, main)  -> Outcome
// engine.resume(value)         -> Outcome     (called with the host's answer)
// engine.take_output()         -> what the program wrote to stdout/stderr since last asked

Printing is deliberately not a round trip. IO.print is Unison’s real wrapper over putBytes; the engine answers the write itself by appending to an in-engine buffer, and the host drains that buffer after every outcome, so order is trivially preserved. Only things the engine cannot know (the time) become Host steps. Buffered output is kept even when the program then fails; UCM loses it on a runtime abort, so the editor’s comparison treats aborted runs as inconclusive for output.

Across the wasm boundary the request and each outcome are JSON (integers travel as decimal strings). The engine lives inside wasm between calls, so a paused program keeps its continuation and resume just names it by id. The host loop, as the editor runs it (packages/compiler/src/local-run.ts):

reply = evalStart(request)
while reply.status == "host":
    reply = evalResume(reply.id, answer_for(reply.op))     # e.g. { int: Date.now() * 1000 }

A user handle beats the host for user abilities: Perform only escapes when no Handle marker claims it.

What IO is

Unison has one {IO} ability; Clay keeps it (no per-capability split) and exposes two operations typed only in int, string and unit: IO.print(text) and IO.now(). Neither is a Unison ability operation: they are ordinary builtins whose types carry {IO}. That shapes everything:

  • Hashing. IO.print is not one builtin but a small wrapper Unison writes: append a newline, encode as UTF-8, putBytes to the standard-output handle, match on the Either result and bug on failure. Both kernels hash that wrapper inline, so Clay’s hash equals the one UCM computes for the same wrapper, with no library to seed. (Either and StdHandle are ordinary library types referenced by hash; constructor indices are not in declaration order. Recorded from UCM, not guessed.) IO.now is simpler: the versioned builtin IO.systemTimeMicroseconds.v1 applied to unit.
  • Checking. A function that prints must say so: let main<IO> = .... Forgetting <IO> is a type error.
  • Running. In UCM the program’s stdout goes to the process stdout, separate from the result value, so Run can show printed lines first, then = result. In the engine it is the buffer above.
  • Comparing. Printing is deterministic, so local and UCM output must match line for line; a mismatch shows as an EVAL DISAGREEMENT line in the Console. A run that read the clock is marked non-deterministic and is not compared.

The design notes and the decision log are both in docs/host-ops-log.md.

If you were building this by hand

  1. Lower Core to a small Code enum with de Bruijn indices (the sketch still looks variables up by name; that is the first performance fix).
  2. Write the frame loop with only App, Lam, If, literals. Add tail calls.
  3. Add Let, data constructors, Switch.
  4. Add Handle and continuations last; they are ~100 lines once frames are immutable.
  5. Replay UCM-recorded results as your test corpus. Every semantic above came from one.