The interpreter, in detail#

The interpreter runs a settled library in-process, with no generated code and no other language's tools. It's what tempest test runs by default, what the playground's Run button shows, and how CI checks the frontend against Temper's 72 functional tests. It lives in temper-frontend, in interp.rs and the interp/ modules, about 4,500 lines, plus a regex engine in regex.rs.

It sits beside to_tmpl, not after it: both read the settled tree and typing's facts. The overview page follows triple.temper through it; this page covers the decisions behind it, its main types, its tests, and its weak spots. For what the language means, see the language reference.

Contents#

Where it sits#

each modulea callwith testsSettled library: tree,scopes, typingrun_withLowering::modulerun_top: frame loop,then the run queueLowering::function(cached)run_frameeach module's test runRun: output lines, failure
each modulea callwith testsSettled library: tree,scopes, typingrun_withLowering::modulerun_top: frame loop,then the run queueLowering::function(cached)run_frameeach module's test runRun: output lines, failure
Diagram source
flowchart LR
  settled["Settled library: tree, scopes, typing"] --> runwith["run_with"]
  runwith -->|"each module"| mod["Lowering::module"]
  mod --> top["run_top: frame loop, then the run queue"]
  top -->|"a call"| fn["Lowering::function (cached)"]
  fn --> frame["run_frame"]
  frame --> top
  runwith -->|"with tests"| tests["each module's test run"]
  tests --> out["Run: output lines, failure"]
  top --> out

Three callers use it:

Decisions#

Each decision says what was chosen, what else was on the table, and whose call it was: team-agreed (with the Temper team), tempest's (the user's call for tempest), or open.

Run the settled tree, not tmpl#

Chosen: the interpreter reads the settled tree, its scopes, and typing's facts directly. tmpl is for backends.

Alternatives:

Pros: it runs whatever settles, including code to_tmpl doesn't cover yet (those are NotYet there, never here), and code with errors. And the settle loop will need to evaluate code before anything is settled enough for tmpl: constants and macros written in Temper (notes/compile-time.md).

Cons: two paths give code its meaning: the interpreter, and to_tmpl plus seven backends. They can drift. A few known differences are written down in kotlin-differences.md ("Keep on trucking"): a body that ends without a value returns void here but fails in generated code, and a non-Boolean condition fails here where a dynamic language tests its truthiness. The shared test programs check every backend against the interpreter's log (Tests), which catches most drift, but not all.

Kotlin Temper has one interpreter, a tree walker over its AST, which both runs programs and partially evaluates them during compilation stages (Interpreter.kt, InterpretiveDance.kt).

Status: tempest's, recorded in history/milestone-3.md ("Interpreters and backend crates"). A small tmpl interpreter is planned there as an oracle for lowerings (run before and after a pass, compare), not as a replacement.

Flat ops, lowered late, per function#

Chosen: on its first call, a function's settled body is lowered to a Code: a flat list of Ops for a stack machine, with jumps for control flow and locals in numbered slots. Lowering caches each Code by node for the run. A module's top level and a class's initializers are lowered the same way.

Alternatives:

Pros: no name lookup or tree walk while running; a small loop that a debugger, a step budget, or cancellation can hook; code never called costs nothing. Lowering is apart from running, so each is easy to read.

Cons: about 1,000 more lines than the tree walk, and lowering itself recurses on expression depth (bounded, below). A stack machine moves more values than a register machine would; nobody has measured whether that matters here.

Status: decided with the user on 2026-10-24 (history/milestone-3.md, "The interpreter: flat frames"). Measured at 180–370 ns a call or loop turn, down from 600 ns–1 µs for the tree walk (size-and-speed.md, "Interpreter").

Calls on the Rust stack; generators own their frame#

Chosen: an ordinary call runs its callee's Frame nested on the Rust stack. A frame holds everything it needs to go on, its position included, so a generator (and an async block, which is one) keeps its body's frame and re-enters run_frame where it paused.

call runs parametersand defaults, thenpausesnext(), or the run queueyield, or await of apending promisebody ends, fails, orreturnsclose()next() gives DoneResultPausedRunningDone
call runs parametersand defaults, thenpausesnext(), or the run queueyield, or await of apending promisebody ends, fails, orreturnsclose()next() gives DoneResultPausedRunningDone
Diagram source
stateDiagram-v2
  [*] --> Paused: call runs parameters and defaults, then pauses
  Paused --> Running: next(), or the run queue
  Running --> Paused: yield, or await of a pending promise
  Running --> Done: body ends, fails, or returns
  Paused --> Done: close()
  Done --> Done: next() gives DoneResult

Alternatives:

Pros: simple, and enough, because a pause appears only directly in a generator's own body, never in a function it calls.

Cons: native stack grows with call depth, so depth needs a limit (Limits). A pause inside a for … of body, which is lowered as its own function, isn't supported yet: it's an error with a clear message, as Kotlin's also fails there.

Status: decided with the user on 2026-10-24, as the "middle path"; the flat form leaves full resumability open if a need appears.

Cells for captured locals, fresh per block#

Chosen: Layout makes one pass over a function's code (its home) and numbers its locals. A local that a nested function or class uses lives in a shared cell; every other local gets a plain slot. Each time a block is entered, its cells are made fresh (Op::FreshCell), so each pass of a loop has its own. A closure captures cells when it's made (Op::MakeFn, with Sources saying where each comes from).

Pros: a closure made in a loop sees that pass's variable, as Temper means; most locals stay cheap slots.

Cons: a method call builds its captures afresh, this included (invoke), which allocates per call.

Kotlin Temper: its environments are chained maps. Its Python output shares one variable across loop passes (its issue #264); tempest's interpreter and backends keep one per pass, with tests (kotlin-differences.md).

Failures as values, caught by table#

Chosen: an op that can't go on returns an Abrupt: a failure, a panic, a yield, a pending await, or a jump out of a for … of body's call. land looks up where it goes: a failure goes to the innermost orelse around the failing op, found in the code's Handler table by position; a jump from a for … of body goes to a Landing. Anything else returns to the caller.

yesnoyesnoAn op failsA handler covers thisop?Drop the stack to itsdepth, jump to itsfallbackReturn Abrupt to thecaller's frameIts handlers cover thecall?Up to the module: therun's failure
yesnoyesnoAn op failsA handler covers thisop?Drop the stack to itsdepth, jump to itsfallbackReturn Abrupt to thecaller's frameIts handlers cover thecall?Up to the module: therun's failure
Diagram source
flowchart TD
  op["An op fails"] --> handler{"A handler covers this op?"}
  handler -->|yes| jump["Drop the stack to its depth, jump to its fallback"]
  handler -->|no| caller["Return Abrupt to the caller's frame"]
  caller --> handler2{"Its handlers cover the call?"}
  handler2 -->|yes| jump
  handler2 -->|no| top["Up to the module: the run's failure"]

Alternatives: check a result after every op, or use Rust's unwinding (panic! and catch_unwind).

Pros: the ordinary path pays nothing for an orelse; a panic (Abrupt::Panic) passes every handler, as Temper means.

Cons: "innermost" depends on an ordering: handlers are pushed after their body is lowered, so inner ones come first in the table and find returns them first. That's sound but implicit; no test names it. The lookup is linear in the number of handlers, which is fine at today's sizes.

Dynamic values, and errors that fail where they run#

Chosen: values are one enum, Val, with Rc for anything shared: strings, lists and their builders, maps, closures, classes, objects, generators, promises, and a few of std's connected values. Types are mostly ignored at run time; they matter for is, as, and picking a generator's kind.

An error node fails where it runs, with its message, as each backend's generated code does. An error that's only about a value's type (type-only) runs as written, as in the dynamically typed backends: error_at emits a failing op only for errors that aren't type-only.

Pros: keeps the interpreter small and lets it run ill-typed code, so one mistake never hides a library's other test results.

Cons: no type-directed speedups, and errors are matched to nodes by their exact source span, built once per run from the diagnostics. A diagnostic whose span is a little off doesn't fail anywhere. to_tmpl builds the same span map on its own (Translator::error_at).

Kotlin Temper: its interpreter is dynamically typed too, and runs ill-typed code. Status: "type-only errors run on" was the user's call, 2026-11-06 (kotlin-differences.md, "Type-only errors run on, where the language can").

Limits: fuel, call depth, and nesting#

Chosen:

Running out of any of these is a panic, which no orelse catches.

Kotlin Temper overflows the JVM's stack between 150 and 200 nested calls in temper test -b interp (2026-11-06), and caps compile-time interpretation with a step counter (ContinueCondition).

Rejected: one budget for the whole run, as before (a slow test starves the ones after it, and one runaway fails them all as not run); streaming tests' output (tempest test prints a library's output only when one of its tests fails, after its name, so it must be kept). Before this, tempest test passed no fuel and kept all output, and a test that looped forever while logging grew until killed (the audit saw 1.2 GB in about 8 s); it now fails at its budget with memory flat (35 MB at 2 billion steps, 40 s).

Builtins and connected code#

Chosen: builtins are Rust, grouped by topic: strings, numbers, maps, lists and builders in interp.rs, and operators in builtin. A builtin type's method dispatches on its name, as text (method). A user's class's method is found by find_member: its own members first, then its supertypes breadth first, an abstract member giving way to a body found later.

@connected declarations without a body run only where the interpreter implements them: std's Date.today(), regex (regexes, over Regex), and net (net, over web_client.rs). They're matched by class and member name, checked to be in the right std module (connected). A user's connected class with a full Temper implementation runs that.

Pros: straightforward to read and extend; matches Kotlin's behavior, which implements a fixed set of connecteds (standardLibraryConnecteds).

Cons: each method call searches for the member and allocates while doing it (a queue and a list), though typing already knows what each call calls. Builtin methods compare strings. This is likely much of Kelpto's 55-second test run. The regex engine is a backtracking matcher in continuation-passing style, so its native stack grows with the input: a repeat over 300,000 characters overflows and aborts the process.

Async: a run queue after the top level#

Chosen: async { … } makes the block's generator and queues it (start_async). Queued blocks run after the module's top level ends (run_tasks). A block that awaits a pending Promise waits in that promise's list; resolving it queues its waiters, in the order they began to wait. Native work, such as net's request, is a Task job that resolves its promise when the queue reaches it.

Promiseasync blockRun queueTop levelPromiseasync blockRun queueTop levelcomplete() or a job resolves itasync { … } queues Btop level endsstepawait (pending)B waits herequeue B againstep, from the await
Promiseasync blockRun queueTop levelPromiseasync blockRun queueTop levelcomplete() or a job resolves itasync { … } queues Btop level endsstepawait (pending)B waits herequeue B againstep, from the await
Diagram source
sequenceDiagram
  participant Top as Top level
  participant Q as Run queue
  participant B as async block
  participant P as Promise
  Top->>Q: async { … } queues B
  Top-->>Q: top level ends
  Q->>B: step
  B->>P: await (pending)
  P-->>P: B waits here
  Note over P: complete() or a job resolves it
  P->>Q: queue B again
  Q->>B: step, from the await

Pros: deterministic, single-threaded, and as Kotlin's interpreter orders it (Promises.kt, AsyncFn.kt). Tempest reports the first failing block, where Kotlin's is silent (kotlin-differences.md).

Cons: net's requests block the queue while they run; fine for tests, not for real I/O. Everything is Rc and RefCell, so a run is tied to one thread.

Tests report through std's own runner#

Chosen: settling turns test blocks into a test run per module, which calls std's runTestCases and returns JUnit XML as a string. run_tests appends that report to the output, and run_settled_tests splits it back out of the output lines at <testsuites> and reads it (parse_junit).

Pros: the same report as every backend's runner gives, from the same Temper code, so tempest test and tempest test -b py print alike.

Cons: the report travels in the log. A program that logs a line reading <testsuites> would confuse the split. Returning the report as its own field of Run would be cleaner.

The main types and functions#

Entry points (interp.rs):

Values: Val; Closure (a function node and its captured cells); Class (layout, captures, statics, and supertypes and operator methods found when first needed); Object; Generator (its paused frame, and GeneratorKind).

Lowering (lower.rs):

Running (frame.rs): Frame (slots then stack in one vector, cells, captures, and the next op), run_frame, and land.

Calls and classes (interp.rs): call (depth check, lowering on demand), apply (any callable value), call_method, invoke, construct (fields from arguments or defaults, then initializers, then the constructor), get_property and set_property (with private-member checks), and class_operator for == and <=> on objects.

Generators and async: start_generator, step, resume, and generator_method; promises.rs for the queue.

Tests#

Test What it pins Where
Settle cases, run Every case in tests/settle/*.cases is settled and then run with 1,000,000 steps; the expected text has what it logs and how it failed. run.cases has 63 cases for the interpreter: wrapping arithmetic, closures per loop pass, jumps out of expressions, generators, async ordering, deep recursion, runaway loops cases_settle_as_expected
Mutation fuzz Randomly edited cases settle and then run without a Rust panic mutated_inputs_settle
Functional tests Temper's 72 functional tests log what their interp log blocks expect, or fail as expected; CI runs it compat/check_functional.py
Library tests tempest test's finding, running, and reporting, on small libraries library_tests.rs
The interpreter as oracle The shared programs log on the interpreter what their log blocks say, so each backend that runs them is checked against it; std net's tests against a local server programs_log_as_the_interpreter_does, net_tests_on_the_interpreter
Regexes The dialect's parsing and the matcher's backtracking regex.rs's unit tests
Errors Code with errors fails only where it runs, on the interpreter and Python errors_fail_only_where_they_run
Real libraries Chromarite and Ordable's tests on the interpreter, in CI; Hrefine, Kelpto, and Wherewith locally CI's tempest test steps

Not covered: tempest test with a runaway test (no budget there); regexes on long inputs; a printed form of lowered ops, so there are no goldens for lowering itself (only for what code logs); and the interpreter against lowered tmpl, which the planned tmpl interpreter would give.

Speed and size#

compat/bench_interp.py measures loops, calls, closures, and fib against Kotlin's warm JVM: 7–15 times faster before flat frames, and flat frames cut the cost of a call or loop turn by about two thirds (size-and-speed.md, "Interpreter"). On Ordable's property tests, the interpreter roughly matches Python's generated code and takes 2–3 times Lua's time. Peak memory for a test run is about 12 MiB. Flat frames added about 48 KiB to the release binary.

Known weaknesses and plans#