FizzBee
GitHub Discord Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

Symmetry Reduction

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:

# Legacy
KEYS = symmetric_values('k', 3)
# Equivalent new API
KEYS = symmetry.nominal(name="k", limit=3, materialize=True).values()

Use symmetric_values() to create interchangeable IDs:

# Create symmetric values
KEYS = symmetric_values('k', 3)  # Creates k0, k1, k2
PROCESS_IDS = symmetric_values(5)  # Creates 0, 1, 2, 3, 4

action Init:
    switches = {}
    for k in KEYS:
        switches[k] = 'OFF'

atomic action TurnOn:
    key = oneof KEYS
    switches[key] = 'ON'

Benefits: States where k0=ON,k1=OFF and k1=ON,k0=OFF are recognized as equivalent.

Example reduction: With 3 processes, instead of exploring all permutations, only canonical states are explored.

Reference: Symmetric Values for IDs

Symmetric Roles

Mark roles as symmetric when instances are indistinguishable:

NUM_WORKERS = 3

symmetric role Worker:
    action Init:
        self.status = Status.IDLE

    atomic action DoWork:
        self.status = Status.BUSY

action Init:
    # CRITICAL: Use bag() or set(), NOT list()
    workers = bag()
    for i in range(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).

Examples:

  • 2 workers: 2! = 2x reduction (~50%)
  • 3 workers: 3! = 6x reduction (~83%)
  • 4 workers: 4! = 24x reduction (~96%)

Reference: Symmetric Roles

Critical Pitfall: Lists Break Symmetry

WRONG - defeats symmetry:

symmetric role Node:
    # ...

action Init:
    nodes = []  # ❌ Lists are order-dependent!
    for i in range(NUM_NODES):
        nodes.append(Node())

CORRECT - preserves symmetry:

symmetric role Node:
    # ...

action Init:
    nodes = bag()  # ✅ Bags are order-independent
    for i in range(NUM_NODES):
        nodes.add(Node())

Why: Lists preserve element order, so [n0, n1] ≠ [n1, n0]. This breaks symmetry reduction even with symmetric role.

Fix: Always use bag() or set() with symmetric roles, never list().

Reference: Common Pitfall - Using List with Symmetric Roles

When to Use Symmetry

Use symmetric values/roles when:

  • All instances start in identical states
  • Instances are truly indistinguishable
  • Identity doesn’t matter for correctness
  • Order is not semantically meaningful

Don’t use when:

  • Instances have different initial states
  • 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 matter
nodes = bag()  # vs nodes = []

# Use sets for unique elements
completed = set()  # vs completed = []

# Dictionaries with symmetric keys work well
data = {}
for k in symmetric_values('k', 3):
    data[k] = initial_value

Reference: Compare state spaces in State Space Comparison

Measuring Impact

To see symmetry reduction in action:

  1. Run without symmetry:

    role Process:  # Regular role
        # ...
    
    action Init:
        processes = []  # List
    
  2. Run with symmetry:

    symmetric role Process:  # Symmetric role
        # ...
    
    action Init:
        processes = bag()  # Bag
    
  3. Compare state/node counts in output

Reference: State Space Comparison provides side-by-side comparison

Symmetry Module API (Advanced)

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.

Quick Reference

Type Allowed Ops Canonicalization Typical Use
nominal ==, != Permutation of IDs User IDs, session tokens
ordinal ==, !=, <, >, <=, >= Rank squashing (0,1,2,…) Logical timestamps, priorities
interval ==, !=, <, >, <=, >=, +int, -int, val-val Zero-shifting (subtract min) Sequence numbers, counters
rotational ==, !=, +int, -int, val-val Rotate to lex-smallest set Ring positions, clock arithmetic

Constructors

symmetry.nominal(name, limit, materialize=False)
symmetry.ordinal(name, limit, reflection=False, materialize=False)
symmetry.interval(name, divergence=None, limit=None, start=0, reflection=False, materialize=False)
symmetry.rotational(name, limit, materialize=False, reflection=False)

Parameters:

  • name (string): Domain identifier, used in value display (e.g., name=“ts” produces ts0, ts1, …)
  • limit (int): Maximum number of values in the domain
  • divergence (int, interval only): Maximum allowed spread (max - min). If only divergence given, limit = divergence + 1. If only limit given, divergence = limit - 1.
  • start (int, interval only): Starting value for first allocation (default 0)
  • reflection (bool): Enable mirror-state equivalence (not available for nominal)
  • materialize (bool): Pre-populate all limit values at declaration time. When true, fresh() is disallowed.

Methods by Type

Method Nominal Ordinal Interval Rotational Description
fresh() Y Y Y Y Allocate new canonical value
values() Y Y Y Y List active values (sorted)
choose() Y - - Y Deterministic default value (like TLA+ CHOOSE)
choices() Y - - Y values() + one fresh()
min() - Y Y - Smallest active value or fresh
max() - Y Y - Largest active value or fresh
segments(after?, before?) - Y - - Gaps between active values

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)

action Init:
    cache = {}

atomic action Put:
    id = oneof IDS.choices()  # existing IDs + one fresh
    cache[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.

Reference: Nominal Symmetry (symmetry module)

Ordinal Symmetry

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)

action Init:
    events = []

atomic action RecordEvent:
    # fresh() appends after max; min()/max() return the extremes.
    t = TIMES.fresh()
    events = events + [t]

atomic action ProcessEvent:
    require len(events) > 0
    events = 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).

Reference: Ordinal Symmetry (symmetry module)

Ordinal Segments (Gap Insertion)

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)

action Init:
    t_start = TIMES.fresh()
    t_end = TIMES.fresh()

atomic action InsertBetween:
    # 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 = oneof gaps
    t = 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 (infinitely divisible in theory).

Reference: Ordinal Segments (gap insertion)

Interval Symmetry

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)

action Init:
    t1 = TICKS.min()
    t2 = TICKS.min()   # same value as t1

atomic fair action Tick1:
    t1 = t1 + 1        # val + int -> val

atomic fair action Tick2:
    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}.

Reference: Interval Symmetry (symmetry module)

Divergence (Bounding Spread)

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 unreachable
SEQ = symmetry.interval(name="s", divergence=3)

action Init:
    head = SEQ.fresh()     # s0
    tail = SEQ.fresh()     # s1

atomic fair action AdvanceHead:
    head = head + 1        # pruned if head - tail > 3

atomic fair action AdvanceTail:
    require tail < head
    tail = 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.

Reference: Interval Divergence

Rotational Symmetry

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)

action Init:
    positions = set()

atomic action Place:
    p = RING.fresh()
    positions.add(p)

atomic action Advance:
    p = oneof positions
    next_p = p + 1          # wraps: 4 + 1 = 0 on ring of 5
    if next_p not in positions:
        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}).

val1 - val2 returns a plain int: (a - b) % limit.

Reference: Rotational Symmetry (symmetry module)

Reflection Symmetry

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 equivalent
RING = 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).

Reference: Reflection Symmetry

Materialize

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)

action Init:
    status = {}
    for n in NODES.values():  # all 4 values available immediately
        status[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.

Reference: Materialized Domains

Gotchas and Tips

  1. 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().

  2. 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.

  3. Limit enforcement is automatic. When fresh() would exceed the limit, the transition is disabled (pruned from the state graph), not an error.

  4. Divergence enforcement is automatic. For interval symmetry, transitions that would make (max - min) > divergence are pruned, including arithmetic results.

  5. Use set() or bag() to store symmetric values, not list(). Lists preserve insertion order, which breaks symmetry reduction (same pitfall as with symmetric role).

  6. 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.
  7. 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 = oneof TEXTS.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 = oneof TEXTS.values()  # Always: [task0, task1, task2]
    
  8. 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

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 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, k2

action Init:
    # Use symmetric keys in a dictionary
    switches = {}
    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=OFF
    key = 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 on
exists 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

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2}
Nodes: 12, queued: 0
Valid Nodes: 10 Unique states: 4
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Symmetric Roles

Using ‘symmetric role’ keyword for indistinguishable role instances

Demonstrates: symmetric role, automatic symmetry reduction, id field

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# Example 0085: Symmetric Roles
# Using 'symmetric role' keyword for indistinguishable role instances
# Demonstrates: symmetric role, automatic symmetry reduction, __id__ field

---
deadlock_detection: false
liveness: "false"
---

Status = enum('INIT', 'WORKING', 'DONE')

NUM_WORKERS = 3

# Mark role as symmetric - tells model checker instances are interchangeable
symmetric 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 reduction
    workers = bag()
    for i in range(NUM_WORKERS):
        workers.add(Worker())

    completed = set()

# Safety: all workers eventually complete
eventually 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!

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2} liveness:"false" deadlock_detection:false
Nodes: 10, queued: 0
Valid Nodes: 10 Unique states: 10
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

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

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# 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 quickly
options:
    max_actions: 10
---

Status = enum('READY', 'DONE')

NUM_NODES = 2

# This role is marked as symmetric
symmetric 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 reduction
action 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 broken
    nodes = []
    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 types

always assertion BothNodesReady:
    # This will fail once a node processes
    # Demonstrates that the model is running
    for n in nodes:
        if n.status != Status.READY:
            return False
    return True

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:10 max_concurrent_actions:2}
FAILED: Model checker failed. Invariant: BothNodesReady
------
Init
--
state: {"nodes":["role Node#0","role Node#1"]}
Node#0: fields(status = "READY")
Node#1: fields(status = "READY")
------
Node#0.Process
--
state: {"nodes":["role Node#0","role Node#1"]}
Node#0: fields(status = "DONE")
Node#1: fields(status = "READY")
------

Source on GitHub

State Space Comparison

Side-by-side comparison showing impact of symmetry reduction

Demonstrates: Measuring state space reduction, scaling analysis

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 symmetry
fizz WithoutSymmetry.fizz
#### Note the output: X unique states, Y nodes

#### Run with symmetry
fizz 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:

  • Model checking completes in seconds vs hours
  • Being able to verify 3 nodes vs 10 nodes
  • Finding bugs vs giving up due to state explosion

WithSymmetry.fizz

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# Example 0087b: State Space WITH Symmetry Reduction
# Run this to see reduced state space
# Compare state count with WithoutSymmetry.fizz

---
options:
    max_actions: 6
---

Status = enum('IDLE', 'BUSY')

NUM_PROCESSES = 2

# Symmetric role - instances are interchangeable
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:
    # CRITICAL: Use bag() not list() for symmetry to work
    processes = 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

WithoutSymmetry.fizz

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# Example 0087a: State Space WITHOUT Symmetry Reduction
# Run this to see baseline state space
# Then compare with WithSymmetry.fizz to see the reduction

---
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)

Source on GitHub

Nominal Symmetry (symmetry module)

Interchangeable identifiers using symmetry.nominal()

Demonstrates: fresh(), values(), choices(), choose(), unordered IDs

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 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 values

IDS = 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

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2}
Nodes: 11, queued: 0
Valid Nodes: 11 Unique states: 4
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Ordinal Symmetry (symmetry module)

Ordered values where only relative rank matters

Demonstrates: symmetry.ordinal(), fresh(), min(), max(), ordering comparisons

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 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) > 0
    events = events[1:]

always assertion EventsOrdered:
    # fresh() always appends, so events are in order
    for i in range(len(events) - 1):
        if not (events[i] < events[i + 1]):
            return False
    return True

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2}
Nodes: 5, queued: 0
Valid Nodes: 5 Unique states: 5
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Ordinal Segments (gap insertion)

Inserting values between existing ordinal values using segments()

Demonstrates: segments(), segment.fresh(), after/before filtering

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 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 timestamps
    gaps = 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_end

atomic 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.

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2}
Nodes: 4, queued: 0
Valid Nodes: 4 Unique states: 1
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Interval Symmetry (symmetry module)

Ordered values where distance is meaningful, with arithmetic

Demonstrates: symmetry.interval(), val + int, val - int, val - val, zero-shifting

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 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 = 6
TICKS = symmetry.interval(name="t", limit=LIMIT)

action Init:
    t1 = TICKS.min()
    t2 = TICKS.min()

atomic fair action Tick1:
    t1 = t1 + 1

atomic 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.

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2}
Nodes: 11, queued: 0
Valid Nodes: 11 Unique states: 11
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Interval Divergence

Bounding the spread of interval values with divergence parameter

Demonstrates: divergence, automatic spread enforcement, derivation rules

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 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()     # s0
    tail = SEQ.fresh()     # s1

atomic 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.

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2}
Nodes: 5, queued: 0
Valid Nodes: 5 Unique states: 5
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Rotational Symmetry (symmetry module)

Ring/modular arithmetic with wrapping values

Demonstrates: symmetry.rotational(), val + int (wrapping), val - val (mod), ring positions

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 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) < 3
    p = RING.fresh()
    positions.add(p)

atomic action Advance:
    require len(positions) > 0
    p = oneof positions
    next_p = p + 1   # wraps mod 5
    if next_p not in positions:
        positions.remove(p)
        positions.add(next_p)

atomic action ComputeGap:
    require len(positions) == 2
    pair = RING.values()
    gap = pair[1] - pair[0]  # plain int, mod 5

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2}
Nodes: 11, queued: 0
Valid Nodes: 11 Unique states: 6
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Reflection Symmetry

Mirror-state equivalence using reflection=True

Demonstrates: reflection=True, interval reflection (v-min vs max-v), rotational reflection (CW=CCW)

ReflectionInterval.fizz

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 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 = 6
TICKS = symmetry.interval(name="t", limit=LIMIT, reflection=True)

action Init:
    t1 = TICKS.min()
    t2 = TICKS.min()

atomic fair action Tick1:
    t1 = t1 + 1

atomic fair action Tick2:
    t2 = t2 + 1

ReflectionNoReflection.fizz

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Interval WITHOUT reflection (for comparison with ReflectionInterval.fizz).
# Run both and compare state/node counts to see the reduction.

LIMIT = 6
TICKS = symmetry.interval(name="t", limit=LIMIT, reflection=False)

action Init:
    t1 = TICKS.min()
    t2 = TICKS.min()

atomic fair action Tick1:
    t1 = t1 + 1

atomic fair action Tick2:
    t2 = t2 + 1

ReflectionRotational.fizz

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 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) < 3
    p = RING.fresh()
    positions.add(p)

atomic action Step:
    require len(positions) > 0
    p = any positions
    next_p = p + 1
    if next_p not in positions:
        positions.remove(p)
        positions.add(next_p)

Source on GitHub

Materialized Domains

Pre-populating all domain values with materialize=True

Demonstrates: materialize=True, values() returns all upfront, fresh() disallowed

Run in playground
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 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 immediately
    status = {}
    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"

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2}
Nodes: 24, queued: 0
Valid Nodes: 21 Unique states: 6
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub