PropertyDamage.Shrinker (PropertyDamage v0.2.0)
View SourceShrinks failing command sequences to minimal reproductions.
When a property test fails, the Shrinker attempts to find the smallest sequence that still reproduces the failure. This makes debugging easier by removing irrelevant commands and simplifying arguments.
Failure Equivalence
The shrinker preserves failure equivalence - a shrunk sequence is only accepted if it produces the same type of failure as the original. This ensures the minimal reproduction demonstrates the same bug, not a different one.
The accepted candidate's failure must match the original on two dimensions,
compared by check_failure_equivalence/2 (via its failure signature):
- Same failure type (
:check_failed,:idempotency_violation, etc.) - Same check name (for invariant violations)
A third property also holds: the failure occurs at the same or an earlier command index than in the original. This one is guaranteed structurally rather than asserted by the signature comparison, and it holds on both the linear and the branching path for the same underlying reason: every shrink candidate is a subset or simplification of a fixed base sequence, so a candidate can only reproduce the failure at the same position or earlier, never later.
On the linear path this is most visible in Phase 1, which first truncates the
sequence at the failure point (Enum.take(commands, failed_at_index + 1)), so
every subsequent candidate is a subset of that prefix.
On the branching path the index is likewise not asserted. The original
failed_at_index is a branch-relative coordinate (branch_start_index + position) that is not directly comparable to the linear index obtained when a
branching sequence is flattened, so it is not reused for truncation: when
try_convert_to_linear decides a race is not required, it takes the failure
index from its own linear re-run of the flattened sequence (which is
self-consistent with that sequence) and hands that to shrink_linear, so
Phase-1 truncation targets the real failure point. The truncation stays
still_fails?-guarded regardless, so even a stale or nil index can only fall
back to leaving the full flattened sequence, never accept a later-failing
candidate. Combined with the subset/simplification property of every branching
strategy (branch removal, branch-content shrinking, prefix/suffix shrinking,
and argument shrinking), the same-or-earlier-index guarantee holds structurally
here as well.
Determinism
Shrinking is fully deterministic given:
- The same seed (which determines the command sequence)
- The same initial failure
- Deterministic SUT behavior
This ensures reproducibility: the same seed always produces the same shrunk sequence, making CI failures reliably reproducible locally.
Sequence Types
The Shrinker handles both linear and branching sequences:
Linear Sequences
Traditional shrinking: remove commands and simplify arguments.
Branching Sequences
Additional strategies:
- Remove entire branches (if failure persists)
- Shrink individual branches
- Convert to linear (if race not required for failure)
- Reduce branch count
Two-Phase Shrinking
Phase 1: Sequence Shrinking
Removes unnecessary commands while preserving the failure:
- Drop unexecuted: Remove commands after the failure point
- Hierarchical shrink: Remove commands grouped by dependency depth
- Linear shrink: Try removing each remaining command individually
Phase 2: Argument Shrinking (optional)
Simplifies values in remaining commands:
- Integers shrink toward 0
- Strings shrink toward empty
- Lists shrink toward empty
- Refs are never shrunk (would break dependencies)
Configuration
See PropertyDamage.Shrinker.Config for tuning options:
granularity_threshold- When to switch from hierarchical to linearmax_iterations- Limit total shrink attemptsmax_time_ms- Time budget for shrinkingshrink_arguments- Whether to attempt argument shrinking
Usage
# After a failure at index 5
shrunk = Shrinker.shrink(
sequence,
failed_at_index: 5,
failure_reason: {:check_failed, :balance_invariant, "..."},
model: MyModel,
adapter: MyAdapter,
config: config
)
Summary
Functions
Check if two failure reasons are equivalent.
Extract a failure signature from a failure reason.
Shrink a failing command sequence.
Types
Failure signature for equivalence checking.
Contains the essential properties that must match for a shrunk sequence to be considered as reproducing the "same" failure.
@type shrink_result() :: %{ sequence: PropertyDamage.Sequence.t(), iterations: non_neg_integer(), time_ms: non_neg_integer() }
Result of shrinking.
Functions
Check if two failure reasons are equivalent.
Two failures are equivalent if they have the same type and (for check failures) the same check name.
@spec failure_signature(term()) :: failure_signature()
Extract a failure signature from a failure reason.
The signature captures the essential properties for equivalence checking.
@spec shrink( PropertyDamage.Sequence.t() | [struct()], keyword() ) :: shrink_result()
Shrink a failing command sequence.
Parameters
sequence- The original failing sequence (or list for backwards compatibility)opts- Shrinking options::failed_at_index- Index where the failure occurred (required):failure_reason- Original failure reason for equivalence checking (optional but recommended):model- Model module (required):adapter- Adapter module (required):adapter_config- Config for adapter setup (default: %{}):config- Shrinker.Config struct (default: Config.new()):event_queue- EventQueue pid for injector events (optional)
Returns
A shrink_result map containing the minimal failing sequence.