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.
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
QuickCheck and its descendants
Where the idea comes from, and the three kinds of property
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.
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:
- 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.
- Model equivalence (differential testing): the real engine and a slow, obviously correct reference produce the same event stream for the same input.
- Round-trips:
decode(encode(m)) == mfor every message (week 11), andreplay(journal) == statefor every journal (week 9).
What does a property-based test give you that a large suite of example tests does not, and what does it cost?
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.
Day 2 — Invariants of a limit order book
What must always be true
Drill 1: an invariant checker with zero false positives
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.
Why is it `submitted = resting + cancelled + 2 × traded` and not `1 × traded`?
Assert order book invariants
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!()
}
Why does the drill's harness test 200 valid books before it tests corrupted ones?
Day 3 — A model you can trust
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.
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.
Why generate a cancel as 'cancel the k-th currently live order' rather than 'cancel order id N'?
Day 4 — Shrinking
From 400 commands to 3
Drill 2: delta debugging on an order stream
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.
Shrink a failing order stream
#[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!()
}
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
Seeds, replays and simulation
Why a deterministic engine is so much easier to test
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'.
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.
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?
Day 6 — Engine v1
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)──▶ publisherIn 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.
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.
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 andFlowConfig::randomized(seed)varies tick, lot, band and the order-type mix for differential runs (both provided).next_command()andtake_commands(n)produce commands (OrderFlowis also anIterator), 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 firstViolation. 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 readsaccepted = 2·traded + cancelled + expired + reduced + resting(Totals::is_balanced). - A naive reference matcher,
NaiveEngine: aVecscanned on every operation, with the same semantics asEngine, validation and every order type included. Plusdifferential(&mut left, &mut right, &commands), which runs any twoMatchers 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.
crates/engineEngine 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.