statespec 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- statespec/__init__.py +57 -0
- statespec/core.py +401 -0
- statespec/expr.py +473 -0
- statespec/identity.py +283 -0
- statespec/render.py +132 -0
- statespec-0.4.0.dist-info/METADATA +117 -0
- statespec-0.4.0.dist-info/RECORD +9 -0
- statespec-0.4.0.dist-info/WHEEL +4 -0
- statespec-0.4.0.dist-info/licenses/LICENSE +21 -0
statespec/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Declarative state-machine specs with a generic enforcement engine.
|
|
2
|
+
|
|
3
|
+
A *spec* is a plain-data description of an entity's lifecycle: its states,
|
|
4
|
+
the named transitions between them, who (which roles) may fire each
|
|
5
|
+
transition, and the guard predicates that must hold. The same artifact is:
|
|
6
|
+
|
|
7
|
+
- **human-readable** — `render.to_mermaid` / `render.to_table` turn it into a
|
|
8
|
+
diagram and a plain-English table a non-engineer can sign off on;
|
|
9
|
+
- **machine-checkable** — `core.validate` proves the graph is coherent
|
|
10
|
+
(no unreachable or stuck states) and `core.apply` is the single, generic
|
|
11
|
+
interpreter every service-layer transition goes through, so the code
|
|
12
|
+
*cannot* drift from the spec without a test noticing.
|
|
13
|
+
|
|
14
|
+
The engine here is deliberately domain-agnostic: it knows nothing about
|
|
15
|
+
any domain. Domain specs live in the consuming application, next to the
|
|
16
|
+
slice they govern.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from statespec.core import (
|
|
20
|
+
Decision,
|
|
21
|
+
Evaluation,
|
|
22
|
+
ExpressionError,
|
|
23
|
+
GuardRejected,
|
|
24
|
+
IllegalTransition,
|
|
25
|
+
Invariant,
|
|
26
|
+
InvariantViolation,
|
|
27
|
+
PermissionDenied,
|
|
28
|
+
StateSpec,
|
|
29
|
+
Transition,
|
|
30
|
+
TransitionError,
|
|
31
|
+
UnknownAction,
|
|
32
|
+
apply,
|
|
33
|
+
can_fire,
|
|
34
|
+
enabled_transitions,
|
|
35
|
+
fire,
|
|
36
|
+
validate,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"Decision",
|
|
41
|
+
"Evaluation",
|
|
42
|
+
"ExpressionError",
|
|
43
|
+
"GuardRejected",
|
|
44
|
+
"IllegalTransition",
|
|
45
|
+
"Invariant",
|
|
46
|
+
"InvariantViolation",
|
|
47
|
+
"PermissionDenied",
|
|
48
|
+
"StateSpec",
|
|
49
|
+
"Transition",
|
|
50
|
+
"TransitionError",
|
|
51
|
+
"UnknownAction",
|
|
52
|
+
"apply",
|
|
53
|
+
"can_fire",
|
|
54
|
+
"enabled_transitions",
|
|
55
|
+
"fire",
|
|
56
|
+
"validate",
|
|
57
|
+
]
|
statespec/core.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
"""The generic state-machine engine: data model, enforcement, and analysis.
|
|
2
|
+
|
|
3
|
+
Nothing in here is invoice-specific. A `StateSpec` is pure data; `apply` is
|
|
4
|
+
the only place a transition is ever allowed to happen; `validate` proves a
|
|
5
|
+
spec is well-formed before it can mislead anyone. Keep this file small and
|
|
6
|
+
domain-free — that is what lets one Hypothesis test (driving `apply` over
|
|
7
|
+
random sequences) stand in as a correctness proof for *every* spec.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections import deque
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Mapping
|
|
15
|
+
|
|
16
|
+
from statespec import expr as _expr
|
|
17
|
+
from statespec.expr import ExpressionError # re-export for callers
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --- Data model -------------------------------------------------------------
|
|
21
|
+
#
|
|
22
|
+
# Guards and invariants are declarative expressions (see app/statespec/expr.py)
|
|
23
|
+
# — pure data, so the whole StateSpec serialises, renders, and diffs. The same
|
|
24
|
+
# object evaluates at runtime, so the rendered policy *is* the enforced policy.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Transition:
|
|
29
|
+
"""A named, role-gated edge from one or more source states to a dest."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
sources: tuple[str, ...]
|
|
33
|
+
dest: str
|
|
34
|
+
# Roles permitted to fire this transition. Empty = no one (a deliberate
|
|
35
|
+
# dead edge is a spec error; `validate` flags it).
|
|
36
|
+
roles: frozenset[str] = frozenset()
|
|
37
|
+
# Optional guard expression that must evaluate True against the context.
|
|
38
|
+
guard: object | None = None # an expr condition (Compare/All/Any/Not/Opaque)
|
|
39
|
+
# Plain-English description for the human-readable render.
|
|
40
|
+
label: str = ""
|
|
41
|
+
# Stable, namespaced control id for audit/compliance references (e.g.
|
|
42
|
+
# "BATCH-APPROVE"). Defaults to the action name; survives a label reword.
|
|
43
|
+
control_id: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Invariant:
|
|
48
|
+
name: str
|
|
49
|
+
condition: object # an expr condition; must hold in every reachable state
|
|
50
|
+
label: str = ""
|
|
51
|
+
control_id: str | None = None # stable control id (defaults to name)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class StateSpec:
|
|
56
|
+
"""A complete lifecycle: states, the context field schema, transitions,
|
|
57
|
+
and invariants."""
|
|
58
|
+
|
|
59
|
+
name: str
|
|
60
|
+
title: str
|
|
61
|
+
# state id -> human description
|
|
62
|
+
states: Mapping[str, str]
|
|
63
|
+
# context field name -> type tag (the contract a service snapshot must
|
|
64
|
+
# satisfy; the basis for validate's field/type checks). See expr.TYPE_TAGS.
|
|
65
|
+
fields: Mapping[str, str]
|
|
66
|
+
initial: str
|
|
67
|
+
terminal: frozenset[str]
|
|
68
|
+
transitions: tuple[Transition, ...]
|
|
69
|
+
invariants: tuple[Invariant, ...] = ()
|
|
70
|
+
# Author-asserted policy version. Bump when the *semantic* digest changes;
|
|
71
|
+
# the (version, semantic_digest) pair is the policy's behavioural identity. A
|
|
72
|
+
# wording-only edit moves presentation_digest, not this. See statespec.identity.
|
|
73
|
+
version: int = 1
|
|
74
|
+
|
|
75
|
+
def transition(self, action: str) -> Transition | None:
|
|
76
|
+
for t in self.transitions:
|
|
77
|
+
if t.name == action:
|
|
78
|
+
return t
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --- Enforcement errors -----------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class TransitionError(Exception):
|
|
86
|
+
"""Base for every reason a transition can be refused."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class UnknownAction(TransitionError):
|
|
90
|
+
"""The action name isn't defined in the spec at all."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class IllegalTransition(TransitionError):
|
|
94
|
+
"""The action exists but not from the entity's current state."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PermissionDenied(TransitionError):
|
|
98
|
+
"""The actor's roles don't include any role permitted to fire it."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class GuardRejected(TransitionError):
|
|
102
|
+
"""The action's guard predicate returned False for this entity."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class InvariantViolation(Exception):
|
|
106
|
+
"""A transition would produce a state that violates a declared invariant.
|
|
107
|
+
|
|
108
|
+
Deliberately NOT a TransitionError: invariants are *consequences* of guards
|
|
109
|
+
(a backstop), so this can only fire from a guard/spec bug or an out-of-band
|
|
110
|
+
mutation. It maps to HTTP 500 (an internal breach of a stated guarantee),
|
|
111
|
+
not a client 4xx. Any client-visible rule must be a guard, never a
|
|
112
|
+
standalone invariant."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, dest: str, violated: list[str]):
|
|
115
|
+
self.dest = dest
|
|
116
|
+
self.violated = violated
|
|
117
|
+
super().__init__(
|
|
118
|
+
f"transition into {dest!r} would violate invariant(s): {violated}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class Decision:
|
|
124
|
+
"""The outcome of a `can_fire` check — allowed, or why not."""
|
|
125
|
+
|
|
126
|
+
allowed: bool
|
|
127
|
+
dest: str | None = None
|
|
128
|
+
reason: str = ""
|
|
129
|
+
error: type[TransitionError] | None = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class Evaluation:
|
|
134
|
+
"""Structured evidence that one condition was evaluated during a transition
|
|
135
|
+
— the audit record's "why was this allowed". `inputs` is just the fields the
|
|
136
|
+
expression referenced (not the whole entity)."""
|
|
137
|
+
|
|
138
|
+
control_id: str
|
|
139
|
+
kind: str # "guard" | "invariant"
|
|
140
|
+
expression: dict # expr.to_dict — the condition as data
|
|
141
|
+
result: bool
|
|
142
|
+
inputs: Mapping[str, object]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --- The single enforcement path -------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def can_fire(
|
|
149
|
+
spec: StateSpec,
|
|
150
|
+
action: str,
|
|
151
|
+
current_state: str,
|
|
152
|
+
actor_roles: frozenset[str],
|
|
153
|
+
entity: Mapping[str, object] | None = None,
|
|
154
|
+
) -> Decision:
|
|
155
|
+
"""Pure decision function. Never mutates anything, and never raises
|
|
156
|
+
TransitionError — refusals come back as a Decision. It can raise
|
|
157
|
+
ExpressionError, for a malformed guard or a missing context field:
|
|
158
|
+
that's a contract/spec bug, not a refusal.
|
|
159
|
+
|
|
160
|
+
Checks, in order: action exists -> reachable from current state ->
|
|
161
|
+
actor permitted -> guard holds. The ordering is deliberate and matches
|
|
162
|
+
the error precedence callers (and tests) rely on.
|
|
163
|
+
"""
|
|
164
|
+
t = spec.transition(action)
|
|
165
|
+
if t is None:
|
|
166
|
+
return Decision(False, reason=f"unknown action {action!r}", error=UnknownAction)
|
|
167
|
+
if current_state not in t.sources:
|
|
168
|
+
return Decision(
|
|
169
|
+
False,
|
|
170
|
+
reason=f"{action!r} not allowed from state {current_state!r}",
|
|
171
|
+
error=IllegalTransition,
|
|
172
|
+
)
|
|
173
|
+
if not (actor_roles & t.roles):
|
|
174
|
+
return Decision(
|
|
175
|
+
False,
|
|
176
|
+
reason=f"none of roles {set(actor_roles)} may fire {action!r}",
|
|
177
|
+
error=PermissionDenied,
|
|
178
|
+
)
|
|
179
|
+
if t.guard is not None:
|
|
180
|
+
# The guard is an expr condition. A malformed guard / missing context
|
|
181
|
+
# field raises ExpressionError (a contract bug) rather than a refusal.
|
|
182
|
+
if not t.guard.evaluate(entity or {}):
|
|
183
|
+
return Decision(
|
|
184
|
+
False,
|
|
185
|
+
reason=f"guard {_expr.render(t.guard)} rejected {action!r}",
|
|
186
|
+
error=GuardRejected,
|
|
187
|
+
)
|
|
188
|
+
return Decision(True, dest=t.dest, reason="ok")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _require_fields(spec: StateSpec, ctx: Mapping[str, object]) -> None:
|
|
192
|
+
missing = set(spec.fields) - set(ctx)
|
|
193
|
+
if missing:
|
|
194
|
+
raise ExpressionError(
|
|
195
|
+
f"context for {spec.name!r} is missing field(s): {sorted(missing)}"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def fire(
|
|
200
|
+
spec: StateSpec,
|
|
201
|
+
action: str,
|
|
202
|
+
current_state: str,
|
|
203
|
+
actor_roles: frozenset[str],
|
|
204
|
+
entity: Mapping[str, object] | None = None,
|
|
205
|
+
*,
|
|
206
|
+
post_overrides: Mapping[str, object] | None = None,
|
|
207
|
+
) -> tuple[str, list[Evaluation]]:
|
|
208
|
+
"""Enforce a transition and return `(dest, evaluations)` — the destination
|
|
209
|
+
state plus structured evidence of every condition evaluated (the guard and
|
|
210
|
+
each invariant, with the inputs it read). The single mutation-decision path;
|
|
211
|
+
`apply` is the dest-only convenience wrapper.
|
|
212
|
+
|
|
213
|
+
After the transition is judged legal (action / source / role / guard), the
|
|
214
|
+
invariants are evaluated against the proposed post-state and the transition
|
|
215
|
+
is refused (InvariantViolation) if any fails. Proposed post-state is
|
|
216
|
+
`{**entity, status: dest, **post_overrides}`.
|
|
217
|
+
"""
|
|
218
|
+
decision = can_fire(spec, action, current_state, actor_roles, entity)
|
|
219
|
+
if not decision.allowed:
|
|
220
|
+
assert decision.error is not None
|
|
221
|
+
raise decision.error(decision.reason)
|
|
222
|
+
assert decision.dest is not None
|
|
223
|
+
dest = decision.dest
|
|
224
|
+
ctx = entity or {}
|
|
225
|
+
t = spec.transition(action)
|
|
226
|
+
evaluations: list[Evaluation] = []
|
|
227
|
+
|
|
228
|
+
if t.guard is not None:
|
|
229
|
+
evaluations.append(
|
|
230
|
+
Evaluation(
|
|
231
|
+
control_id=t.control_id or t.name,
|
|
232
|
+
kind="guard",
|
|
233
|
+
expression=_expr.to_dict(t.guard),
|
|
234
|
+
result=True, # can_fire allowed => the guard held
|
|
235
|
+
inputs={f: ctx[f] for f in t.guard.fields() if f in ctx},
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
post = {**ctx, "status": dest, **(post_overrides or {})}
|
|
240
|
+
_require_fields(spec, post)
|
|
241
|
+
_expr.validate_context(spec.fields, post) # runtime values match declared types
|
|
242
|
+
violated: list[str] = []
|
|
243
|
+
for inv in spec.invariants:
|
|
244
|
+
ok = bool(inv.condition.evaluate(post))
|
|
245
|
+
evaluations.append(
|
|
246
|
+
Evaluation(
|
|
247
|
+
control_id=inv.control_id or inv.name,
|
|
248
|
+
kind="invariant",
|
|
249
|
+
expression=_expr.to_dict(inv.condition),
|
|
250
|
+
result=ok,
|
|
251
|
+
inputs={f: post[f] for f in inv.condition.fields() if f in post},
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
if not ok:
|
|
255
|
+
violated.append(inv.name)
|
|
256
|
+
if violated:
|
|
257
|
+
raise InvariantViolation(dest, violated)
|
|
258
|
+
return dest, evaluations
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def apply(
|
|
262
|
+
spec: StateSpec,
|
|
263
|
+
action: str,
|
|
264
|
+
current_state: str,
|
|
265
|
+
actor_roles: frozenset[str],
|
|
266
|
+
entity: Mapping[str, object] | None = None,
|
|
267
|
+
*,
|
|
268
|
+
post_overrides: Mapping[str, object] | None = None,
|
|
269
|
+
) -> str:
|
|
270
|
+
"""Enforce a transition and return the destination state, or raise. The
|
|
271
|
+
dest-only wrapper around `fire` for callers that don't need the evidence."""
|
|
272
|
+
dest, _ = fire(
|
|
273
|
+
spec, action, current_state, actor_roles, entity, post_overrides=post_overrides
|
|
274
|
+
)
|
|
275
|
+
return dest
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def enabled_transitions(
|
|
279
|
+
spec: StateSpec,
|
|
280
|
+
current_state: str,
|
|
281
|
+
actor_roles: frozenset[str],
|
|
282
|
+
entity: Mapping[str, object] | None = None,
|
|
283
|
+
) -> list[Transition]:
|
|
284
|
+
"""Every transition the given actor could fire right now."""
|
|
285
|
+
return [
|
|
286
|
+
t
|
|
287
|
+
for t in spec.transitions
|
|
288
|
+
if can_fire(spec, t.name, current_state, actor_roles, entity).allowed
|
|
289
|
+
]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# --- Static analysis (well-formedness) -------------------------------------
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def reachable_states(spec: StateSpec) -> set[str]:
|
|
296
|
+
"""States reachable from `initial` by following transitions (roles/guards
|
|
297
|
+
ignored — this is structural reachability)."""
|
|
298
|
+
seen = {spec.initial}
|
|
299
|
+
queue: deque[str] = deque([spec.initial])
|
|
300
|
+
while queue:
|
|
301
|
+
s = queue.popleft()
|
|
302
|
+
for t in spec.transitions:
|
|
303
|
+
if s in t.sources and t.dest not in seen:
|
|
304
|
+
seen.add(t.dest)
|
|
305
|
+
queue.append(t.dest)
|
|
306
|
+
return seen
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _can_reach_terminal(spec: StateSpec, start: str) -> bool:
|
|
310
|
+
if not spec.terminal:
|
|
311
|
+
return True # nothing to reach; treated as vacuously fine
|
|
312
|
+
seen = {start}
|
|
313
|
+
queue: deque[str] = deque([start])
|
|
314
|
+
while queue:
|
|
315
|
+
s = queue.popleft()
|
|
316
|
+
if s in spec.terminal:
|
|
317
|
+
return True
|
|
318
|
+
for t in spec.transitions:
|
|
319
|
+
if s in t.sources and t.dest not in seen:
|
|
320
|
+
seen.add(t.dest)
|
|
321
|
+
queue.append(t.dest)
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def validate(
|
|
326
|
+
spec: StateSpec, known_roles: frozenset[str] | None = None
|
|
327
|
+
) -> list[str]:
|
|
328
|
+
"""Return a list of human-readable problems. Empty list == well-formed.
|
|
329
|
+
|
|
330
|
+
A well-formed spec has: a declared initial state; declared, terminal-only
|
|
331
|
+
sink states; every state reachable from initial; every non-terminal state
|
|
332
|
+
able to reach some terminal (no traps); unique transition + invariant
|
|
333
|
+
names; valid field-schema type tags; and guard/invariant expressions that
|
|
334
|
+
type-check against the field schema (every referenced field declared, every
|
|
335
|
+
comparison type-compatible, every opaque registered).
|
|
336
|
+
|
|
337
|
+
If `known_roles` is supplied (the application's role catalogue), every
|
|
338
|
+
transition's roles must be drawn from it — this catches a misspelled role
|
|
339
|
+
(e.g. `aprover`) that no user could ever hold, which would otherwise make
|
|
340
|
+
the transition silently un-fireable.
|
|
341
|
+
"""
|
|
342
|
+
problems: list[str] = []
|
|
343
|
+
states = set(spec.states)
|
|
344
|
+
|
|
345
|
+
if spec.initial not in states:
|
|
346
|
+
problems.append(f"initial state {spec.initial!r} is not declared")
|
|
347
|
+
for term in spec.terminal:
|
|
348
|
+
if term not in states:
|
|
349
|
+
problems.append(f"terminal state {term!r} is not declared")
|
|
350
|
+
|
|
351
|
+
for fname, tag in spec.fields.items():
|
|
352
|
+
if tag not in _expr.TYPE_TAGS:
|
|
353
|
+
problems.append(f"field {fname!r} has unknown type tag {tag!r}")
|
|
354
|
+
|
|
355
|
+
seen_actions: set[str] = set()
|
|
356
|
+
for t in spec.transitions:
|
|
357
|
+
if t.name in seen_actions:
|
|
358
|
+
problems.append(f"duplicate transition name {t.name!r}")
|
|
359
|
+
seen_actions.add(t.name)
|
|
360
|
+
for s in t.sources:
|
|
361
|
+
if s not in states:
|
|
362
|
+
problems.append(f"transition {t.name!r} has undeclared source {s!r}")
|
|
363
|
+
if t.dest not in states:
|
|
364
|
+
problems.append(f"transition {t.name!r} has undeclared dest {t.dest!r}")
|
|
365
|
+
if any(src in spec.terminal for src in t.sources):
|
|
366
|
+
problems.append(
|
|
367
|
+
f"transition {t.name!r} leaves terminal state(s) "
|
|
368
|
+
f"{set(t.sources) & spec.terminal} — terminals must be sinks"
|
|
369
|
+
)
|
|
370
|
+
if not t.roles:
|
|
371
|
+
problems.append(f"transition {t.name!r} has no permitted roles (dead edge)")
|
|
372
|
+
if known_roles is not None and (t.roles - known_roles):
|
|
373
|
+
problems.append(
|
|
374
|
+
f"transition {t.name!r} references role(s) not in the catalogue: "
|
|
375
|
+
f"{sorted(t.roles - known_roles)}"
|
|
376
|
+
)
|
|
377
|
+
if t.guard is not None:
|
|
378
|
+
problems += [
|
|
379
|
+
f"transition {t.name!r} guard: {p}"
|
|
380
|
+
for p in _expr.typecheck(t.guard, spec.fields)
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
seen_invariants: set[str] = set()
|
|
384
|
+
for inv in spec.invariants:
|
|
385
|
+
if inv.name in seen_invariants:
|
|
386
|
+
problems.append(f"duplicate invariant name {inv.name!r}")
|
|
387
|
+
seen_invariants.add(inv.name)
|
|
388
|
+
problems += [
|
|
389
|
+
f"invariant {inv.name!r}: {p}"
|
|
390
|
+
for p in _expr.typecheck(inv.condition, spec.fields)
|
|
391
|
+
]
|
|
392
|
+
|
|
393
|
+
reachable = reachable_states(spec)
|
|
394
|
+
for s in states - reachable:
|
|
395
|
+
problems.append(f"state {s!r} is unreachable from initial {spec.initial!r}")
|
|
396
|
+
|
|
397
|
+
for s in states - spec.terminal:
|
|
398
|
+
if not _can_reach_terminal(spec, s):
|
|
399
|
+
problems.append(f"state {s!r} cannot reach any terminal state (trap)")
|
|
400
|
+
|
|
401
|
+
return problems
|