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 Symmetry Reduction 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: Symmetry Reduction 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?, include_points?, gt?, ge?, lt?, le?) - Y - - 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)

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

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

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

Reference: Ordinal Segments

Choosing an Existing or New Ordinal Value

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)

action Init:
    deadline = TIMES.fresh()

atomic action Observe:
    # (-inf, deadline)  [deadline]  (deadline, +inf)
    seg = oneof TIMES.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.

Reference: Ordinal Segment Points

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

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

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 Symmetry 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}
Nodes: 2, queued: 0
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

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

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

Source on GitHub

Nominal Symmetry

Interchangeable identifiers with symmetry.nominal() for user IDs, session tokens and cache keys

Demonstrates: symmetry.nominal(), fresh(), values(), choices(), choose()

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

Ordered values where only relative rank matters, like logical timestamps and priority levels

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

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

Insert values between existing ordinal values using segments() and gap fresh()

Demonstrates: segments(), gap insertion, after and 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
45
46
47
48
# 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 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

Ordered values with meaningful distance and arithmetic, like sequence numbers and log indices

Demonstrates: symmetry.interval(), arithmetic, 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
38
39
40
41
# 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 = 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

Bound the spread of interval values with the divergence parameter to keep the state space finite

Demonstrates: divergence, interval symmetry, state space bounding

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

Ring positions with wrapping modular arithmetic for ring topologies, hash rings and clocks

Demonstrates: symmetry.rotational(), modular arithmetic, ring topologies

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
# 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) < 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 states are equivalent with reflection=True, shown for interval and rotational domains

Demonstrates: reflection=True, interval symmetry, rotational symmetry

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
29
30
31
32
# 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 = 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
16
17
18
19
# 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 = 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
31
32
33
34
# 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) < 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 Symmetry Domains

Pre-populate all domain values at declaration with materialize=True for fixed known sets

Demonstrates: materialize=True, values(), fixed domains

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

Symmetry Origins

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

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

Expected result (from fizz):

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

Source on GitHub

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

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
---
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"
    elif t == 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:
    if status == "early":
        return t < deadline
    if status == "on_time":
        return t == deadline
    if status == "late":
        return t > deadline
    return True

Expected result (from fizz):

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

Source on GitHub

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

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
# 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 = 0

atomic fair action InsertBefore:
    require count < 100
    gap = oneof TIMES.segments(before=t)
    t = gap.fresh()
    count += 1

always eventually assertion ReachesHundred:
    return count == 100

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2}
Nodes: 201, queued: 0
Valid Nodes: 201 Unique states: 101
Checking always eventually ReachesHundred
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

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

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
# 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 = 0

atomic fair action Append:
    require count < 100
    t = TIMES.fresh()
    count += 1

always eventually assertion ReachesHundred:
    return count == 100

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2}
Nodes: 101, queued: 0
Valid Nodes: 101 Unique states: 101
Checking always eventually ReachesHundred
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub