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.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. 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