← Back

Codex to Roc and back

A Codex program from Cobblestone's test suite goes through two translators and two interpreters and comes out the other side printing exactly what it printed before:

codex/test/X.codex
  -> rocemit (rust-codex-compiler)      Codex to Roc
  -> rocflight's parser and checker     the Roc, typed
  -> roc2codex (our rocflight fork)     Roc back to Codex
  -> codexrun (rust-codex-compiler)     runs it
  == the output Cobblestone captured for X

The subjects are roc-apps' ported tests: 525 Codex programs that rocemit writes as Roc, each with the exact bytes the original prints. 515 of them make the whole trip and print those bytes. None prints anything else. The other 10 are refused by design: a program that pokes raw memory is written over rocemit's simulated machine memory, threaded through every function, and undoing that is a whole-program rewrite rather than a translation.

The output is not the original Codex. sum-pair (p) = when p is (x, y) -> x + y comes back as itself, but a text built by a fold comes back as the fold rocemit wrote, and a program's own chapters sit in a unit beside a small Roc--Wrap chapter of helpers. What matches is what the program does, judged the only way that is not circular: the Cobblestone verdict, captured long before any of this existed.

The pieces

rocflight is Brian Teague's Roc interpreter in Rust: a parser, a bidirectional type checker and a register VM. It is not ours. We fork it (showell/rocflight), send fixes back as pull requests, and keep the Codex work on a branch of our own, codex-emit. The emitter reads the tree rocflight's parser builds and the types its checker infers; the one thing it asks of rocflight is a record of every node's type, which the checker now keeps on request and otherwise does not.

roc2codex is about 1,000 lines in src/codex/. It writes each Roc module as a Codex chapter and main! as the chapter's opening, and it writes one resolved unit: the Foreword chapters every Codex program carries, then the program's. codexrun runs a resolved unit from anywhere.

codexrun is rust-codex-compiler's Codex interpreter. It was the oracle's weak point three times: it did not match Nil and Cons against a real list, it read a negative exponent as a huge unsigned one, and a tail view of a list read the whole list (the same list-pattern bug). It passed those programs' originals no more than their round trips. Those are fixed, and each fix moved exactly the verdicts it should over the 275 U61 programs that reach them.

What made it work: the types have to survive

The first version passed 296 of 526, and most of what separated 296 from 515 was not cleverness in roc2codex. It was making the Roc say what the Codex meant.

rocemit had been lossy in ways nobody noticed while Roc was the destination:

The names are Steve's call: CceText and CceChar say whose text and whose characters they are. Another Roc program's Text means its own thing, and a CCE code is a position in Cobblestone's alphabet, where 'a' is 15.

Each change went through rocemit's own ladder of 1,036 Cobblestone programs first, and the ledger never moved: 767 pass, 26 set aside as slow.

What made it work: idioms read back

rocemit writes a Codex builtin as a Roc idiom, and roc2codex reads each idiom back as the builtin it came from: U64.to_i64_wrap(List.len(xs)) is list-length xs, and List.get(xs, I64.to_u64_wrap(i)) ?? crash(..), which rocflight's parser has already desugared into a match, is list-at xs i. The table is rocemit's builtin table run backwards. That is "cheating" in Steve's sense: roc2codex is tuned to rocemit's Roc, not to Roc in general.

The price of cheating is ambiguity, and the rule for ambiguity is to refuse and say why. Some of the refusals it gave on the way: a record literal that "could be any of Byte, Wide"; a Char pattern nested inside another pattern, which would need a literal conversion rocflight cannot do; a call into one of Roc's own modules with no idiom behind it. It never guesses. The one place it gets close is Codex's single string type: every Roc string literal is written as a Codex Text, because a Str that behaves differently from a Text has to do it through a Str builtin, and those are refused.

A few things have no Roc form at all and come back as scaffolding. rocemit writes wrapping arithmetic on a Codex Integer wrapping as I64.plus_wrap, and the Roc no longer says which integers wrapped. So the unit carries a Roc--Wrap chapter whose helpers take an Integer wrapping: a plain Integer passes for one, and arithmetic on it wraps where plain arithmetic traps.

What it found

The round trip is a test of four programs at once, and it found bugs in all of them.

The next target

A round trip proves something a one-way translator cannot: that the typed Roc tree carries enough to rebuild the program. That tree is now the interesting thing. A second emitter reading it, beside roc2codex, gets the same subjects, the same verdicts and the same discipline for free: 525 programs, each with the exact bytes it must print.

Zig is the ecosystem's own language: Roc's compiler is written in it, its platforms' hosts are, and Cobblestone already emits Zig from Codex (the zig plug; our own codex-zig-transpiler, codexzig, is another). What makes it hard from Roc is what Roc takes for granted. Zig has no closures, so every lambda that captures becomes a struct of its captures and a function taking it; rocemit's programs pass functions around freely (apply add 20 22, a record field holding a function). Memory is the program's to manage. A pure program that allocates and never frees fits an arena allocated once and dropped at exit, which is honest for these tests and wrong for a long-running one. Generics are comptime functions returning types, which a generic Maybe(a) or ListUtils fold would need.

Rust is closer than it looks. The borrow checker is mostly about who may change what, and in a pure program nobody changes anything: a value is either moved into its one consumer or shared. Rc makes sharing cheap where the emitter cannot prove a value has one consumer; closures are Rc<dyn Fn(..)>; recursive unions are enums with a Box or Rc in the recursive position; records are structs deriving Clone and PartialEq, which is what the nominal records' field-by-field is_eq already is. The places Rust pushes back are known ones:

So my read agrees with Steve's hunch: Rust is the easier of the two. Almost every Roc construct in these programs has a direct Rust counterpart, and the two that don't (tail calls, in-place list writes) have well-worn answers. Zig needs closure conversion and a memory story before the first test passes.

The practical plan would mirror roc2codex: a src/rust/ beside src/codex/ on the same branch, the same checker types, a roundtrip.sh twin that compiles each program with rustc and diffs its output against the same 525 verdicts, and a refusal for everything it does not yet write. Starting from the cheapest tests, as roc2codex did with arithmetic, it would show within a day how much of the Rust story is true.