ExTLA tutorial. Previous: From a system you have to a specification you can check.
Sooner or later a run does not come back. The mathematics explains why — the state space is a product, so each participant multiplies it — and modelling measured it: one more job took that specification from 64 states to 512. This page is what to do about it, with every number measured on one specification that genuinely hurts.
It is the same job runner, at three jobs and three attempts:
defmodule Boom.Runner do
use ExTLA.Spec
constant :jobs, default: set([:j1, :j2, :j3])
constant :max_attempts, default: 3
variable :status, type: [^jobs ~> [:queued, :running, :done, :failed]]
variable :attempts, type: [^jobs ~> 0..^max_attempts]
init do
status = for_each(^jobs, fn _ -> :queued end)
attempts = for_each(^jobs, fn _ -> 0 end)
end
action :start, params: [j: ^jobs] do
require status[j] == :queued
require attempts[j] < ^max_attempts
status = update(status, j, fn _ -> :running end)
attempts = update(attempts, j, &(&1 + 1))
end
action :succeed, params: [j: ^jobs] do
require status[j] == :running
status = update(status, j, fn _ -> :done end)
end
action :crash, params: [j: ^jobs] do
require status[j] == :running
status = update(status, j, fn _ -> :queued end)
end
action :give_up, params: [j: ^jobs] do
require status[j] == :queued
require attempts[j] == ^max_attempts
status = update(status, j, fn _ -> :failed end)
end
invariant :attempts_bounded do
Enum.all?(^jobs, fn j -> attempts[j] <= ^max_attempts end)
end
terminal :settled do
Enum.all?(^jobs, fn j -> status[j] == :done or status[j] == :failed end)
end
endBoom.Runner — verified
1331 distinct states, 3630 transitions, depth 22, 46msEleven reachable combinations per job, three jobs, 11^3. Each job you add multiplies the space
by eleven, so this is 1,331; four jobs is 14,641 and five is 161,051, which takes nine seconds.
Eight jobs would not finish at all. That is the arithmetic the rest of this page is about.
First, know which kind of "did not finish" you have
Three outcomes look similar in a terminal and are not the same fact.
A state limit. --max-states stops the search:
Boom.Runner — INCOMPLETE (state limit)
502 distinct states, 1199 transitions, depth 11, 16msExit code 2, and the coverage footer records bounded_by: :max_states. Nothing was
established. This is the flag to reach for when you want to know whether a spec starts correctly
before committing an hour to it — a bounded run that finds a violation has still found a real one.
A state constraint, an option of ExTLA.ModelChecker.run/2, is not exhaustive for the same
reason: the result's status is :constrained, with bounded_by: :state_constraint — §Constrain
the space below.
Still running is the third, and the only one with no output to read. --max-states is how you
convert it into one of the first two.
The levers, cheapest first
Shrink the constants
Almost always the right answer, and the one people resist because it feels like giving up. The same specification at two jobs and two attempts:
Modelling.Runner — verified
64 distinct states, 112 transitions, depth 11, 2ms1,331 states become 64. What is lost is nothing you were relying on: an interleaving bug that needs three jobs and three attempts to appear is rare to the point of being worth doubting, and modelling makes the argument in full. Try this before anything else on this page.
Symmetry, when the participants really are interchangeable
The largest single win available here, because jobs is exactly the kind of constant the
reduction is for:
Boom.Runner — verified
286 distinct states, 780 transitions, depth 22, 22ms1,331 to 286 — a factor of nearly five, with the same depth and the same verdict, and
--verify-symmetry confirming the declaration was sound. Symmetry is the page
for what that flag does and why it is your obligation; the point here is that when it applies it
beats every other lever combined.
Use more cores
mix extla.check Boom.Runner --workers 4
On a model this size that is usually slower than one worker, and the reason is worth knowing:
each breadth-first level waits for every worker before the next begins, and that coordination costs
more than the work saved when there is barely any work. --workers pays on a model large enough
to need it — tens of thousands of states and up, where each level has real work to divide.
What it never buys is tractability. It explores the identical space, so a run that would never
finish still never finishes — reach for it after the levers above, not instead of them. It also
refuses to check liveness (§Parallel exploration), so a spec with a temporal has
its safety checked in parallel and says so.
Skip liveness while you are iterating
--safety-only turns off the temporal checking, and with it the liveness graph the check needs.
Useful when the question you are currently asking is about an invariant; not a reduction, since
the state space is the same.
Constrain the space
state_constraint restricts the search to states worth exploring. It is the sharpest tool here
and the only one that can quietly invalidate your result, so both halves matter.
# In a test, or wherever you call the checker directly.
ExTLA.ModelChecker.run(Boom.Runner, %{
constants: %{jobs: MapSet.new([:j1, :j2, :j3, :j4, :j5]), max_attempts: 3},
state_constraint: fn state ->
Enum.count(state.status, fn {_job, s} -> s == :running end) <= 2
end
})Measured on this specification, three constraints and what each bought:
| Constraint | States | Time |
|---|---|---|
| none | 1,331 | 55 ms |
| at most 2 jobs running | 1,331 | 45 ms |
| at most 2 jobs not queued | 1,304 | 46 ms |
| no job past its first retry | 112 | 5 ms |
Those four rows were measured with ExTLA.ModelChecker.run/2, since state_constraint: is a
function and can only be passed there — mix extla.check has no flag for it. The times are one
machine's; the state counts are the specification's.
The first constraint is the lesson people do not expect: a constraint can buy nothing at all. Limiting concurrency to two removed not one state — with three jobs there is no reachable state with three running, so the constraint excludes nothing that existed. The second removed 2%. Both are guesses about where the states are, and this model's states differ in their attempt counts rather than in how many jobs run at once. Measure rather than assume.
And the last row is the trap. It is a twelvefold reduction, it runs in 5 ms, and it is
exactly the wrong constraint for this specification: the property being checked is
attempts_bounded, and the constraint excludes every state where a job has attempted more than
once — which is where a violation of that property could possibly live. The run comes back with
status :constrained and bounded_by: :state_constraint, and that is the tool telling you it
answered a smaller question than you asked. It is not a verdict, and the danger is reading it as
one.
The rule that follows: a state constraint must not restrict the variables your property is about. Constrain the parts of the state the question does not depend on.
Fingerprint width, and what a verdict rests on
The checker identifies states by a hash rather than by keeping them, so two distinct states could in principle collide and one of them go unexplored. Every run reports its own collision probability:
--fingerprint-bits | Collision estimate at 1,331 states |
|---|---|
| 64 (default) | 4.8 × 10⁻¹⁴ |
| 128 | 2.6 × 10⁻³³ |
At this size the default is already far below any other risk in the exercise — you are more likely to have mis-modelled the system. It is worth knowing the number exists, and worth raising the width for a run whose result you intend to rely on at a much larger state count, where the estimate grows with the square of the number of states.