dbit.one© 2026
000
booting_
Loading experience0%
dbit.one

Tests do not prove the absence of a bug. This does

Short version: a test suite is a search. It can say “found” and it cannot say “none”. For most code that is fine — but not for a payment protocol, an order state machine, or anywhere two tasks touch shared state: there the bug is a rare combination of steps. Model checking visits every reachable state and answers the question tests cannot answer at all.

12 min read

In short

  • A test is an existential claim: “there is a run where this works”. Finding nothing is not the same as there being nothing.
  • Model checking visits every reachable state of the model and returns either a shortest counterexample or a proof for that model.
  • Brute force is exponential, so the work is in the reductions: symmetric processes, and actions that commute.
  • A broken reduction answers exactly what a working one answers — “no violation”. So the exhaustive search is kept alongside and the verdicts must match.

What a green run actually says

A test is an existential statement: “there is a run in which the system behaves correctly”. A suite is many such statements. Neither one of them nor all of them together says “no behaviour that breaks the system exists” — they say “we did not run into one”.

For straight-line code the difference barely matters: the paths are countable and coverage closes them. It becomes decisive where the order of steps is not chosen by the programmer: two requests against one account, a payment retried after a timeout, a queue and its consumer, a lock and the wait for it. There the space of behaviours is combinatorial, and tests take a handful out of it.

Making a rare schedule reproducible is a separate job, and it is solved by deterministic simulation: a seed instead of randomness, byte-for-byte replay. But that is still sampling. Two hundred schedules out of an astronomical space is a very good fuzz run, not a theorem.

Where the difference costs money

What happens in production
Why tests miss it
Deadlock: two transfers take two locks in opposite order
it needs a precise order of four steps, which does not come up in a test
A thread is alive but never reaches the critical section
nothing crashes — it simply does not finish; there is no assertion to fail
An order reaches a state that is not on the diagram
the diagram is in someone’s head; the code has transitions, and the test checks the expected ones
A payment is confirmed to the client and cancelled by the provider
the scenario needs a failure exactly between two calls

None of these is about the amount of code; all are about the number of combinations. That is exactly where exhaustive checking applies: protocols, state machines, locking, coordination between services. Ordinary CRUD is not in that set, and dragging model checking there is a waste.

What model checking is

You describe the system as a state machine: initial states, actions (each with a guard and an effect) and the statements that must always hold. The tool then visits every reachable state and either finds a path into a state where a statement is false, or proves that no such path exists.

ts
const spec = {
  name: 'counter',
  processes: 2,
  init: [{ n: 0 }],
  actions: [
    {
      name: 'p0: increment',
      process: 0,
      reads: ['n'],
      writes: ['n'],
      step: (s) => (s.n < 3 ? [{ n: s.n + 1 }] : []),
    },
  ],
  invariants: [{ name: 'n stays small', reads: ['n'], holds: (s) => s.n <= 3 }],
  terminal: (s) => s.n === 3,
};
A specification: an action returns its successor states, an empty list means “not enabled”

One detail of the shape matters: the guard and the effect live in one function. The action returns its successor states, and an empty list means “impossible right now”. That way the two cannot drift apart — a classic source of wrong hand-written models.

The second: reads, writes and the symmetric process sets are declared, not guessed. TLA+ asks for the same thing. The price is honest: a wrong declaration becomes a bug in the specification rather than silent unsoundness in a tool that quietly dropped the states that mattered.

The counterexample matters more than the verdict

“A violation is possible” is useless without the path to it. Breadth-first search makes the path shortest by construction, so the counterexample reads like an incident report rather than a dump.

text
✗ peterson (check then set) — invariant "at most one process in the critical section"

  trace
    (initial)                    pc=00 flag=00
    p0: check the other flag     pc=10 flag=00
    p1: check the other flag     pc=11 flag=00
    p0: raise flag and enter     pc=31 flag=10
    p1: raise flag and enter     pc=33 flag=11
Peterson’s algorithm, broken: check the other flag first, raise your own second

Both look, both see a lowered flag, both walk in. Four steps, and it is obvious why the flag has to go up before the check. Things like this take a minute with a trace and weeks with “sometimes two of them end up inside”.

Why brute force does not work directly

The number of states grows as a product: six processes in five phases is 15,625 combinations, and that is a toy. So the substance of the tool is not the search but everything done to avoid searching.

Specification
States: unreduced → with both reductions
workers(4)
625 → 17, a factor of 37
workers(6)
15,625 → 25, a factor of 625
peterson
20 → 20, no gain at all

The last row matters more than the first two. Where processes constantly read and write each other’s variables there is nothing to reduce — and the tool must honestly deliver nothing. That row is an assertion in the test suite: a tool that claimed a gain there would be claiming something false.

Two ways not to search

Symmetry. When processes are interchangeable, states differing only by permuting them are the same state. Six workers in five phases give 15,625 assignments but only 210 multisets: what matters is how many are where, not which one is which.

Commuting actions. When two actions touch nothing in common, the order between them is not observable: having explored one order, you learn nothing from the other. Formally these are ample sets, and their four conditions each earn their place — drop one and the checker will confidently miss violations.

How to check the thing doing the reducing

Here is the ugliest part of the whole exercise. A reduction that drops states it should have kept produces exactly the same output as one that works: “no violation found”. From inside the reduced search there is no way to tell which one you are holding.

The only workable answer is not to take the reduction at its word. The exhaustive search is kept alongside: slow, complete, and unable to be wrong. Every specification runs through both, and the reduced verdict is required to match the full one. The reduction is trusted not because its author is confident but because it agrees with something that cannot be mistaken.

That guarantee has a boundary worth naming: the comparison is only possible where the exhaustive search still runs — on small models. Precisely where the reduction is not needed. It is inherent to the approach, and it does not invalidate the check, but it sets its ceiling.

The same move elsewhere: a bug museum where each exhibit switches off one rule of the algorithm and has to be caught, and a ladder of isolation levels where each rung must produce what the next one forbids.
The rule “a green run is not proof” in our engineering standards

Liveness: nothing crashes, it just never gets there

Safety asks whether a bad state is reachable, and breadth-first search answers it. Liveness asks whether something good eventually happens, and it is violated by an infinite run in which it never does. In a finite state space such a run is a lasso: a path into a cycle, then the cycle forever.

The trouble is that most such cycles are absurd: they require a process that could run to simply never be scheduled. Weak fairness rules those out — a continuously enabled process must eventually move. Without that condition every concurrent program “fails” liveness and the checker is useless.

text
✗ spinlock — can loop forever without process 0 reaching its critical section
  The cycle is fair: process 1 keeps moving; process 0 is blocked

  then forever
    p1: acquire   pc=03 lock=1
    p1: release   pc=00 lock=-1
The difference between a spinlock and Peterson’s algorithm shows up exactly where it should

A spinlock is perfectly safe — two processes are never inside at once — and it starves. Peterson passes the same check, and the turn variable exists for no other reason. The difference between “safe” and “safe and makes progress” is worth being able to name: in queues, locks and distributed leases it is the source of hangs that monitoring never reports as errors.

What this method cannot do

  • It checks the model, not the code. That is both the main limit and the main risk: a proven model and an implementation that drifts from it is an ordinary situation. Models catch mistakes of design, tests catch mistakes of execution; neither replaces the other.
  • Reductions are sound with respect to what you declare. An action that writes an undeclared variable can cost you dropped states. It is a contract, not an analysis.
  • Only invariants and one form of liveness. Statements of the form “always true” and “eventually happens” under weak fairness. No nested temporal operators, no strong fairness, no full LTL.
  • Everything is in memory. Thousands or tens of thousands of states, not billions: no disk-backed store, no symbolic representation.
  • For serious work there are TLC and SPIN. Decades of engineering behind them, disk-backed state storage, distributed checking, and languages designed for the job.

When it pays off on a commercial project

How to tell your problem needs exhaustive checking rather than ten more tests
  1. 01

    Count who touches the shared state

    If two or more independent processes work on the same data — a payment and a webhook, a worker and a scheduler, a user and a background reconciliation — there are more combinations than you will write tests.

  2. 02

    State the invariant in one sentence

    “A paid order never returns to awaiting payment”, “two transfers cannot take out more than the balance”. If the invariant cannot be stated, there is nothing to check yet — first agree on what must be true.

  3. 03

    Write a model of 30–60 lines, not the whole system

    You model the protocol, not the application: order states, provider steps, failures and retries. Everything else stays out — that is not a simplification but the condition under which the search terminates at all.

  4. 04

    Turn the counterexample into a test

    The trace is a ready-made regression scenario. The model stays in the repository, the test guards the implementation, and each does what it is good at.

In money terms this pays off where wrong data costs more than downtime: financial systems, stock accounting, integrations with external providers. Neighbouring write-ups: payment idempotency and what happens to your data when the master dies.

Frequently asked questions

Is this the same thing as TLA+?

The same idea and the same algorithms: describe the system as a state machine and visit the reachable states. TLA+ with TLC is an industrial tool with its own language, disk-backed state storage and distributed checking; for real protocol verification, use it. The difference is the barrier to entry: a model in TypeScript lives in the same repository as the code and is read by the whole team rather than by one enthusiast.

Do we now have to describe the system twice — as a model and as code?

The model describes the protocol, not the system: states, transitions, failures. That is tens of lines, not thousands. There is no duplication precisely because the model is deliberately poorer than the implementation — it has no database, no network and no interface, but it does have every combination of steps.

What if there are too many states?

Shrink the model, not the machine: three processes instead of ten, two currencies instead of thirty. Protocol bugs almost always show up on small instances — a well-known practical observation that the whole practice of modelling rests on. If that still does not help, the problem belongs to TLC or SPIN.

Why write your own model checker when mature ones exist?

To know what I am trusting. Search reductions are code that can quietly drop the states that mattered and still return the same green answer; having written them, you understand exactly what you are risking when you use someone else’s tool. The code is open: pnueli, MIT, linked in the sources.

Sources

Every claim here can be checked: below are the primary sources, not a retelling.

  1. 01Clarke, Grumberg, Peled. Model CheckingPartial-order reduction and the ample-set conditions
  2. 02TLA+ and TLCThe industrial tool for the same job
  3. 03SPINModel checking of protocols, decades of practice
  4. 04Amir Pnueli — Turing AwardTemporal logic in program verification: “always” and “eventually”
  5. 05pnueliThe tool from this article: MIT, exhaustive search kept beside the reductions
Author
Doniyor Botirov
Founder of dbit.one · author of this piece

This piece is signed by name, unlike the rest: there is no client behind it. The code it discusses is fully open and published under the same name — you can read it, run it and check the article’s claims instead of taking any of them on trust.

Related services

Read next

Questions about your project?

Describe your task — within 24 hours we’ll come back with an estimate, timeline and plan.

[email protected]