Gotchas and Common Issues
Known pitfalls, language quirks, and their workarounds.
Symptom: fizz functions can be called only in the following ways error.
Cause: Direct assignment self.x = otherRole.someFn() is not supported.
Workaround: Use a local variable as an intermediary.
# CRASHES
atomic action ReadDoc:
self.cached = db.ReadDoc(doc_id)
# WORKS
atomic action ReadDoc:
content = db.ReadDoc(doc_id)
self.cached = content
This applies to any cross-role function call assigned directly to self.*.
Local-to-local (x = otherRole.fn()) and same-role (self.x = self.fn())
both work fine.
Symptom: Parse error or unexpected behavior with for k, v in dict.items().
Cause: FizzBee/Starlark does not support tuple unpacking in for loop headers.
Workaround: Iterate over keys and index into the dict.
# FAILS
for k, v in my_dict.items():
process(k, v)
# WORKS
for k in my_dict:
v = my_dict[k]
process(k, v)
Note: Dict comprehensions with tuple unpacking do work:
{k: v for k, v in items} is fine.
Symptom: Role’s Init action fails to see a global variable that is being assigned in the same top-level Init block.
Cause: When a role is instantiated with Role(), its Init runs
immediately. If the global hasn’t been assigned yet in the enclosing
Init, the role’s Init can’t see it.
Workaround: Assign globals before creating roles that depend on them.
# FAILS: db is not yet assigned when Employee() runs
action Init:
emp = Employee() # Employee.Init might reference db
db = Server()
# WORKS: db exists before Employee is created
action Init:
db = Server()
emp = Employee()
Despite being based on Starlark, FizzBee supports the None keyword.
# All of these work
x = None
if x == None: # True
if x != None: # False
Note: There is no is operator. Use == None and != None.
Symptom: State space is much larger than expected with symmetric roles.
Cause: Lists preserve insertion order, which distinguishes otherwise
equivalent states. [A, B] and [B, A] are different list states even
if A and B are symmetric.
Fix: Always use bag() or set() to hold symmetric role instances.
# BAD: list preserves order, breaks symmetry
workers = []
workers.append(Worker())
# GOOD: bag is unordered, symmetry works
workers = bag()
workers.add(Worker())
See reference example Common Pitfall - Using List with Symmetric Roles.
Symptom: Model passes even though you expected a failure.
Cause: require condition silently disables the action when the
condition is false. It does not report a failure.
# This does NOT check that balance is always >= 0.
# It just prevents Withdraw from running when balance < amount.
atomic action Withdraw:
require self.balance >= amount
self.balance = self.balance - amount
# To check the invariant, use an assertion:
always assertion BalanceNonNegative:
return account.balance >= 0
Symptom: Action never fires.
Cause: any [] (empty iterable) disables the action, similar to
require False. This is by design — there’s nothing to choose from.
Implication: You don’t need require len(xs) > 0 before any xs:
# Redundant guard — any already handles empty case
require len(items) > 0
chosen = any items
# Equivalent and simpler
chosen = any items
Symptom: State space explodes with interval or ordinal symmetry despite having a small limit.
Cause: Arithmetic on symmetric values (val + 1, val - 1) creates
new values without checking the domain’s limit. Only fresh() checked
the limit. (Fixed in commit after Feb 2026.)
Affected: interval and ordinal symmetry types. Not affected: rotational (wraps mod limit) and nominal (no arithmetic).
Mitigation: Update to the latest FizzBee version which includes the
limit check in CheckSymmetryConstraints. After the fix, transitions
that would create more distinct values than the limit are automatically
pruned.
FizzBee uses Starlark, which is a Python subset with some differences:
- No
isoperator: Use==instead ofis - No
delstatement: Reassign to remove (e.g., filter a list) - No tuple unpacking in
for: See gotcha #2 above - No
try/except: Errors terminate the model checker - No classes: Use
rolefor stateful objects,recordfor data - No
import: Everything is in one file (hermetic) - Integers only: No floats (use scaled integers for ratios)
- Dicts are insertion-ordered: But don’t rely on this for symmetry
set()requires hashable elements: Usegenericset()for non-hashable (dicts, lists) but only in local scope
Removing a role from a bag requires rebuilding the bag with a filter:
# Remove a specific role instance by __id__
employees = bag([e for e in employees if e.__id__ != target_id])
There is no bag.remove() method. The list comprehension creates a
new bag without the removed role.
For symmetric roles, this works naturally — a removed role’s slot is reusable because new instances are interchangeable with old ones.
Symptom: Parse error or unexpected behavior when using any(...) to
check if at least one element in a collection satisfies a condition.
Cause: any is a FizzBee keyword for nondeterministic choice
(deprecated — prefer oneof). The parser tries to interpret any as the
nondeterministic choice operator first. When used in an assignment like
x = any([...]), the parser sees it as a nondeterministic choice over a
list literal, not a function call, causing a parse error or wrong semantics.
In some contexts (e.g., directly inside if or require) the parser
falls back to treating any as an identifier, so the Python any()
function does work — but this is context-dependent and fragile.
# FAILS: parser treats this as nondeterministic choice, not function call
is_valid = any([x > 0 for x in items])
# WORKS in these contexts (any treated as identifier/function):
if not any([x > 0 for x in items]):
return
require any([x > 0 for x in items])
Workarounds (prefer these — they work unambiguously in all contexts):
# Option 1: use all() with negation (symmetric, always safe)
require all([x > 0 for x in items]) # require all positive
is_valid = all([x > 0 for x in items]) # assign works fine
# Option 2: use len() > 0 on a filtered comprehension
is_valid = len([x for x in items if x > 0]) > 0
# Option 3: inline into if/require directly (works but couples logic)
if not any([x > 0 for x in items]):
return
Rule of thumb: never write x = any(...). Use all(...) or
len([...]) > 0 for assignments. For guards, require all([not ...]) is
the idiomatic FizzBee style anyway (see Performance Guide).
Note: The oneof keyword (preferred over any for nondeterministic
choice) does not have this collision — oneof cannot be used as an
identifier, so oneof([...]) is always a parse error. Use oneof to
avoid the ambiguity entirely:
x = oneof items # preferred (no collision risk)
x = any items # deprecated (emits DeprecationWarning)
Symptom: fizz functions can be called only in the following ways
error, or undefined: my_function.
Cause: A func call must be a standalone statement of the form
[var =] [role.]fn(args). Used inside a larger expression (a condition,
an index expression, a call argument), the statement is evaluated as
plain Starlark, which cannot see fizz functions.
# FAILS: call embedded in an expression
if replica.get_state() == "active":
pass
# WORKS: extract to a variable first
state = replica.get_state()
if state == "active":
pass
The call contexts themselves are unrestricted: calls work in atomic, serial, parallel, and oneof blocks, at the top level or within roles.
Symptom: require cannot be a direct statement of a parallel block error.
Cause: Parallel statements are unordered, so there is no defined point at which the condition would be checked relative to the other statements’ effects.
# FAILS: require as one of the unordered parallel statements
parallel action Both:
require ready
a = a + 1
b = b + 1
# WORKS: guard the whole action - place it before the parallel block
action Both:
require ready
parallel:
a = a + 1
b = b + 1
# WORKS: guard one branch - wrap it with its dependent statements
parallel action Both:
atomic:
require ready
a = a + 1
serial:
b = b + 1
Symptom: Spec PASSES under max_actions: N, but state count keeps
growing as you raise N, or BFS runs out of memory at larger configs.
Cause: max_actions prunes paths past the cap. A spec with truly
unbounded growth (e.g., a counter that can be incremented forever via a
loop with no require bound) will look “complete” because the cap stops
the exploration, not natural exhaustion.
Fix: Add always bound assertions on any counter, accumulator, or
collection size that you believe is structurally bounded. Use a small
safety multiplier (e.g., BOUND_SLACK = 2).
BOUND_SLACK = 2
always assertion WriterCountersBounded:
for w in writers:
if w.writes > MAX_WRITES_PER_WRITER * BOUND_SLACK:
return False
return True
always assertion InboxBounded:
for n in nodes:
if len(n.inbox) > MAX_INFLIGHT * BOUND_SLACK:
return False
return True
Run simulation first — it often trips these in seconds:
./fizz -x --max_runs 1000 spec.fizz
An assertion firing means the bound might be wrong (loosen it) or the spec is genuinely unbounded (find the loop and fix the spec). PASSED at 2× across many runs is evidence of finiteness, not a proof.
See VERIFICATION_GUIDE.md §8 for the full pattern, more examples, and
how to identify which variables to bound.
Symptom: before.leader == after.leader is always False in a transition
assertion even when it’s “the same” role. n.role_id == w never matches in
a next_states() filter. after.workers membership checks or dict lookups
keyed by a role from before silently fail.
Cause: Role values compare by pointer identity, and every state
(before, after, each next_states() successor, each assertion
evaluation) is a separate clone. Within ONE state, aliasing is preserved, so
current_leader == workers[0] works — but across states, the “same” role is
a different pointer and every comparison is silently False. (Roles do hash
by value, which makes dict lookups extra confusing: the bucket is found, the
key still doesn’t match.)
Fix: Compare identity VALUES, not role objects:
# ❌ WRONG: pointer comparison across states — always False
transition assertion LeaderStable(before, after):
return before.leader == after.leader
# ✅ CORRECT: compare __id__ (a value with proper equality)
transition assertion LeaderStable(before, after):
return before.leader.__id__ == after.leader.__id__
# ✅ CORRECT: next_states() role filtering via role_id
witnesses = [n for n in next_states() if n.role_id == w.__id__]
Store __id__ values (not role objects) in records and dicts whenever the
data crosses state boundaries: record(customer=self.__id__).
Id values expose their components: w.__id__.name → "Worker",
w.__id__.index → 0.
Symptom: mismatched input 'action' expecting {..., NAME} parse error
on something like n.action or self.role.
Cause: fizz keywords (action, role, func, oneof, …) are
reserved even in attribute position after a dot.
Fix: Pick non-keyword names for record/struct fields (action_name,
role_ref). Built-in structs follow the same convention — next_states()
transitions use action_name and role_ref for exactly this reason.
Multi-line list comprehensions also fail to parse — keep comprehensions on
one line.