← Back

Safari to Roc: where it stands

2026-09-11, evening. A status report, written while the first full sweep of the emitter is still running. Numbers are as of the moment of writing and the final tally follows in the console.

The pivot, in one paragraph

The Rust compiler is parked at 9044a29 with every instrument at 100% or filed. The next subject is Roc: take safari, our hand-written Codex screensaver, and make it a Roc program, so that the Roc chapters become the real code and Codex is the source we translated from. Two roads were open, porting by hand and emitting from the compiler, and the day walked both, in that order, on purpose: the hand ports taught the Roc that the emitter then had to write.

The compiler

Roc's new compiler (zig, roc-lang/roc main at 68267ddd) is built from source on this box, as a debug build. The ReleaseFast build was killed at its final link twice; that link is all of LLVM, statically, and the box has 8 GB. Debug is fine for what we do with it: a spec compiles and runs in about three seconds.

The installation check is Roc's own eval suite, which the hand-port session ran in two processes: 2086 passed, 0 failed, 47 minutes.

The hand ports

Two specs were ported by hand into roc-apps/safari/, each with the Codex verdict frozen beside it as its expected output:

Both run on roc debug-68267ddd and match their verdicts. safari/run.sh in roc-apps runs them.

The subset

Before writing an emitter, roc-apps/docs/codex-subset.md counted every form safari uses, over the 54 frozen IRs the Rust compiler emits for the specs. It is small: records and literals dominate, then names, calls, F64 arithmetic, comparisons, if, let, match over four sums and Maybe, list literals, field access, negation, one act per unit. Absent: handle, try, fork, with-timeout, field stores, lazy, induction, vectors, units. Fifteen builtins. That is the whole target.

The emitter

rocemit, a new binary in rust-codex-compiler (commit 8fe6596), takes a resolved unit down the same road as irdump whole -- resolve, parse, desugar, check, lower, pipeline, prune -- and then spells the in-memory IR as one Roc program instead of as IR text. lower_chapter was extracted so both emitters read one document.

Its rules, each of which was a probe against the real roc first:

roc-apps/safari/emitted.sh runs every unit through it and through roc and diffs against the frozen verdict, counting pass, fail and refused apart.

The first sweep, as of writing

units 54
pass, byte for byte 34
fail 0
refused 4, one cause
still running the rest

The one cause: four units carry a comprehension the compiler lifted to __lam_0, and Roc reads a leading underscore as "unused". The fix strips the underscores; it is built and the sweep picked it up midway, so those four will be re-run.

The surprise is time, not correctness. CatStillsSpec took eight minutes under roc run's default dev backend where the Rust interpreter takes seconds. Whether --opt=speed or --opt=interpreter changes that is the first measurement after the tally.

Steve's question: how do the modules get organised?

The question makes sense and the answer is good news. Roc's module system is type modules: a file ViewYaw.roc is a module if it declares a top-level nominal type named ViewYaw, and everything other files may see hangs off that type as an associated item. A module that is just a namespace of functions is a void module, ViewYaw :: [].{ ... }, and the docs recommend it for exactly this case. A record type a chapter declares nests inside: Rider.RiderState.

That is a Codex chapter. Every IR definition carries its chapter_slug, so the emitter already knows which chapter each definition came from; the single-file output groups by it today. Step two is to emit one type module per chapter and have the spec be an app that imports them. Then the screensaver itself is another app importing the same chapter modules, built for a wasm platform instead of the Echo platform, and the specs go on grading the chapters from the side. The Echo platform is for the specs only; the app needs a platform with a canvas, which is its own piece of work and not started.

Single file first, modules second, was deliberate: a failure in the first sweep should have one axis to bisect, and the module split is a projection over a def list that already passes.

What is next

  1. The tally, then re-run the four refused units on the fixed binary.
  2. Measure the slow units under roc's other execution modes.
  3. The module split.
  4. Commit the run-test-eval and build facts to roc-apps's README (done), and keep emitted.sh as the gate.
repo revision role
roc (roc-lang/roc) main @ 68267ddd the compiler, debug build
roc-apps 9cae655 hand ports, emitted.sh, the subset doc
rust-codex-compiler 8fe6596 rocemit and lower_chapter
safari-codex units as of 2026-09-10 the 54 units and their verdicts

Postscript, later the same evening

The tally. 54 of 54, in both layouts. Single file: five minutes for the sweep once the stills were baked. One type module per chapter, the spec as an app importing them: 54 of 54 in five minutes eighteen, after two more Roc facts. A module named Cat that declares a type Cat sees a bare Cat as its own void type, so every type reference is now qualified, even at home. And a lifted lambda needed its enclosing definition's chapter to have a module at all.

The superlinear checker was the debug build. A profile of roc check on the 2k-element file put 64% of samples under Check.finalizeLiteralDispatchResolutions, whose loop over the literal dispatch plans re-fetches the plan list each iteration through NodeStore.literalDispatchPlans(). In Debug mode that function asserts over every plan, with a string-to-enum lookup per plan, so each literal pays for all of them. In ReleaseFast the assertion block compiles out. The fix is to use the slice the function already took, one line at src/check/Check.zig:34522. Not yet verified by rebuilding, and not reported upstream, both at Steve's call.

So the stills baker is a workaround for the compiler we happen to be running, not for Roc, and the Rust-side generalisation of it can wait until the rebuild says whether it is needed.