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

Assertions

Assertions specify properties the model checker should verify.

Always (Safety)

Must hold in all reachable states:

always assertion Invariant:
    return counter >= 0

Use cases: Invariants, safety properties

Exists

Must hold in at least one reachable state:

exists assertion Reachability:
    return counter == 10

Use cases: Reachability, possibility

Transition

Must hold for all state transitions:

transition assertion MonotonicIncrease(before, after):
    if after.counter != before.counter:
        return after.counter > before.counter
    return True  # Stutter step

Stutter Invariance (critical requirement):

Transition assertions must allow stutter steps (transitions where state doesn’t change):

# ✅ CORRECT: Allows stuttering
transition assertion NoDecrease(before, after):
    if after.value != before.value:
        return after.value >= before.value
    return True  # Stutter allowed

# ❌ WRONG: Requires change every step
transition assertion AlwaysIncreases(before, after):
    return after.value > before.value
    # Fails on stutter steps!

Why stutter invariance matters:

  • Model checker may have steps where only thread-local variables change
  • Some actions may execute without changing state variables
  • FizzBee automatically passes assertion if no state variables changed

Automatic handling: If no state variables changed, assertion assumed satisfied (even if thread-local variables changed)

Pattern: Always check before != after before asserting a property about the change

next_states() — One-Step Lookahead (ENABLED-style properties)

Inside an always assertion, next_states() returns the list of successor transitions from the state under check — one entry per enabled transition, each executed to its first yield point. This expresses TLA+ ENABLED-style safety properties with arbitrary quantifier nesting, e.g. “every item in the trash must be restorable right now”:

always assertion EveryTrashItemRestorable:
    for k in items:
        if items[k] == "trash":
            if len([n for n in next_states() if n.state.items[k] == "active"]) == 0:
                return False
    return True

Each transition is a struct:

Field Type Example Notes
name string "Customer#0.PlaceOrder" Flat label; also "thread-0", "channel-0-message-1"
kind string "action" "action", "thread", or "channel"
role_ref string / None "Customer#0" None for non-role transitions
role_id id value / None Customer0 Same value as the role’s __id__; compares by value
action_name string / None "PlaceOrder" Bare action name; None for thread/channel
state struct n.state.items Successor state vars — same shape as before/after in transition assertions

(action_name/role_ref — not action/role — because action and role are fizz keywords and cannot appear as attribute names.)

To scope a check to a specific role instance, compare ids — never role objects (roles compare by pointer and never match across states; see GOTCHAS):

always assertion EveryIdleWorkerCanWork:
    for w in workers:
        if w.status == "idle":
            if len([n for n in next_states() if n.action_name == "Work" and n.role_id == w.__id__]) == 0:
                return False
    return True

Id values also expose their components: w.__id__.name → "Worker", w.__id__.index → 0.

Semantics:

  • Successors ignore exploration bounds (max_actions depth, max_concurrent_actions, per-action caps). Enabledness is a property of the state, not the scheduler — so the check does not false-fail at the depth frontier or when in-flight actions exhaust the concurrency cap.
  • Crash variants are excluded.
  • For non-atomic (serial) actions, the successor is the action’s first yield point — effects later in the body are not yet visible. Use effect predicates (n.state.x == ...) with atomic witness actions; use name filtering (n.action_name == ...) to check a serial action can start.
  • Computed lazily, once per checked state. Works in model checking (BFS/DFS), simulation, and no-graph modes. Not available in composition specs.
  • Reference examples: 09-06 through 09-09.

Eventually Always (Liveness)

Eventually reaches a stable state:

eventually always assertion EventuallyStable:
    return done == True

Requires: Fairness on relevant actions

Always Eventually (Progress)

Always makes progress:

always eventually assertion AlwaysProgresses:
    return processed_count > 0

Requires: Strong fairness typically

Temporal Formulas

An assertion without a prefix returns a temporal formula. The body may compute locals first, as any assertion can, and must end with a single return:

assertion Answered:
    waiting = [c for c in range(NUM_CLIENTS) if pending[c]]
    return len(waiting) > 0 leadsto len(waiting) == 0

assertion NoRequestsBeforeStart:
    return no_requests() until started

assertion StaysStarted:
    return always (started implies always started)
FizzBee LTL TLA+ Holds on a run when
always P □P or G P []P P holds in every state
eventually P ◇P or F P <>P P holds in some state, now or later
P until Q P U Q none built in Q holds in some state, and P in every state before it
P unless Q P W Q none built in P holds in every state before the first where Q holds; if Q never holds, in every state
P leadsto Q □(P → ◇Q) P ~> Q every state where P holds is followed, now or later, by one where Q holds
P implies Q P → Q P => Q P is false or Q holds
not, and, or ¬, ∧, ∨ ~, /\, \/ as usual
possibly P none; CTL’s EF P none built in some path from the current state reaches a state where P holds (see Possibility below)

There is no next operator (LTL X, TLA+ primes): a formula can’t tell a state from a repeat of it.

Operator precedence, from tightest to loosest binding:

Level Operators Meaning
1 any expression evaluated in a state
2 not P, always P, eventually P, possibly P prefix
3 P and Q
4 P or Q
5 P implies Q, P until Q, P unless Q, P leadsto Q need parentheses to combine

and and or chain (P and Q and R). The level 5 operators don’t: write (P until Q) until R or P until (Q until R); P until Q until R is an error, and so is mixing two of them without parentheses.

  • P until Q: Q eventually holds, and P holds in every state before it (strong until).
  • P unless Q: weak until. P until Q requires Q to happen. P unless Q does not: if Q never happens, P must hold forever. Once Q happens, neither operator constrains P afterward. It is not “P if not Q”: in x == 0 unless done, a state with x == 1 before done fails it.
  • P leadsto Q: always (P implies eventually Q).
  • implies, until, unless, leadsto and possibly are keywords only in an assertion’s return, so they can still be used as names elsewhere.

Atoms. Each largest part of the formula with no temporal operator is an ordinary expression (it may call funcs), evaluated in every state. Locals are recomputed in every state: they are definitions, not snapshots. So v = x then return eventually x > v compares x with itself and is always false.

Parentheses. Prefix operators bind tightest, so always x >= 0 and y >= 0 would mean (always x >= 0) and y >= 0. That is an error; write always (x >= 0 and y >= 0). The same holds for eventually, possibly and not always on the left of any binary operator.

One formula per value. A for clause on the header checks one instance per value, named like Answered[0]. Iterables and filters may use only constants and builtins, and are evaluated once, before Init. The targets are names; with several, like for a, b in PAIRS, each value must be a tuple or list of that length. Several clauses nest in order, and a later clause can use an earlier one’s names. Inside the assertion (its filters, later clauses and body), a header name shadows a constant with the same name, as a loop variable would:

assertion Answered for c in range(NUM_CLIENTS):
    return pending[c] leadsto not pending[c]

assertion Replicated for k in KEYS for r in REPLICAS if r != PRIMARY:
    return written[k] leadsto r in copies[k]

A for clause also works on prefixed assertions:

always eventually assertion Copied for k in KEYS for r in REPLICAS if r != PRIMARY:
    return r in copies[k]

return always P, return always eventually P and return eventually always P (with P free of temporal operators) are the same checks as the prefixed always, always eventually and eventually always assertions.

One return. The body computes locals, then returns one formula. A formula can’t be chosen with if:

assertion Progress:
    if MODE == 1:                 # error: a single return, as the last statement
        return always x >= 0
    return eventually x == 2

Instead, use one of these:

# Separate assertions, when the cases are separate properties.
assertion Bounded:
    return always x >= 0

assertion Reaches:
    return eventually x == 2

# A local for the non-temporal part.
assertion Progress:
    ready = MODE == 1 and x >= 0
    return ready leadsto x == 2

# Connectives. An atom outside every temporal operator, such as MODE == 1
# here, is evaluated in the first state only.
assertion ByMode:
    return (MODE == 1 and always x >= 0) or (MODE != 1 and eventually x == 2)

Common patterns. For a request that is made and then served:

Pattern Formula Meaning
Invariance always (served implies requested) never served without being requested
Absence always not (served and not requested) the bad state never occurs
Existence eventually served served at some point
Response requested leadsto served every request is eventually served
Precedence (not served) unless requested not served before it’s requested; it may never be requested
Strict precedence (not served) until requested requested at some point, and not served before then
Stays true always (served implies always served) once served, served forever
Stability eventually always served eventually settles with served true
Recurrence always eventually served served infinitely often
Constrained response always (requested implies (requested until served)) once requested, stays requested until served
Held until released always (held implies (held unless released)) once held, stays held until released, or forever
Conditional (always eventually requested) implies (always eventually served) if requested infinitely often, served infinitely often
Absence after always (basic implies always not refunded) once basic, never refunded, whatever happens next

Properties that start from a condition. always (P implies F) checks the formula F from every state where P holds: a precondition for the property. It replaces a history variable in the model. For example, a basic ticket can’t be refunded, but it can be upgraded to premium, which can. The invariant not (cabin == "basic" and refunded) misses the upgrade-then-cancel loophole, because the ticket is premium by the time it is refunded. The formula talks about every basic ticket’s future instead:

assertion BasicNeverRefunded:
    return always (cabin == "basic" implies always not refunded)

It fails, and the counterexample is the loophole: buy basic, Upgrade, Cancel. F can be any formula: always Q (safety after P), eventually Q (that is P leadsto Q), possibly Q, or Q unless R. See 09-20.

until is strong and unless is weak: P unless Q means (P until Q) or always P. Like any formula, a bare P unless Q is judged from the first state only; to check it from every state where P starts, write always (start implies (P unless Q)), as in the held-until-released pattern.

Possibility. The other operators are linear-time, as in LTL: they describe each fair run. possibly is the one branching-time operator, CTL’s EF: possibly P holds in a state when some path from it, of zero or more steps, reaches a state where P holds. It says P can happen, never that it will: the path may take any action, fair or not. The operators around it keep their meaning over fair runs:

# From every booked state, cancelling is still possible.
assertion CanCancel:
    return always (booked implies possibly cancelled)

# The system can always get back to idle.
assertion CanReset:
    return always possibly status == "idle"
action Init:
    x = 0

atomic action Leave:        # not fair
    x = 1

atomic fair action Stay:
    x = 0

assertion CanLeave:
    return always possibly (x == 1)   # passes: Leave is a path to x == 1

assertion WillLeave:
    return eventually (x == 1)        # fails: a fair run can repeat Stay forever
  • What follows possibly is a condition on a state: expressions combined with and, or, not, implies and nested possibly. Temporal operators can’t appear inside it, so possibly (always P) is an error, and possibly (eventually P) is written possibly P.
  • The path never takes a crash: recovery that needs a fault isn’t something a user can count on. States reached after a crash are still checked by an enclosing always.
  • exists eventually P is another spelling of possibly P. An exists assertion is close to possibly checked in the initial state, except that it also counts states reached by a crash.
  • next_states() looks one step ahead; possibly looks any number of steps ahead.
  • When a formula with possibly fails, a note under the assertion’s name gives the step of the counterexample after which that possibly is false.
  • An assertion has at most 64 atoms. Each distinct condition counts once, and each use of possibly adds one, so always (possibly ready and possibly recovered) uses four.

Limitations.

  • Formulas are checked by the default (strict) liveness checker only, not with liveness: eventual, simulation or --experimental_no_graph.
  • Only one return, at the end (see below).
  • Temporal operators and implies can’t be stored in locals.
  • for clauses can’t iterate over symmetric domains yet.
  • There is no release operator (LTL R). P R Q is Q unless (P and Q).
  • Reference examples: 09-14 (passing) and 09-15 (a violation); for possibly, 09-16 (passing) and 09-17 (a violation); for unless, 09-18; for a property that starts from a condition, 09-20; for for clauses, 09-21.

Incremental Liveness Checking (experimental)

By default, liveness assertions and formulas are checked once the whole state space is explored. With --experimental_incremental_liveness, the checker also checks the part explored so far each time the explored node count doubles, and stops at the first violation:

./fizz --experimental_incremental_liveness spec.fizz
  • It never changes a verdict. A violation found early is a real one, and a run with no violation still ends with the usual full check. Only when a failure is reported changes.
  • It helps most on large models where the bad run sits close to Init.
  • Formulas with possibly are checked at the end only.
  • It needs the default BFS exploration and strict liveness. It is refused with simulation, an explicit --experimental_processed_queue and --experimental_no_graph, and has no effect with liveness: eventual. It turns off the default processed queue.
  • Reference example: 09-19 (the flag is in its flags.txt).

Examples

Always Assertion (Safety Property)

Safety invariants that must hold in every state

Demonstrates: always assertion, safety properties

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
# Example 0038: Always Assertion (Safety Property)
# Safety invariants that must hold in every state
# Demonstrates: always assertion, safety properties

MAX = 5

action Init:
    counter = 0
    total = 0

atomic action Increment:
    require counter < MAX

    counter = counter + 1
    total = total + counter

# Safety property: must be true in ALL states
always assertion CounterBounded:
    return counter <= MAX

always assertion TotalCorrect:
    # Total should be sum of 1..counter
    expected = (counter * (counter + 1)) / 2
    return total == expected

# If either assertion fails, model checker reports violation

atomic action NoOp:
    if counter >= MAX:
        pass

Expected result (from fizz):

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

Source on GitHub

Exists Assertion

Existential properties - at least one state must satisfy

Demonstrates: exists assertion

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 0039: Exists Assertion
# Existential properties - at least one state must satisfy
# Demonstrates: exists assertion

action Init:
    value = 0
    reached_five = False

atomic action Increment:
    require value < 10

    value = value + 1

    if value == 5:
        reached_five = True

# Existential property: must be true in AT LEAST ONE state
exists assertion ReachesFive:
    return reached_five == True

# Model checker verifies that some execution path reaches this state

atomic action NoOp:
    if value >= 10:
        pass

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

Transition Assertion

Properties about state transitions

Demonstrates: transition assertion with before/after states

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
# Example 0040: Transition Assertion
# Properties about state transitions
# Demonstrates: transition assertion with before/after states

action Init:
    counter = 0

atomic action Increment:
    require counter < 5

    counter = counter + 1

atomic action BigIncrement:
    require counter < 5

    counter = counter + 3

# Transition assertion: checks state changes
# Parameters are before and after states
transition assertion NoLargeJumps(before, after):
    # Counter should not increase by more than 3 in one step
    delta = after.counter - before.counter
    return delta <= 3

# This checks the transition, not individual states
# Stutter-invariant: if state doesn't change, assertion passes

atomic action NoOp:
    if counter >= 5:
        pass

Expected result (from fizz):

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

Source on GitHub

Eventually Always (Liveness)

Eventually the system reaches a stable state

Demonstrates: eventually always assertion (liveness property)

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
# Example 0041: Eventually Always (Liveness)
# Eventually the system reaches a stable state
# Demonstrates: eventually always assertion (liveness property)

action Init:
    counter = 0
    stable = False

fair action Increment:
    if counter >= 5:
        stable = True
        return

    counter = counter + 1

# Liveness property: eventually reaches a state where it stays true
eventually always assertion BecomesStable:
    return stable == True

# "Eventually always" means:
# - Eventually: at some point in the future
# - Always: from that point onward, stays true
# This requires fairness to prevent infinite incrementing

atomic action NoOp:
    if stable:
        pass

Expected result (from fizz):

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

Source on GitHub

Always Eventually (Progress)

System always makes progress eventually

Demonstrates: always eventually assertion (strong fairness)

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 0042: Always Eventually (Progress)
# System always makes progress eventually
# Demonstrates: always eventually assertion (strong fairness)

action Init:
    counter = 0

fair action Process:
    require counter < 3

    counter = counter + 1

# Progress property: eventually the counter reaches target
always eventually assertion AlwaysMakesProgress:
    # Eventually counter reaches 3
    return counter >= 3

# "Always eventually" means:
# - Always: in every state
# - Eventually: progress happens within finite steps
# With fairness, Process will eventually run enough times

atomic action NoOp:
    if counter >= 3:
        pass

Expected result (from fizz):

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

Source on GitHub

One-Step Reachability with next_states()

Assert that every trashed item can be restored in one step using next_states()

Demonstrates: next_states(), one-step reachability, always assertion

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
# Example 0096: One-Step Reachability with next_states()
# Assert that every trashed item can be restored in one step using next_states()
# Demonstrates: next_states(), one-step reachability, always assertion

---
deadlock_detection: false
options:
    max_actions: 8
---

# Soft-delete CRUDL: any trashed item can be restored directly.
# EveryTrashItemRestorable must PASS.

NUM_ITEMS = 3

action Init:
    items = {}
    next_id = 0

atomic action Create:
    require next_id < NUM_ITEMS
    items[next_id] = "active"
    next_id += 1

atomic action Delete:
    id = oneof [k for k in items if items[k] == "active"]
    items[id] = "trash"

atomic action Restore:
    id = oneof [k for k in items if items[k] == "trash"]
    items[id] = "active"

always assertion EveryTrashItemRestorable:
    for k in items:
        if items[k] == "trash":
            if len([n for n in next_states() if n.state.items[k] == "active"]) == 0:
                return False
    return True

Expected result (from fizz):

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

Source on GitHub

Detecting Unrestorable States with next_states()

A stack-based trash means only the most recent item is restorable, so the next_states() assertion fails with a counterexample

Demonstrates: next_states(), assertion failure, counterexample

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
# Example 0097: Detecting Unrestorable States with next_states()
# A stack-based trash means only the most recent item is restorable, so the next_states() assertion fails with a counterexample
# Demonstrates: next_states(), assertion failure, counterexample

---
deadlock_detection: false
options:
    max_actions: 8
---

# Soft-delete CRUDL where the trash is a STACK: only the most recently
# deleted item can be restored. With two items in the trash, the bottom
# one is not restorable in one step, so EveryTrashItemRestorable must
# FAIL. This is the case a global-reachability (`exists`) formulation
# cannot catch.

NUM_ITEMS = 3

action Init:
    items = {}
    trash_stack = []
    next_id = 0

atomic action Create:
    require next_id < NUM_ITEMS
    items[next_id] = "active"
    next_id += 1

atomic action Delete:
    id = oneof [k for k in items if items[k] == "active"]
    items[id] = "trash"
    trash_stack = trash_stack + [id]

atomic action Restore:
    require len(trash_stack) > 0
    id = trash_stack[-1]
    trash_stack = trash_stack[:-1]
    items[id] = "active"

always assertion EveryTrashItemRestorable:
    for k in items:
        if items[k] == "trash":
            if len([n for n in next_states() if n.state.items[k] == "active"]) == 0:
                return False
    return True

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:8 max_concurrent_actions:2} deadlock_detection:false
FAILED: Model checker failed. Invariant: EveryTrashItemRestorable
------
Init
--
state: {"items":{},"next_id":0,"trash_stack":[]}
------
Create
--
state: {"items":{"0":"active"},"next_id":1,"trash_stack":[]}
------
Create
--
state: {"items":{"0":"active","1":"active"},"next_id":2,"trash_stack":[]}
------
Delete
--
state: {"items":{"0":"active","1":"active"},"next_id":2,"trash_stack":[]}
------
Any:id=0
--
state: {"items":{"0":"trash","1":"active"},"next_id":2,"trash_stack":[0]}
------
Delete
--
state: {"items":{"0":"trash","1":"active"},"next_id":2,"trash_stack":[0]}
------
Any:id=1
--
state: {"items":{"0":"trash","1":"trash"},"next_id":2,"trash_stack":[0,1]}
------

Source on GitHub

next_states() Ignores the Concurrency Cap

The next_states() probe sees enabled transitions even when max_concurrent_actions is exhausted

Demonstrates: next_states(), max_concurrent_actions, serial actions

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
# Example 0098: next_states() Ignores the Concurrency Cap
# The next_states() probe sees enabled transitions even when max_concurrent_actions is exhausted
# Demonstrates: next_states(), max_concurrent_actions, serial actions

---
deadlock_detection: false
options:
    max_actions: 6
    max_concurrent_actions: 1
---

# Regression guard for the concurrency-cap false positive: Churn is a
# serial (non-atomic) action. At its mid-action yield, the one in-flight
# thread exhausts max_concurrent_actions=1, so the real explorer cannot
# schedule Restore from that state. The next_states() probe must ignore
# the cap (ENABLED is a property of the state, not the scheduler), so
# TrashRestorable must PASS. If the probe respected the cap, this spec
# would FAIL at every mid-Churn yield.

action Init:
    items = {0: "trash"}
    counter = 0

action Churn:
    counter = counter + 1
    counter = counter + 1

atomic action Restore:
    id = oneof [k for k in items if items[k] == "trash"]
    items[id] = "active"

always assertion TrashRestorable:
    for k in items:
        if items[k] == "trash":
            if len([n for n in next_states() if n.state.items[k] == "active"]) == 0:
                return False
    return True

Expected result (from fizz):

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

Source on GitHub

Matching next_states() Transitions to Roles

Use role_id and action_name to match next_states() transitions to a specific role instance

Demonstrates: next_states(), role_id, symmetric roles

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
# Example 0099: Matching next_states() Transitions to Roles
# Use role_id and action_name to match next_states() transitions to a specific role instance
# Demonstrates: next_states(), role_id, symmetric roles

---
deadlock_detection: false
options:
    max_actions: 6
---

# next_states() with symmetric roles: transitions carry structured fields
# (kind, role_ref, role_id, action_name) alongside the flat name. role_id
# is the role's identity value (same as the role's __id__) and compares by
# VALUE, so `n.role_id == w.__id__` works across the assertion/probe clone
# boundary. Comparing Role objects directly would silently be False —
# roles compare by pointer. See GOTCHAS.
# (The fields are named action_name/role_ref because `action` and `role`
# are fizz keywords and cannot appear as attribute names.)
#
# Also exercises the id component attrs: id.name ("Worker") and id.index.

NUM_WORKERS = 2

symmetric role Worker:
    action Init:
        self.status = "idle"

    atomic action Work:
        require self.status == "idle"
        self.status = "done"

action Init:
    workers = bag()
    for i in range(NUM_WORKERS):
        workers.add(Worker())

always assertion EveryIdleWorkerCanWork:
    for w in workers:
        if w.status == "idle":
            witnesses = [n for n in next_states() if n.kind == "action" and n.action_name == "Work" and n.role_id == w.__id__]
            # Exactly one: each worker has exactly one Work transition of its
            # own. If role_id failed to discriminate between instances, both
            # workers' transitions would match and this would be 2.
            if len(witnesses) != 1:
                return False
            if w.__id__.name != "Worker":
                return False
            if w.__id__.index < 0:
                return False
    return True

Expected result (from fizz):

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

Source on GitHub

Exists Assertion in No-Graph Mode (Unsatisfiable)

An unsatisfiable exists assertion must fail under –experimental_no_graph, matching full-graph mode

Demonstrates: exists assertion, –experimental_no_graph

Needs command-line flags (--experimental_no_graph); run locally with fizz.

# Example 0100: Exists Assertion in No-Graph Mode (Unsatisfiable)
# An unsatisfiable exists assertion must fail under --experimental_no_graph, matching full-graph mode
# Demonstrates: exists assertion, --experimental_no_graph

---
deadlock_detection: false
options:
    max_actions: 6
    crash_on_yield: false
---

# Regression pin for the no_graph exists bug: an unsatisfiable `exists`
# must FAIL under --experimental_no_graph (flags.txt) exactly as it does
# with the full graph. Before the fix, exists assertions were silently
# skipped in no_graph mode and this spec reported PASSED.

action Init:
    counter = 0

atomic action Inc:
    require counter < 3
    counter = counter + 1

exists assertion ImpossibleProbe:
    return counter > 999

exists assertion ReachableProbe:
    return counter == 2

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:6 max_concurrent_actions:2 crash_on_yield:false} deadlock_detection:false
Nodes: 4, queued: 0
FAILED: Expected states never reached
Invariant 0: ImpossibleProbe

Source on GitHub

Exists Assertion in No-Graph Mode (Satisfiable)

A satisfiable exists assertion passes under –experimental_no_graph by detecting a witness

Demonstrates: exists assertion, –experimental_no_graph, witness detection

Needs command-line flags (--experimental_no_graph); run locally with fizz.

# Example 0101: Exists Assertion in No-Graph Mode (Satisfiable)
# A satisfiable exists assertion passes under --experimental_no_graph by detecting a witness
# Demonstrates: exists assertion, --experimental_no_graph, witness detection

---
deadlock_detection: false
options:
    max_actions: 6
    crash_on_yield: false
---

# Companion to 09-10: a satisfiable `exists` must PASS under
# --experimental_no_graph (flags.txt) — the witness accumulator must
# actually detect witnesses, not just fail everything.

action Init:
    counter = 0

atomic action Inc:
    require counter < 3
    counter = counter + 1

exists assertion ReachableProbe:
    return counter == 2

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:6 max_concurrent_actions:2 crash_on_yield:false} deadlock_detection:false
Nodes: 4, queued: 0
PASSED: Model checker completed successfully

Source on GitHub

Exists Coverage Warning in Simulation Mode

In simulation mode an unwitnessed exists assertion produces a warning instead of a failure

Demonstrates: exists assertion, simulation mode, coverage warning

Needs command-line flags (-x --seed 42); run locally with fizz.

# Example 0102: Exists Coverage Warning in Simulation Mode
# In simulation mode an unwitnessed exists assertion produces a warning instead of a failure
# Demonstrates: exists assertion, simulation mode, coverage warning

---
deadlock_detection: false
options:
    max_actions: 6
---

# Simulation-mode exists coverage warning: with a fixed seed (flags.txt,
# forces a single deterministic run), the unsatisfiable exists must
# produce a named WARNING — not a failure, since simulation is not
# exhaustive. The reachable probe must not be warned about if this
# seed's trace witnesses it; the impossible one always warns.

action Init:
    counter = 0

atomic action Inc:
    require counter < 3
    counter = counter + 1

exists assertion ImpossibleProbe:
    return counter > 999

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:6 max_concurrent_actions:2} deadlock_detection:false
WARNING: 1 exists assertion(s) never satisfied in 1 simulation run(s):
exists assertion ImpossibleProbe
Simulation is not exhaustive: this may be a coverage gap rather than unreachability. Model check to verify.

Source on GitHub

Func Calls in Assertions

Fizz funcs can be called anywhere in an assertion expression, each on its own copy of the state

Demonstrates: role funcs and top-level funcs called inside expressions

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
# Example 0115: Func Calls in Assertions
# Fizz funcs can be called anywhere in an assertion expression, each on its own copy of the state
# Demonstrates: role funcs and top-level funcs called inside expressions

---
deadlock_detection: false
---

# Inside an assertion, fizz funcs can be called anywhere in an expression:
# in a return, in a condition, in a list comprehension, or on an
# invariants line. Each call runs on its own copy of the state, so a func
# can't change the model. It must be deterministic: no oneof, any or
# parallel blocks.

MAX_MESSAGES = 2

role Consumer:
    action Init:
        self.received = 0

    atomic fair action Receive:
        require self.received < MAX_MESSAGES
        self.received = self.received + 1

    func done():
        return self.received == MAX_MESSAGES

func total_received():
    total = 0
    for c in consumers:
        total = total + c.received
    return total

func all_done():
    return all([c.done() for c in consumers])

action Init:
    consumers = []
    for i in range(2):
        consumers.append(Consumer())

invariants:
    always total_received() <= MAX_MESSAGES * len(consumers)

always assertion DoneMeansAllReceived:
    return not all_done() or total_received() == MAX_MESSAGES * len(consumers)

always assertion SomeDoneCount:
    return len([c for c in consumers if c.done()]) <= len(consumers)

eventually always assertion AllDone:
    return all_done()

Expected result (from fizz):

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

Source on GitHub

Temporal Formulas

An assertion without a prefix returns a temporal formula built from always, eventually, until, leadsto and implies

Demonstrates: temporal formulas, until, leadsto, for clauses on an assertion header

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
# Example 0116: Temporal Formulas
# An assertion without a prefix returns a temporal formula built from always, eventually, until, leadsto and implies
# Demonstrates: temporal formulas, until, leadsto, for clauses on an assertion header

---
deadlock_detection: false
---
# An assertion can return a formula built from always, eventually, until,
# leadsto, implies, and, or and not. Each maximal non-temporal part of the
# formula (an atom) is an ordinary expression, evaluated in every state.
# Locals before the return are recomputed in every state too.
#
# A server starts, then answers its clients' requests. Each client sends at
# most two requests, one at a time. Start and Respond are fair, so each
# pending request is eventually answered.
# Respond's oneof isn't fair, but the bound keeps it from serving one client
# forever: once a client is done, only the other is left to pick.

NUM_CLIENTS = 2

action Init:
    started = False
    pending = [False] * NUM_CLIENTS
    answered = [0] * NUM_CLIENTS

atomic fair action Start:
    require not started
    started = True

atomic action Request:
    require started
    c = oneof [i for i in range(NUM_CLIENTS) if not pending[i] and answered[i] < 2]
    pending[c] = True

atomic fair action Respond:
    c = oneof [i for i in range(NUM_CLIENTS) if pending[i]]
    pending[c] = False
    answered[c] += 1

func no_requests():
    return True not in pending

# No request arrives before the server starts, and it does start (strong until).
assertion NoRequestsBeforeStart:
    return no_requests() until started

# A `for` clause on the header checks one formula per client: Answered[0]
# and Answered[1]. The iterable may use only constants.
assertion Answered for c in range(NUM_CLIENTS):
    return pending[c] leadsto not pending[c]

# Once started, the server stays started.
assertion StaysStarted:
    return always (started implies always started)

# `return always eventually P` is the same check as the prefixed
# `always eventually assertion`, with the local recomputed in each state.
assertion EventuallyIdle:
    busy = len([c for c in range(NUM_CLIENTS) if pending[c]])
    return always eventually busy == 0

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2} deadlock_detection:false
Nodes: 58, queued: 0
Valid Nodes: 58 Unique states: 26
Checking formula NoRequestsBeforeStart
Checking formula Answered[0]
Checking formula Answered[1]
Checking formula StaysStarted
Checking always eventually EventuallyIdle
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Temporal Formula Violation

A temporal formula that fails, with a counterexample run that violates it

Demonstrates: temporal formulas, assertion failure, counterexample

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 0117: Temporal Formula Violation
# A temporal formula that fails, with a counterexample run that violates it
# Demonstrates: temporal formulas, assertion failure, counterexample

---
deadlock_detection: false
---
# A temporal formula that fails. The checker reports the first failing
# assertion and a counterexample: a run that violates it.
#
# A server starts, then answers its clients' requests. Each client sends at
# most two requests. Respond is not fair, so a request may never be answered.

NUM_CLIENTS = 2

action Init:
    started = False
    pending = [False] * NUM_CLIENTS
    answered = [0] * NUM_CLIENTS

atomic fair action Start:
    require not started
    started = True

atomic action Request:
    require started
    c = oneof [i for i in range(NUM_CLIENTS) if not pending[i] and answered[i] < 2]
    pending[c] = True

atomic action Respond:
    c = oneof [i for i in range(NUM_CLIENTS) if pending[i]]
    pending[c] = False
    answered[c] += 1

func no_requests():
    return True not in pending

# No request arrives before the server starts, and it does start (strong until).
assertion NoRequestsBeforeStart:
    return no_requests() until started

# A `for` clause on the header checks one formula per client: Answered[0]
# and Answered[1]. The iterable may use only constants.
assertion Answered for c in range(NUM_CLIENTS):
    return pending[c] leadsto not pending[c]

# Once started, the server stays started.
assertion StaysStarted:
    return always (started implies always started)

# `return always eventually P` is the same check as the prefixed
# `always eventually assertion`, with the local recomputed in each state.
assertion EventuallyIdle:
    busy = len([c for c in range(NUM_CLIENTS) if pending[c]])
    return always eventually busy == 0

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2} deadlock_detection:false
Nodes: 58, queued: 0
Valid Nodes: 58 Unique states: 26
Checking formula NoRequestsBeforeStart
Checking formula Answered[0]
IsLive: false
FAILED: Liveness check failed
Invariant: Answered[0]
------
Init
--
state: {"answered":[0,0],"pending":[false,false],"started":false}
------
Start
--
state: {"answered":[0,0],"pending":[false,false],"started":true}
------
Request
--
state: {"answered":[0,0],"pending":[false,false],"started":true}
------
Any:c=0
--
state: {"answered":[0,0],"pending":[true,false],"started":true}
------
stutter
--
state: {"answered":[0,0],"pending":[true,false],"started":true}
------

Source on GitHub

Possibility

possibly P: some path from this state reaches a state where P holds

Demonstrates: possibly, can happen versus will happen, nested possibly, exists eventually

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
# Example 0118: Possibility
# possibly P: some path from this state reaches a state where P holds
# Demonstrates: possibly, can happen versus will happen, nested possibly, exists eventually

---
deadlock_detection: false
---
# `possibly P` holds in a state when some path from it, of zero or more
# steps, reaches a state where P holds. It asks what *can* happen, so it
# follows every action, fair or not (but never a crash). The operators
# around it keep their usual meaning over fair runs.
#
# A ticket is booked, then flown or cancelled. A cancelled ticket can be
# booked again, and flying ends it. Upgrade is not fair: it may happen, but
# no run has to take it.

action Init:
    status = "none"
    upgraded = False

atomic fair action Book:
    require status == "none"
    status = "booked"

atomic fair action Cancel:
    require status == "booked"
    status = "cancelled"

atomic fair action Rebook:
    require status == "cancelled"
    status = "none"

atomic fair<strong> action Fly:
    require status == "booked"
    status = "flown"

atomic action Upgrade:
    require status == "booked" and not upgraded
    upgraded = True

# From every booked state, cancelling is still possible.
assertion CanCancel:
    return always (status == "booked" implies possibly status == "cancelled")

# An upgrade can happen from any booked, not yet upgraded ticket, even
# though Upgrade isn't fair. `eventually upgraded` would fail: a fair run can
# fly without upgrading. possibly says it *can* happen, not that it *will*.
assertion CanUpgrade:
    return always ((status == "booked" and not upgraded) implies possibly upgraded)

# Nested: from the start, the ticket can be booked in a state from which it
# can still fly.
assertion CanBookThenFly:
    return possibly (status == "booked" and possibly status == "flown")

# Once flown, booking is no longer possible.
assertion FlyingIsFinal:
    return always (status == "flown" implies not possibly status == "booked")

# Fly is strongly fair, so every fair run reaches a state from which booking
# is no longer possible.
assertion EventuallyDone:
    return eventually not possibly status == "booked"

# `exists eventually P` is another spelling of `possibly P`.
assertion CanFly:
    return exists eventually status == "flown"

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2} deadlock_detection:false
Nodes: 8, queued: 0
Valid Nodes: 8 Unique states: 8
Checking formula CanCancel
Checking formula CanUpgrade
Checking formula CanBookThenFly
Checking formula FlyingIsFinal
Checking formula EventuallyDone
Checking formula CanFly
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Possibility Violation

A possibly property that fails: from some state, no path reaches the condition

Demonstrates: possibly, recoverability, counterexample note

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
# Example 0119: Possibility Violation
# A possibly property that fails: from some state, no path reaches the condition
# Demonstrates: possibly, recoverability, counterexample note

---
deadlock_detection: false
---
# A document can be moved to the trash and restored, or purged from the
# trash for good. Every trashed document can still be restored, but once
# purged it can't come back, so "the document can always become active
# again" fails.
#
# The counterexample ends in a state where the possibly is false, and a note
# says the step after which no path reaches its condition.

action Init:
    status = "active"

atomic action Delete:
    require status == "active"
    status = "trash"

atomic action Restore:
    require status == "trash"
    status = "active"

atomic action Purge:
    require status == "trash"
    status = "purged"

# Holds: a trashed document can still be restored.
assertion Restorable:
    return always (status == "trash" implies possibly status == "active")

# Fails: after Purge, no path leads back to an active document.
assertion Recoverable:
    return always possibly status == "active"

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2} deadlock_detection:false
Nodes: 3, queued: 0
Valid Nodes: 3 Unique states: 3
Checking formula Restorable
Checking formula Recoverable
IsLive: false
FAILED: Liveness check failed
Invariant: Recoverable
Note: the possibly at line 37, column 19 is false in the state after Purge (step 2; Init is step 0): no path from there, without a crash, reaches its condition
------
Init
--
state: {"status":"active"}
------
Delete
--
state: {"status":"trash"}
------
Purge
--
state: {"status":"purged"}
------
stutter
--
state: {"status":"purged"}
------

Source on GitHub

Weak Until

P unless Q: P holds until Q happens, or forever if Q never happens

Demonstrates: unless, unless versus until, unless under always

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 0120: Weak Until
# P unless Q: P holds until Q happens, or forever if Q never happens
# Demonstrates: unless, unless versus until, unless under always

---
deadlock_detection: false
---
# `P until Q` requires Q to happen. `P unless Q` does not: if Q never
# happens, P must hold forever. Once Q happens, neither says anything more
# about P.
#
# A client may take a lease, and renews it while it works. It may hand the
# lease back, but nothing forces it to, so a run may keep the lease forever.
# Only handing it back ends the lease.

action Init:
    leased = False
    returned = False
    renewals = 0

atomic action Take:
    require not leased and not returned
    leased = True

atomic fair action Renew:
    require leased and renewals < 2
    renewals += 1

atomic action Return:
    require leased
    leased = False
    returned = True

# Once taken, the lease stays held until it is handed back, or forever.
assertion HeldUntilReturned:
    return always (leased implies (leased unless returned))

# The lease is never handed back before it is taken. Taking it isn't fair,
# so a run may never take it, and until would fail here.
assertion NotReturnedBeforeTaken:
    return (not returned) unless leased

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2} deadlock_detection:false
Nodes: 7, queued: 0
Valid Nodes: 7 Unique states: 7
Checking formula HeldUntilReturned
Checking formula NotReturnedBeforeTaken
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Incremental liveness checking

Find a liveness violation without exploring every state first

Demonstrates: –experimental_incremental_liveness (see flags.txt)

Needs command-line flags (--experimental_incremental_liveness); run locally with fizz.

# Example 0121: Incremental liveness checking
# Find a liveness violation without exploring every state first
# Demonstrates: --experimental_incremental_liveness (see flags.txt)

---
deadlock_detection: false
options:
  max_actions: 200
---
# A producer may send up to 60 messages and a consumer acknowledges them.
# The consumer is weakly fair, so every message sent is acknowledged. The
# producer isn't fair, so nothing makes it send them all: a run may only
# ever exchange heartbeats. AllSent is false on that run.
#
# Normally liveness is checked once the whole state space is explored.
# With --experimental_incremental_liveness the checker also checks the part
# explored so far, each time the node count doubles, and stops at the
# first violation. Here the run of heartbeats sits next to Init, so the
# first partial check finds it, long before all 3,782 states are
# explored. A run with no violation still ends with the full check, so the
# option never changes a verdict, only when a failure is reported.

action Init:
    sent = 0
    acked = 0
    beat = False

atomic action Send:
    require sent < 60
    sent += 1

atomic fair action Ack:
    require acked < sent
    acked += 1

atomic fair action Heartbeat:
    beat = not beat

always eventually assertion AllAcked:
    return acked == sent

always eventually assertion AllSent:
    return sent == 60

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:200 max_concurrent_actions:2} deadlock_detection:false
Checking liveness on 953 closed nodes of 1000
FAILED: Liveness check failed
Invariant: AllSent
------
Init
--
state: {"acked":0,"beat":false,"sent":0}
------
Heartbeat
--
state: {"acked":0,"beat":true,"sent":0}
------
Heartbeat
--
state: {"acked":0,"beat":false,"sent":0}
------

Source on GitHub

Scoped Absence Violation

A property about what happens after a condition, which a state invariant can’t express

Demonstrates: always (P implies always Q), why an invariant misses it, counterexample

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 0122: Scoped Absence Violation
# A property about what happens after a condition, which a state invariant can't express
# Demonstrates: always (P implies always Q), why an invariant misses it, counterexample

---
deadlock_detection: false
---
# Basic economy tickets can't be refunded. Premium tickets get a full
# refund on cancel. A basic ticket can be upgraded to premium.
#
# The loophole: buy basic, upgrade, then cancel for a full refund. The
# invariant NoBasicRefund can't see it, because once upgraded the ticket
# is premium, and a refund on a premium ticket is fine. The formula
# BasicNeverRefunded talks about the future of every basic ticket: once
# basic, never refunded, whatever happens after. It fails, and the
# counterexample is the loophole. Without formulas this needs a history
# variable that remembers the original cabin.

action Init:
    cabin = oneof ["basic", "premium"]
    status = "booked"
    refunded = False

atomic action Upgrade:
    require status == "booked" and cabin == "basic"
    cabin = "premium"

atomic action Cancel:
    require status == "booked"
    status = "cancelled"
    if cabin == "premium":
        refunded = True

atomic action Fly:
    require status == "booked"
    status = "flown"

# Passes: no state is both basic and refunded.
always assertion NoBasicRefund:
    return not (cabin == "basic" and refunded)

# Fails: Upgrade then Cancel refunds a ticket that was bought as basic.
assertion BasicNeverRefunded:
    return always (cabin == "basic" implies always not refunded)

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2} deadlock_detection:false
Nodes: 7, queued: 0
Valid Nodes: 7 Unique states: 6
Checking formula BasicNeverRefunded
IsLive: false
FAILED: Liveness check failed
Invariant: BasicNeverRefunded
------
Init
--
state: {}
------
Any:cabin="basic"
--
state: {"cabin":"basic","refunded":false,"status":"booked"}
------
Upgrade
--
state: {"cabin":"premium","refunded":false,"status":"booked"}
------
Cancel
--
state: {"cabin":"premium","refunded":true,"status":"cancelled"}
------
stutter
--
state: {"cabin":"premium","refunded":true,"status":"cancelled"}
------

Source on GitHub

Assertion For Clauses

One assertion header checks one property per value, with filters, nesting and tuples

Demonstrates: for clauses on prefixed and formula assertions, if filters, several clauses, tuple targets

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 0123: Assertion For Clauses
# One assertion header checks one property per value, with filters, nesting and tuples
# Demonstrates: for clauses on prefixed and formula assertions, if filters, several clauses, tuple targets

---
deadlock_detection: false
---
# A primary holds every key, and a fair Copy action copies a key to one
# more replica at a time.
#
# A `for` clause on the assertion header checks one instance per value,
# named like Copied[a, 1]. It works on prefixed assertions (always,
# always eventually, eventually always) as well as on formula assertions.
# Iterables and filters may use only constants and builtins: they are
# evaluated once, before Init.

KEYS = ["a", "b"]
REPLICAS = [0, 1, 2]
PRIMARY = 0
WATCHED = [("a", 1), ("b", 2)]

action Init:
    copies = {k: [PRIMARY] for k in KEYS}

atomic fair action Copy:
    k = oneof KEYS
    r = oneof [r for r in REPLICAS if r not in copies[k]]
    copies[k] = copies[k] + [r]

# One safety check per key: Bounded[a] and Bounded[b].
always assertion Bounded for k in KEYS:
    return len(copies[k]) <= len(REPLICAS)

# Several clauses nest in order, and `if` filters the values: one check
# per key and non-primary replica, four in all.
always eventually assertion Copied for k in KEYS for r in REPLICAS if r != PRIMARY:
    return r in copies[k]

# With several targets, each value is a tuple of that length.
assertion StaysCopied for k, r in WATCHED:
    return always (r in copies[k] implies always r in copies[k])

Expected result (from fizz):

StateSpaceOptions: options:{max_actions:100 max_concurrent_actions:2} deadlock_detection:false
Nodes: 80, queued: 0
Valid Nodes: 76 Unique states: 25
Checking always eventually Copied[a, 1]
Checking always eventually Copied[a, 2]
Checking always eventually Copied[b, 1]
Checking always eventually Copied[b, 2]
Checking formula StaysCopied[a, 1]
Checking formula StaysCopied[b, 2]
IsLive: true
PASSED: Model checker completed successfully

Source on GitHub

Patterns that use this