Viscosity
Review
Part VII · Building Viscosity · Day 28

112. Differential testing at scale

Chapter 88 showed how one program can test a compiler. This chapter is about the folder of programs: what goes into each file, how 55 programs become 115 tests and the Rust port’s fixtures, and what the folder still can’t find.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Explain what a test corpus is, and the rules each corpus file follows.
  • Describe how Viscosity’s programs multiply into checks across parsers, languages and implementations.
  • Say how the TypeScript compiler serves as the oracle for the Rust port.
  • Build a seeded random-program generator and use it to find a bug in a toy compiler.

From one test to a folder

Chapter 88 gave you the method: compile a program, run both versions, and the original is the oracle. One program checks one thing. Viscosity has to be right about escapes, aliases, try, NaN, strings in number fields, Python’s integers, two parsers and a second implementation in Rust. That takes many programs, and a way of using each one many times.

Here is the whole TypeScript test suite, run for this chapter:

$ cd engine/ts && npx vitest run
 ✓ frontend-js/test/mini-parser.test.ts
     (43 tests)
 ✓ frontend-js/test/differential.test.ts
     (38 tests)
 ✓ bench/test/layouts.test.ts (8 tests)
 ✓ core/test/core.test.ts (11 tests)
 ✓ frontend-python/test/differential...
     (15 tests)
 Test Files  5 passed (5)
      Tests  115 passed (115)

(Paths shortened and wrapped.) 91 of those 115 tests were never written one by one: they are generated, one per program, from folders of programs.

Intuition

A driving examiner doesn’t invent a new route for every learner. There is a route, chosen to include the hard junction, the hill start and the school crossing. Every learner drives it. When someone finds a new way to fail, a new turn is added to the route.

Definition

A test corpus is a large, curated collection of programs used as test inputs. Each program is chosen to exercise one case. The tests are generated from the collection, so adding a case means adding a file, not writing test code.

What goes in a corpus file

The JavaScript corpus has 36 files, each probing one decision. Here is try_catch.js:

// viscosity: ps=soa
const ps = [];
for (let i = 0; i < 100; i++)
  ps.push({ x: i });
let s = 0;
try {
  for (let i = 0; i < ps.length; i++)
    s += ps[i].x;
  ps[1000].x = 1;
} catch (e) {
  console.log('caught',
    e.constructor.name, e.message);
}
console.log(s);

(Long lines wrapped.) It follows the rules every corpus file follows:

  • One idea. This file asks a single question: does an out-of-bounds write inside try throw the same error after compiling? The name says what it probes.
  • A header with the expected decision for every collection, so a compiler that stops optimizing fails too (chapter 88).
  • Observable. It prints the error’s class and message, and the sum. A difference that isn’t printed can’t be caught.
  • Small and deterministic. No timing and no randomness, so two correct runs always print the same thing.

Some files are traps for a specific kind of mistake. In typed_column_semantics.js, every field is Infinity or -Infinity, then multiplied by 0 and checked for NaN, a case where a typed column must behave exactly like a plain number. In the Python corpus, ints_stay_ints.py prints type(cs[5].count).__name__: a careless array('d') column would turn the integer 6 into 6.0, and the output would say float. Viscosity gives count a list column and the output stays int.

One file, many checks

The trick that makes the corpus scale is that each program is used by several tests, for different questions. Step through the matrix:

Step throughA scaled differential testing matrix
A grid: rows are the JavaScript corpus, the end-to-end programs and the Python corpus; columns are the decision check, the behaviour check, the two-parser check and the Rust fixtures. Cells fill in step by step.decisionbehaviourtwo parsersRust fixturesJS corpus36 files····e2e programs5 files—···Python corpus14 files··——SO FARprograms: 55◇ stored, not yet compared
The raw material: 36 small JavaScript corpus files, 5 end-to-end benchmark programs, and 14 Python corpus files. Each row is a folder of programs; each column is a kind of check.
1/7

All of these tests share one shape. The real loop, from differential.test.ts:

for (const f of readdirSync(dir)
    .filter((f) => f.endsWith('.js'))
    .sort()) {
  it(f, () => { /* ...compile, check
    the header, run both... */ });
}

The tests are written once, per kind of check. The cases are data.

Other languages, other processes

The Python test can’t use Node’s VM contexts, so it writes both programs to a temporary folder and runs each with the real python3. It compares standard output, and for a crash the last line of standard error, where Python prints the exception:

const run = (file) => {
  try {
    return { out: execFileSync('python3',
      [file], opts), err: null };
  } catch (e) {
    return { out: e.stdout, err: e.stderr
      .trim().split('\n').pop() };
  }
};
expect(run(b)).toEqual(run(a));

The oracle is still the original program. Only the way of running it changed.

The TypeScript compiler as an oracle

Part XI rebuilds Viscosity in Rust. How do you know the Rust version decides and emits the same things? The same way: differential testing, with the TypeScript compiler as the oracle.

engine/rust/scripts/gen_expected.mjs makes the fixtures. For every JS corpus file, every benchmark program and 20 extra programs written for the port, it runs the TypeScript compiler twice, once with the default bounds: 'check' and once with bounds: 'assume' (so the unchecked code paths are covered too), and writes both the compiled code and the explain report. That is 61 programs, 2 modes and 2 files: 244 fixture files, in engine/rust/tests/expected/.

The parity test, engine/rust/crates/cli/tests/parity.rs, recompiles each source in Rust and compares byte for byte. Run for this chapter, all 122 fixture pairs match. Part XI builds the port that passes it. The parity work also paid the reference back. It found bugs in the TypeScript compiler, among them a soundness bug: an element thrown from inside a loop wasn’t seen as escaping. All are fixed now.

What a corpus can’t find

Every file in the corpus was written by someone who thought of the case. The corpus is very good at keeping found bugs found, and at checking that a new feature didn’t break an old one. It can’t find the case nobody imagined. For that you need programs nobody wrote: the random programs of chapter 88, at scale, which Part XI returns to with coverage-guided fuzzing. Viscosity doesn’t fuzz yet.

Under the hood

Language implementations live by their corpora. Test262, the conformance suite for JavaScript itself, is a corpus of tens of thousands of test files covering the language specification[1]. Csmith is the opposite strategy: a generator of random C programs that finds compiler bugs by differential testing, rather than from hand-written cases[2]. Real compilers use both: a growing corpus of known cases, and a generator that keeps adding the unknown ones.

Your turn

ExerciseFuzz a toy layout compiler

A toy language has three statements over a list of records: ['push', v] adds { x: v }, ['inc', k] adds k to every x, and ['get', i] prints element i’s x, or none. runAoS runs a program with objects; runSoA, the “compiled” version, uses one Float64Array column, and has bugs. Write gen(rand): a random program of 1 to 8 statements, each a push of rand(10), an inc of 1 + rand(3), or a get of rand(6), all equally likely. Then write fuzz(seed, n, f, g): make one generator with makeRand(seed), generate up to n programs, and return the first program on which f and g give different output, or null.

// Given: a seeded random generator.
function makeRand(seed) {
  let s = seed;
  return (k) => {
    s = (s * 16807) % 2147483647;
    return s % k;
  };
}

// Given: the original, objects.
function runAoS(prog) {
  const ps = [];
  const out = [];
  for (const [op, a] of prog) {
    if (op === 'push') {
      ps.push({ x: a });
    }
    if (op === 'inc') {
      for (const p of ps) p.x += a;
    }
    if (op === 'get') {
      const p = ps[a];
      out.push(p ? p.x : 'none');
    }
  }
  return out.join(',');
}

// Given: the "compiled" version.
function runSoA(prog) {
  const xs = new Float64Array(4);
  let n = 0;
  const out = [];
  for (const [op, a] of prog) {
    if (op === 'push') xs[n++] = a;
    if (op === 'inc') {
      for (let i = 0; i < n; i++) {
        xs[i] += a;
      }
    }
    if (op === 'get') {
      const ok = a < xs.length;
      out.push(ok ? xs[a] : 'none');
    }
  }
  return out.join(',');
}

// Your code: a random program.
function gen(rand) {
  return [];
}

// Your code: the differential loop.
function fuzz(seed, n, f, g) {
  return null;
}
Extra practiceRead a corpus header

Write parseHeader(src). Find the first line that starts with // viscosity or # viscosity (not // @viscosity, which is an annotation). Return null if there is none. Otherwise return { mode, want }: mode is 'opt-in' if the line contains (opt-in), else 'auto'; want maps each name to its layout, from the comma-separated name=layout pairs after the first :. Ignore empty pairs.

function parseHeader(src) {
  const first = src.split('\n')[0];
  const [name, layout] = first
    .slice(first.indexOf(':') + 1)
    .trim().split('=');
  return {
    mode: 'auto',
    want: { [name]: layout },
  };
}

What you learned

  • A test corpus is a curated collection of programs; the tests are generated from it, so a new case is a new file.
  • A good corpus file probes one idea, states the expected decisions, prints what could differ, and is deterministic.
  • Each program feeds several checks: decisions, behaviour, parser equivalence and, for the Rust port, stored fixtures. 55 programs give 115 tests.
  • The TypeScript compiler is the Rust port’s oracle: 244 fixture files, compared byte for byte by the Rust parity test.
  • A corpus keeps known bugs found. Random programs find unknown ones.

Check your understanding

0/6
  1. 1.

    What makes a collection of test programs a corpus in Viscosity’s sense?

  2. 2.

    A corpus program computes something but prints only “done”. What is wrong with it?

  3. 3.

    How many tests does adding one JavaScript corpus file add to the TypeScript suite?

  4. 4.

    What is true of the Rust port’s parity testing today?

  5. 5.

    Why does the Python differential test run python3 in a subprocess instead of a VM context?

  6. 6.

    A fuzzer creates a new random generator from the same seed for every program. What happens?

References

  1. [1] Ecma TC39 and Test262 contributors. Test262: ECMAScript Test Suite. github.com/tc39/test262. github.com/tc39/test262 (checked 2026-10-31)
  2. [2] Xuejun Yang, Yang Chen, Eric Eide, John Regehr. Csmith: a random generator of C programs. github.com/csmith-project/csmith (README). github.com/csmith-project/csmith (checked 2026-10-31)