175. Round-trip tests across engines
The Rust engine promises the TypeScript engine’s output, byte for byte, for every test program. Here is how that promise is checked on every cargo test, what one deleted line looks like to the tests, and the ten bugs in the reference that the port turned up.
- Describe the parity loop, from gen_expected.mjs and its fixtures to parity.rs.
- Explain why byte equality needs a behaviour test beside it, and what differential.rs runs.
- Say what parity forces on the port (ordered maps, stable sorts, js_number) and why.
- Write the helper that reports where two outputs first differ.
Two engines, one answer
Chapter 112 compared a program with its compiled version: same
behaviour, or a bug. Now there is a second compiler. The Rust engine
claims to make the TypeScript engine’s decisions and print its exact
output: the same JavaScript or Python, and the same explain text, for
every input. “Mostly the same” would be worse than useless. A playground
running the wasm build would disagree with the CLI, and nobody could
tell which one to believe.
Two translators work on the same book. You can’t read the language, but you can still check them: lay their pages side by side and look for any character that differs. If one of them is a trusted veteran, the newcomer’s pages should simply be identical.
Fixtures from the reference
The veteran’s pages are written once. engine/rust/scripts/gen_expected.mjs
imports the TypeScript compiler and runs it over six suites: the JS
corpus, the end-to-end benchmarks, and extra programs written for the
port, plus the same three for Python. Its core loop, adapted:
const variants = [
['', {}],
['.assume', { bounds: 'assume' }],
];
for (const [suffix, extra] of variants) {
const r = compile(src, jsPlugin(), extra);
write(`${base}${suffix}.js`, r.code);
write(`${base}${suffix}.explain.txt`,
r.explain);
}
The .assume variant takes the emitter’s unchecked paths, which the
default (bounds checks on) never reaches. The files land in
tests/expected and are committed. When the reference changes, someone
reruns the script and reviews the diff.
The Rust side
crates/cli/tests/parity.rs is a Cargo integration test. It compiles
the same sources with the Rust engine and compares (adapted):
for p in programs() {
for (suffix, bounds) in VARIANTS {
let name =
format!("{}{suffix}", p.name);
let (code, explain) =
expected(&p, &name);
let r = p.compile(bounds);
if r.code != code { /* record */ }
if r.explain != explain { /*…*/ }
}
}
It records every mismatch and fails once, listing them all. Each entry
says where the texts first differ, which is your exercise. Other tests
in the same file check that the fixture folder matches the programs
exactly (none missing, none stale), that the 14 explain --frames
fixtures and four --profile fixtures match too, that each corpus program’s
// viscosity: ps=soa header holds, and that the built viscosity
binary prints the same output. Cargo tells the test where the binary is
through CARGO_BIN_EXE_viscosity[1].
When I ran it (rustc 1.97, on a copy of the repo with its own target directory, so as not to disturb other work):
parity: 190/190 (54 Python)
That is 95 programs, each in two variants. The count grows as programs are added (it was 186 when this chapter was first written). What matters is that the two numbers are equal.
A cross-implementation test runs two implementations of the same specification on the same inputs and requires the same outputs. Here the TypeScript engine is the reference. Its outputs are stored as fixtures (expected files) by a generator script. The new engine’s test compares against them byte for byte, for every program and option variant, and also checks that the fixture set has no missing or stale files.
Same bytes isn’t the same as right
Two engines can agree on a wrong answer. So differential.rs runs each
original and compiled JavaScript program with node (in an isolated VM
context, by a copy of the TS runner) and compares everything they print
and throw. Python programs run with python3. On my run, 68 of 68
JavaScript and 27 of 27 Python programs behaved identically.
To see both tests at work, I deleted one line from a copy of
crates/core/src/builder.rs: the case that rewrites a throw operand
when a trivial phi is removed (chapter 176). Parity, trimmed (this was
when it had 186 pairs):
4 of 186 differ:
corpus_throw_from_loop.js: line 1:
want: // viscosity: ps=aos
got: // Generated by Viscosity: "ps" ...
and the differential test:
corpus_throw_from_loop:
original: {"out":["object 1"],…}
compiled: {"out":["number undefined"],…}
Parity says something changed in exactly one program. The differential test says why it matters: the compiled program throws a number where the original threw an object.
What parity forces
Matching decisions isn’t enough. explain lists its reasons in the
order the analyses find them, and the emitter’s edits come in the order
the plan is walked. So the port copies the reference’s order too. A JS
Map iterates in insertion order, so where the TS code uses one, the
port uses IndexMap. Sorts are stable. Use lists keep duplicates, and
the numeric fixpoint updates in the same sequence. Numbers are formatted
by vir::js_number, because Rust and JavaScript print some doubles
differently:
fn main() {
// JavaScript's String(x), captured
// once from Node.
let x3 = 0.1 + 0.2;
let fixtures = [
(x3, "0.30000000000000004"),
(100.0, "100"),
(1e21, "1e+21"),
(1e-7, "1e-7"),
(-0.0, "0"),
];
let mut bad = 0;
for (x, want) in fixtures {
let got = format!("{}", x);
if got != want {
bad += 1;
println!("{want} vs {got}");
}
}
let n = fixtures.len();
let ok = n - bad;
println!("{ok} of {n} match");
}Ten bugs in the reference
When the engines disagree, the obvious suspect is the newcomer. It isn’t always guilty. The README lists ten bugs the port turned up in the TypeScript engine. Each was fixed in both, and each now has a test program:
- A
throwof a removed phi wasn’t seen as an escape (a miscompile). push({ y: (a, b) })lost its parentheses (chapter 174).- Shapes were compared by joined names, so
'a,b'matcheda, b. export default function () {}crashed the resolver.- Annotation comments split lines at
\nonly. - A number at the very end of a Python file read past the string:
1became a float, and0x1hung the tokenizer. - f-string dataclass defaults were copied to each call (a miscompile).
global gwith no module-levelgcrashed.len()of an element crashed when a field was namedlength.- A rebound dataclass name was still compiled as the class (a miscompile).
Bug 6 is a nice example of what a second implementation catches. Past
the end, JavaScript’s src[i] is undefined, and a regex test turns
that into the string "undefined", which contains an e. The Rust
tokenizer reads s.get(k), which past the end gives no character at
all, so the two engines disagreed.
The wasm crate wraps the same compile, so the WebAssembly build is
held to the same fixtures. I loaded the package’s Node build
(packages/wasm/dist/node) and compiled all six suites through
compileLanguage: 190 of 190 fixture pairs were identical.
packages/wasm/smoke.mjs goes further: it packs the npm package,
installs it into an empty directory, and compares every corpus program
with the TypeScript CLI three ways (CommonJS require, ES import, and
the web build initialized from bytes). Fixtures only cover programs
someone wrote, so scripts/fuzz.mjs generates random ones and checks
them both ways: TS against Rust byte for byte, and original against
compiled under node. A 200-program run is part of cargo test, and its
finds so far are fixed in both engines (chapter 193).
Your turn
A parity failure should say where the outputs differ. Write first_difference(want, got): None if the texts are equal. Otherwise compare them line by line (.lines()) and, at the first differing line, return line_msg(n, w, g) with the 1-based line number. If every line they share is equal, the texts differ in length (a missing line, or only a final newline): return "lengths: A vs B bytes".
// The message for differing line n.
fn line_msg(n: usize, w: &str, g: &str)
-> String {
format!("line {n}: {w:?} vs {g:?}")
}
fn first_difference(
want: &str,
got: &str,
) -> Option<String> {
todo!()
}What you learned
- A cross-implementation test holds a new engine to a reference: same inputs, byte-identical outputs.
gen_expected.mjswrites the reference’s fixtures (two variants per program).parity.rscompares the Rust engine with them on everycargo test, and checks for missing or stale fixtures.- Equal bytes can still be wrong together, so a differential test
runs both programs in
nodeandpython3. - Byte parity forces the reference’s order:
IndexMap, stable sorts, and JavaScript number formatting. - The disagreements found ten bugs in the reference, fixed in both engines.
Check your understanding
0/6- 1.
What exactly does
rust_output_equals_typescript_outputcompare? - 2.
Why are the fixtures generated by a Node script and committed, rather than produced by running the TS engine inside
cargo test? - 3.
One line was removed from the Rust builder (the
throwcase of trivial-phi removal). What did the tests report? - 4.
Rust’s
format!("{}", 1e21)gives1000000000000000000000. What does the port do about it? - 5.
Why does the port keep the reference’s insertion-ordered maps, stable sorts and use lists with duplicates?
- 6.
The port disagreed with the TS engine on
def f(): global g; g = 1. What happened next?
References
- [1] The Cargo Team. Cargo Targets (The Cargo Book). The Cargo Book; source in doc/book/src/reference/cargo-targets.md of github.com/rust-lang/cargo. doc.rust-lang.org/cargo/reference/cargo-targets.html (checked 2026-10-31)