Symmetry reduction is a powerful technique to reduce state space by exploiting symmetries in the model. When values or role instances are truly interchangeable, the model checker can treat permutations as equivalent, dramatically reducing the number of states to explore.
Symmetric Values (Legacy)
Recommendation: Prefer the Symmetry Module API for new models. It provides finer-grained control (nominal, ordinal, interval, rotational) and additional features (reflection, divergence, segments). The legacy symmetric_values() is equivalent to materialized nominal symmetry:
# LegacyKEYS=symmetric_values('k',3)# Equivalent new APIKEYS=symmetry.nominal(name="k",limit=3,materialize=True).values()
Use symmetric_values() to create interchangeable IDs:
Mark roles as symmetric when instances are indistinguishable:
NUM_WORKERS=3symmetricroleWorker:actionInit:self.status=Status.IDLEatomicactionDoWork:self.status=Status.BUSYactionInit:# CRITICAL: Use bag() or set(), NOT list()workers=bag()foriinrange(NUM_WORKERS):workers.add(Worker())
Benefits: Model checker recognizes role instance permutations as equivalent.
Reduction factor: For N symmetric role instances, state space reduces by factor of N! (factorial).
Some instances have special roles (leader, coordinator)
Instance identity is semantically meaningful
Order matters for the algorithm
Order-Independent Collections
For maximum symmetry reduction:
# Prefer bags over lists when order doesn't matternodes=bag()# vs nodes = []# Use sets for unique elementscompleted=set()# vs completed = []# Dictionaries with symmetric keys work welldata={}forkinsymmetric_values('k',3):data[k]=initial_value
The symmetry module provides fine-grained control over symmetric value domains with four symmetry types, each preserving different mathematical structure. Unlike symmetric_values() (which is nominal-only), the module lets you declare what operations are meaningful for your domain, enabling more aggressive state space reduction.
Gaps between active values, optionally with existing values as points
Nominal Symmetry
Values are unordered and interchangeable. Only equality testing is allowed. The model checker treats any permutation of IDs as equivalent.
IDS=symmetry.nominal(name="id",limit=3)actionInit:cache={}atomicactionPut:id=oneofIDS.choices()# existing IDs + one freshcache[id]="data"
fresh() allocates the smallest unused ID (canonical form). choices() returns all active values plus one fresh value if the limit allows – use with oneof for nondeterministic selection. choose() returns a deterministic default value (like TLA+’s CHOOSE) – use it when you need an initial value, not for nondeterministic selection.
Values are ordered but only relative rank matters. Distances between values are not preserved. The model checker squeezes values to dense ranks (0, 1, 2, …).
TIMES=symmetry.ordinal(name="ts",limit=4)actionInit:events=[]atomicactionRecordEvent:# fresh() appends after max; min()/max() return the extremes.t=TIMES.fresh()events=events+[t]atomicactionProcessEvent:requirelen(events)>0events=events[1:]# frees a slot for fresh() again
fresh() always allocates a value greater than all existing values (tail append). Use segments() to insert between existing values (see below).
segments() returns gap objects between active values. Each gap has a fresh() method to allocate a value within that range.
TIMES=symmetry.ordinal(name="ts",limit=6)actionInit:t_start=TIMES.fresh()t_end=TIMES.fresh()atomicactionInsertBetween:# segments(after=v, before=v) filters to gaps in range.# Ordinal gaps are always non-empty (the domain is dense).gaps=TIMES.segments(after=t_start,before=t_end)gap=oneofgapst=gap.fresh()# guaranteed: t_start < t < t_end
With N active values, segments() returns N+1 gaps: a head gap (before first), body gaps (between consecutive pairs), and a tail gap (after last). Ordinal gaps are always non-empty since the domain is dense. You can insert into the same gap as many times as you like along a path: the model checker respaces the stored values by rank when a gap gets too small, which keeps their order.
segments(include_points=True) also returns each existing value as a point segment, interleaved with the gaps in rank order. value() resolves the chosen segment: a point returns its existing value, and a gap allocates a new value in that position.
TIMES=symmetry.ordinal(name="ts",limit=3)actionInit:deadline=TIMES.fresh()atomicactionObserve:# (-inf, deadline) [deadline] (deadline, +inf)seg=oneofTIMES.segments(include_points=True)t=seg.value()# t < deadline, t == deadline, or t > deadline
With N active values this gives 2N+1 choices: N points and N+1 gaps.
value() on a gap allocates once; calling it again on the same segment returns the same value. At the limit it disables the transition, like fresh().
seg.is_point tells points from gaps. fresh() on a point is an error.
Bounds: gt/lt exclude the bound, ge/le include it, and the bound must be a value from the same domain (it need not still be active). Only gaps that lie entirely in range are returned, so an empty or reversed range returns an empty list, and oneof over it disables the transition. after/before are aliases of gt/lt. For example, segments(include_points=True, ge=t_start, lt=t_end) offers t_start, every value up to t_end, and every gap between them.
Resolve a segment in the action that selected it. If other values are inserted into its gap before value() runs, the new value still lands between the segment’s endpoints, but its position relative to those newer values is arbitrary.
Values are ordered and distances are meaningful. Supports arithmetic on values. The model checker normalizes by subtracting the minimum (zero-shifting), so {5,7,8} and {0,2,3} are equivalent.
TICKS=symmetry.interval(name="t",limit=6)actionInit:t1=TICKS.min()t2=TICKS.min()# same value as t1atomicfairactionTick1:t1=t1+1# val + int -> valatomicfairactionTick2:t2=t2+1
Arithmetic: val + int and val - int produce new symmetric values. val1 - val2 produces a plain int (the distance). Only the gap pattern matters – the model checker recognizes that {t1=0,t2=3} is equivalent to {t1=100,t2=103}.
The divergence parameter limits max - min across all active values. Transitions that would exceed this bound are pruned from the state space.
# max spread of 3: states where (max - min) > 3 are unreachableSEQ=symmetry.interval(name="s",divergence=3)actionInit:head=SEQ.fresh()# s0tail=SEQ.fresh()# s1atomicfairactionAdvanceHead:head=head+1# pruned if head - tail > 3atomicfairactionAdvanceTail:requiretail<headtail=tail+1
Derivation rules: Provide divergence, limit, or both. If only one is given, the other is derived: limit = divergence + 1 or divergence = limit - 1.
Values are integers mod limit (ring positions). Arithmetic wraps around. No ordering operators (<, > not supported) since the domain is circular.
RING=symmetry.rotational(name="pos",limit=5)actionInit:positions=set()atomicactionPlace:p=RING.fresh()positions.add(p)atomicactionAdvance:p=oneofpositionsnext_p=p+1# wraps: 4 + 1 = 0 on ring of 5ifnext_pnotinpositions:positions.remove(p)positions.add(next_p)
The model checker rotates all values by a constant to find the lexicographically smallest set. So {0,2}, {1,3}, {2,4}, {3,0}, {4,1} are all the same state (gap pattern = {0,2}).
Setting reflection=True makes mirror-image states equivalent. Available for ordinal, interval, and rotational symmetry (not nominal, since nominal has no structure to reflect).
Interval reflection: {t1=0, t2=3} is equivalent to {t1=3, t2=0}. The model checker considers both v - min and max - v normalizations and picks the canonical one.
# With reflection: "t1 ahead by 3" = "t2 ahead by 3"TICKS=symmetry.interval(name="t",limit=6,reflection=True)
Rotational reflection: Clockwise and counterclockwise orientations are equivalent. On a ring of 6, {0,1} (gap=1 clockwise) and {0,5} (gap=1 counterclockwise) collapse to one state.
# Undirected ring: CW and CCW are equivalentRING=symmetry.rotational(name="pos",limit=6,reflection=True)
Impact: For the two-ticker interval example with limit=6: without reflection = 11 states, with reflection = 6 states (~45% reduction).
With materialize=True, all limit values exist from the start. fresh() is not allowed. Useful when the value set is fixed and known upfront.
NODES=symmetry.rotational(name="n",limit=4,materialize=True)actionInit:status={}forninNODES.values():# all 4 values available immediatelystatus[n]="idle"
Works with all symmetry types. For interval, values start at start: e.g., symmetry.interval(name="x", limit=3, start=10, materialize=True) creates values 10, 11, 12.
fresh() is deterministic, not nondeterministic. It always returns the canonical next value. For nondeterministic choice, use oneof domain.choices() (nominal/rotational) or oneof domain.values().
Domain methods cannot be called from assertions. The symmetry context is only available during action execution. Store values in state variables and check those in assertions instead.
Limit enforcement is automatic. When fresh() would exceed the limit, the transition is disabled (pruned from the state graph), not an error.
Divergence enforcement is automatic. For interval symmetry, transitions that would make (max - min) > divergence are pruned, including arithmetic results.
Use set() or bag() to store symmetric values, not list(). Lists preserve insertion order, which breaks symmetry reduction (same pitfall as with symmetric role).
Choosing the right type:
Only need identity? Use nominal (maximum reduction).
Need ordering but not distances? Use ordinal.
Need ordering AND distances/arithmetic? Use interval.
Values wrap around (mod N)? Use rotational.
Choosing the right method for your use case:
Use case
Method
Why
Unique ID per entity (UUID, primary key)
id = DOMAIN.fresh()
Deterministic, exactly one new value. No oneof needed.
Pick from interchangeable pool (text, color, category)
v = oneof DOMAIN.choices()
Explores existing values + one fresh. First call offers only 1 choice (the canonical fresh value), avoiding spurious branching.
Fixed known set (ring nodes, enum-like)
materialize=True + oneof DOMAIN.values()
All values exist upfront. fresh() is disallowed.
Why prefer choices() over materialize=True + values() for pools? With materialize=True, the first nondeterministic choice offers all N values even though they are all equivalent (symmetry would collapse them to one canonical state anyway). With choices() (default materialize=False), the first call offers just 1 fresh value — no unnecessary branching. Subsequent calls offer previously-used values plus one fresh, naturally growing the pool only as needed. The total number of unique states is the same, but choices() avoids redundant transitions.
# Recommended: dynamic pool with choices()TEXTS=symmetry.nominal(name="task",limit=3)text=oneofTEXTS.choices()# 1st call: [task0]. 2nd call: [task0, task1]. etc.# Also valid but creates unnecessary initial branching:TEXTS=symmetry.nominal(name="task",limit=3,materialize=True)text=oneofTEXTS.values()# Always: [task0, task1, task2]
require is a guard, not an assertion.require cond disables the transition when cond is false – the action simply doesn’t execute. It does not report a failure. To check properties, use always assertion. Prefer require over if not cond: return for enabling conditions – they have the same effect but require is more concise and idiomatic.
Examples
Symmetric Values for IDs
Using symmetric_values() to reduce state space by treating permutations as equivalent
Demonstrates: symmetric_values(), state space optimization, interchangeable IDs
# Example 0084: Symmetric Values for IDs# Using symmetric_values() to reduce state space by treating permutations as equivalent# Demonstrates: symmetric_values(), state space optimization, interchangeable IDs## NOTE: Prefer the symmetry module API for new models. This is equivalent to:# KEYS = symmetry.nominal(name="k", limit=3, materialize=True).values()# The symmetry module offers more types (ordinal, interval, rotational)# and features (reflection, divergence, segments). See 16-05 through 16-12.# Define symmetric values instead of regular numbers or strings# symmetric_values('prefix', count) creates prefix0, prefix1, prefix2, ...KEYS= symmetric_values('k', 3)# Creates k0, k1, k2action Init:
# Use symmetric keys in a dictionaryswitches={}for k in KEYS:
switches[k]='OFF'atomic action TurnOn:
# Model checker explores all possible choices# With symmetric values: states where k0=ON,k1=OFF,k2=OFF# are equivalent to k1=ON,k0=OFF,k2=OFFkey= oneof KEYS
if switches[key]=='OFF':
switches[key]='ON'atomic action TurnOff:
key= oneof KEYS
if switches[key]=='ON':
switches[key]='OFF'# Verify at least one switch can be onexists assertion SomeOn:
return any([switches[k]=='ON'for k in KEYS])# Why use symmetric_values?# ============================# Without symmetry: KEYS = range(3) or KEYS = ['k0', 'k1', 'k2']# - State (k0=ON, k1=OFF, k2=OFF) is different from (k1=ON, k0=OFF, k2=OFF)# - Model checker explores all permutations# - State space: MUCH LARGER## With symmetry: KEYS = symmetric_values('k', 3)# - These states are recognized as equivalent (just permutations)# - Model checker only explores one representative state# - State space: SIGNIFICANTLY REDUCED## When to use:# - Process IDs, Node IDs, Client IDs that are truly interchangeable# - Keys/IDs where order and identity don't matter# - All instances start in identical states## When NOT to use:# - IDs have different initial states# - Identity matters (e.g., "master" vs "replica")# - Order is semantically meaningful
# Example 0085: Symmetric Roles# Using 'symmetric role' keyword for indistinguishable role instances# Demonstrates: symmetric role, automatic symmetry reduction, __id__ field---
deadlock_detection: falseliveness: "false"---
Status= enum('INIT', 'WORKING', 'DONE')NUM_WORKERS=3# Mark role as symmetric - tells model checker instances are interchangeablesymmetric role Worker:
action Init:
self.status = Status.INIT
self.result = None
atomic action StartWork:
require self.status == Status.INIT
self.status = Status.WORKING
atomic action FinishWork:
require self.status == Status.WORKING
self.status = Status.DONE
self.result ='OK'# Add this worker's ID to the done set completed.add(self.__id__)action Init:
# CRITICAL: Use bag() or set(), NOT list()# Lists are order-dependent and break symmetry reductionworkers= bag()for i in range(NUM_WORKERS):
workers.add(Worker())completed= set()# Safety: all workers eventually completeeventually always assertion AllWorkersComplete:
for w in workers:
if w.status != Status.DONE:
return False
return True
# Why use symmetric role?# ============================# Without 'symmetric' keyword:# - State (w0=DONE, w1=INIT, w2=INIT) is different from (w1=DONE, w0=INIT, w2=INIT)# - Model checker explores all permutations# - For N workers, explores N! times more states## With 'symmetric' keyword:# - Model checker recognizes these as equivalent# - Only explores one representative permutation# - State space: N! times smaller## Example with 3 workers:# - Without symmetry: explores 3! = 6x more states# - With symmetry: only explores canonical representative## When to use symmetric role:# - All instances start in identical states# - Instances are truly indistinguishable# - Identity doesn't matter for correctness## When NOT to use:# - Instances have different initial states# - Some instances have special roles (leader, coordinator)# - Instance identity is semantically meaningful## IMPORTANT: Must use bag() or set() for workers collection# Using list() defeats symmetry because lists are order-dependent!
# Example 0086: Common Pitfall - Using List with Symmetric Roles# Demonstrates why you MUST use bag() or set() with symmetric roles, not list()# Shows: symmetry broken by order-dependent collections---
# Limit to show the pitfall quicklyoptions:
max_actions: 10---
Status= enum('READY', 'DONE')NUM_NODES=2# This role is marked as symmetricsymmetric role Node:
action Init:
self.status = Status.READY
atomic action Process:
require self.status == Status.READY
self.status = Status.DONE
# WRONG APPROACH: Using list() defeats symmetry reductionaction Init:
# Lists are ORDER-DEPENDENT# State [Node(id=0), Node(id=1)] is different from [Node(id=1), Node(id=0)]# Even though the role is symmetric, the list ordering creates distinct states# Result: NO state space reduction, symmetry is brokennodes=[]for i in range(NUM_NODES):
nodes.append(Node())# CORRECT APPROACH: Using bag() preserves symmetry# To see the correct version, comment out the Init above and uncomment below:# action Init:# # Bags are ORDER-INDEPENDENT (multiset)# # Bag{Node(id=0), Node(id=1)} is same as Bag{Node(id=1), Node(id=0)}# # Model checker can exploit symmetry# # Result: State space reduction works correctly# nodes = bag()# for i in range(NUM_NODES):# nodes.add(Node())# The Pitfall Explained:# ======================# Common mistake when migrating to symmetric roles:## 1. Start with: participants = [] (list of participants)# 2. Add: symmetric role Participant# 3. Expect: automatic state space reduction# 4. Reality: NO reduction because list preserves order!## Fix: Change to participants = bag() or participants = set()## Why this matters:# - With 2 symmetric nodes and list: explores both orderings# - With 2 symmetric nodes and bag: explores only one canonical state# - With N nodes: difference is N! (factorial) states## When to use each collection with symmetric roles:# - bag(): When duplicates are possible/meaningful# - set(): When duplicates are impossible/don't matter# - list(): NEVER with symmetric roles (breaks symmetry)# - dict: OK if keys are symmetric_values() or other symmetric typesalways assertion BothNodesReady:
# This will fail once a node processes# Demonstrates that the model is runningfor n in nodes:
if n.status != Status.READY:
return False
return True
The same spec with and without symmetric roles, comparing state space sizes side by side
Demonstrates: symmetric roles, state space reduction, comparison
This example demonstrates the impact of symmetry reduction by providing two nearly-identical specs:
Files
WithoutSymmetry.fizz: Regular roles with list
WithSymmetry.fizz: Symmetric roles with bag
How to Compare
#### Run without symmetryfizz WithoutSymmetry.fizz
#### Note the output: X unique states, Y nodes#### Run with symmetryfizz WithSymmetry.fizz
#### Note the output: Much fewer states!
Expected Results
With NUM_PROCESSES = 2 and max_actions = 6:
Version
Approx States
Reduction
Without
~40-60 states
Baseline
With
~20-30 states
~50% reduction
Try Scaling Up
Edit both files and increase NUM_PROCESSES:
NUM_PROCESSES=3# Change to 3 or 4
Expected reduction factors:
2 processes: 2! = 2x reduction (~50%)
3 processes: 3! = 6x reduction (~83%)
4 processes: 4! = 24x reduction (~96%)
Key Differences
Aspect
WithoutSymmetry.fizz
WithSymmetry.fizz
Role declaration
role Process:
symmetric role Process:
Collection
processes = []
processes = bag()
Add to collection
.append(Process())
.add(Process())
State space
Larger
Smaller
The Lesson
Always use symmetric role + bag()/set() for indistinguishable instances!
This simple change can make the difference between:
# Example 0103: Symmetry Reduction State Space Comparison# The same spec with and without symmetric roles, comparing state space sizes side by side# Demonstrates: symmetric roles, state space reduction, comparison---
options:
max_actions: 6---
Status= enum('IDLE', 'BUSY')NUM_PROCESSES=2# Symmetric role - instances are interchangeablesymmetric role Process:
action Init:
self.status = Status.IDLE
self.counter =0 atomic action Work:
require self.status == Status.IDLE
self.status = Status.BUSY
self.counter = self.counter + 1 atomic action Rest:
require self.status == Status.BUSY
self.status = Status.IDLE
action Init:
# CRITICAL: Use bag() not list() for symmetry to workprocesses= bag()for i in range(NUM_PROCESSES):
processes.add(Process())# Example state space exploration (approximate):# With 2 processes marked symmetric in a bag:## States like:# Bag{p0:(IDLE,0), p1:(BUSY,1)} and Bag{p0:(BUSY,1), p1:(IDLE,0)}# are SAME state (explores only one)## Expected: ~20-30 unique states (50% reduction from WithoutSymmetry.fizz)## State space reduction:# - 2 processes: ~50% reduction# - 3 processes: ~6x reduction (3! = 6)# - 4 processes: ~24x reduction (4! = 24)# - N processes: N! reduction factor## How to verify:# 1. Run: fizz WithoutSymmetry.fizz# Note: X states, Y nodes# 2. Run: fizz WithSymmetry.fizz# Note: Much smaller state count# 3. Compare the state graphs in explorer
# Example 0104: State Space Without Symmetry Reduction# Baseline state space using a regular role, for comparison with the symmetric version# Demonstrates: roles, state space, symmetry comparison---
options:
max_actions: 6---
Status= enum('IDLE', 'BUSY')NUM_PROCESSES=2# Regular role (NOT symmetric)role Process:
action Init:
self.status = Status.IDLE
self.counter =0 atomic action Work:
require self.status == Status.IDLE
self.status = Status.BUSY
self.counter = self.counter + 1 atomic action Rest:
require self.status == Status.BUSY
self.status = Status.IDLE
action Init:
processes=[]for i in range(NUM_PROCESSES):
processes.append(Process())# Example state space exploration (approximate):# With 2 processes, each can be (IDLE,c=0), (BUSY,c=1), (IDLE,c=1), etc.## States like:# [p0:(IDLE,0), p1:(BUSY,1)] and [p0:(BUSY,1), p1:(IDLE,0)]# are DIFFERENT states (explores both)## Expected: ~40-60 unique states (varies by max_actions)
# Example 0105: Nominal Symmetry# Interchangeable identifiers with symmetry.nominal() for user IDs, session tokens and cache keys# Demonstrates: symmetry.nominal(), fresh(), values(), choices(), choose()# Nominal Symmetry: Unordered, interchangeable identifiers.# Operations: == and != only. No ordering, no arithmetic.# Reduction: permutations of active set are equivalent.# Use cases: user IDs, session tokens, cache keys.## Key methods:# fresh() - allocate a new unique value (deterministic, canonical)# values() - list all active values# choices() - values() + one fresh (if limit allows)# choose() - deterministic default value (like TLA+ CHOOSE); for initial valuesIDS= symmetry.nominal(name="id", limit=3)action Init:
cache={}atomic action Put:
# choices() returns existing IDs + one fresh ID (if under limit).# Use with `any` for nondeterministic selection.id= oneof IDS.choices() cache[id]="data"atomic action Evict:
# `oneof` on an empty list disables the transition, so no guard needed.id= oneof IDS.values()if id in cache:
cache.pop(id)always assertion BoundedSize:
return len(cache) <=3
# Example 0106: Ordinal Symmetry# Ordered values where only relative rank matters, like logical timestamps and priority levels# Demonstrates: symmetry.ordinal(), fresh(), min(), max(), ordering# Ordinal Symmetry: Ordered values where only relative rank matters.# Operations: ==, !=, <, <=, >, >=. No arithmetic.# Reduction: values are squeezed to dense ranks (0,1,2,...).# Use cases: logical timestamps, priority levels, version numbers.## Key methods:# fresh() - allocate value greater than all existing (appends to end)# min() - smallest active value (or fresh if empty)# max() - largest active value (or fresh if empty)# values() - all active values, sorted ascending## The model checker treats states with the same relative ordering# as equivalent. E.g., timestamps {10,20,30} and {1,5,99} are# equivalent because both have the same rank order.TIMES= symmetry.ordinal(name="ts", limit=4)action Init:
events=[]atomic action RecordEvent:
# fresh() appends after max; min()/max() return the extremes.t= TIMES.fresh()events= events + [t]atomic action ProcessEvent:
# Consuming the oldest event frees a slot, allowing fresh()# to allocate again -- enabling infinite traces without deadlock. require len(events) > 0events= events[1:]always assertion EventsOrdered:
# fresh() always appends, so events are in orderfor i in range(len(events) - 1):
if not (events[i] < events[i + 1]):
return False
return True
# Example 0107: Ordinal Segments# Insert values between existing ordinal values using segments() and gap fresh()# Demonstrates: segments(), gap insertion, after and before filtering# Ordinal Segments: Inserting values between existing ordinal values.# segments() returns gap objects between active values. Each gap# has a fresh() method to allocate a value within that gap.## With 2 values [a, b], segments() returns 3 gaps:# (-inf, a) -- head: values less than a# (a, b) -- body: values between a and b# (b, +inf) -- tail: values greater than b## Filtering: segments(after=v) excludes gaps before v.# segments(before=v) excludes gaps after v.## Use cases: inserting events in a timeline, priority insertion.TIMES= symmetry.ordinal(name="ts", limit=6)action Init:
t_start= TIMES.fresh()t_end= TIMES.fresh()atomic action InsertAnywhere:
# Get all gaps between existing timestampsgaps= TIMES.segments()gap= oneof gaps
t= gap.fresh()atomic action InsertBetween:
# Insert strictly between t_start and t_end.# Ordinal gaps are always non-empty (the domain is dense).gaps= TIMES.segments(after=t_start, before=t_end)gap= oneof gaps
t= gap.fresh()# Guaranteed: t_start < t < t_endatomic action InsertBeforeEarliest:
# Insert before the minimum existing value.# (Inserting after max is just fresh(), but before min needs segments.)earliest= TIMES.min()gaps= TIMES.segments(before=earliest)gap= oneof gaps
t= gap.fresh()# Note: domain methods like values() cannot be called from assertions# (no symmetry context in invariant scope). Use stored state instead.
# Example 0108: Interval Symmetry# Ordered values with meaningful distance and arithmetic, like sequence numbers and log indices# Demonstrates: symmetry.interval(), arithmetic, zero-shifting# Interval Symmetry: Ordered values where distance is meaningful.# Operations: ==, !=, <, <=, >, >=, +int, -int, val-val.# Reduction: zero-shifting (subtract minimum to normalize).# Use cases: sequence numbers, log indices, counters.## Key differences from ordinal:# - Supports arithmetic: val + 1, val - 1# - Value difference: val1 - val2 returns a plain int# - fresh() allocates max+1 (pessimistic, sequential)# - `divergence` parameter bounds the max spread (max - min)## Key methods:# fresh() - allocate max+1 (or start value if empty)# min() - smallest active value (or fresh if empty)# max() - largest active value (or fresh if empty)# values() - all active values, sorted ascending## The model checker normalizes by shifting min to 0.# E.g., {5,7,8} and {0,2,3} are equivalent (same distances).LIMIT=6TICKS= symmetry.interval(name="t", limit=LIMIT)action Init:
t1= TICKS.min()t2= TICKS.min()atomic fair action Tick1:
t1= t1 + 1atomic fair action Tick2:
t2= t2 + 1# With interval symmetry, only the *distances* between t1 and t2# matter. States {t1=0,t2=0}, {t1=5,t2=5}, {t1=100,t2=100}# are all equivalent (distance = 0).# Total unique states: O(LIMIT^2) instead of unbounded.
# Example 0109: Interval Divergence# Bound the spread of interval values with the divergence parameter to keep the state space finite# Demonstrates: divergence, interval symmetry, state space bounding# Interval Divergence: Bounding the spread of interval values.## The `divergence` parameter limits (max - min) across all active# values in the domain. This prevents unbounded state growth.## Parameter rules:# - divergence only: limit = divergence + 1 (auto-derived)# - limit only: divergence = limit - 1 (auto-derived)# - both: must satisfy limit <= divergence + 1# - at least one of divergence or limit is required## When a fresh() or arithmetic would exceed the divergence bound,# the transition is disabled (pruned from state space).## Use case: modeling sliding windows where values can't drift# too far apart (e.g., TCP window, Raft log gap).# divergence=3 means max - min <= 3 at all times.# limit is auto-derived to 4 (divergence + 1).SEQ= symmetry.interval(name="s", divergence=3)action Init:
head= SEQ.fresh()# s0tail= SEQ.fresh()# s1atomic fair action AdvanceHead:
head= head + 1# If head - tail > 3, this transition is pruned.atomic fair action AdvanceTail:
require tail < head
tail= tail + 1# Note: domain methods (values(), max(), min()) cannot be called# from assertions. The divergence bound is enforced automatically# by the symmetry engine — transitions that would violate it# are pruned from the state space.
# Example 0110: Rotational Symmetry# Ring positions with wrapping modular arithmetic for ring topologies, hash rings and clocks# Demonstrates: symmetry.rotational(), modular arithmetic, ring topologies# Rotational Symmetry: Integers mod N (ring positions).# Operations: ==, !=, +int, -int, val-val (all mod limit).# No ordering (<, >, etc.) since the domain wraps around.# Reduction: rotate all values to lexicographically smallest set.# Use cases: ring topologies, clock arithmetic, hash ring slots.## Key methods:# fresh() - next unused position (sequential, wrapping)# values() - all active values# choices() - values() + one fresh (if under limit)# choose() - min active value (or fresh if empty)## Arithmetic wraps: on a ring of size 5, position 4 + 1 = 0.# Difference returns plain int: (a - b) % limit.## With rotational symmetry, the model checker recognizes that# {0,2} and {1,3} and {3,0} are all equivalent (same gap pattern).RING= symmetry.rotational(name="pos", limit=5)action Init:
positions= set()atomic action Place:
require len(positions) < 3p= RING.fresh() positions.add(p)atomic action Advance:
require len(positions) > 0p= oneof positions
next_p= p + 1# wraps mod 5if next_p not in positions:
positions.remove(p) positions.add(next_p)atomic action ComputeGap:
require len(positions)==2pair= RING.values()gap= pair[1] - pair[0]# plain int, mod 5
# Example 0111: Reflection Symmetry# Mirror states are equivalent with reflection=True, shown for interval and rotational domains# Demonstrates: reflection=True, interval symmetry, rotational symmetry# Reflection Symmetry (Interval): Mirror states are equivalent.## With reflection=True, the model checker also considers the# "mirror image" of each state. For interval symmetry, this means# {a, b} with distances [0, d] is equivalent to [d, 0] (reversed).## Concretely: state (t1=0, t2=3) is equivalent to (t1=3, t2=0)# because reflecting maps v -> (max - v), turning {0,3} into {3,0}# which normalizes back to {0,3} with swapped variable bindings.## This is useful when two processes are truly interchangeable and# "t1 ahead of t2 by 3" is equivalent to "t2 ahead of t1 by 3".## Compare: Run this file vs IntervalNoReflection.fizz to see# the state space reduction from reflection.LIMIT=6TICKS= symmetry.interval(name="t", limit=LIMIT, reflection=True)action Init:
t1= TICKS.min()t2= TICKS.min()atomic fair action Tick1:
t1= t1 + 1atomic fair action Tick2:
t2= t2 + 1
# Example 0112: Interval Without Reflection# Baseline interval domain without reflection, for comparing state counts with the reflected version# Demonstrates: interval symmetry, reflection comparison# Interval WITHOUT reflection (for comparison with ReflectionInterval.fizz).# Run both and compare state/node counts to see the reduction.LIMIT=6TICKS= symmetry.interval(name="t", limit=LIMIT, reflection=False)action Init:
t1= TICKS.min()t2= TICKS.min()atomic fair action Tick1:
t1= t1 + 1atomic fair action Tick2:
t2= t2 + 1
# Example 0113: Rotational Reflection Symmetry# Clockwise and counterclockwise orientations collapse to one state with reflection=True# Demonstrates: reflection=True, rotational symmetry, undirected rings# Reflection Symmetry (Rotational): Clockwise = counterclockwise.## With reflection=True on a rotational domain, the model checker# treats clockwise and counterclockwise orientations as equivalent.# On a ring of size 6: {0,1} (gap=1) and {0,5} (gap=5, i.e. -1)# are equivalent because one is the mirror of the other.## Without reflection: {0,1} and {0,5} are distinct states.# With reflection: they collapse to one canonical state.## Use case: undirected ring networks where message direction# doesn't matter, or symmetric physical arrangements.RING= symmetry.rotational(name="pos", limit=6, reflection=True)action Init:
positions= set()atomic action Place:
require len(positions) < 3p= RING.fresh() positions.add(p)atomic action Step:
require len(positions) > 0p= any positions
next_p= p + 1if next_p not in positions:
positions.remove(p) positions.add(next_p)
# Example 0114: Materialized Symmetry Domains# Pre-populate all domain values at declaration with materialize=True for fixed known sets# Demonstrates: materialize=True, values(), fixed domains# Materialize: Pre-populate all domain values at declaration.## With materialize=True, all `limit` values exist from the start.# - values() immediately returns all values# - fresh() is NOT allowed (raises error)# - Useful when the set of values is fixed and known upfront## Works with all symmetry types:# nominal: values 0, 1, ..., limit-1# ordinal: values 0, 1, ..., limit-1# interval: values start, start+1, ..., start+limit-1# rotational: values 0, 1, ..., limit-1## Use cases: fixed-size process rings, known node sets,# enumerated configurations.NODES= symmetry.rotational(name="n", limit=4, materialize=True)action Init:
# All 4 values exist immediatelystatus={}for n in NODES.values():
status[n]="idle"atomic action Activate:
n= oneof NODES.values() require status[n]=="idle" status[n]="active"atomic action Deactivate:
n= oneof NODES.values() require status[n]=="active" status[n]="idle"atomic action PassToken:
n= oneof NODES.values() require status[n]=="active"next_n= n + 1# wraps mod 4 status[n]="idle" status[next_n]="active"
Example: Symmetry origins the checker must notice Symmetric values that are not top-level globals: a domain stored inside a list, and symmetric_values() called inside an action and kept in a list. Demonstrates: symmetry reduction still applies wherever the values are born
# Example: Symmetry origins the checker must notice# Symmetric values that are not top-level globals: a domain stored inside a# list, and symmetric_values() called inside an action and kept in a list.# Demonstrates: symmetry reduction still applies wherever the values are born---
deadlock_detection: false---
# The domain is an element of a list, not a global of its own.DOMAINS=[symmetry.nominal(name="k", limit=3, materialize=True)]action Init:
switches={}for k in DOMAINS[0].values():
switches[k]='OFF'# Created at runtime inside an action, not at the top level.marks= set()tokens= symmetric_values('t', 2)atomic action TurnOn:
key= oneof DOMAINS[0].values()if switches[key]=='OFF':
switches[key]='ON'atomic action Mark:
tok= oneof tokens
marks.add(tok)# 3 switches (4 classes up to permutation) x 2 tokens (3 classes) = 12# unique states under symmetry; 8 x 4 = 32 without it.always assertion Bounded:
return len(marks) <=2
---
deadlock_detection: false---
# Example 0120: Ordinal Segment Points# Choose an existing ordinal value or a new one in any position with segments(include_points=True)# Demonstrates: include_points=True, segment.value(), is_point, gt/ge/lt/le bounds# segments(include_points=True) returns the existing values as point segments,# interleaved with the gaps between them. With values [a, b]:# (-inf, a) [a, a] (a, b) [b, b] (b, +inf)## seg.value() resolves the chosen segment:# point -> the existing value# gap -> a new value in that gap (allocated once; later calls return the same value)# At the limit, value() on a gap disables the transition, just like fresh().## Bounds: gt/lt exclude the bound, ge/le include it. after/before are aliases of gt/lt.## Use case: an observed time compared with a deadline can be <, == or >.# Gaps alone give < and >; points add ==.TIMES= symmetry.ordinal(name="ts", limit=3)action Init:
deadline= TIMES.fresh()status= None
t= None
atomic action Observe:
require status== None
seg= oneof TIMES.segments(include_points=True)t= seg.value()if t < deadline:
status="early"elift== deadline:
status="on_time"else:
status="late"atomic action ObserveNotEarly:
# Only the deadline itself or anything after it. require status== None
seg= oneof TIMES.segments(include_points=True, ge=deadline)t= seg.value()if seg.is_point:
status="on_time"else:
status="late"always assertion StatusMatchesTime:
ifstatus=="early":
return t < deadline
ifstatus=="on_time":
returnt== deadline
ifstatus=="late":
return t > deadline
return True
Regression test: inserting before the minimum many times along one path must keep working. Ordinal values are allocated by midpoint, so each insertion halves the gap below the minimum. The model checker respaces the stored ordinal IDs by rank before the gap runs out.
Demonstrates: segments(before=…), ordinal ID respacing
# Example 1615: Repeated insertion into the same ordinal gap# Regression test: inserting before the minimum many times along one path# must keep working. Ordinal values are allocated by midpoint, so each# insertion halves the gap below the minimum. The model checker respaces# the stored ordinal IDs by rank before the gap runs out.# Demonstrates: segments(before=...), ordinal ID respacing# Only one timestamp is live at a time, so the limit never binds. Every# state is distinct (count differs), so symmetry reduction cannot merge# the path: all 100 insertions run back to back into the head gap.TIMES= symmetry.ordinal(name="ts", limit=2)action Init:
t= TIMES.fresh()count=0atomic fair action InsertBefore:
require count < 100gap= oneof TIMES.segments(before=t)t= gap.fresh()count+=1always eventually assertion ReachesHundred:
returncount==100
Regression test: appending after the maximum many times along one path must keep working. fresh() allocates the midpoint between the current maximum and the top of the ID range, so each append halves the space left above the maximum. The model checker respaces the stored ordinal IDs by rank before that space runs out.
Demonstrates: ordinal fresh(), ordinal ID respacing
# Example 1616: Repeated fresh() appends in an ordinal domain# Regression test: appending after the maximum many times along one path# must keep working. fresh() allocates the midpoint between the current# maximum and the top of the ID range, so each append halves the space# left above the maximum. The model checker respaces the stored ordinal# IDs by rank before that space runs out.# Demonstrates: ordinal fresh(), ordinal ID respacing# Only one timestamp is live at a time, so the limit never binds. Every# state is distinct (count differs), so symmetry reduction cannot merge# the path: all 100 appends run back to back.TIMES= symmetry.ordinal(name="ts", limit=2)action Init:
t= TIMES.fresh()count=0atomic fair action Append:
require count < 100t= TIMES.fresh()count+=1always eventually assertion ReachesHundred:
returncount==100