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,374 @@
1
+ """Structural properties of WF-nets (van der Aalst, *Workflow Verification*, §3 and §6).
2
+
3
+ These look only at the drawing -- places, transitions, arcs -- never at
4
+ markings, so they are quick and they point at the *construct* that causes a
5
+ problem instead of at a firing sequence.
6
+
7
+ Free-choice (Definition 7)
8
+ Whenever two transitions share an input place, they have exactly the
9
+ same input places: ``•t1 ∩ •t2 ≠ ∅ ⇒ •t1 = •t2``. Every choice is then
10
+ a "free" one, not influenced by what happened in parallel branches.
11
+ (Corollary 1: soundness of a free-choice WF-net can be decided in
12
+ polynomial time; Lemma 1: a sound free-choice WF-net is safe.)
13
+
14
+ Well-handled (Definition 13) and well-structured (Definition 14)
15
+ A net is well-handled when there is no place/transition pair ``x, y``
16
+ with two *different* elementary paths from ``x`` to ``y`` that share
17
+ only ``x`` and ``y``. Such a pair is a **handle**:
18
+
19
+ * PT-handle -- ``x`` is a place, ``y`` a transition: a choice (OR-split)
20
+ that is later synchronised (AND-join).
21
+ * TP-handle -- ``x`` is a transition, ``y`` a place: parallel branches
22
+ (AND-split) that are later merged as alternatives (OR-join).
23
+
24
+ A WF-net is well-structured iff its short-circuited net ``N̄`` is
25
+ well-handled (the extra transition ``t*`` catches handles that go
26
+ "around" the net). Corollary 2 / Lemma 3: soundness is polynomial and a
27
+ sound well-structured WF-net is safe.
28
+
29
+ Two paths sharing only their ends are two *internally vertex-disjoint*
30
+ paths, so (Menger's theorem) a handle exists iff the maximum number of
31
+ such paths from ``x`` to ``y`` is at least 2 -- a small max-flow problem
32
+ with every inner node given capacity 1.
33
+
34
+ S-coverable (Definitions 8--10 and 16)
35
+ An S-component is a strongly connected subnet that is a *state
36
+ machine* (each transition has one input and one output place inside it)
37
+ and takes along every arc of its places. Think of it as one "document"
38
+ or thread that is always in exactly one place. A WF-net is S-coverable
39
+ when every node of ``N̄`` lies in some S-component. Sound free-choice
40
+ and sound well-structured WF-nets are S-coverable (Corollaries 3, 4),
41
+ and an S-coverable WF-net is safe.
42
+
43
+ Lemma 4 (a necessary condition for soundness)
44
+ In a sound WF-net a transition that consumes from ``i`` consumes *only*
45
+ from ``i``, and a transition that produces into ``o`` produces *only*
46
+ into ``o`` -- otherwise it needs ``i`` (or ``o``) together with another
47
+ marked place, which never happens.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ from collections import deque
53
+ from dataclasses import dataclass, field
54
+
55
+ from .petrinet import PetriNet
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Free-choice
60
+ # ---------------------------------------------------------------------------
61
+ def free_choice_violations(net: PetriNet) -> list[tuple[str, str, str]]:
62
+ """``(t1, t2, shared place)`` for every pair that breaks Definition 7."""
63
+ violations = []
64
+ transitions = list(net.transitions)
65
+ for index, t1 in enumerate(transitions):
66
+ inputs1 = net.preset(t1)
67
+ for t2 in transitions[index + 1:]:
68
+ inputs2 = net.preset(t2)
69
+ shared = inputs1 & inputs2
70
+ if shared and inputs1 != inputs2:
71
+ violations.append((t1, t2, sorted(shared)[0]))
72
+ return violations
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Handles (well-handledness)
77
+ # ---------------------------------------------------------------------------
78
+ @dataclass
79
+ class Handle:
80
+ """Two elementary paths from ``start`` to ``end`` sharing only the ends."""
81
+
82
+ start: str
83
+ end: str
84
+ paths: tuple[list[str], list[str]]
85
+
86
+ @property
87
+ def kind(self) -> str:
88
+ return "PT" if self.start.startswith("p:") else "TP"
89
+
90
+
91
+ def _two_disjoint_paths(successors: dict[str, list[str]], start: str, end: str):
92
+ """Two internally vertex-disjoint paths ``start → end``, or None.
93
+
94
+ Max-flow with vertex splitting: node ``v`` becomes ``(v, 0) → (v, 1)``
95
+ with capacity 1 (unlimited for ``start``/``end``); an arc ``u → v`` is
96
+ ``(u, 1) → (v, 0)`` with capacity 1. Two augmenting paths = two paths.
97
+ """
98
+ capacity: dict[tuple, dict[tuple, int]] = {}
99
+
100
+ def add(u, v, c):
101
+ capacity.setdefault(u, {})[v] = capacity.get(u, {}).get(v, 0) + c
102
+ capacity.setdefault(v, {}).setdefault(u, 0)
103
+
104
+ for node, targets in successors.items():
105
+ add((node, 0), (node, 1), 2 if node in (start, end) else 1)
106
+ for target in targets:
107
+ add((node, 1), (target, 0), 1)
108
+ source, sink = (start, 1), (end, 0)
109
+ flow_edges: dict[tuple, dict[tuple, int]] = {u: dict(v) for u, v in capacity.items()}
110
+ found = 0
111
+ for _ in range(2):
112
+ parent = {source: None}
113
+ queue = deque([source])
114
+ while queue and sink not in parent:
115
+ u = queue.popleft()
116
+ for v, c in flow_edges.get(u, {}).items():
117
+ if c > 0 and v not in parent:
118
+ parent[v] = u
119
+ queue.append(v)
120
+ if sink not in parent:
121
+ break
122
+ v = sink
123
+ while parent[v] is not None:
124
+ u = parent[v]
125
+ flow_edges[u][v] -= 1
126
+ flow_edges[v][u] += 1
127
+ v = u
128
+ found += 1
129
+ if found < 2:
130
+ return None
131
+ # Read the two paths off the flow: follow edges that carry flow.
132
+ carried = {u: {v: capacity[u][v] - c for v, c in targets.items()
133
+ if capacity[u][v] > 0 and capacity[u][v] - c > 0}
134
+ for u, targets in flow_edges.items()}
135
+ paths = []
136
+ for _ in range(2):
137
+ path, node = [start], source
138
+ while node != sink:
139
+ nxt = next(iter(carried[node]))
140
+ carried[node][nxt] -= 1
141
+ if not carried[node][nxt]:
142
+ del carried[node][nxt]
143
+ if nxt[1] == 0:
144
+ path.append(nxt[0])
145
+ node = nxt
146
+ paths.append(path)
147
+ return paths[0], paths[1]
148
+
149
+
150
+ def _graph(net: PetriNet) -> dict[str, list[str]]:
151
+ """Successor lists over prefixed ids (``p:…`` places, ``t:…`` transitions)."""
152
+ graph = {f"p:{p}": [f"t:{t}" for t in net.postset(p)] for p in net.places}
153
+ graph.update({f"t:{t}": [f"p:{p}" for p in net.postset(t)] for t in net.transitions})
154
+ return graph
155
+
156
+
157
+ def handles(net: PetriNet, limit: int = 5) -> list[Handle]:
158
+ """Up to ``limit`` handles of ``net`` (empty list: the net is well-handled).
159
+
160
+ Only splits (≥ 2 outgoing arcs) can start a handle and only joins (≥ 2
161
+ incoming arcs) can end one, which keeps this fast on real models.
162
+ """
163
+ graph = _graph(net)
164
+ incoming: dict[str, int] = {node: 0 for node in graph}
165
+ for targets in graph.values():
166
+ for target in targets:
167
+ incoming[target] += 1
168
+ splits = [n for n, targets in graph.items() if len(targets) >= 2]
169
+ joins = [n for n, count in incoming.items() if count >= 2]
170
+ found = []
171
+ for start in sorted(splits):
172
+ for end in sorted(joins):
173
+ if start[0] == end[0]:
174
+ continue # a handle links a place and a transition
175
+ paths = _two_disjoint_paths(graph, start, end)
176
+ if paths is not None:
177
+ found.append(Handle(start, end, paths))
178
+ if len(found) >= 50:
179
+ break
180
+ # The shortest handles are the easiest to see in the drawing.
181
+ found.sort(key=lambda h: len(h.paths[0]) + len(h.paths[1]))
182
+ return found[:limit]
183
+
184
+
185
+ # ---------------------------------------------------------------------------
186
+ # S-components
187
+ # ---------------------------------------------------------------------------
188
+ class _Budget(Exception):
189
+ pass
190
+
191
+
192
+ def _s_component_search(net: PetriNet, start: str, must_contain: str, budget: list[int]):
193
+ """An S-component (set of place ids) containing place ``start`` and node
194
+ ``must_contain`` (a place or transition id), or None.
195
+
196
+ Unknowns are the places; x_p = 1 means "p is in the component". For
197
+ every transition the state-machine rule says
198
+ ``Σ_{p ∈ •t} x_p = Σ_{p ∈ t•} x_p ≤ 1``. We propagate that rule, branch
199
+ where a transition still has a choice, and check strong connectivity of
200
+ each solution (a solution may split into several cycles; any of them
201
+ that is closed is an S-component on its own).
202
+ """
203
+ pre = {t: sorted(net.preset(t)) for t in net.transitions}
204
+ post = {t: sorted(net.postset(t)) for t in net.transitions}
205
+ touching = {p: sorted(net.preset(p) | net.postset(p)) for p in net.places}
206
+
207
+ def propagate(x: dict[str, int], changed: list[str]) -> bool:
208
+ queue = deque(t for p in changed for t in touching[p])
209
+ while queue:
210
+ t = queue.popleft()
211
+ sides = []
212
+ for side in (pre[t], post[t]):
213
+ ones = [p for p in side if x.get(p) == 1]
214
+ unknown = [p for p in side if p not in x]
215
+ sides.append((side, ones, unknown))
216
+ (_, ones_in, unk_in), (_, ones_out, unk_out) = sides
217
+ if len(ones_in) > 1 or len(ones_out) > 1:
218
+ return False
219
+ assign: list[tuple[str, int]] = []
220
+ for (side, ones, unknown), (_, other_ones, other_unknown) in (
221
+ (sides[0], sides[1]), (sides[1], sides[0])):
222
+ if ones:
223
+ assign += [(p, 0) for p in unknown]
224
+ if not other_ones:
225
+ if not other_unknown:
226
+ return False
227
+ if len(other_unknown) == 1:
228
+ assign.append((other_unknown[0], 1))
229
+ elif not unknown:
230
+ if other_ones:
231
+ return False
232
+ assign += [(p, 0) for p in other_unknown]
233
+ for place, value in assign:
234
+ if place in x:
235
+ if x[place] != value:
236
+ return False
237
+ continue
238
+ x[place] = value
239
+ queue.extend(touching[place])
240
+ return True
241
+
242
+ def closed_components(places: set[str]):
243
+ """The strongly connected parts of a solution that are S-components."""
244
+ succ = {}
245
+ for p in places:
246
+ succ[f"p:{p}"] = [f"t:{t}" for t in net.postset(p)]
247
+ for t in net.postset(p) | net.preset(p):
248
+ succ.setdefault(f"t:{t}", [f"p:{q}" for q in net.postset(t) if q in places])
249
+ from .analysis import strongly_connected_components
250
+ nodes = list(succ)
251
+ index = {n: i for i, n in enumerate(nodes)}
252
+ for component in strongly_connected_components(
253
+ len(nodes), lambda i: [index[m] for m in succ[nodes[i]]]):
254
+ members = {nodes[i] for i in component}
255
+ q = {n[2:] for n in members if n.startswith("p:")}
256
+ if not q:
257
+ continue
258
+ ts = {t for p in q for t in net.postset(p) | net.preset(p)}
259
+ if all(len(net.preset(t) & q) == 1 and len(net.postset(t) & q) == 1 for t in ts) \
260
+ and {f"t:{t}" for t in ts} <= members:
261
+ yield q, ts
262
+
263
+ def search(x: dict[str, int]):
264
+ budget[0] -= 1
265
+ if budget[0] < 0:
266
+ raise _Budget
267
+ for t in net.transitions:
268
+ for side, other in ((pre[t], post[t]), (post[t], pre[t])):
269
+ if any(x.get(p) == 1 for p in side) and not any(x.get(p) == 1 for p in other):
270
+ options = [p for p in other if p not in x]
271
+ for choice in options:
272
+ trial = dict(x)
273
+ trial[choice] = 1
274
+ for p in options:
275
+ if p != choice:
276
+ trial[p] = 0
277
+ if propagate(trial, options):
278
+ result = search(trial)
279
+ if result is not None:
280
+ return result
281
+ return None
282
+ places = {p for p, v in x.items() if v == 1}
283
+ for q, ts in closed_components(places):
284
+ if start in q and (must_contain in q or must_contain in ts):
285
+ return q
286
+ return None
287
+
288
+ x = {start: 1}
289
+ if not propagate(x, [start]):
290
+ return None
291
+ return search(x)
292
+
293
+
294
+ @dataclass
295
+ class SCoverage:
296
+ components: list[set[str]] = field(default_factory=list)
297
+ uncovered: list[str] = field(default_factory=list) # node ids
298
+ complete: bool = True # False: search budget ran out
299
+
300
+ @property
301
+ def s_coverable(self) -> bool | None:
302
+ if self.uncovered and self.complete:
303
+ return False
304
+ return True if not self.uncovered else None
305
+
306
+
307
+ def s_coverage(net: PetriNet, budget: int = 20_000) -> SCoverage:
308
+ """Find S-components until every node is covered (or show which is not)."""
309
+ result = SCoverage()
310
+ covered: set[str] = set()
311
+ remaining = [budget]
312
+ for node in list(net.places) + list(net.transitions):
313
+ if node in covered:
314
+ continue
315
+ starts = [node] if node in net.places else sorted(net.preset(node))
316
+ found = None
317
+ try:
318
+ for start in starts:
319
+ found = _s_component_search(net, start, node, remaining)
320
+ if found is not None:
321
+ break
322
+ except _Budget:
323
+ result.complete = False
324
+ result.uncovered.append(node)
325
+ continue
326
+ if found is None:
327
+ result.uncovered.append(node)
328
+ continue
329
+ result.components.append(found)
330
+ covered |= found
331
+ covered |= {t for p in found for t in net.preset(p) | net.postset(p)}
332
+ return result
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Everything for a WF-net
337
+ # ---------------------------------------------------------------------------
338
+ @dataclass
339
+ class StructureReport:
340
+ free_choice: list[tuple[str, str, str]] # violations of Definition 7
341
+ handles: list[Handle] | None # of N̄; None if skipped (too big)
342
+ coverage: SCoverage | None # of N̄
343
+ state_machine: bool # every transition 1 in, 1 out
344
+ lemma4: list[str] = field(default_factory=list) # transition ids breaking Lemma 4
345
+
346
+ @property
347
+ def is_free_choice(self) -> bool:
348
+ return not self.free_choice
349
+
350
+ @property
351
+ def well_structured(self) -> bool | None:
352
+ return None if self.handles is None else not self.handles
353
+
354
+ @property
355
+ def s_coverable(self) -> bool | None:
356
+ return None if self.coverage is None else self.coverage.s_coverable
357
+
358
+
359
+ def check_structure(net: PetriNet, source: str, sink: str,
360
+ max_nodes: int = 400) -> StructureReport:
361
+ """Free-choice, well-structured and S-coverable, as in §6 of the paper."""
362
+ from .analysis import short_circuit
363
+ closed = short_circuit(net, source, sink)
364
+ small = len(closed.places) + len(closed.transitions) <= max_nodes
365
+ lemma4 = [t for t in net.transitions
366
+ if (source in net.preset(t) and net.preset(t) != {source})
367
+ or (sink in net.postset(t) and net.postset(t) != {sink})]
368
+ return StructureReport(
369
+ free_choice=free_choice_violations(net),
370
+ handles=handles(closed) if small else None,
371
+ coverage=s_coverage(closed) if small else None,
372
+ state_machine=all(len(net.preset(t)) == 1 and len(net.postset(t)) == 1
373
+ for t in net.transitions),
374
+ lemma4=lemma4)