Porting to Rust by freezing the old answers

Published

Our platform has two services at the edge. One is the memory, which stores and searches what our agents learn. The other is the broker, which holds the work, the questions and the leases. Both were written in TypeScript. Between late August and early October 2026 we moved both to Rust, compiled to WebAssembly and still run at the edge. We did not rewrite them and hope. We recorded what the old code answered, and we held the new code to those answers.

Beside, not in place of

The first piece of Rust, on 25 August, was the chunker of the memory. It came next to the TypeScript chunker, not in place of it. The production path still went through TypeScript. The Rust had one user: a differential test.

A differential test is a list of cases and two answers per case. A seeded generator makes the cases. Each case goes to the live TypeScript, run by node in a subprocess, and to the Rust. The two answers must be equal. If node is missing, the test does not skip. It fails, because a differential without its oracle measures nothing.

The core crate has a rule for what goes into it. The code must be proven on the host, with a real test runner, and it must run in WebAssembly. The compiler does not check the second half: file access and threads still compile to WebAssembly. So a failing test holds that rule, not a comment.

The differences that hide

The cases are hard on purpose. JavaScript strings are UTF-16 code units, and Rust strings are UTF-8. Many cases carry a character outside the basic plane, which takes two code units in JavaScript and one character in Rust. The chunker works on UTF-16 code units in Rust too, because that is the contract.

The tests found places where the natural Rust is silently different from the JavaScript. We keep each one in one module, with its measurement:

  • The byte order mark counts as white space in JavaScript and not in Rust. The next-line character is the opposite. A plain trim in Rust is wrong in two directions.
  • A hash over the text must run over UTF-16 code units, not bytes.
  • Math.max does not swallow a NaN.
  • Rust’s test for a letter is not the same set as a letter in a JavaScript regular expression.
  • A JavaScript regular expression knows four line ends. Rust’s line split knows one. A plain port gave a different answer in 40 of 294 cases.

None of these was found by guessing. Each one was measured against node.

A case goes to the live TypeScript and to the Rust, and the answers must match. Before the TypeScript is deleted, every answer is written into a ledger. After that, the Rust is checked against the ledger, and nothing in the tree can write a ledger again.

Freezing the oracle

A differential needs the old code. But the goal was to delete the old code. So before we deleted a TypeScript module, we ran its differentials once more against the live code and wrote every answer into a ledger file. The ledger is plain text: a case line, then the answer. From then on the test reads the frozen answer instead of asking node. The generator stays unchanged, and each ledger must match its generator’s list line for line.

The first ledgers came on 3 October. Two modules that draw the memory’s pages went, behind three ledgers of 316, 286 and 492 cases. The case counts were the same as in the live run, so no case was dropped in the freeze. Two days later the memory held no TypeScript in its source, and the broker’s domain modules had gone behind their own ledgers. The memory’s nine live oracles were captured twice before its source went, and the two captures were byte-equal. On 30 October 2026 the core held 30 ledgers.

The frozen answers kept working after the deletion. When the memory’s entry point moved to Rust, the ledgers caught two differences: the key order and number form in a JSON detail, and calls that overtook each other.

A test that cannot report its own absence

We measured one more failure. We deleted the chunker’s differential, and the suite still passed. It had two tests fewer and stayed green. A wrong answer turns a test red. A missing test turns nothing red.

So one more test guards the others. It has two halves. The first checks that every differential still exists. The second checks that no corpus shrank and that each one still has its cases outside the basic plane. It also fails if any program in the tree can write a ledger. The only thing that could print the answers again is the Rust under test. A ledger changes by hand, and only when a behaviour changes on purpose.

Proof that the tests can fail

A test that never fails proves little. Our mutation scripts change the Rust on purpose and name the frozen test that must turn red. If a script is stopped, it puts the source back and checks the file byte for byte.

These scripts taught us two lessons. One script built its backup name from the file name alone. Two files had the same name, so the restore wrote the wrong content back, and the run still ended green. The backup now uses the full path, and a check refuses the old pattern in every script. The scripts also raced on the same WebAssembly build. A green result from a race can measure the wrong module, so the runs now take a lock and go one at a time.

All notes