openprocess 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- openprocess-0.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,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}”")
|