← Back to roadmap
Phase 1Week 12~120 min read
0%·0/4

Property-Based Testing & Engine v1

Let a generator find the bugs you would never write a test for.

Foundation · Concurrency, Memory & the Core Engine

Why this week

Phase 1 ends here. You have a book (week 6), matching (7), order types (8), a journal (9), market data (10) and a wire format (11). Each was built against example tests: you wrote down an input and an expected output. Example tests check the cases you thought of. The bugs that reach production in matching engines are the ones nobody thought of. A cancel arrives for an order that was partially filled earlier in the same command. A fill-or-kill walks five levels and the fourth holds a zero-quantity ghost. Two orders at one price get their FIFO positions swapped after a modify.

Property-based testing turns this around. You state what must always be true, generate thousands of random order streams, and let the computer find a counterexample. When it finds one, a shrinker cuts the 400-command failing stream down to the 3 commands that matter. You'll write the two pieces that make this work, an invariant checker and a shrinker, and then point the full machinery at your engine as it becomes engine v1.

💡
Tip

The mental shift: stop asking "what does this input produce?" and start asking "what is true of every output?" A good property is one you could check on a production snapshot at 3 a.m. "The book is never crossed" is a property. "Input X gives output Y" is an example.

Day 1 — Properties, not examples

Day 01

QuickCheck and its descendants

Where the idea comes from, and the three kinds of property

Paperby Koen Claessen and John Hughes (ICFP 2000)
QuickCheck: A Lightweight Tool for Random Testing of Haskell Programs

The paper that started it. Read sections 1–3 for the core idea: properties as executable specifications, generators as values you compose, and the observation that random testing finds real bugs far more often than intuition suggests.

Videoby John Hughes
Testing the Hard Stuff and Staying Sane

Watch how he tests stateful systems: generate a sequence of API calls, run them against the real system and a simple model, and compare after every step. That is exactly the differential test you build on Day 3.

Three kinds of property cover nearly everything you'll write for an exchange:

  1. Invariants: facts about state that hold after every command. The book is not crossed. Every resting order has positive quantity. L2 equals the aggregate of L3.
  2. Model equivalence (differential testing): the real engine and a slow, obviously correct reference produce the same event stream for the same input.
  3. Round-trips: decode(encode(m)) == m for every message (week 11), and replay(journal) == state for every journal (week 9).
Recall#property-vs-example

What does a property-based test give you that a large suite of example tests does not, and what does it cost?

Design note#invariant-list

Write down every invariant your engine should satisfy after any command, including the order types from week 8 (IOC never rests, FOK never partially fills, post-only never takes liquidity). For each, note whether it can be checked from the final state alone or needs the event stream.

0 chars

Day 2 — Invariants of a limit order book

Day 02

What must always be true

Drill 1: an invariant checker with zero false positives

Matching sandboxprice-time priority · chips are resting orders in FIFO order
104
60
103
65
102
50
101
50
spread 1
100
50
99
45
98
50
97
70
Events (newest first)

Submit an order that crosses the spread to see trades.

The book invariants, from the most to the least obvious:

  • Not crossed, and not locked. Best bid strictly below best ask. In a single venue's own book, bid equal to ask is also a bug: those two orders should have traded on arrival. (Locked markets do appear across venues in a fragmented market, which is a different story.)
  • Live, unique orders. Every resting order has quantity > 0 and a unique id. A zero-quantity order means a fill path forgot to remove it. That ghost then sits in the FIFO and corrupts queue position for everyone behind it.
  • L2 agrees with L3. Every published level's total and count equals the sum over the orders at that price. Every price with orders has a level, and every level has orders. This is the invariant that catches market-data bugs (week 10) before your clients do.
  • Conservation of quantity. Over the lifetime of the book: submitted = resting + cancelled + 2 × traded. The factor of 2 is the subtle part. A trade of 5 removes 5 from the aggressor and 5 from the passive order.
Recall#conservation-2x

Why is it `submitted = resting + cancelled + 2 × traded` and not `1 × traded`?

Drill

Assert order book invariants

Target: 6/6 cases · no false positives on 200 random valid booksNot yet run
use std::collections::{HashMap, HashSet};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Side {
    Buy,
    Sell,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Resting {
    pub id: u64,
    pub side: Side,
    pub price: i64,
    pub qty: u64,
}

/// One L2 level as the book publishes it (week 10).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Level {
    pub side: Side,
    pub price: i64,
    pub total_qty: u64,
    pub count: u32,
}

/// Engine state plus lifetime counters, captured after a command.
pub struct Snapshot {
    pub orders: Vec<Resting>,  // every resting order (L3)
    pub levels: Vec<Level>,    // the published L2 view
    pub submitted_qty: u64,    // sum of qty over every accepted order
    pub traded_qty: u64,       // sum of qty over every trade (counted once)
    pub cancelled_qty: u64,    // cancels + IOC/market remainders
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Violation {
    /// best bid >= best ask (locked counts: it should have traded).
    Crossed { bid: i64, ask: i64 },
    ZeroQty { id: u64 },
    /// Report each duplicated id once, however many copies there are.
    DuplicateId { id: u64 },
    /// L2 and L3 disagree at (side, price): wrong total or count, a level
    /// with no orders, or orders with no level. Report each key once.
    LevelMismatch { side: Side, price: i64 },
    /// submitted != resting + cancelled + 2 * traded
    QtyNotConserved { submitted: u64, accounted: u64 },
}

/// Every violation in the snapshot; order of the Vec does not matter.
pub fn check(s: &Snapshot) -> Vec<Violation> {
    todo!()
}
Rust
Recall#false-positives

Why does the drill's harness test 200 valid books before it tests corrupted ones?

Day 3 — A model you can trust

Day 03

Differential testing and generators

A 60-line matcher that is obviously right

Invariants catch broken states. They don't catch a wrong but internally consistent outcome, such as a fill at the wrong price, or the second order at a level filling before the first. For that you need a model: a second implementation that is slow, simple and obviously correct, run on the same input, with outputs compared event by event.

For a matching engine the model is a Vec of resting orders, each tagged with an arrival counter. To match, scan for the best opposite order (highest bid or lowest ask, ties broken by lowest arrival counter), fill, repeat. Cancels are a linear search. It's O(n) per operation and about 60 lines, and you can verify it by reading it. Never optimize the model. Its only job is to be right.

Paperby William M. McKeeman (Digital Technical Journal, 1998)
Differential Testing for Software

Originally about C compilers: feed the same random programs to several implementations and investigate disagreements. Your naive matcher plays the role of the second compiler.

Generators decide what you find. Uniformly random prices over a wide range almost never cross the spread, so matching code goes untested. Shape the distribution:

  • Prices within a few ticks of a drifting mid, so that crossing, partial fills and multi-level sweeps are common.
  • Small quantities (1–10), so partial fills dominate.
  • Cancels and modifies that mostly target live orders, with a small fraction targeting dead or unknown ids to exercise the reject paths.
  • Every order type from week 8 represented, with post-only and FOK at meaningful rates.

Use a seeded generator you own. A small, fast, well-known choice is SplitMix64:

struct SplitMix64(u64);
impl SplitMix64 {
    fn next(&mut self) -> u64 {
        self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
        let mut z = self.0;
        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
        z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
        z ^ (z >> 31)
    }
}

The repo's types::Rng is exactly this generator. Print the seed on every failure. A failing seed is a complete, deterministic reproduction. Your engine is deterministic (week 9), so the same seed gives the same stream, the same events and the same bug.

Recall#cancel-by-index

Why generate a cancel as 'cancel the k-th currently live order' rather than 'cancel order id N'?

Day 4 — Shrinking

Day 04

From 400 commands to 3

Drill 2: delta debugging on an order stream

Paperby Andreas Zeller and Ralf Hildebrandt (IEEE TSE, 2002)
Simplifying and Isolating Failure-Inducing Input

The ddmin algorithm. Read for the definition of 1-minimality and for the chunking strategy: try removing large chunks first, halve on failure. Note the worst-case quadratic bound and why it rarely bites in practice.

A random failing stream is useless as a bug report. Nobody can debug 400 commands. Shrinking finds a 1-minimal failing input: removing any single command makes the failure disappear. That is weaker than globally minimal, which would need exponential search, but in practice it's nearly always the reproduction you'd have written by hand.

The algorithm in one paragraph: try deleting the first half, then the second half. Whenever a deletion still fails, keep it. When no chunk of the current size can be removed, halve the chunk size, down to single commands. Then shrink values: for each order, binary-search its quantity downward while the failure persists. Repeat both phases until nothing changes. Count your predicate calls. Each call is a full engine run, so a shrinker that restarts from scratch after every successful deletion becomes quadratic and slow.

Library context: QuickCheck-style libraries shrink values through per-type shrink functions. proptest shrinks through the ValueTree that generated the value, and Python's Hypothesis shrinks the underlying sequence of random choices. The repo doesn't use proptest: its engine::testkit::shrink is a hand-written delta-debugging shrinker over commands, the deletion half of today's drill. The drill makes you write the core yourself so you know what a library is doing when it takes 30 seconds.

Drill

Shrink a failing order stream

Target: 5/5 cases · 1-minimal, minimal qty, ≤1500 calls on 400 opsNot yet run
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
    Buy,
    Sell,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Op {
    Add { id: u64, side: Side, price: i64, qty: u64 },
    Cancel { id: u64 },
}

/// `fails(&input)` is true. Return a smaller stream for which it is still true:
///   1. 1-minimal: removing any single op makes `fails` return false;
///   2. each Add's qty is minimal: qty == 1, or qty - 1 no longer fails;
///   3. surviving ops keep their original relative order.
/// You may assume `fails` is monotone in qty (raising a qty never fixes it).
/// The harness counts calls to `fails` on a 400-op input: keep it under 1500.
pub fn shrink<F: Fn(&[Op]) -> bool>(input: Vec<Op>, fails: F) -> Vec<Op> {
    todo!()
}
Rust
Recall#one-minimal

What is the difference between a 1-minimal failing input and a minimum one, and why do shrinkers settle for 1-minimal?

Day 5 — Determinism as a testing superpower

Day 05

Seeds, replays and simulation

Why a deterministic engine is so much easier to test

Videoby Will Wilson (Strange Loop 2014)
Testing Distributed Systems w/ Deterministic Simulation

How FoundationDB ran its whole database, network and disks inside a deterministic simulator driven by one seed. Your exchange is a much easier case: the engine is already a pure function of its input log. Notice how much leverage the team gets from 'every failure is a seed'.

Docs
proptest

Read the strategy combinators (prop_oneof!, collection::vec, prop_map) and the failure-persistence section: failing cases are saved under proptest-regressions/ and replayed first on the next run. Commit that directory. It is your regression corpus.

Your previous weeks already supply three properties that only work because the engine is deterministic:

  • Replay identity (week 9): run a generated stream, then rebuild state from the journal. The state hashes must match.
  • Market-data fidelity (week 10): a client book built only from your L2 incrementals equals the engine's book after every command.
  • Wire round-trip (week 11): every generated command survives encode → decode unchanged.

Run all of them inside the same generated stream, after every command. The marginal cost is small and the coverage compounds.

Design note#failing-seed

CI reports a property failure with seed 0x5EED. Describe exactly what happens next, from reproducing locally to the fix landing. Where does the shrunk case end up so it can never regress, and what do you do if the failure doesn't reproduce with that seed?

0 chars

Day 6 — Engine v1

Day 06

Wiring Phase 1 together

One command in, journal, match, events, market data out

Engine v1 is the first time all the Phase 1 pieces run as one pipeline on one thread:

bytes ──decode (w11)──▶ command ──sequence + journal (w09)──▶ validate (w08)
      ──match (w07, book w06)──▶ events ──L2 incrementals (w10)──▶ publisher

In the repo, engine v1 is engine::Pipeline, and it starts from a decoded Command: journal append, Engine::process (validation and matching), then the L2 publisher. Decoding bytes stays at the edge, in the week 13 gateway.

The journal comes before matching. The input is made durable (in memory for now) before any state changes, so replay reproduces exactly what happened. That's the LMAX-style "journal the input, then run the business logic" ordering. Matching emits events into a reusable buffer, and market data consumes that buffer, not the book.

The week's throughput target, 5M commands per second end to end, is a budget of 200 ns per command on average. Your earlier targets were stated as p99 (journal append 100 ns, L2 update 100 ns, matching 450 ns). Averages run well below p99, so the budget is reachable, but only if nothing on the path allocates per command. If you miss it, profile before guessing. Week 26 makes profiling a habit.

Design note#phase1-retro

Phase 1 retrospective: which week's code was hardest to put under property tests, and what does that say about its design? Name one invariant your property tests found broken this week, or, if none failed, explain why you trust the generator enough to believe that.

0 chars

Capstone

The repo work this week lives in crates/engine and has two halves: the testing machinery, and engine v1 itself.

The testing machinery lives in engine::testkit:

  • A seeded command generator, OrderFlow::new(FlowConfig), producing streams with the distributions from Day 3: prices around a random-walking mid, every week 8 order type, and a small share of malformed commands for the reject paths. FlowConfig::default() is a realistic mix and FlowConfig::randomized(seed) varies tick, lot, band and the order-type mix for differential runs (both provided). next_command() and take_commands(n) produce commands (OrderFlow is also an Iterator), and the tests and the w12 benchmark share the one generator. Cancels and reduces name concrete ids drawn from the generator's own live set rather than "the k-th live order"; observe(&cmd, &events) feeds the engine's events back to keep that set exact. Without feedback, or after the shrinker deletes the add, some of them hit dead ids and are rejected as unknown, identically in the engine and the model, so the differential test stays meaningful.
  • Invariant checks after every command: InvariantChecker::check(&cmd, &events, &book) returns the first Violation. It checks the event-order contract, the matching rules, the book (never crossed, levels ordered with correct aggregates, FIFO, no zero-quantity orders) and conservation of quantity. With every way quantity can leave, the repo's law reads accepted = 2·traded + cancelled + expired + reduced + resting (Totals::is_balanced).
  • A naive reference matcher, NaiveEngine: a Vec scanned on every operation, with the same semantics as Engine, validation and every order type included. Plus differential(&mut left, &mut right, &commands), which runs any two Matchers on the same stream and compares the events and the full resting book after every command, returning the first divergence.
  • Shrinking: shrink(&commands, fails) returns a 1-minimal failing stream by deleting commands. The repo does not shrink values. On a failure, the tests print the seed and the shrunk stream; turn it into an example test so it can never regress.

Engine v1: Pipeline in src/pipeline.rs (Pipeline::new(config), or Pipeline::with_journal(journal, engine)). Its process(&cmd) appends the command to the journal, runs Engine::process, feeds each event to the L2 publisher, and returns an Output with the sequence number, the events and the L2 updates. All of it is single-threaded and allocation-free in steady state, with the event and update buffers reused. The engine also implements journal::StateMachine, so the week 9 replay can rebuild it from the journal.

Pitfalls:

  • A generator that never reaches the bug. Measure coverage of interesting events: what fraction of commands produce a trade, a multi-level sweep, an FOK kill, a post-only reject? If any of those is near zero, fix the distribution before trusting a green run.
  • A model that shares code with the engine. If the model calls the same price-level helper as the engine, a bug in the helper agrees with itself. Keep the model independent.
  • Comparing too little. Compare event streams, not just final books. Two engines can arrive at the same book through different fills.
  • Benchmarking with checks on. Invariant checks are O(book size) per command. Run them in tests only, and gate them behind cfg(test) or a feature in the benchmark build.

How to think about the throughput target: w12.engine_v1_throughput runs a million commands of the default OrderFlow mix through Pipeline with an in-memory journal, drained every 256 KiB as if its frames had gone to a disk writer. 5M commands per second means the whole pipeline averages 200 ns. Measure each stage separately first (you have targets from weeks 7, 9 and 10). If the parts add up to the target and the whole doesn't, the difference is usually allocation, a cache-hostile data structure between stages, or a debug assertion left on.

Capstone · in your exchange · crates/engine

Engine v1 under a generator

Seeded order-stream generator, invariants after every command, a differential test against a naive matcher, and engine v1 wired end to end.

cargo xtask test --week 12      # tests named w12_*
cargo xtask bench --week 12     # p50 / p99 / p99.9 on your machine
cargo xtask report --upload  # update this page and your lab
  • All week 12 tests pass w12_*
  • Engine v1 end to end (journal + match + md) w12.engine_v1_throughputtarget ≥ 5.0M ops/s

Feeds into

  • Week 13 puts the engine behind a TCP gateway, with sessions, framing and the week 11 codec in front of it.
  • Week 36 shards the engine by instrument. The differential test against a single-engine model is how you prove sharding changed nothing per instrument.