openprocess 0.7.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.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- openprocess-0.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"""Behavioural analysis of P/T nets: state spaces, properties, soundness.
|
|
2
|
+
|
|
3
|
+
Everything here answers questions of the form "can the net ever reach a
|
|
4
|
+
marking where ...?", so everything starts by exploring the markings the net
|
|
5
|
+
can reach.
|
|
6
|
+
|
|
7
|
+
Reachability graph
|
|
8
|
+
------------------
|
|
9
|
+
Nodes are the reachable markings, and an edge ``M --t--> M'`` means firing
|
|
10
|
+
``t`` in ``M`` gives ``M'``. It is built breadth-first from the initial
|
|
11
|
+
marking. It is finite iff the net is **bounded** (no place can hold an
|
|
12
|
+
unbounded number of tokens).
|
|
13
|
+
|
|
14
|
+
Coverability graph (Karp–Miller)
|
|
15
|
+
--------------------------------
|
|
16
|
+
For an unbounded net the reachability graph is infinite, so it cannot be
|
|
17
|
+
built. The coverability graph is a finite over-approximation: when a new
|
|
18
|
+
marking ``M'`` *strictly covers* an ancestor ``M`` on its path (``M' ≥ M`` and
|
|
19
|
+
``M' ≠ M``), the difference can be pumped forever by repeating the same
|
|
20
|
+
firing sequence, so every place where ``M'(p) > M(p)`` is set to **ω**
|
|
21
|
+
("arbitrarily many"). ω is represented by ``math.inf``, which already obeys
|
|
22
|
+
``ω + n = ω - n = ω``.
|
|
23
|
+
|
|
24
|
+
Properties (course lecture "Petri net properties")
|
|
25
|
+
--------------------------------------------------
|
|
26
|
+
bounded / k-bounded / safe
|
|
27
|
+
Every place holds at most k tokens in every reachable marking (safe:
|
|
28
|
+
k = 1). Read off the coverability graph: bounded iff no ω appears.
|
|
29
|
+
deadlock-free
|
|
30
|
+
No reachable marking in which nothing is enabled.
|
|
31
|
+
dead transition
|
|
32
|
+
A transition that can never fire (no edge carries it).
|
|
33
|
+
live transition
|
|
34
|
+
From *every* reachable marking the transition can eventually fire again.
|
|
35
|
+
In a finite graph: it must occur inside every *bottom* SCC (a strongly
|
|
36
|
+
connected component with no way out), because the net always ends up
|
|
37
|
+
trapped in some bottom SCC.
|
|
38
|
+
reversible
|
|
39
|
+
The initial marking can be reached back from every reachable marking.
|
|
40
|
+
|
|
41
|
+
Soundness of workflow nets (van der Aalst, Workflow Verification, Def. 2 & 3)
|
|
42
|
+
------------------------------------------------------------------------------
|
|
43
|
+
A **WF-net** has one source place ``i`` (empty pre-set), one sink place ``o``
|
|
44
|
+
(empty post-set), and every node lies on a path from ``i`` to ``o``.
|
|
45
|
+
It is **sound** iff, starting from ``[i]``:
|
|
46
|
+
|
|
47
|
+
1. *option to complete* -- from every reachable marking, ``[o]`` is reachable;
|
|
48
|
+
2. *proper completion* -- if a reachable marking has a token in ``o``, it is
|
|
49
|
+
exactly ``[o]`` (no tokens left behind);
|
|
50
|
+
3. *no dead transitions* -- every transition can fire in some reachable
|
|
51
|
+
marking.
|
|
52
|
+
|
|
53
|
+
A sound WF-net is necessarily bounded, so if exploration finds the net to be
|
|
54
|
+
unbounded we can stop and report unsoundness. (The paper's Theorem 1 gives
|
|
55
|
+
the equivalent formulation: N is sound iff the short-circuited net, with an
|
|
56
|
+
extra transition from ``o`` back to ``i``, is live and bounded.)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import math
|
|
62
|
+
from collections import deque
|
|
63
|
+
from dataclasses import dataclass, field
|
|
64
|
+
|
|
65
|
+
from .petrinet import Marking, PetriNet
|
|
66
|
+
|
|
67
|
+
OMEGA = math.inf
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# State spaces
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
@dataclass
|
|
74
|
+
class StateGraph:
|
|
75
|
+
"""A reachability or coverability graph.
|
|
76
|
+
|
|
77
|
+
``states[0]`` is always the initial marking. ``edges`` holds
|
|
78
|
+
``(source index, transition id, target index)`` triples.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
net: PetriNet
|
|
82
|
+
states: list[Marking] = field(default_factory=list)
|
|
83
|
+
edges: list[tuple[int, str, int]] = field(default_factory=list)
|
|
84
|
+
#: True when exploration stopped at ``max_states`` before finishing.
|
|
85
|
+
truncated: bool = False
|
|
86
|
+
#: True for a coverability graph that contains ω somewhere.
|
|
87
|
+
has_omega: bool = False
|
|
88
|
+
kind: str = "reachability"
|
|
89
|
+
|
|
90
|
+
def __post_init__(self) -> None:
|
|
91
|
+
self.index: dict[Marking, int] = {}
|
|
92
|
+
self._successors: list[list[tuple[str, int]]] = []
|
|
93
|
+
self._predecessors: list[list[tuple[str, int]]] = []
|
|
94
|
+
|
|
95
|
+
def _add_state(self, marking: Marking) -> int:
|
|
96
|
+
self.index[marking] = len(self.states)
|
|
97
|
+
self.states.append(marking)
|
|
98
|
+
self._successors.append([])
|
|
99
|
+
self._predecessors.append([])
|
|
100
|
+
return len(self.states) - 1
|
|
101
|
+
|
|
102
|
+
def _add_edge(self, source: int, transition: str, target: int) -> None:
|
|
103
|
+
self.edges.append((source, transition, target))
|
|
104
|
+
self._successors[source].append((transition, target))
|
|
105
|
+
self._predecessors[target].append((transition, source))
|
|
106
|
+
|
|
107
|
+
def successors(self, state: int) -> list[tuple[str, int]]:
|
|
108
|
+
return self._successors[state]
|
|
109
|
+
|
|
110
|
+
def predecessors(self, state: int) -> list[tuple[str, int]]:
|
|
111
|
+
return self._predecessors[state]
|
|
112
|
+
|
|
113
|
+
def dead_states(self) -> list[int]:
|
|
114
|
+
return [s for s in range(len(self.states)) if not self._successors[s]]
|
|
115
|
+
|
|
116
|
+
def fired_transitions(self) -> set[str]:
|
|
117
|
+
return {t for _, t, _ in self.edges}
|
|
118
|
+
|
|
119
|
+
def backward_reachable(self, targets: set[int]) -> set[int]:
|
|
120
|
+
"""All states from which some state in ``targets`` can be reached."""
|
|
121
|
+
seen = set(targets)
|
|
122
|
+
queue = deque(targets)
|
|
123
|
+
while queue:
|
|
124
|
+
state = queue.popleft()
|
|
125
|
+
for _, previous in self._predecessors[state]:
|
|
126
|
+
if previous not in seen:
|
|
127
|
+
seen.add(previous)
|
|
128
|
+
queue.append(previous)
|
|
129
|
+
return seen
|
|
130
|
+
|
|
131
|
+
def distances(self) -> dict[int, int]:
|
|
132
|
+
"""Firing-sequence length of the shortest path to each state."""
|
|
133
|
+
distance = {0: 0}
|
|
134
|
+
queue = deque([0])
|
|
135
|
+
while queue:
|
|
136
|
+
state = queue.popleft()
|
|
137
|
+
for _, successor in self._successors[state]:
|
|
138
|
+
if successor not in distance:
|
|
139
|
+
distance[successor] = distance[state] + 1
|
|
140
|
+
queue.append(successor)
|
|
141
|
+
return distance
|
|
142
|
+
|
|
143
|
+
def path_to(self, target: int) -> list[str]:
|
|
144
|
+
"""A shortest firing sequence (transition ids) from the initial state."""
|
|
145
|
+
parent: dict[int, tuple[int, str] | None] = {0: None}
|
|
146
|
+
queue = deque([0])
|
|
147
|
+
while queue:
|
|
148
|
+
state = queue.popleft()
|
|
149
|
+
if state == target:
|
|
150
|
+
break
|
|
151
|
+
for transition, successor in self._successors[state]:
|
|
152
|
+
if successor not in parent:
|
|
153
|
+
parent[successor] = (state, transition)
|
|
154
|
+
queue.append(successor)
|
|
155
|
+
if target not in parent:
|
|
156
|
+
return []
|
|
157
|
+
path: list[str] = []
|
|
158
|
+
node = target
|
|
159
|
+
while parent[node] is not None:
|
|
160
|
+
previous, transition = parent[node] # type: ignore[misc]
|
|
161
|
+
path.append(transition)
|
|
162
|
+
node = previous
|
|
163
|
+
return path[::-1]
|
|
164
|
+
|
|
165
|
+
def describe_path(self, target: int) -> str:
|
|
166
|
+
names = [self.net.transitions[t].name for t in self.path_to(target)]
|
|
167
|
+
return " → ".join(names) if names else "(initial marking)"
|
|
168
|
+
|
|
169
|
+
def bottom_sccs(self) -> list[set[int]]:
|
|
170
|
+
"""Strongly connected components with no edge leaving them."""
|
|
171
|
+
components = strongly_connected_components(
|
|
172
|
+
len(self.states), lambda s: [t for _, t in self._successors[s]])
|
|
173
|
+
component_of = {}
|
|
174
|
+
for number, component in enumerate(components):
|
|
175
|
+
for state in component:
|
|
176
|
+
component_of[state] = number
|
|
177
|
+
bottoms = []
|
|
178
|
+
for number, component in enumerate(components):
|
|
179
|
+
if all(component_of[t] == number
|
|
180
|
+
for s in component for _, t in self._successors[s]):
|
|
181
|
+
bottoms.append(component)
|
|
182
|
+
return bottoms
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def reachability_graph(net: PetriNet, initial: Marking | None = None,
|
|
186
|
+
max_states: int = 100_000) -> StateGraph:
|
|
187
|
+
"""Breadth-first exploration of all reachable markings.
|
|
188
|
+
|
|
189
|
+
Stops early (``truncated=True``) after ``max_states`` states, and also
|
|
190
|
+
stops early if the net is found to be *unbounded*: a new marking strictly
|
|
191
|
+
covering one of its own ancestors proves that tokens can be pumped
|
|
192
|
+
forever, so the graph would never finish. In that case
|
|
193
|
+
``graph.has_omega`` is set and :func:`coverability_graph` gives the finite
|
|
194
|
+
picture.
|
|
195
|
+
"""
|
|
196
|
+
graph = StateGraph(net)
|
|
197
|
+
start = initial if initial is not None else net.initial_marking
|
|
198
|
+
graph._add_state(start)
|
|
199
|
+
parent: dict[int, int | None] = {0: None}
|
|
200
|
+
queue = deque([0])
|
|
201
|
+
while queue:
|
|
202
|
+
state = queue.popleft()
|
|
203
|
+
marking = graph.states[state]
|
|
204
|
+
for transition in net.enabled(marking):
|
|
205
|
+
successor = net.fire(marking, transition)
|
|
206
|
+
target = graph.index.get(successor)
|
|
207
|
+
if target is None:
|
|
208
|
+
if len(graph.states) >= max_states:
|
|
209
|
+
graph.truncated = True
|
|
210
|
+
return graph
|
|
211
|
+
target = graph._add_state(successor)
|
|
212
|
+
parent[target] = state
|
|
213
|
+
if _covers_ancestor(graph, parent, target):
|
|
214
|
+
graph._add_edge(state, transition, target)
|
|
215
|
+
graph.has_omega = True
|
|
216
|
+
graph.truncated = True
|
|
217
|
+
return graph
|
|
218
|
+
queue.append(target)
|
|
219
|
+
graph._add_edge(state, transition, target)
|
|
220
|
+
return graph
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _covers_ancestor(graph: StateGraph, parent: dict[int, int | None], node: int) -> bool:
|
|
224
|
+
marking = graph.states[node]
|
|
225
|
+
ancestor = parent[node]
|
|
226
|
+
while ancestor is not None:
|
|
227
|
+
earlier = graph.states[ancestor]
|
|
228
|
+
if marking >= earlier and marking != earlier:
|
|
229
|
+
return True
|
|
230
|
+
ancestor = parent[ancestor]
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def coverability_graph(net: PetriNet, initial: Marking | None = None,
|
|
235
|
+
max_states: int = 50_000) -> StateGraph:
|
|
236
|
+
"""The Karp–Miller coverability graph (always finite)."""
|
|
237
|
+
graph = StateGraph(net, kind="coverability")
|
|
238
|
+
start = initial if initial is not None else net.initial_marking
|
|
239
|
+
graph._add_state(start)
|
|
240
|
+
parent: dict[int, int | None] = {0: None}
|
|
241
|
+
queue = deque([0])
|
|
242
|
+
while queue:
|
|
243
|
+
state = queue.popleft()
|
|
244
|
+
marking = graph.states[state]
|
|
245
|
+
for transition in net.enabled(marking):
|
|
246
|
+
successor = net.fire(marking, transition)
|
|
247
|
+
# Acceleration: compare with every ancestor on the tree path,
|
|
248
|
+
# including the current state itself.
|
|
249
|
+
accelerated = dict(successor.items())
|
|
250
|
+
ancestor: int | None = state
|
|
251
|
+
while ancestor is not None:
|
|
252
|
+
earlier = graph.states[ancestor]
|
|
253
|
+
current = Marking(accelerated)
|
|
254
|
+
if current >= earlier and current != earlier:
|
|
255
|
+
for place, tokens in accelerated.items():
|
|
256
|
+
if tokens > earlier[place]:
|
|
257
|
+
accelerated[place] = OMEGA
|
|
258
|
+
ancestor = parent[ancestor]
|
|
259
|
+
successor = Marking(accelerated)
|
|
260
|
+
if any(v == OMEGA for v in accelerated.values()):
|
|
261
|
+
graph.has_omega = True
|
|
262
|
+
target = graph.index.get(successor)
|
|
263
|
+
if target is None:
|
|
264
|
+
if len(graph.states) >= max_states:
|
|
265
|
+
graph.truncated = True
|
|
266
|
+
return graph
|
|
267
|
+
target = graph._add_state(successor)
|
|
268
|
+
parent[target] = state
|
|
269
|
+
queue.append(target)
|
|
270
|
+
graph._add_edge(state, transition, target)
|
|
271
|
+
return graph
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def strongly_connected_components(count: int, successors) -> list[set[int]]:
|
|
275
|
+
"""Tarjan's algorithm, iterative so deep graphs cannot hit the recursion limit."""
|
|
276
|
+
index_of: dict[int, int] = {}
|
|
277
|
+
low: dict[int, int] = {}
|
|
278
|
+
on_stack: set[int] = set()
|
|
279
|
+
stack: list[int] = []
|
|
280
|
+
components: list[set[int]] = []
|
|
281
|
+
counter = 0
|
|
282
|
+
for root in range(count):
|
|
283
|
+
if root in index_of:
|
|
284
|
+
continue
|
|
285
|
+
work = [(root, iter(successors(root)))]
|
|
286
|
+
index_of[root] = low[root] = counter
|
|
287
|
+
counter += 1
|
|
288
|
+
stack.append(root)
|
|
289
|
+
on_stack.add(root)
|
|
290
|
+
while work:
|
|
291
|
+
node, children = work[-1]
|
|
292
|
+
advanced = False
|
|
293
|
+
for child in children:
|
|
294
|
+
if child not in index_of:
|
|
295
|
+
index_of[child] = low[child] = counter
|
|
296
|
+
counter += 1
|
|
297
|
+
stack.append(child)
|
|
298
|
+
on_stack.add(child)
|
|
299
|
+
work.append((child, iter(successors(child))))
|
|
300
|
+
advanced = True
|
|
301
|
+
break
|
|
302
|
+
if child in on_stack:
|
|
303
|
+
low[node] = min(low[node], index_of[child])
|
|
304
|
+
if advanced:
|
|
305
|
+
continue
|
|
306
|
+
work.pop()
|
|
307
|
+
if work:
|
|
308
|
+
parent_node = work[-1][0]
|
|
309
|
+
low[parent_node] = min(low[parent_node], low[node])
|
|
310
|
+
if low[node] == index_of[node]:
|
|
311
|
+
component = set()
|
|
312
|
+
while True:
|
|
313
|
+
member = stack.pop()
|
|
314
|
+
on_stack.discard(member)
|
|
315
|
+
component.add(member)
|
|
316
|
+
if member == node:
|
|
317
|
+
break
|
|
318
|
+
components.append(component)
|
|
319
|
+
return components
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# General properties
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
@dataclass
|
|
326
|
+
class PropertyReport:
|
|
327
|
+
graph: StateGraph
|
|
328
|
+
bounded: bool
|
|
329
|
+
bound: float # max tokens in any place (inf if unbounded)
|
|
330
|
+
place_bounds: dict[str, float]
|
|
331
|
+
dead_markings: list[int] # state indices
|
|
332
|
+
dead_transitions: list[str]
|
|
333
|
+
live_transitions: list[str] | None # None when not decidable here
|
|
334
|
+
reversible: bool | None
|
|
335
|
+
|
|
336
|
+
@property
|
|
337
|
+
def safe(self) -> bool:
|
|
338
|
+
return self.bounded and self.bound <= 1
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def deadlock_free(self) -> bool:
|
|
342
|
+
return not self.dead_markings
|
|
343
|
+
|
|
344
|
+
def lines(self) -> list[tuple[str, bool | None, str]]:
|
|
345
|
+
"""(property, verdict, detail) rows for display."""
|
|
346
|
+
net = self.graph.net
|
|
347
|
+
rows: list[tuple[str, bool | None, str]] = []
|
|
348
|
+
partial = self.graph.truncated and not self.graph.has_omega
|
|
349
|
+
rows.append(("Bounded", self.bounded,
|
|
350
|
+
f"at most {int(self.bound)} token(s) per place" if self.bounded
|
|
351
|
+
else "places marked ω can hold arbitrarily many tokens: "
|
|
352
|
+
+ ", ".join(net.places[p].name for p, b in self.place_bounds.items()
|
|
353
|
+
if b == OMEGA)))
|
|
354
|
+
rows.append(("Safe", self.safe, "every place holds at most one token"
|
|
355
|
+
if self.safe else "some place can hold two or more tokens"))
|
|
356
|
+
rows.append(("Deadlock-free", self.deadlock_free,
|
|
357
|
+
"every reachable marking enables something" if self.deadlock_free
|
|
358
|
+
else f"{len(self.dead_markings)} dead marking(s), e.g. "
|
|
359
|
+
+ self.graph.states[self.dead_markings[0]].describe(net)
|
|
360
|
+
+ " via " + self.graph.describe_path(self.dead_markings[0])))
|
|
361
|
+
rows.append(("No dead transitions", not self.dead_transitions,
|
|
362
|
+
"every transition can fire" if not self.dead_transitions
|
|
363
|
+
else "never fire: " + ", ".join(net.transitions[t].name
|
|
364
|
+
for t in self.dead_transitions)))
|
|
365
|
+
if self.live_transitions is None:
|
|
366
|
+
rows.append(("Live", None, "not decided (unbounded or truncated state space)"))
|
|
367
|
+
else:
|
|
368
|
+
not_live = [t for t in net.transitions if t not in self.live_transitions]
|
|
369
|
+
rows.append(("Live", not not_live, "every transition can always fire again"
|
|
370
|
+
if not not_live else "not live: " + ", ".join(
|
|
371
|
+
net.transitions[t].name for t in not_live)))
|
|
372
|
+
rows.append(("Reversible", self.reversible,
|
|
373
|
+
"not decided" if self.reversible is None else
|
|
374
|
+
("the initial marking is reachable from every marking" if self.reversible
|
|
375
|
+
else "some marking cannot return to the initial marking")))
|
|
376
|
+
if partial:
|
|
377
|
+
rows.insert(0, ("Complete state space", False,
|
|
378
|
+
f"stopped after {len(self.graph.states)} states; "
|
|
379
|
+
"verdicts below are NOT proofs"))
|
|
380
|
+
return rows
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def analyse(net: PetriNet, max_states: int = 100_000) -> PropertyReport:
|
|
384
|
+
"""Compute the standard behavioural properties of ``(net, initial marking)``."""
|
|
385
|
+
graph = reachability_graph(net, max_states=max_states)
|
|
386
|
+
if graph.has_omega:
|
|
387
|
+
graph = coverability_graph(net, max_states=max_states)
|
|
388
|
+
|
|
389
|
+
place_bounds: dict[str, float] = {p: 0 for p in net.places}
|
|
390
|
+
for marking in graph.states:
|
|
391
|
+
for place, tokens in marking.items():
|
|
392
|
+
place_bounds[place] = max(place_bounds[place], tokens)
|
|
393
|
+
bound = max(place_bounds.values(), default=0)
|
|
394
|
+
bounded = not graph.has_omega
|
|
395
|
+
|
|
396
|
+
fired = graph.fired_transitions()
|
|
397
|
+
dead_transitions = [t for t in net.transitions if t not in fired]
|
|
398
|
+
dead_markings = graph.dead_states()
|
|
399
|
+
|
|
400
|
+
live: list[str] | None = None
|
|
401
|
+
reversible: bool | None = None
|
|
402
|
+
if bounded and not graph.truncated:
|
|
403
|
+
bottoms = graph.bottom_sccs()
|
|
404
|
+
live = []
|
|
405
|
+
for transition in net.transitions:
|
|
406
|
+
if all(any(t == transition and target in component
|
|
407
|
+
for state in component for t, target in graph.successors(state))
|
|
408
|
+
for component in bottoms):
|
|
409
|
+
live.append(transition)
|
|
410
|
+
reversible = len(graph.backward_reachable({0})) == len(graph.states)
|
|
411
|
+
|
|
412
|
+
return PropertyReport(graph, bounded, bound, place_bounds, dead_markings,
|
|
413
|
+
dead_transitions, live, reversible)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ---------------------------------------------------------------------------
|
|
417
|
+
# Workflow nets and soundness
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
@dataclass
|
|
420
|
+
class WorkflowCheck:
|
|
421
|
+
"""Structural WF-net check (Definition 2 in the paper)."""
|
|
422
|
+
|
|
423
|
+
is_workflow_net: bool
|
|
424
|
+
source: str | None
|
|
425
|
+
sink: str | None
|
|
426
|
+
problems: list[str]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def check_workflow_net(net: PetriNet) -> WorkflowCheck:
|
|
430
|
+
problems: list[str] = []
|
|
431
|
+
sources, sinks = net.source_places(), net.sink_places()
|
|
432
|
+
names = net.node_name
|
|
433
|
+
if len(sources) != 1:
|
|
434
|
+
problems.append("needs exactly one source place (empty pre-set); found "
|
|
435
|
+
f"{len(sources)}" + (": " + ", ".join(map(names, sources)) if sources else ""))
|
|
436
|
+
if len(sinks) != 1:
|
|
437
|
+
problems.append("needs exactly one sink place (empty post-set); found "
|
|
438
|
+
f"{len(sinks)}" + (": " + ", ".join(map(names, sinks)) if sinks else ""))
|
|
439
|
+
# Source/sink transitions also violate the definition.
|
|
440
|
+
for t in net.transitions:
|
|
441
|
+
if not net.pre(t):
|
|
442
|
+
problems.append(f"transition {names(t)!r} has no input place")
|
|
443
|
+
if not net.post(t):
|
|
444
|
+
problems.append(f"transition {names(t)!r} has no output place")
|
|
445
|
+
|
|
446
|
+
source = sources[0] if len(sources) == 1 else None
|
|
447
|
+
sink = sinks[0] if len(sinks) == 1 else None
|
|
448
|
+
if source and sink:
|
|
449
|
+
forward = _reach(net, source, net.postset)
|
|
450
|
+
backward = _reach(net, sink, net.preset)
|
|
451
|
+
every = set(net.places) | set(net.transitions)
|
|
452
|
+
off_path = [n for n in every if n not in forward or n not in backward]
|
|
453
|
+
if off_path:
|
|
454
|
+
problems.append("not on a path from source to sink: "
|
|
455
|
+
+ ", ".join(sorted(map(names, off_path))))
|
|
456
|
+
return WorkflowCheck(not problems, source, sink, problems)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def short_circuit(net: PetriNet, source: str, sink: str) -> PetriNet:
|
|
460
|
+
"""The short-circuited net N̄: N plus a transition t* from the sink back to the source.
|
|
461
|
+
|
|
462
|
+
Theorem 1 of the paper: a WF-net N is sound iff (N̄, [i]) is live and
|
|
463
|
+
bounded. Analysing N̄ therefore turns "can every case finish?" into the
|
|
464
|
+
classical questions "is every transition live?" and "is it bounded?".
|
|
465
|
+
"""
|
|
466
|
+
closed = net.copy()
|
|
467
|
+
closed.name = f"{net.name} (short-circuited)"
|
|
468
|
+
back = closed.add_transition(None, name="t*", id="t_star")
|
|
469
|
+
into, out = closed.add_arc(sink, back), closed.add_arc(back, source)
|
|
470
|
+
positions = [n.position for n in list(net.places.values()) + list(net.transitions.values())
|
|
471
|
+
if n.position is not None]
|
|
472
|
+
start, end = net.places[source].position, net.places[sink].position
|
|
473
|
+
if positions and start and end:
|
|
474
|
+
# Draw t* above the net, with the arcs going round the top.
|
|
475
|
+
top = min(y for _, y in positions) - 90
|
|
476
|
+
back.position = ((start[0] + end[0]) / 2, top)
|
|
477
|
+
into.points = [(end[0], top)]
|
|
478
|
+
out.points = [(start[0], top)]
|
|
479
|
+
closed.initial_marking = Marking({source: 1})
|
|
480
|
+
closed.final_marking = Marking()
|
|
481
|
+
return closed
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _reach(net: PetriNet, start: str, step) -> set[str]:
|
|
485
|
+
seen = {start}
|
|
486
|
+
queue = deque([start])
|
|
487
|
+
while queue:
|
|
488
|
+
node = queue.popleft()
|
|
489
|
+
for neighbour in step(node):
|
|
490
|
+
if neighbour not in seen:
|
|
491
|
+
seen.add(neighbour)
|
|
492
|
+
queue.append(neighbour)
|
|
493
|
+
return seen
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@dataclass
|
|
497
|
+
class SoundnessReport:
|
|
498
|
+
workflow: WorkflowCheck
|
|
499
|
+
graph: StateGraph | None = None
|
|
500
|
+
bounded: bool | None = None
|
|
501
|
+
option_to_complete: bool | None = None
|
|
502
|
+
proper_completion: bool | None = None
|
|
503
|
+
no_dead_transitions: bool | None = None
|
|
504
|
+
#: human-readable counterexamples, one string each
|
|
505
|
+
findings: list[str] = field(default_factory=list)
|
|
506
|
+
#: for each finding, the firing sequence (transition ids) that leads to
|
|
507
|
+
#: the problem from the initial marking -- or None when there is none
|
|
508
|
+
#: (a structural problem, dead transitions)
|
|
509
|
+
paths: list[list[str] | None] = field(default_factory=list)
|
|
510
|
+
#: Theorem 1: the short-circuited net N̄, analysed from [i]
|
|
511
|
+
short_circuit: "ShortCircuitReport | None" = None
|
|
512
|
+
#: §6: free-choice, well-structured, S-coverable (see :mod:`.structure`)
|
|
513
|
+
structure: object | None = None
|
|
514
|
+
#: A state index in :attr:`graph` witnessing each violated condition:
|
|
515
|
+
#: ``"option_to_complete"`` (cannot reach [o]), ``"proper_completion"``
|
|
516
|
+
#: (a token in o with others left behind), ``"bounded"`` (an ω marking).
|
|
517
|
+
witness: dict[str, int] = field(default_factory=dict)
|
|
518
|
+
#: Transitions that can never fire from [i].
|
|
519
|
+
dead_transitions: list[str] = field(default_factory=list)
|
|
520
|
+
|
|
521
|
+
def add(self, finding: str, path: list[str] | None = None) -> None:
|
|
522
|
+
self.findings.append(finding)
|
|
523
|
+
self.paths.append(path)
|
|
524
|
+
|
|
525
|
+
@property
|
|
526
|
+
def sound(self) -> bool | None:
|
|
527
|
+
"""True / False, or None if it could not be decided (state-space cap)."""
|
|
528
|
+
if not self.workflow.is_workflow_net:
|
|
529
|
+
return False
|
|
530
|
+
verdicts = [self.bounded, self.option_to_complete, self.proper_completion,
|
|
531
|
+
self.no_dead_transitions]
|
|
532
|
+
if any(v is False for v in verdicts):
|
|
533
|
+
return False
|
|
534
|
+
if any(v is None for v in verdicts):
|
|
535
|
+
return None
|
|
536
|
+
return True
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def check_soundness(net: PetriNet, max_states: int = 200_000) -> SoundnessReport:
|
|
540
|
+
"""Decide classical soundness, with a counterexample for each violation."""
|
|
541
|
+
workflow = check_workflow_net(net)
|
|
542
|
+
report = SoundnessReport(workflow)
|
|
543
|
+
if not workflow.is_workflow_net:
|
|
544
|
+
for problem in workflow.problems:
|
|
545
|
+
report.add(f"Not a WF-net: {problem}.")
|
|
546
|
+
return report
|
|
547
|
+
|
|
548
|
+
from .structure import check_structure
|
|
549
|
+
report.structure = check_structure(net, workflow.source, workflow.sink)
|
|
550
|
+
report.short_circuit = check_short_circuited(net, workflow.source, workflow.sink,
|
|
551
|
+
max_states=max_states)
|
|
552
|
+
|
|
553
|
+
initial = Marking({workflow.source: 1})
|
|
554
|
+
final = Marking({workflow.sink: 1})
|
|
555
|
+
graph = reachability_graph(net, initial, max_states=max_states)
|
|
556
|
+
report.graph = graph
|
|
557
|
+
names = net.node_name
|
|
558
|
+
|
|
559
|
+
if graph.has_omega:
|
|
560
|
+
report.bounded = False
|
|
561
|
+
witness = len(graph.states) - 1
|
|
562
|
+
report.witness["bounded"] = witness
|
|
563
|
+
report.add(
|
|
564
|
+
"Unbounded: tokens can accumulate without limit, e.g. after "
|
|
565
|
+
f"{graph.describe_path(witness)} the marking {graph.states[witness].describe(net)} "
|
|
566
|
+
"strictly covers an earlier one. A sound WF-net is always bounded.",
|
|
567
|
+
graph.path_to(witness))
|
|
568
|
+
return report
|
|
569
|
+
if graph.truncated:
|
|
570
|
+
report.add(f"State space exceeds {max_states} markings; soundness "
|
|
571
|
+
"could not be decided.")
|
|
572
|
+
return report
|
|
573
|
+
report.bounded = True
|
|
574
|
+
|
|
575
|
+
# 1. option to complete: every state can reach [o]
|
|
576
|
+
final_index = graph.index.get(final)
|
|
577
|
+
can_finish = graph.backward_reachable({final_index}) if final_index is not None else set()
|
|
578
|
+
stuck = [s for s in range(len(graph.states)) if s not in can_finish]
|
|
579
|
+
report.option_to_complete = not stuck
|
|
580
|
+
if stuck:
|
|
581
|
+
# Prefer a dead marking as the example: it is the most intuitive one.
|
|
582
|
+
dead = [s for s in stuck if not graph.successors(s)]
|
|
583
|
+
example = dead[0] if dead else stuck[0]
|
|
584
|
+
report.witness["option_to_complete"] = example
|
|
585
|
+
kind = "deadlocks in" if dead else "can never complete from"
|
|
586
|
+
report.add(
|
|
587
|
+
f"No option to complete: the net {kind} {graph.states[example].describe(net)} "
|
|
588
|
+
f"after firing {graph.describe_path(example)}. "
|
|
589
|
+
f"({len(stuck)} of {len(graph.states)} reachable markings cannot reach [o].)",
|
|
590
|
+
graph.path_to(example))
|
|
591
|
+
|
|
592
|
+
# 2. proper completion: M ≥ [o] implies M = [o]
|
|
593
|
+
improper = [s for s, m in enumerate(graph.states) if m >= final and m != final]
|
|
594
|
+
report.proper_completion = not improper
|
|
595
|
+
if improper:
|
|
596
|
+
example = improper[0]
|
|
597
|
+
report.witness["proper_completion"] = example
|
|
598
|
+
report.add(
|
|
599
|
+
f"No proper completion: {graph.states[example].describe(net)} is reachable "
|
|
600
|
+
f"via {graph.describe_path(example)}. A token reached {names(workflow.sink)!r} "
|
|
601
|
+
"while other tokens were left behind.", graph.path_to(example))
|
|
602
|
+
|
|
603
|
+
# 3. no dead transitions
|
|
604
|
+
fired = graph.fired_transitions()
|
|
605
|
+
dead = [t for t in net.transitions if t not in fired]
|
|
606
|
+
report.no_dead_transitions = not dead
|
|
607
|
+
report.dead_transitions = dead
|
|
608
|
+
if dead:
|
|
609
|
+
report.add("Dead transitions (can never fire): "
|
|
610
|
+
+ ", ".join(names(t) for t in dead) + ".")
|
|
611
|
+
return report
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
# ---------------------------------------------------------------------------
|
|
615
|
+
# Theorem 1: the short-circuited net
|
|
616
|
+
# ---------------------------------------------------------------------------
|
|
617
|
+
@dataclass
|
|
618
|
+
class ShortCircuitReport:
|
|
619
|
+
"""(N̄, [i]) -- the WF-net plus ``t*`` from ``o`` back to ``i``.
|
|
620
|
+
|
|
621
|
+
Theorem 1 of the paper: N is sound **iff** (N̄, [i]) is *live* and
|
|
622
|
+
*bounded*. Why this works, informally:
|
|
623
|
+
|
|
624
|
+
* t* can only fire once a case has finished with a token in ``o``; it
|
|
625
|
+
starts the next case. So "every transition can always fire again"
|
|
626
|
+
(live) includes "t* can always fire again", i.e. every case can
|
|
627
|
+
finish -- option to complete. And it includes every ordinary
|
|
628
|
+
transition, so none is dead.
|
|
629
|
+
* If a case could finish with tokens left behind, t* would start the
|
|
630
|
+
next case on top of them; cycling like that piles tokens up without
|
|
631
|
+
limit -- unbounded. So bounded rules out improper completion.
|
|
632
|
+
|
|
633
|
+
Deadlock-freedom is weaker than liveness (a live net never deadlocks,
|
|
634
|
+
but a net can keep going in a loop while some transition is dead), so
|
|
635
|
+
it is shown for information only. Note that N itself always stops in
|
|
636
|
+
[o]; in N̄ that marking enables t*, so there deadlocks mean "stuck".
|
|
637
|
+
"""
|
|
638
|
+
|
|
639
|
+
net: PetriNet
|
|
640
|
+
properties: PropertyReport
|
|
641
|
+
#: transition id -> a reachable state from which it can never fire again
|
|
642
|
+
not_live: dict[str, int] = field(default_factory=dict)
|
|
643
|
+
|
|
644
|
+
@property
|
|
645
|
+
def bounded(self) -> bool:
|
|
646
|
+
return self.properties.bounded
|
|
647
|
+
|
|
648
|
+
@property
|
|
649
|
+
def safe(self) -> bool:
|
|
650
|
+
return self.properties.safe
|
|
651
|
+
|
|
652
|
+
@property
|
|
653
|
+
def deadlock_free(self) -> bool:
|
|
654
|
+
return self.properties.deadlock_free
|
|
655
|
+
|
|
656
|
+
@property
|
|
657
|
+
def live(self) -> bool | None:
|
|
658
|
+
if self.properties.live_transitions is None:
|
|
659
|
+
return None
|
|
660
|
+
return len(self.properties.live_transitions) == len(self.net.transitions)
|
|
661
|
+
|
|
662
|
+
@property
|
|
663
|
+
def sound(self) -> bool | None:
|
|
664
|
+
"""Theorem 1's verdict: live and bounded."""
|
|
665
|
+
if not self.bounded or self.live is False:
|
|
666
|
+
return False
|
|
667
|
+
return self.live
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def check_short_circuited(net: PetriNet, source: str, sink: str,
|
|
671
|
+
max_states: int = 200_000) -> ShortCircuitReport:
|
|
672
|
+
closed = short_circuit(net, source, sink)
|
|
673
|
+
properties = analyse(closed, max_states=max_states)
|
|
674
|
+
report = ShortCircuitReport(closed, properties)
|
|
675
|
+
graph = properties.graph
|
|
676
|
+
if properties.live_transitions is not None:
|
|
677
|
+
distance = graph.distances()
|
|
678
|
+
for transition in closed.transitions:
|
|
679
|
+
if transition in properties.live_transitions:
|
|
680
|
+
continue
|
|
681
|
+
enabling = {state for state in range(len(graph.states))
|
|
682
|
+
if any(t == transition for t, _ in graph.successors(state))}
|
|
683
|
+
can_fire = graph.backward_reachable(enabling) if enabling else set()
|
|
684
|
+
never = [s for s in range(len(graph.states)) if s not in can_fire]
|
|
685
|
+
if never:
|
|
686
|
+
# the one reached by the shortest firing sequence
|
|
687
|
+
report.not_live[transition] = min(
|
|
688
|
+
never, key=lambda s: distance.get(s, len(graph.states)))
|
|
689
|
+
return report
|