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,1180 @@
1
+ """Computed answers: the right answer worked out from the exercise's own files.
2
+
3
+ An answer block says ``compute: alpha.T_I`` instead of writing the answer
4
+ down, so an exercise about a log does not need its answers worked out by
5
+ hand, and changing the log changes the answers with it. Every compute is
6
+ one function here, registered with :func:`computed` the way a box is
7
+ registered with ``@box``::
8
+
9
+ @computed("deadlocks", of="net")
10
+ def deadlocks(context: Context, argument: str, task: Task) -> int:
11
+ \"\"\"How many dead markings the net reaches (none for a sound WF-net).\"\"\"
12
+ ...
13
+
14
+ The name is what the author writes; ``argument`` is what is between the
15
+ parentheses (``dead(t)``, ``fire(a, [p1])``); ``task`` gives the block's
16
+ other keys (``of: other.pnml``). ``openprocess exercises check`` lists every
17
+ compute with its docstring, and tells an author when a block refers to one
18
+ that does not exist.
19
+
20
+ Of the log
21
+ ``activities``, ``start activities``, ``end activities``, ``variants``,
22
+ ``cases``, ``events``, ``alpha.T_L``, ``alpha.T_I``, ``alpha.T_O``,
23
+ ``alpha.X_L``, ``alpha.Y_L``, ``alpha.P_L``, ``alpha.place(p)``,
24
+ ``dfg``, ``prefixes``, ``language.M``, ``language.M'``, ``im.cut``,
25
+ ``im.split(2)``, ``im.log(2.1)``, ``im.tree``, ``im.dfg(2)``,
26
+ ``fitness``, ``replay``, ``alignment fitness(⟨a, b⟩)``,
27
+ ``alignment.kind``, ``batches(activity)``, ``weekends``,
28
+ ``timeout(14 days)``, ``arrival rate``.
29
+ Of the net
30
+ ``wf-net``, ``sound``, ``option to complete``, ``proper completion``,
31
+ ``no dead transitions``, ``dead transitions``, ``bounded``, ``safe``,
32
+ ``live``, ``deadlock-free``, ``reversible``, ``free-choice``,
33
+ ``well-structured``, ``s-coverable``, ``terminating``, ``dead(t)``,
34
+ ``bound``, ``bound(p)``, ``deadlocks``, ``net.P``, ``net.T``, ``net.F``,
35
+ ``net.m0``, ``pre(t)``, ``post(t)``, ``enabled([p1])``,
36
+ ``fire(t, [p1])``, ``reachable``, ``terminal``, ``reachability graph``,
37
+ ``incidence``, ``parikh(⟨a, b⟩)``, ``state equation(⟨a, b⟩)``,
38
+ ``synchronous product(⟨a, b⟩)``.
39
+ Of the transition system
40
+ ``regions``, ``minimal regions``, ``ger(e)``, ``pre-regions(e)``,
41
+ ``post-regions(e)``, ``region({s1, s3})``, ``elementary``,
42
+ ``state separation``, ``forward closure``.
43
+ Of a box or a workflow
44
+ ``box(alpha_miner).net.transitions``: the box run on the exercise's
45
+ log, then the attribute path; ``workflow(file).node.path`` likewise for
46
+ a saved workflow.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import math
52
+ import re
53
+ from collections import Counter
54
+ from dataclasses import dataclass
55
+ from typing import Callable
56
+
57
+ from . import answers, notation
58
+ from .context import Context, TaskError
59
+ from .sheet import Task
60
+
61
+
62
+ @dataclass
63
+ class Compute:
64
+ name: str
65
+ function: Callable
66
+ #: What it needs: "log", "net", "ts", "box", "" (the block says).
67
+ of: str
68
+ help: str
69
+
70
+ def __call__(self, context: Context, argument: str, task: Task):
71
+ return self.function(context, argument, task)
72
+
73
+
74
+ #: Every compute by its (case-folded) name.
75
+ COMPUTED: dict[str, Compute] = {}
76
+
77
+
78
+ def computed(name: str, *aliases: str, of: str = ""):
79
+ """Register a compute under ``name`` (and ``aliases``)."""
80
+ def decorate(function: Callable) -> Callable:
81
+ text = (function.__doc__ or "").strip().split("\n\n")[0]
82
+ text = " ".join(line.strip() for line in text.splitlines())
83
+ for key in (name, *aliases):
84
+ COMPUTED[" ".join(key.split()).casefold()] = Compute(key, function, of, text)
85
+ return function
86
+ return decorate
87
+
88
+
89
+ _CALL = re.compile(r"^([\w.'\-\s]+?)\s*\((.*)\)\s*$", re.S)
90
+
91
+
92
+ def split_expression(expression: str) -> tuple[str, str]:
93
+ """``"fire(a, [p1])"`` → ``("fire", "a, [p1]")``; a trailing attribute path
94
+ after the call (``box(alpha_miner).net.transitions``) stays with the name."""
95
+ expression = expression.strip()
96
+ match = re.match(r"^([\w.'\-\s]+?)\s*\((.*?)\)((?:\.[\w\s]+)*)\s*$", expression, re.S)
97
+ if match:
98
+ key = match.group(1) + (match.group(3) or "")
99
+ return key, match.group(2)
100
+ return expression, ""
101
+
102
+
103
+ def lookup(expression: str) -> tuple[Compute, str]:
104
+ """The compute an expression names, and its argument."""
105
+ key, argument = split_expression(expression)
106
+ key = " ".join(key.split()).casefold()
107
+ if key in COMPUTED:
108
+ return COMPUTED[key], argument
109
+ # box(id).path and workflow(file).path keep the path in the key
110
+ for prefix in ("box", "workflow"):
111
+ if key.startswith(prefix) and prefix in COMPUTED:
112
+ head, _, path = key.partition(".") if not key.startswith(prefix + "(") else (prefix, "", key[len(prefix):])
113
+ return COMPUTED[prefix], argument + "|" + path.strip(". ")
114
+ raise TaskError(f"unknown compute “{expression}” (known: " + ", ".join(sorted(COMPUTED)) + ")")
115
+
116
+
117
+ def compute(context: Context, task: Task, expression: str | None = None):
118
+ """The value of the block's ``compute:`` (or of ``expression``)."""
119
+ expression = (expression if expression is not None else task.get("compute", "")).strip()
120
+ found, argument = lookup(expression)
121
+ value = found(context, argument, task)
122
+ if value is None:
123
+ raise TaskError(f"“{expression}” could not be decided for this exercise "
124
+ "(the state space is too large)")
125
+ return value
126
+
127
+
128
+ def describe_all() -> list[tuple[str, str, str]]:
129
+ """``(name, of, help)`` for every compute, for the author's listing."""
130
+ seen, rows = set(), []
131
+ for item in COMPUTED.values():
132
+ if item.name in seen:
133
+ continue
134
+ seen.add(item.name)
135
+ rows.append((item.name, item.of, item.help))
136
+ return rows
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Helpers
141
+ # ---------------------------------------------------------------------------
142
+ def _net_of(context: Context, task: Task):
143
+ return context.net(task.get("of") or task.get("net"))
144
+
145
+
146
+ def _log_of(context: Context, task: Task):
147
+ source = task.get("log") or task.get("of")
148
+ if source and source.lower().endswith(".pnml"):
149
+ source = None
150
+ return context.simple_log(None if source in (None, "", "log") else source)
151
+
152
+
153
+ def _with_start(net):
154
+ from ..mining.analysis import check_workflow_net
155
+ from ..mining.petrinet import Marking
156
+ if net.initial_marking:
157
+ return net
158
+ workflow = check_workflow_net(net)
159
+ if workflow.is_workflow_net:
160
+ net = net.copy()
161
+ net.initial_marking = Marking({workflow.source: 1})
162
+ net.final_marking = net.final_marking or Marking({workflow.sink: 1})
163
+ return net
164
+
165
+
166
+ def _soundness(net):
167
+ from ..mining.analysis import check_soundness
168
+ return check_soundness(net, max_states=50_000)
169
+
170
+
171
+ def _structure(net):
172
+ from ..mining.analysis import check_workflow_net
173
+ from ..mining.structure import check_structure
174
+ workflow = check_workflow_net(net)
175
+ if not workflow.is_workflow_net:
176
+ raise TaskError("the net is not a WF-net, so this structural property is not defined")
177
+ return check_structure(net, workflow.source, workflow.sink)
178
+
179
+
180
+ def _properties(net):
181
+ from ..mining.analysis import analyse
182
+ return analyse(_with_start(net), max_states=50_000)
183
+
184
+
185
+ def _graph(net):
186
+ from ..mining.analysis import reachability_graph
187
+ net = _with_start(net)
188
+ graph = reachability_graph(net, max_states=50_000)
189
+ if graph.has_omega:
190
+ raise TaskError("the net is unbounded: its markings cannot be listed")
191
+ if graph.truncated:
192
+ raise TaskError("the net has too many markings to list")
193
+ return net, graph
194
+
195
+
196
+ def _place_name(net, place_id: str) -> str:
197
+ return net.places[place_id].name or place_id
198
+
199
+
200
+ def _transition_name(net, transition_id: str) -> str:
201
+ t = net.transitions[transition_id]
202
+ return t.label or t.name or transition_id
203
+
204
+
205
+ def _labels(net, ids) -> frozenset:
206
+ return frozenset(_transition_name(net, t) for t in ids)
207
+
208
+
209
+ def _find_transition(net, text: str) -> str:
210
+ wanted = answers.name(text)
211
+ for transition_id, t in net.transitions.items():
212
+ if wanted in {answers.name(x) for x in (t.label or "", t.name or "", transition_id) if x}:
213
+ return transition_id
214
+ raise TaskError(f"the net has no transition “{text}”")
215
+
216
+
217
+ def _find_place(net, text: str) -> str:
218
+ wanted = answers.name(text)
219
+ for place_id, p in net.places.items():
220
+ if wanted in {answers.name(x) for x in (p.name or "", place_id) if x}:
221
+ return place_id
222
+ raise TaskError(f"the net has no place “{text}”")
223
+
224
+
225
+ def _marking_of(net, text: str):
226
+ """A typed marking as a :class:`Marking` of ``net`` (by place names)."""
227
+ from ..mining.petrinet import Marking
228
+ if not text.strip():
229
+ return _with_start(net).initial_marking
230
+ counts = notation.marking(text)
231
+ return Marking({_find_place(net, name): n for name, n in counts.items()})
232
+
233
+
234
+ def _marking_counter(net, marking) -> Counter:
235
+ return Counter({_place_name(net, p): n for p, n in marking.items() if n})
236
+
237
+
238
+ def _trace_argument(argument: str) -> tuple[str, ...]:
239
+ try:
240
+ return tuple(answers.trace(argument))
241
+ except answers.AnswerSyntaxError as error:
242
+ raise TaskError(f"could not read the trace “{argument}”: {error}") from None
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Of the log
247
+ # ---------------------------------------------------------------------------
248
+ @computed("activities", "alpha.T_L", of="log")
249
+ def _activities(context, argument, task):
250
+ """The activities of the log, T_L."""
251
+ return frozenset(context.alpha.T_L) if not task.get("of") else \
252
+ frozenset(a for trace in _log_of(context, task) for a in trace)
253
+
254
+
255
+ @computed("start activities", "alpha.T_I", of="log")
256
+ def _start(context, argument, task):
257
+ """The activities a trace of the log begins with, T_I."""
258
+ return frozenset(t[0] for t in _log_of(context, task) if t)
259
+
260
+
261
+ @computed("end activities", "alpha.T_O", of="log")
262
+ def _end(context, argument, task):
263
+ """The activities a trace of the log ends with, T_O."""
264
+ return frozenset(t[-1] for t in _log_of(context, task) if t)
265
+
266
+
267
+ @computed("alpha.X_L", of="log")
268
+ def _x_l(context, argument, task):
269
+ """The candidate pairs (A, B) of the α-algorithm, X_L."""
270
+ return frozenset((frozenset(a), frozenset(b)) for a, b in context.alpha.X_L)
271
+
272
+
273
+ @computed("alpha.Y_L", of="log")
274
+ def _y_l(context, argument, task):
275
+ """The maximal pairs of the α-algorithm, Y_L."""
276
+ return frozenset((frozenset(a), frozenset(b)) for a, b in context.alpha.Y_L)
277
+
278
+
279
+ @computed("alpha.P_L", of="log")
280
+ def _p_l(context, argument, task):
281
+ """The places of α(L): i_L, o_L and one p(A, B) per pair of Y_L."""
282
+ return frozenset({"i_L", "o_L"} | {f"p({','.join(sorted(a))}|{','.join(sorted(b))})"
283
+ for a, b in context.alpha.Y_L})
284
+
285
+
286
+ @computed("alpha.place", of="log")
287
+ def _alpha_place(context, argument, task):
288
+ """Could the α-algorithm have produced the place p(A, B)? ``yes``, or why
289
+ not: ``a relation forbids it`` (some a → b fails, or A or B is not
290
+ independent) or ``not maximal`` (a bigger pair contains it). Write the
291
+ place as ``({a}, {b, c})``."""
292
+ items = answers.parse(argument)
293
+ if len(items) == 1 and isinstance(items[0], tuple) and len(items[0]) == 2:
294
+ a_set, b_set = items[0]
295
+ elif len(items) == 2:
296
+ a_set, b_set = items
297
+ else:
298
+ raise TaskError("write the place as ({a}, {b, c})")
299
+ a_set = frozenset(a_set) if isinstance(a_set, frozenset) else frozenset([a_set])
300
+ b_set = frozenset(b_set) if isinstance(b_set, frozenset) else frozenset([b_set])
301
+ footprint = context.alpha.footprint
302
+ known = {answers.name(x): x for x in footprint.activities}
303
+ try:
304
+ a_real = frozenset(known[x] for x in a_set)
305
+ b_real = frozenset(known[x] for x in b_set)
306
+ except KeyError as missing:
307
+ raise TaskError(f"“{missing.args[0]}” is not an activity of the log") from None
308
+ for a in a_real:
309
+ for b in b_real:
310
+ if footprint.relation(a, b) != "→":
311
+ return "a relation forbids it"
312
+ for group in (a_real, b_real):
313
+ for x in group:
314
+ for y in group:
315
+ if footprint.relation(x, y) != "#":
316
+ return "a relation forbids it"
317
+ y_l = {(frozenset(a), frozenset(b)) for a, b in context.alpha.Y_L}
318
+ if (a_real, b_real) in y_l:
319
+ return "yes"
320
+ return "not maximal"
321
+
322
+
323
+ @computed("variants", of="log")
324
+ def _variants(context, argument, task):
325
+ """How many different traces the log has."""
326
+ return len(_log_of(context, task))
327
+
328
+
329
+ @computed("cases", "traces", of="log")
330
+ def _cases(context, argument, task):
331
+ """How many cases (traces) the log has."""
332
+ return sum(_log_of(context, task).values())
333
+
334
+
335
+ @computed("events", of="log")
336
+ def _events(context, argument, task):
337
+ """How many events the log has."""
338
+ return sum(len(t) * n for t, n in _log_of(context, task).items())
339
+
340
+
341
+ @computed("dfg", "directly follows", of="log")
342
+ def _dfg(context, argument, task):
343
+ """The directly-follows relation of the log, as pairs (a, b)."""
344
+ from ..mining.dfg import dfg_from_simple_log
345
+ return frozenset(dfg_from_simple_log(_log_of(context, task)).edges)
346
+
347
+
348
+ @computed("prefixes", "prefix-closed language", of="log")
349
+ def _prefixes(context, argument, task):
350
+ """The prefix-closed language of the log: every prefix of every trace,
351
+ the empty sequence included."""
352
+ found = set()
353
+ for trace in _log_of(context, task):
354
+ for n in range(len(trace) + 1):
355
+ found.add(answers.Sequence(trace[:n]))
356
+ return frozenset(found)
357
+
358
+
359
+ def _language_matrix(context, task, drop_last: bool) -> notation.Matrix:
360
+ log = _log_of(context, task)
361
+ labels = sorted({a for t in log for a in t})
362
+ prefixes = sorted({t[:n] for t in log for n in range(1, len(t) + 1)}, key=lambda p: (len(p), p))
363
+ rows, cells = [], {}
364
+ for prefix in prefixes:
365
+ name = "⟨" + ", ".join(prefix) + "⟩"
366
+ rows.append(name)
367
+ counted = Counter(prefix[:-1] if drop_last else prefix)
368
+ for label in labels:
369
+ cells[(name, label)] = float(counted[label])
370
+ return notation.Matrix(rows, labels, cells)
371
+
372
+
373
+ @computed("language.M", "regions.M", of="log")
374
+ def _matrix_m(context, argument, task):
375
+ """The matrix M of language-based regions: one row per non-empty prefix of
376
+ the log (shortest first, then alphabetical), one column per activity
377
+ (alphabetical), the number of times the activity occurs in the prefix."""
378
+ return _language_matrix(context, task, drop_last=False)
379
+
380
+
381
+ @computed("language.M'", "regions.M'", "language.M′", "regions.M′", of="log")
382
+ def _matrix_m_prime(context, argument, task):
383
+ """The matrix M′: like M, but each prefix without its last event."""
384
+ return _language_matrix(context, task, drop_last=True)
385
+
386
+
387
+ # -- the Inductive Miner, step by step --------------------------------------------------
388
+ def _im_path(argument: str) -> list[int]:
389
+ path = argument.strip().strip("()")
390
+ if not path:
391
+ return []
392
+ try:
393
+ return [int(x) - 1 for x in re.split(r"[.,\s/]+", path) if x]
394
+ except ValueError:
395
+ raise TaskError(f"“{argument}” is not a position like 2 or 2.1") from None
396
+
397
+
398
+ def _im_sublog(context, task, path: list[int]):
399
+ """The sublog at ``path`` (1-based positions down the cuts), and the cut it
400
+ gets (None at a base case)."""
401
+ from ..mining.dfg import dfg_from_simple_log
402
+ from ..mining.discovery.inductive import _Miner, split_log
403
+ miner = _Miner(0.0)
404
+ log = Counter(_log_of(context, task))
405
+ for position in path:
406
+ dfg = dfg_from_simple_log(log)
407
+ found = miner.find_cut(dfg)
408
+ if found is None:
409
+ raise TaskError("the Inductive Miner finds no cut here (a base case or a fall-through)")
410
+ operator, groups = found
411
+ sublogs = split_log(log, operator, groups, dfg)
412
+ if not 0 <= position < len(sublogs):
413
+ raise TaskError(f"the cut has {len(sublogs)} parts, not {position + 1}")
414
+ log = sublogs[position]
415
+ dfg = dfg_from_simple_log(log)
416
+ return log, miner.find_cut(dfg)
417
+
418
+
419
+ @computed("im.cut", of="log")
420
+ def _im_cut(context, argument, task):
421
+ """The cut the Inductive Miner finds: on the whole log, or on the sublog at a
422
+ position (``im.cut(2)``: the second part of the first cut; ``im.cut(2.1)``:
423
+ the first part of that)."""
424
+ _, found = _im_sublog(context, task, _im_path(argument))
425
+ if found is None:
426
+ raise TaskError("the Inductive Miner finds no cut here (a base case or a fall-through)")
427
+ operator, groups = found
428
+ return operator.value, tuple(frozenset(g) for g in groups)
429
+
430
+
431
+ @computed("im.split", "im.log", of="log")
432
+ def _im_split(context, argument, task):
433
+ """The sublog at a position after the cuts (``im.split(2)``: the second
434
+ part of the first cut)."""
435
+ log, _ = _im_sublog(context, task, _im_path(argument))
436
+ return Counter(log)
437
+
438
+
439
+ @computed("im.dfg", of="log")
440
+ def _im_dfg(context, argument, task):
441
+ """The directly-follows relation of the sublog at a position."""
442
+ from ..mining.dfg import dfg_from_simple_log
443
+ log, _ = _im_sublog(context, task, _im_path(argument))
444
+ return frozenset(dfg_from_simple_log(log).edges)
445
+
446
+
447
+ @computed("im.tree", "inductive.tree", "process tree", of="log")
448
+ def _im_tree(context, argument, task):
449
+ """The process tree the Inductive Miner discovers (of the sublog at a
450
+ position, when one is given)."""
451
+ from ..mining.discovery.inductive import inductive_miner
452
+ log, _ = _im_sublog(context, task, _im_path(argument))
453
+ return notation.tree_from_engine(inductive_miner(log).tree)
454
+
455
+
456
+ # -- conformance --------------------------------------------------------------------------
457
+ @computed("fitness", "token fitness", of="net")
458
+ def _fitness(context, argument, task):
459
+ """Token-based replay fitness of the log on the net (``of: net.pnml``)."""
460
+ from ..mining.conformance.token_replay import token_replay
461
+ return token_replay(_with_start(_net_of(context, task)), _log_of(context, task)).fitness
462
+
463
+
464
+ @computed("replay", of="net")
465
+ def _replay(context, argument, task):
466
+ """Token replay per trace: produced, consumed, missing and remaining tokens
467
+ and the fitness, for every variant of the log (or the one in ``trace:``)."""
468
+ from ..mining.conformance.token_replay import replay_trace
469
+ net = _with_start(_net_of(context, task))
470
+ log = _log_of(context, task)
471
+ traces = [_trace_argument(task.get("trace"))] if task.get("trace") else sorted(log)
472
+ rows = {}
473
+ for trace in traces:
474
+ result = replay_trace(net, tuple(trace), 1)
475
+ rows[tuple(answers.name(a) for a in trace)] = {
476
+ "p": float(result.produced), "c": float(result.consumed),
477
+ "m": float(result.missing), "r": float(result.remaining),
478
+ "fitness": result.fitness}
479
+ return rows
480
+
481
+
482
+ @computed("alignment fitness", "alignment.fitness", of="net")
483
+ def _alignment_fitness(context, argument, task):
484
+ """The fitness of an optimal alignment of the trace (``alignment fitness(⟨a, b⟩)``,
485
+ or of the block's ``trace:``) on the net: 1 − cost / worst case."""
486
+ from ..mining.conformance.alignments import align_trace
487
+ net = _with_start(_net_of(context, task))
488
+ trace = _trace_argument(argument or task.get("trace") or "")
489
+ empty = align_trace(net, ())
490
+ best = align_trace(net, trace)
491
+ if not best.complete:
492
+ raise TaskError("the alignment search gave up (too many states)")
493
+ worst = len(trace) + empty.cost
494
+ return 1 - best.cost / worst if worst else 1.0
495
+
496
+
497
+ @computed("alignment cost", "alignment.cost", of="net")
498
+ def _alignment_cost(context, argument, task):
499
+ """The cost of an optimal alignment of the trace on the net (one per log
500
+ move or model move; silent moves are free)."""
501
+ from ..mining.conformance.alignments import align_trace
502
+ net = _with_start(_net_of(context, task))
503
+ trace = _trace_argument(argument or task.get("trace") or "")
504
+ best = align_trace(net, trace)
505
+ if not best.complete:
506
+ raise TaskError("the alignment search gave up (too many states)")
507
+ return best.cost
508
+
509
+
510
+ def alignment_kind(net, moves: tuple[list[str], list[str]], trace=None) -> tuple[str, str]:
511
+ """``(kind, why)`` for a typed alignment: ``optimal``, ``sub-optimal`` or
512
+ ``not an alignment``."""
513
+ from ..mining.conformance.alignments import align_trace
514
+ net = _with_start(net)
515
+ top, bottom = moves
516
+ log_row = [m for m in top if m != notation.SKIP]
517
+ if trace is not None and tuple(answers.name(a) for a in trace) != tuple(log_row):
518
+ return "not an alignment", "the log moves do not spell the trace"
519
+ labels = {}
520
+ for transition_id, t in net.transitions.items():
521
+ for key in (t.label, t.name, transition_id):
522
+ if key:
523
+ labels.setdefault(answers.name(key), []).append(transition_id)
524
+ marking = net.initial_marking
525
+ cost = 0
526
+ for a, b in zip(top, bottom):
527
+ if b == notation.SKIP:
528
+ cost += 1
529
+ continue
530
+ if a != notation.SKIP and a != b:
531
+ return "not an alignment", f"the moves {a} and {b} in one column do not agree"
532
+ if b in ("τ", "tau"):
533
+ candidates = [t for t in net.transitions if net.transitions[t].label is None
534
+ and net.is_enabled(marking, t)]
535
+ else:
536
+ candidates = [t for t in labels.get(b, []) if net.is_enabled(marking, t)]
537
+ if not candidates:
538
+ return "not an alignment", f"“{b}” is not enabled at that point in the model"
539
+ marking = net.fire(marking, candidates[0])
540
+ if a == notation.SKIP and b not in ("τ", "tau"):
541
+ cost += 1
542
+ if net.final_marking and marking != net.final_marking:
543
+ return "not an alignment", "the model moves do not reach the final marking"
544
+ best = align_trace(net, tuple(log_row))
545
+ if best.complete and cost > best.cost:
546
+ return "sub-optimal", f"it costs {cost}; an optimal alignment costs {best.cost}"
547
+ return "optimal", f"it costs {cost}, as little as any alignment of this trace"
548
+
549
+
550
+ @computed("alignment.kind", "alignment kind", of="net")
551
+ def _alignment_kind(context, argument, task):
552
+ """Is the alignment written in the block's ``alignment:`` (two rows) optimal,
553
+ sub-optimal or not an alignment of ``trace:`` on the net?"""
554
+ text = task.get("alignment")
555
+ if not text:
556
+ raise TaskError("the block needs an “alignment:” with the two rows")
557
+ moves = notation.alignment(text)
558
+ trace = _trace_argument(task.get("trace")) if task.get("trace") else None
559
+ return alignment_kind(_net_of(context, task), moves, trace)[0]
560
+
561
+
562
+ @computed("synchronous product", of="net")
563
+ def _synchronous_product(context, argument, task):
564
+ """The synchronous product of the net and the trace (``synchronous
565
+ product(⟨a, b, c⟩)``): the trace as a sequence of places and transitions,
566
+ beside the net, with a synchronous transition for every pair of equal labels."""
567
+ from ..mining.petrinet import Marking, PetriNet
568
+ net = _with_start(_net_of(context, task))
569
+ trace = _trace_argument(argument or task.get("trace") or "")
570
+ product = PetriNet(name="Synchronous product")
571
+ places = {}
572
+ for place_id, place in net.places.items():
573
+ places[place_id] = product.add_place(place.name, id=f"m_{place_id}")
574
+ model = {}
575
+ for transition_id, t in net.transitions.items():
576
+ model[transition_id] = product.add_transition(t.label, name=f"({t.label or 'τ'}, ≫)",
577
+ id=f"m_{transition_id}")
578
+ for arc in net.arcs:
579
+ if arc.source in net.places:
580
+ product.add_arc(f"m_{arc.source}", f"m_{arc.target}", arc.weight)
581
+ else:
582
+ product.add_arc(f"m_{arc.source}", f"m_{arc.target}", arc.weight)
583
+ trace_places = [product.add_place(f"q{i}", id=f"q{i}") for i in range(len(trace) + 1)]
584
+ for i, activity in enumerate(trace):
585
+ log_move = product.add_transition(activity, name=f"(≫, {activity})", id=f"l_{i}")
586
+ product.add_arc(trace_places[i].id, log_move.id)
587
+ product.add_arc(log_move.id, trace_places[i + 1].id)
588
+ for transition_id, t in net.transitions.items():
589
+ if t.label == activity:
590
+ sync = product.add_transition(activity, name=f"({activity}, {activity})",
591
+ id=f"s_{i}_{transition_id}")
592
+ product.add_arc(trace_places[i].id, sync.id)
593
+ product.add_arc(sync.id, trace_places[i + 1].id)
594
+ for arc in net.arcs:
595
+ if arc.target == transition_id:
596
+ product.add_arc(f"m_{arc.source}", sync.id, arc.weight)
597
+ elif arc.source == transition_id:
598
+ product.add_arc(sync.id, f"m_{arc.target}", arc.weight)
599
+ initial = {f"m_{p}": n for p, n in net.initial_marking.items()}
600
+ initial[trace_places[0].id] = 1
601
+ product.initial_marking = Marking(initial)
602
+ if net.final_marking:
603
+ final = {f"m_{p}": n for p, n in net.final_marking.items()}
604
+ final[trace_places[-1].id] = 1
605
+ product.final_marking = Marking(final)
606
+ return product
607
+
608
+
609
+ # -- the dotted chart: time ---------------------------------------------------------------
610
+ def _timed_events(context, task):
611
+ log = context.event_log(task.get("log") or task.get("of"))
612
+ events = []
613
+ for trace in log:
614
+ case = trace.case_id
615
+ for event in trace:
616
+ when = event.timestamp
617
+ if when is not None:
618
+ events.append((when, event.activity or "", case))
619
+ if not events:
620
+ raise TaskError("the log has no timestamps")
621
+ return events
622
+
623
+
624
+ @computed("batches", of="log")
625
+ def _batches(context, argument, task):
626
+ """How many batches an activity is done in: runs of that activity, each
627
+ within an hour of the previous one, separated by more than an hour
628
+ (``batches(send invoices)``; ``batches(send invoices, 2 hours)`` for
629
+ another gap)."""
630
+ from datetime import timedelta
631
+ parts = [p.strip() for p in argument.split(",")]
632
+ wanted = answers.name(parts[0]) if parts and parts[0] else None
633
+ gap = _duration(parts[1]) if len(parts) > 1 else timedelta(hours=1)
634
+ times = sorted(when for when, activity, _ in _timed_events(context, task)
635
+ if wanted is None or answers.name(activity) == wanted)
636
+ if not times:
637
+ raise TaskError(f"the log has no events of “{parts[0]}”")
638
+ count, previous = 0, None
639
+ for when in times:
640
+ if previous is None or when - previous > gap:
641
+ count += 1
642
+ previous = when
643
+ return count
644
+
645
+
646
+ @computed("weekends", "weekend events", of="log")
647
+ def _weekends(context, argument, task):
648
+ """How many events happen on a Saturday or a Sunday."""
649
+ return sum(1 for when, _, _ in _timed_events(context, task) if when.weekday() >= 5)
650
+
651
+
652
+ def _duration(text: str):
653
+ from datetime import timedelta
654
+ match = re.match(r"^\s*([\d.]+)\s*(day|days|d|hour|hours|h|minute|minutes|min|m|week|weeks|w)?\s*$",
655
+ text, re.I)
656
+ if not match:
657
+ raise TaskError(f"“{text}” is not a duration like 14 days")
658
+ amount = float(match.group(1))
659
+ unit = (match.group(2) or "days").lower()[0]
660
+ return timedelta(**{{"d": "days", "h": "hours", "m": "minutes", "w": "weeks"}[unit]: amount})
661
+
662
+
663
+ @computed("timeout", "timeouts", of="log")
664
+ def _timeout(context, argument, task):
665
+ """How many cases have a gap longer than the duration between two of
666
+ their events (``timeout(14 days)``): the cases that timed out."""
667
+ gap = _duration(argument or "14 days")
668
+ by_case: dict[str, list] = {}
669
+ for when, _, case in _timed_events(context, task):
670
+ by_case.setdefault(case, []).append(when)
671
+ count = 0
672
+ for times in by_case.values():
673
+ times.sort()
674
+ if any(b - a > gap for a, b in zip(times, times[1:])):
675
+ count += 1
676
+ return count
677
+
678
+
679
+ @computed("arrival rate", of="log")
680
+ def _arrival_rate(context, argument, task):
681
+ """Cases per day: the number of cases divided by the days between the
682
+ first and the last case start (at least one day)."""
683
+ starts: dict[str, object] = {}
684
+ for when, _, case in _timed_events(context, task):
685
+ if case not in starts or when < starts[case]:
686
+ starts[case] = when
687
+ times = sorted(starts.values())
688
+ days = max((times[-1] - times[0]).total_seconds() / 86400, 1.0)
689
+ return len(times) / days
690
+
691
+
692
+ # ---------------------------------------------------------------------------
693
+ # Of the net
694
+ # ---------------------------------------------------------------------------
695
+ @computed("wf-net", "workflow net", of="net")
696
+ def _wf(context, argument, task):
697
+ """Is the net a WF-net (one source, one sink, every node on a path between them)?"""
698
+ from ..mining.analysis import check_workflow_net
699
+ return check_workflow_net(_net_of(context, task)).is_workflow_net
700
+
701
+
702
+ @computed("sound", of="net")
703
+ def _sound(context, argument, task):
704
+ """Is the WF-net sound?"""
705
+ return _soundness(_net_of(context, task)).sound
706
+
707
+
708
+ @computed("option to complete", of="net")
709
+ def _option(context, argument, task):
710
+ """Can every reachable marking still reach the final marking?"""
711
+ return _soundness(_net_of(context, task)).option_to_complete
712
+
713
+
714
+ @computed("proper completion", of="net")
715
+ def _proper(context, argument, task):
716
+ """Is the sink the only place marked when it is marked?"""
717
+ return _soundness(_net_of(context, task)).proper_completion
718
+
719
+
720
+ @computed("no dead transitions", of="net")
721
+ def _no_dead(context, argument, task):
722
+ """Can every transition fire in some reachable marking?"""
723
+ return _soundness(_net_of(context, task)).no_dead_transitions
724
+
725
+
726
+ @computed("dead transitions", of="net")
727
+ def _dead_transitions(context, argument, task):
728
+ """The transitions that never fire from the initial marking (a WF-net: from [i])."""
729
+ net = _net_of(context, task)
730
+ return _labels(net, _properties(net).dead_transitions)
731
+
732
+
733
+ @computed("dead", of="net")
734
+ def _dead(context, argument, task):
735
+ """Is the transition dead: never enabled in any reachable marking (``dead(t)``)?"""
736
+ net = _net_of(context, task)
737
+ return _find_transition(net, argument) in _properties(net).dead_transitions
738
+
739
+
740
+ @computed("bounded", of="net")
741
+ def _bounded(context, argument, task):
742
+ """Is the net bounded?"""
743
+ return _properties(_net_of(context, task)).bounded
744
+
745
+
746
+ @computed("bound", "k", of="net")
747
+ def _bound(context, argument, task):
748
+ """The smallest k for which the net is k-bounded (``bound(p)``: of one place);
749
+ inf for an unbounded net."""
750
+ net = _net_of(context, task)
751
+ report = _properties(net)
752
+ if not report.bounded:
753
+ return math.inf
754
+ if argument.strip():
755
+ return float(report.place_bounds[_find_place(net, argument)])
756
+ return float(report.bound)
757
+
758
+
759
+ @computed("safe", of="net")
760
+ def _safe(context, argument, task):
761
+ """Is the net safe (1-bounded)?"""
762
+ return _properties(_net_of(context, task)).safe
763
+
764
+
765
+ @computed("deadlock-free", "deadlock free", of="net")
766
+ def _deadlock_free(context, argument, task):
767
+ """Does every reachable marking enable a transition?"""
768
+ return _properties(_net_of(context, task)).deadlock_free
769
+
770
+
771
+ @computed("deadlocks", "dead markings", of="net")
772
+ def _deadlocks(context, argument, task):
773
+ """How many reachable markings enable nothing."""
774
+ report = _properties(_net_of(context, task))
775
+ return len(report.dead_markings)
776
+
777
+
778
+ @computed("terminating", of="net")
779
+ def _terminating(context, argument, task):
780
+ """Does every run end: is the reachability graph finite and without cycles?"""
781
+ net, graph = _graph(_net_of(context, task))
782
+ from ..mining.analysis import strongly_connected_components
783
+ sccs = strongly_connected_components(len(graph.states), lambda s: [t for _, t in graph.successors(s)])
784
+ for component in sccs:
785
+ if len(component) > 1:
786
+ return False
787
+ state = next(iter(component))
788
+ if any(target == state for _, target in graph.successors(state)):
789
+ return False
790
+ return True
791
+
792
+
793
+ @computed("live", of="net")
794
+ def _live(context, argument, task):
795
+ """Is the net live: from every reachable marking, every transition can fire again?"""
796
+ report = _properties(_net_of(context, task))
797
+ if report.live_transitions is None:
798
+ return None
799
+ return set(report.live_transitions) == set(_net_of(context, task).transitions)
800
+
801
+
802
+ @computed("reversible", of="net")
803
+ def _reversible(context, argument, task):
804
+ """Can the initial marking be reached again from every reachable marking?"""
805
+ return _properties(_net_of(context, task)).reversible
806
+
807
+
808
+ @computed("free-choice", "free choice", of="net")
809
+ def _free_choice(context, argument, task):
810
+ """Is the net free-choice (places that share an output transition share all)?"""
811
+ from ..mining.structure import free_choice_violations
812
+ return not free_choice_violations(_net_of(context, task))
813
+
814
+
815
+ @computed("well-structured", of="net")
816
+ def _well_structured(context, argument, task):
817
+ """Is the WF-net well-structured?"""
818
+ return _structure(_net_of(context, task)).well_structured
819
+
820
+
821
+ @computed("s-coverable", of="net")
822
+ def _s_coverable(context, argument, task):
823
+ """Is the WF-net S-coverable?"""
824
+ return _structure(_net_of(context, task)).s_coverable
825
+
826
+
827
+ @computed("net.P", "places", of="net")
828
+ def _net_places(context, argument, task):
829
+ """The places of the net, P."""
830
+ net = _net_of(context, task)
831
+ return frozenset(_place_name(net, p) for p in net.places)
832
+
833
+
834
+ @computed("net.T", "transitions", of="net")
835
+ def _net_transitions(context, argument, task):
836
+ """The transitions of the net, T."""
837
+ net = _net_of(context, task)
838
+ return frozenset(_transition_name(net, t) for t in net.transitions)
839
+
840
+
841
+ @computed("net.F", "arcs", "flow", of="net")
842
+ def _net_arcs(context, argument, task):
843
+ """The arcs of the net, F, as pairs (p, t) and (t, p)."""
844
+ net = _net_of(context, task)
845
+
846
+ def name(node_id: str) -> str:
847
+ return _place_name(net, node_id) if node_id in net.places else _transition_name(net, node_id)
848
+ return frozenset((name(arc.source), name(arc.target)) for arc in net.arcs)
849
+
850
+
851
+ @computed("net.m0", "initial marking", "m0", of="net")
852
+ def _net_m0(context, argument, task):
853
+ """The initial marking of the net (a WF-net without one: [i])."""
854
+ net = _net_of(context, task)
855
+ return _marking_counter(net, _with_start(net).initial_marking)
856
+
857
+
858
+ @computed("pre", "preset", of="net")
859
+ def _pre(context, argument, task):
860
+ """The preset of a transition or place (``pre(t)``): the nodes with an arc to it."""
861
+ net = _net_of(context, task)
862
+ wanted = answers.name(argument)
863
+ for node_id in [*net.places, *net.transitions]:
864
+ label = _place_name(net, node_id) if node_id in net.places else _transition_name(net, node_id)
865
+ if answers.name(label) == wanted or answers.name(node_id) == wanted:
866
+ return frozenset(_place_name(net, n) if n in net.places else _transition_name(net, n)
867
+ for n in net.preset(node_id))
868
+ raise TaskError(f"the net has no node “{argument}”")
869
+
870
+
871
+ @computed("post", "postset", of="net")
872
+ def _post(context, argument, task):
873
+ """The postset of a transition or place (``post(t)``): the nodes it has an arc to."""
874
+ net = _net_of(context, task)
875
+ wanted = answers.name(argument)
876
+ for node_id in [*net.places, *net.transitions]:
877
+ label = _place_name(net, node_id) if node_id in net.places else _transition_name(net, node_id)
878
+ if answers.name(label) == wanted or answers.name(node_id) == wanted:
879
+ return frozenset(_place_name(net, n) if n in net.places else _transition_name(net, n)
880
+ for n in net.postset(node_id))
881
+ raise TaskError(f"the net has no node “{argument}”")
882
+
883
+
884
+ @computed("enabled", of="net")
885
+ def _enabled(context, argument, task):
886
+ """The transitions enabled in a marking (``enabled([p1, p2])``; empty: the
887
+ initial marking)."""
888
+ net = _net_of(context, task)
889
+ marking = _marking_of(net, argument)
890
+ return _labels(net, net.enabled(marking))
891
+
892
+
893
+ @computed("fire", of="net")
894
+ def _fire(context, argument, task):
895
+ """The marking after firing a transition in a marking (``fire(t, [p1])``; a
896
+ sequence works too: ``fire(⟨a, b⟩, [p1])``)."""
897
+ net = _net_of(context, task)
898
+ match = re.match(r"^\s*(.+?)\s*,\s*(\[.*\]|\{.*\}|∅)\s*$", argument, re.S)
899
+ if match:
900
+ steps_text, marking_text = match.group(1), match.group(2)
901
+ else:
902
+ steps_text, marking_text = argument, ""
903
+ marking = _marking_of(net, marking_text)
904
+ steps = answers.trace(steps_text)
905
+ for step in steps:
906
+ transition = _find_transition(net, step)
907
+ if not net.is_enabled(marking, transition):
908
+ raise TaskError(f"“{step}” is not enabled in {notation.show_marking(_marking_counter(net, marking))}")
909
+ marking = net.fire(marking, transition)
910
+ return _marking_counter(net, marking)
911
+
912
+
913
+ @computed("reachable", "reachable markings", of="net")
914
+ def _reachable(context, argument, task):
915
+ """Every reachable marking of the net (the initial one included)."""
916
+ net, graph = _graph(_net_of(context, task))
917
+ return frozenset(notation.marking_key(_marking_counter(net, m)) for m in graph.states)
918
+
919
+
920
+ @computed("terminal", "terminal markings", "dead markings set", of="net")
921
+ def _terminal(context, argument, task):
922
+ """The reachable markings that enable nothing."""
923
+ net, graph = _graph(_net_of(context, task))
924
+ return frozenset(notation.marking_key(_marking_counter(net, graph.states[i]))
925
+ for i in graph.dead_states())
926
+
927
+
928
+ @computed("reachability graph", "marking graph", "ts", of="net")
929
+ def _reachability_graph(context, argument, task):
930
+ """The reachability graph as a transition system: the markings as states,
931
+ the transition labels as events."""
932
+ from ..mining.transition_system import TransitionSystem
933
+ net, graph = _graph(_net_of(context, task))
934
+ ts = TransitionSystem(name="Reachability graph")
935
+ names = [notation.show_marking(_marking_counter(net, m)) for m in graph.states]
936
+ for name in names:
937
+ ts.add_state(name)
938
+ for source, transition, target in graph.edges:
939
+ ts.add_transition(names[source], _transition_name(net, transition), names[target])
940
+ ts.initial = [names[0]]
941
+ ts.final = [names[i] for i in graph.dead_states()]
942
+ return ts
943
+
944
+
945
+ @computed("incidence", "incidence matrix", of="net")
946
+ def _incidence(context, argument, task):
947
+ """The incidence matrix: a row per place, a column per transition, the
948
+ tokens the transition adds to the place (produced minus consumed)."""
949
+ from ..mining.invariants import incidence_matrix
950
+ net = _net_of(context, task)
951
+ places, transitions, matrix = incidence_matrix(net)
952
+ rows = [_place_name(net, p) for p in places]
953
+ columns = [_transition_name(net, t) for t in transitions]
954
+ cells = {(rows[i], columns[j]): float(matrix[i][j]) for i in range(len(rows))
955
+ for j in range(len(columns))}
956
+ return notation.Matrix(rows, columns, cells)
957
+
958
+
959
+ @computed("parikh", "parikh vector", of="net")
960
+ def _parikh(context, argument, task):
961
+ """The Parikh vector of a firing sequence (``parikh(⟨a, b, a⟩)``): how often
962
+ each transition fires, as a one-row matrix."""
963
+ net = _net_of(context, task)
964
+ steps = answers.trace(argument)
965
+ counts = Counter(_transition_name(net, _find_transition(net, s)) for s in steps)
966
+ columns = [_transition_name(net, t) for t in net.transitions]
967
+ return notation.Matrix(["x"], columns, {("x", c): float(counts[c]) for c in columns})
968
+
969
+
970
+ @computed("state equation", "marking equation", of="net")
971
+ def _state_equation(context, argument, task):
972
+ """The marking the state equation gives for a firing sequence,
973
+ m = m₀ + C·x (``state equation(⟨a, b⟩)``; also ``state equation(⟨a, b⟩, [p1])``
974
+ from another marking)."""
975
+ from ..mining.invariants import incidence_matrix
976
+ net = _net_of(context, task)
977
+ match = re.match(r"^\s*(.+?)\s*,\s*(\[.*\]|\{.*\}|∅)\s*$", argument, re.S)
978
+ steps_text, marking_text = (match.group(1), match.group(2)) if match else (argument, "")
979
+ marking = _marking_of(net, marking_text)
980
+ counts = Counter(_find_transition(net, s) for s in answers.trace(steps_text))
981
+ places, transitions, matrix = incidence_matrix(net)
982
+ result = Counter()
983
+ for i, place in enumerate(places):
984
+ value = marking[place] + sum(matrix[i][j] * counts[t] for j, t in enumerate(transitions))
985
+ if value:
986
+ result[_place_name(net, place)] = value
987
+ return result
988
+
989
+
990
+ # ---------------------------------------------------------------------------
991
+ # Of the transition system
992
+ # ---------------------------------------------------------------------------
993
+ def _regions(ts):
994
+ from ..mining.regions import analyse_regions
995
+ return analyse_regions(ts)
996
+
997
+
998
+ def _event_regions(ts, event: str):
999
+ analysis = _regions(ts)
1000
+ for key, regions in analysis.by_event.items():
1001
+ if answers.name(key) == answers.name(event):
1002
+ return regions
1003
+ raise TaskError(f"the transition system has no event “{event}”")
1004
+
1005
+
1006
+ def _ts_of(context, task):
1007
+ return context.ts(task.get("of") or task.get("ts"))
1008
+
1009
+
1010
+ @computed("regions", of="ts")
1011
+ def _regions_all(context, argument, task):
1012
+ """Every region of the transition system (sets of states)."""
1013
+ return frozenset(_regions(_ts_of(context, task)).regions)
1014
+
1015
+
1016
+ @computed("minimal regions", of="ts")
1017
+ def _minimal_regions(context, argument, task):
1018
+ """The minimal regions."""
1019
+ return frozenset(_regions(_ts_of(context, task)).minimal)
1020
+
1021
+
1022
+ @computed("ger", of="ts")
1023
+ def _ger(context, argument, task):
1024
+ """The generalised excitation region of an event (``ger(e)``)."""
1025
+ return _event_regions(_ts_of(context, task), argument).ger
1026
+
1027
+
1028
+ @computed("pre-regions", of="ts")
1029
+ def _pre_regions(context, argument, task):
1030
+ """The minimal pre-regions of an event (``pre-regions(e)``)."""
1031
+ return frozenset(_event_regions(_ts_of(context, task), argument).minimal_pre)
1032
+
1033
+
1034
+ @computed("post-regions", of="ts")
1035
+ def _post_regions(context, argument, task):
1036
+ """The minimal post-regions of an event (``post-regions(e)``)."""
1037
+ return frozenset(_event_regions(_ts_of(context, task), argument).minimal_post)
1038
+
1039
+
1040
+ @computed("region", of="ts")
1041
+ def _is_region(context, argument, task):
1042
+ """Is a set of states a region (``region({s1, s3})``)?"""
1043
+ from ..mining.regions import check_region
1044
+ ts = _ts_of(context, task)
1045
+ states = answers.readings(argument)[-1]
1046
+ known = {answers.name(s): s for s in ts.states}
1047
+ unknown = [s for s in states if s not in known]
1048
+ if unknown:
1049
+ raise TaskError("not states of the transition system: " + ", ".join(sorted(unknown)))
1050
+ return check_region(ts, {known[s] for s in states}).is_region
1051
+
1052
+
1053
+ @computed("elementary", of="ts")
1054
+ def _elementary(context, argument, task):
1055
+ """Is the transition system elementary (state separation and forward closure)?"""
1056
+ return _regions(_ts_of(context, task)).elementary
1057
+
1058
+
1059
+ @computed("state separation", of="ts")
1060
+ def _state_separation(context, argument, task):
1061
+ """Does the state separation property hold?"""
1062
+ return _regions(_ts_of(context, task)).state_separation
1063
+
1064
+
1065
+ @computed("forward closure", of="ts")
1066
+ def _forward_closure(context, argument, task):
1067
+ """Does the forward closure property hold?"""
1068
+ return _regions(_ts_of(context, task)).forward_closure
1069
+
1070
+
1071
+ # ---------------------------------------------------------------------------
1072
+ # Of a box or a workflow
1073
+ # ---------------------------------------------------------------------------
1074
+ def follow_path(value, path: str):
1075
+ """``value.net.transitions`` → the attribute path followed, with nets, logs
1076
+ and tables turned into things an answer can be compared with."""
1077
+ from ..mining.petrinet import PetriNet
1078
+ for part in [p for p in path.split(".") if p.strip()]:
1079
+ part = part.strip()
1080
+ if isinstance(value, PetriNet) and part in ("net", "model"):
1081
+ continue # the box gave the net itself
1082
+ if isinstance(value, PetriNet):
1083
+ if part in ("P", "places"):
1084
+ value = frozenset(_place_name(value, p) for p in value.places)
1085
+ continue
1086
+ if part in ("T", "transitions"):
1087
+ value = frozenset(_transition_name(value, t) for t in value.transitions)
1088
+ continue
1089
+ if part in ("F", "arcs"):
1090
+ value = len(value.arcs)
1091
+ continue
1092
+ if isinstance(value, dict) and part in value:
1093
+ value = value[part]
1094
+ elif hasattr(value, part):
1095
+ value = getattr(value, part)
1096
+ if callable(value) and not isinstance(value, type):
1097
+ value = value()
1098
+ elif hasattr(value, "values") and isinstance(getattr(value, "values", None), dict) \
1099
+ and part in value.values:
1100
+ value = value.values[part]
1101
+ else:
1102
+ raise TaskError(f"the result has no “{part}”")
1103
+ if isinstance(value, (set, list)) and all(isinstance(v, str) for v in value):
1104
+ return frozenset(value)
1105
+ if isinstance(value, dict) and all(isinstance(v, (int, float)) for v in value.values()):
1106
+ return value
1107
+ return value
1108
+
1109
+
1110
+ def run_box(context: Context, box_id: str, task: Task, settings: dict | None = None):
1111
+ """Run one box of the library on the exercise's log (or net), as a one-box
1112
+ workflow: its result value."""
1113
+ from ..flow.runner import Cache, Runner
1114
+ from ..flow.workflow import Workflow
1115
+ library = context.library
1116
+ spec = None
1117
+ for candidate in library:
1118
+ if candidate.id == box_id or candidate.function.__name__ == box_id or \
1119
+ answers.name(candidate.name) == answers.name(box_id):
1120
+ spec = candidate
1121
+ break
1122
+ if spec is None:
1123
+ raise TaskError(f"no box called “{box_id}” (see the box list)")
1124
+ wf = Workflow("Exercise", library)
1125
+ inputs = []
1126
+ for port in spec.inputs:
1127
+ if port.type_name in ("EventLog", "Log") or port.name == "log":
1128
+ inputs.append(wf.add("typed_log", {"text": notation.show_log(_log_of(context, task))}))
1129
+ elif port.type_name in ("PetriNet", "Net", "Model") or port.name in ("net", "model"):
1130
+ if context.files.net is None and not task.get("of"):
1131
+ raise TaskError(f"“{spec.name}” needs a net and the exercise has none")
1132
+ inputs.append(wf.add("open_net", {"path": str(context.path(task.get("of"), context.files.net, "net"))}))
1133
+ else:
1134
+ inputs.append(None)
1135
+ node = wf.add(spec.id, settings or {})
1136
+ for port, source in zip(spec.inputs, inputs):
1137
+ if source is not None:
1138
+ wf.connect(source, node, port.name)
1139
+ run = Runner(library, Cache(), folder=context.exercise.folder).run(wf)
1140
+ result = run.result(node)
1141
+ if result is None or result.status != "done":
1142
+ raise TaskError(f"“{spec.name}” did not run: " + (result.error if result else "no result"))
1143
+ return result.value
1144
+
1145
+
1146
+ @computed("box", of="box")
1147
+ def _box(context, argument, task):
1148
+ """A box of the library run on the exercise's log, then an attribute of its
1149
+ result: ``box(alpha_miner).net.transitions``, ``box(check_fit).fitness``."""
1150
+ box_id, _, path = argument.partition("|")
1151
+ settings = None
1152
+ if task.get("settings"):
1153
+ import json
1154
+ try:
1155
+ settings = json.loads(task.get("settings"))
1156
+ except ValueError:
1157
+ raise TaskError("“settings:” must be JSON, like {\"k\": 5}") from None
1158
+ value = run_box(context, box_id.strip(), task, settings)
1159
+ return follow_path(value, path)
1160
+
1161
+
1162
+ @computed("workflow", of="box")
1163
+ def _workflow(context, argument, task):
1164
+ """A saved workflow of the exercise run, then a box's result by the box's
1165
+ title: ``workflow(analysis.cpnflow).Check fit.fitness``."""
1166
+ from ..flow.record import load
1167
+ from ..flow.runner import Cache, Runner
1168
+ file_name, _, path = argument.partition("|")
1169
+ wf_path = context.path(file_name.strip(), None, "workflow")
1170
+ wf, _record = load(wf_path, context.library)
1171
+ run = Runner(context.library, Cache(), folder=context.exercise.folder).run(wf)
1172
+ node_name, _, rest = path.partition(".")
1173
+ wanted = answers.name(node_name)
1174
+ for node in wf.nodes.values():
1175
+ if answers.name(wf.title(node.id)) == wanted or node.id == node_name:
1176
+ result = run.result(node)
1177
+ if result is None or result.status != "done":
1178
+ raise TaskError(f"“{wf.title(node.id)}” did not run")
1179
+ return follow_path(result.value, rest)
1180
+ raise TaskError(f"the workflow has no box called “{node_name}”")