2026-06-11 04:36:17 +02:00
|
|
|
"""State machine invariant checks.
|
2026-05-04 23:43:55 +02:00
|
|
|
|
|
|
|
|
Originally specced as hypothesis-driven, but hypothesis isn't a project
|
|
|
|
|
dependency, so the same invariants are asserted via deterministic
|
|
|
|
|
exhaustive enumeration plus a bounded random walk. The intent is identical:
|
|
|
|
|
produce a structural sweep that catches the audit's identified risks
|
|
|
|
|
(orphan states, transitions writing fields outside the lifecycle module,
|
|
|
|
|
terminal states being mistakenly listed as escape points).
|
|
|
|
|
|
|
|
|
|
Invariants checked:
|
|
|
|
|
1. Every state declared in ``VALID_TRANSITIONS`` appears as either a
|
|
|
|
|
source or a target — no entries that nothing transitions into and
|
|
|
|
|
nothing transitions out of.
|
|
|
|
|
2. Every non-terminal state has at least one outgoing transition.
|
|
|
|
|
3. Terminal states (``completed``, ``cancelled``) have no outgoing
|
|
|
|
|
transitions.
|
|
|
|
|
4. ``is_terminal_state`` is consistent with the empty-transition list
|
|
|
|
|
in ``VALID_TRANSITIONS``.
|
|
|
|
|
5. Every state is reachable from the initial state ``backlog`` (BFS).
|
|
|
|
|
6. Bounded random walk from ``backlog``: any sequence of valid
|
|
|
|
|
transitions stays within the declared state set; never crosses into
|
|
|
|
|
undeclared states; terminal states absorb (no further transitions).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import random
|
|
|
|
|
|
|
|
|
|
from roboco.enforcement.task_lifecycle import (
|
|
|
|
|
VALID_TRANSITIONS,
|
|
|
|
|
get_valid_transitions,
|
|
|
|
|
is_terminal_state,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_INITIAL_STATE = "backlog"
|
|
|
|
|
_TERMINAL_STATES = {"completed", "cancelled"}
|
|
|
|
|
_DECLARED_STATES = set(VALID_TRANSITIONS.keys())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_orphan_states() -> None:
|
|
|
|
|
"""Invariant 1 — every state is reachable + has an exit if non-terminal."""
|
|
|
|
|
targets: set[str] = set()
|
|
|
|
|
for outgoing in VALID_TRANSITIONS.values():
|
|
|
|
|
targets.update(outgoing)
|
|
|
|
|
targets.add(_INITIAL_STATE) # initial state has no inbound by convention
|
|
|
|
|
|
|
|
|
|
sources = {state for state, outs in VALID_TRANSITIONS.items() if outs}
|
|
|
|
|
|
|
|
|
|
# Every declared state must appear as a target or be terminal.
|
|
|
|
|
orphan_targets = _DECLARED_STATES - targets
|
|
|
|
|
assert not orphan_targets, f"states with no inbound transition: {orphan_targets}"
|
|
|
|
|
|
|
|
|
|
# Every non-terminal declared state must have outbound transitions.
|
|
|
|
|
orphan_sources = (_DECLARED_STATES - sources) - _TERMINAL_STATES
|
|
|
|
|
assert not orphan_sources, (
|
|
|
|
|
f"non-terminal states with no outbound transitions: {orphan_sources}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_terminal_states_have_no_exits() -> None:
|
|
|
|
|
"""Invariant 3 — terminal states must not list any outgoing transitions."""
|
|
|
|
|
for state in _TERMINAL_STATES:
|
|
|
|
|
assert state in VALID_TRANSITIONS, f"terminal state {state} not declared"
|
|
|
|
|
assert VALID_TRANSITIONS[state] == [], (
|
|
|
|
|
f"terminal state {state} declares outgoing transitions: "
|
|
|
|
|
f"{VALID_TRANSITIONS[state]}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_terminal_state_consistent_with_transitions() -> None:
|
|
|
|
|
"""Invariant 4 — is_terminal_state(s) iff VALID_TRANSITIONS[s] is empty."""
|
|
|
|
|
for state, outgoing in VALID_TRANSITIONS.items():
|
|
|
|
|
assert is_terminal_state(state) == (len(outgoing) == 0), (
|
|
|
|
|
f"is_terminal_state({state!r})={is_terminal_state(state)} "
|
|
|
|
|
f"but outgoing transitions = {outgoing}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_state_reachable_from_initial() -> None:
|
|
|
|
|
"""Invariant 5 — BFS from backlog covers every declared state."""
|
|
|
|
|
visited: set[str] = set()
|
|
|
|
|
frontier: list[str] = [_INITIAL_STATE]
|
|
|
|
|
while frontier:
|
|
|
|
|
state = frontier.pop()
|
|
|
|
|
if state in visited:
|
|
|
|
|
continue
|
|
|
|
|
visited.add(state)
|
|
|
|
|
for nxt in VALID_TRANSITIONS.get(state, []):
|
|
|
|
|
if nxt not in visited:
|
|
|
|
|
frontier.append(nxt)
|
|
|
|
|
unreachable = _DECLARED_STATES - visited
|
|
|
|
|
assert not unreachable, f"states unreachable from {_INITIAL_STATE!r}: {unreachable}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_random_walks_stay_within_declared_states() -> None:
|
|
|
|
|
"""Invariant 6 — bounded random walks from backlog never leave the declared set."""
|
|
|
|
|
rng = random.Random(20260504)
|
|
|
|
|
walks = 100
|
|
|
|
|
max_steps = 50
|
|
|
|
|
|
|
|
|
|
for _ in range(walks):
|
|
|
|
|
state = _INITIAL_STATE
|
|
|
|
|
for _ in range(max_steps):
|
|
|
|
|
assert state in _DECLARED_STATES, f"walked into undeclared state {state!r}"
|
|
|
|
|
outs = get_valid_transitions(state)
|
|
|
|
|
if not outs:
|
|
|
|
|
# Terminal — walk stops.
|
|
|
|
|
assert is_terminal_state(state), (
|
|
|
|
|
f"non-terminal state {state!r} has no transitions"
|
|
|
|
|
)
|
|
|
|
|
break
|
|
|
|
|
state = rng.choice(outs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_no_self_loops() -> None:
|
|
|
|
|
"""Bonus invariant — no state may transition to itself."""
|
|
|
|
|
for state, outs in VALID_TRANSITIONS.items():
|
|
|
|
|
assert state not in outs, (
|
|
|
|
|
f"state {state!r} has a self-loop in VALID_TRANSITIONS"
|
|
|
|
|
)
|