PropertyDamage.Linearization (PropertyDamage v0.2.0)
View SourceLinearization checking for parallel execution results.
When commands execute in parallel branches, the Linearization module verifies that the observed results can be explained by some sequential (linear) ordering of the commands.
What is Linearizability?
A parallel execution is linearizable if there exists a sequential ordering of the commands such that:
- The ordering respects the happens-before relationship within each branch
- Each command's observed events are consistent with what the model predicts for the state at that command's position in the ordering
If no such ordering exists, the system has exhibited non-linearizable behavior, which typically indicates a race condition or consistency bug.
Algorithm
Given branch execution results:
- Generate interleavings (orderings that preserve intra-branch order)
- For each interleaving, walk the commands in order, asking the model's simulator what events each command SHOULD produce given the model state at that point, and compare against the events the SUT actually produced for that command
- Advance the model state with the OBSERVED events and, at that same
position, run the model's synchronous (
@trigger) assertions against the advanced state - Advance and continue
- If any interleaving is fully consistent (events compatible AND all triggered assertions hold at every position), the execution is linearizable
Soundness invariant (why this module exists)
A concurrent/branching execution is a failure ONLY if NO single ordering of the branches can simultaneously (a) reproduce every command's observed events and (b) satisfy every triggered synchronous assertion at that command's position. The observed events and the model-state prediction an assertion runs against MUST come from the SAME candidate ordering.
This invariant is the antidote to a soundness bug that over-reported races:
the executor used to run each branch's synchronous assertions against that
branch's forked projection state. A fork is a partial view: it sees the
prefix plus its own branch, never the concurrently-executing sibling
branches' effects. So a read in one branch could observe a value written by a
sibling's write (the real interleaving) while the reader's model state never
recorded that write, firing a spurious assertion. Put k v ∥ Get k was
flagged even though Put-then-Get is a perfectly legal serialization. The fix:
the executor disables those unsound per-branch assertions, and assertion
checking moves HERE, where it is evaluated against observed events and the
model prediction drawn from one consistent ordering. check/5 therefore
refutes an execution only when every ordering fails, and its refutation
carries the specific assertion (when one is the cause) so the report is as
precise as the old per-branch path was, without its false positives.
Verification strength
The check is exactly as strong as the model's simulator: predicted event
fields that are nil, unresolved refs, or external markers are treated as
wildcards, and commands the simulator does not predict (returns []) are
unconstrained. A model without a simulator/0 cannot be verified at all;
check/5 then returns {:indeterminate, 0} rather than fabricating a
verdict.
Complexity
The number of interleavings grows multinomially with branch count/length
(see linearization_count/1). check/5 examines at most :max_candidates
interleavings (default 1000); if none is consistent but the candidate space
was not exhausted, it returns {:indeterminate, checked} instead of
claiming a race.
Usage
case Linearization.check(branches, branch_events, projections, model,
start_index: 3) do
{:ok, linearization} -> # consistent ordering found
:no_linearization -> # exhaustively refuted: race condition
{:indeterminate, n} -> # cannot verify (no simulator / cap reached)
end
Summary
Functions
Check if branch execution results are linearizable.
Check if linearization checking is feasible for given branches.
Generate all valid linearizations of branch commands (untagged).
Count the number of possible linearizations for given branches.
Verify a specific tagged linearization against observed per-command events.
Types
@type branch_events() :: %{ required(non_neg_integer()) => [PropertyDamage.EventLog.Entry.t()] }
@type linearization() :: [tagged_command()]
@type projection_state() :: map()
@type refutation() :: nil | %{ branch_id: non_neg_integer(), position: non_neg_integer(), command: struct(), check_name: atom(), reason: term() }
@type tagged_command() :: {branch_id :: non_neg_integer(), position :: non_neg_integer(), struct()}
Functions
@spec check([[struct()]], branch_events(), projection_state(), module(), keyword()) :: {:ok, linearization()} | {:no_linearization, refutation()} | {:indeterminate, non_neg_integer()}
Check if branch execution results are linearizable.
Parameters
branch_commands- List of command lists, one per branch (commands should have refs resolved so simulator predictions see concrete values)branch_events- Map of branch_id to CHRONOLOGICAL event log entriesprojections- Initial projection states (from prefix execution), as a%{projection_module => state}mapmodel- Model module (verification requiressimulator/0)opts::start_index- The executor command index of each branch's first command (default 0); used to translate entry command indices into branch positions:max_candidates- Cap on interleavings examined (default 1000)
Options
:counters- Assertion counters carried from the prefix, soevery: Ntriggers continue counting across the branch region (default fresh zeros)
Returns
{:ok, linearization}- A consistent sequential ordering (tagged commands){:no_linearization, refutation}- All interleavings examined and refuted.refutationisnilwhen every ordering failed purely on event incompatibility (a classic race, e.g. a lost update), or a map%{branch_id:, position:, command:, check_name:, reason:}describing the synchronous assertion that failed in the furthest-progressing ordering, so the executor can report it with the same precision as a linear failure.{:indeterminate, checked}- Verification impossible (no simulator) or candidate cap reached without success
@spec feasibility([[struct()]]) :: :ok | {:warning, non_neg_integer()}
Check if linearization checking is feasible for given branches.
Returns :ok if the number of linearizations is manageable,
or {:warning, count} if it may be slow.
Generate all valid linearizations of branch commands (untagged).
Preserves the order within each branch while exploring all possible
interleavings between branches. Prefer check/5 for verification; this
remains for diagnostics and tooling.
@spec linearization_count([[struct()]]) :: non_neg_integer()
Count the number of possible linearizations for given branches.
Useful for estimating complexity before attempting verification.
Formula
For branches of lengths n1, n2, ..., nk, the count is: (n1 + n2 + ... + nk)! / (n1! n2! ... * nk!)
@spec verify( linearization(), %{required({non_neg_integer(), non_neg_integer()}) => [struct()]}, projection_state(), module(), keyword() ) :: boolean()
Verify a specific tagged linearization against observed per-command events.
Walks the commands in the given order; for each, the simulator's predicted
events (given the model state at that point) must be compatible with the
events observed for that command, and the state advances with the observed
events. Returns true if every command is consistent.
@spec verify_candidate( linearization(), %{required({non_neg_integer(), non_neg_integer()}) => [struct()]}, projection_state(), module(), map() ) :: :ok | {:refuted, non_neg_integer(), refutation()}