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,953 @@
|
|
|
1
|
+
"""Checking answers.
|
|
2
|
+
|
|
3
|
+
Each answer block is checked by :func:`check` against what the block says
|
|
4
|
+
is right. The right answer can be written into the block (``answer: {a,
|
|
5
|
+
b}``) or *computed* from the exercise's own files (``compute: alpha.T_I``;
|
|
6
|
+
see :mod:`.computed` for every compute), so an exercise about a log does
|
|
7
|
+
not need its answers worked out by hand, and changing the log changes the
|
|
8
|
+
answers with it.
|
|
9
|
+
|
|
10
|
+
A check returns a :class:`Result`: right or not, a sentence that says why
|
|
11
|
+
without giving the answer away, and for a footprint, a matrix or a replay
|
|
12
|
+
table the cells that are wrong. ``points`` are what the answer earns of
|
|
13
|
+
the block's ``points:`` (a partial answer earns a part).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from . import answers, notation
|
|
23
|
+
from .answers import AnswerSyntaxError
|
|
24
|
+
from .computed import COMPUTED, alignment_kind, compute, lookup
|
|
25
|
+
from .context import Context, TaskError
|
|
26
|
+
from .pack import Exercise
|
|
27
|
+
from .sheet import Task
|
|
28
|
+
|
|
29
|
+
CORRECT, INCORRECT, PARTIAL, UNKNOWN = "correct", "incorrect", "partial", "unknown"
|
|
30
|
+
|
|
31
|
+
YES = {"yes", "y", "true", "ja", "1"}
|
|
32
|
+
NO = {"no", "n", "false", "nee", "0"}
|
|
33
|
+
|
|
34
|
+
__all__ = ["CORRECT", "INCORRECT", "PARTIAL", "UNKNOWN", "Context", "Result", "TaskError",
|
|
35
|
+
"check", "compute", "expected", "footprint_of", "model_answer_text", "validate",
|
|
36
|
+
"COMPUTED"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Result:
|
|
41
|
+
status: str
|
|
42
|
+
message: str
|
|
43
|
+
#: Footprint cells that are wrong, as (row activity, column activity).
|
|
44
|
+
wrong_cells: list[tuple[str, str]] = field(default_factory=list)
|
|
45
|
+
#: For a net: the comparison with the model answer (openprocess.mining.compare_nets).
|
|
46
|
+
comparison: object | None = None
|
|
47
|
+
#: The share of the block's points this answer earns (1 when right).
|
|
48
|
+
share: float = 0.0
|
|
49
|
+
#: Parts of a compound answer (a tuple's P, T, F, m0) with their own verdicts.
|
|
50
|
+
parts: dict[str, str] = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
if self.status == CORRECT and not self.share:
|
|
54
|
+
self.share = 1.0
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def correct(self) -> bool:
|
|
58
|
+
return self.status == CORRECT
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def expected(context: Context, task: Task):
|
|
62
|
+
"""The right answer of a block, from ``answer`` or ``compute``."""
|
|
63
|
+
if "compute" in task.settings:
|
|
64
|
+
return compute(context, task)
|
|
65
|
+
if "answer" not in task.settings:
|
|
66
|
+
raise TaskError("the block has neither an answer nor a compute")
|
|
67
|
+
return task.settings["answer"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _yes_no(value) -> bool:
|
|
71
|
+
if isinstance(value, bool):
|
|
72
|
+
return value
|
|
73
|
+
text = answers.name(str(value))
|
|
74
|
+
if text in YES:
|
|
75
|
+
return True
|
|
76
|
+
if text in NO:
|
|
77
|
+
return False
|
|
78
|
+
raise TaskError(f"“{value}” is not yes or no")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# The checks, one per type
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
def check(exercise: Exercise, task: Task, answer, context: Context | None = None) -> Result:
|
|
85
|
+
"""Is ``answer`` (as the answer box stores it) right for ``task``?"""
|
|
86
|
+
context = context or Context(exercise)
|
|
87
|
+
checker = CHECKERS.get(task.type)
|
|
88
|
+
if checker is None:
|
|
89
|
+
return Result(UNKNOWN, "Compare your answer with the model answer.")
|
|
90
|
+
if task.type == "workflow":
|
|
91
|
+
return checker(context, task, answer or "") # the answer is a file beside the sheet
|
|
92
|
+
if answer in (None, "", [], {}) or (isinstance(answer, dict) and not any(
|
|
93
|
+
str(v).strip() for v in answer.values() if not isinstance(v, dict))
|
|
94
|
+
and not any(isinstance(v, dict) and any(str(x).strip() for x in v.values())
|
|
95
|
+
for v in answer.values())):
|
|
96
|
+
return Result(UNKNOWN, "Answer first, then check.")
|
|
97
|
+
try:
|
|
98
|
+
return checker(context, task, answer)
|
|
99
|
+
except AnswerSyntaxError as error:
|
|
100
|
+
return Result(UNKNOWN, f"Could not read your answer: {error}.")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _partial(hits: int, total: int, what: str = "item") -> tuple[str, float, str]:
|
|
104
|
+
"""``(status, share, text)`` for ``hits`` of ``total`` right."""
|
|
105
|
+
if total and hits == total:
|
|
106
|
+
return CORRECT, 1.0, f"Right: all {total} {what}{'s' * (total != 1)}."
|
|
107
|
+
share = hits / total if total else 0.0
|
|
108
|
+
return (PARTIAL if hits else INCORRECT), share, f"{hits} of {total} {what}s"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _check_set(context, task, answer) -> Result:
|
|
112
|
+
right = answers.normalised(expected(context, task))
|
|
113
|
+
right = answers.as_set(right) if isinstance(right, str) else frozenset(right)
|
|
114
|
+
options = answers.readings(str(answer))
|
|
115
|
+
if right in options:
|
|
116
|
+
return Result(CORRECT, f"Right: {len(right)} item{'s' * (len(right) != 1)}.")
|
|
117
|
+
nested = all(isinstance(x, frozenset | tuple) for x in right)
|
|
118
|
+
|
|
119
|
+
def fit(reading) -> tuple:
|
|
120
|
+
same_kind = all(isinstance(x, frozenset | tuple) == nested for x in reading)
|
|
121
|
+
return len(reading & right), same_kind
|
|
122
|
+
mine = max(options, key=fit)
|
|
123
|
+
hits, extra = len(mine & right), len(mine - right)
|
|
124
|
+
missing = len(right - mine)
|
|
125
|
+
parts = []
|
|
126
|
+
if hits:
|
|
127
|
+
parts.append(f"{hits} of your items {'is' if hits == 1 else 'are'} right")
|
|
128
|
+
if extra:
|
|
129
|
+
parts.append(f"{extra} {'does' if extra == 1 else 'do'} not belong")
|
|
130
|
+
if missing:
|
|
131
|
+
parts.append(f"{missing} {'is' if missing == 1 else 'are'} missing")
|
|
132
|
+
share = max(0.0, (hits - extra) / len(right)) if right else 0.0
|
|
133
|
+
return Result(PARTIAL if hits else INCORRECT, "Not yet: " + ", ".join(parts) + ".", share=share)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _number_value(task, right) -> float:
|
|
137
|
+
return float(right) if not isinstance(right, str) else answers.number(right)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_number(context, task, answer) -> Result:
|
|
141
|
+
right = _number_value(task, expected(context, task))
|
|
142
|
+
try:
|
|
143
|
+
mine = answers.number(str(answer))
|
|
144
|
+
except (ValueError, ZeroDivisionError):
|
|
145
|
+
return Result(UNKNOWN, "That is not a number.")
|
|
146
|
+
tolerance = float(task.get("tolerance") or 0) or max(1e-9, abs(right) * 1e-6)
|
|
147
|
+
if task.get("tolerance") is None and "compute" in task.settings:
|
|
148
|
+
tolerance = 0.005 # a computed figure: two decimals will do
|
|
149
|
+
if right == float("inf"):
|
|
150
|
+
return Result(CORRECT, "Right: unbounded.") if mine == float("inf") else \
|
|
151
|
+
Result(INCORRECT, "Not right: there is no bound.")
|
|
152
|
+
if abs(mine - right) <= tolerance:
|
|
153
|
+
return Result(CORRECT, "Right.")
|
|
154
|
+
return Result(INCORRECT, "Not yet: " + ("too high." if mine > right else "too low."))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _check_yesno(context, task, answer) -> Result:
|
|
158
|
+
right = _yes_no(expected(context, task))
|
|
159
|
+
if _yes_no(answer) == right:
|
|
160
|
+
return Result(CORRECT, "Right.")
|
|
161
|
+
return Result(INCORRECT, "Not right.")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _check_choice(context, task, answer) -> Result:
|
|
165
|
+
chosen = set(answer if isinstance(answer, list) else [answer])
|
|
166
|
+
right = {i for i, option in enumerate(task.options) if option.correct}
|
|
167
|
+
if not right and task.get("compute"):
|
|
168
|
+
# The right option is the one whose text matches the computed value.
|
|
169
|
+
value = str(compute(context, task))
|
|
170
|
+
right = {i for i, option in enumerate(task.options)
|
|
171
|
+
if answers.name(option.text) == answers.name(value)
|
|
172
|
+
or answers.name(value) in answers.name(option.text)}
|
|
173
|
+
if chosen == right:
|
|
174
|
+
return Result(CORRECT, "Right.")
|
|
175
|
+
if len(right) > 1 and chosen & right:
|
|
176
|
+
return Result(PARTIAL, f"Not yet: {len(chosen & right)} of the {len(right)} right "
|
|
177
|
+
"options, " + (f"and {len(chosen - right)} wrong one"
|
|
178
|
+
f"{'s' * (len(chosen - right) != 1)}"
|
|
179
|
+
if chosen - right else "but not all"),
|
|
180
|
+
share=max(0.0, (len(chosen & right) - len(chosen - right)) / len(right)))
|
|
181
|
+
return Result(INCORRECT, "Not right.")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_text(context, task, answer) -> Result:
|
|
185
|
+
mine = answers.name(str(answer))
|
|
186
|
+
pattern = task.get("pattern")
|
|
187
|
+
if pattern and re.fullmatch(pattern, str(answer).strip(), re.I):
|
|
188
|
+
return Result(CORRECT, "Right.")
|
|
189
|
+
accepted = ([task.settings["answer"]] if "answer" in task.settings else []) \
|
|
190
|
+
+ task.alternatives
|
|
191
|
+
if "compute" in task.settings:
|
|
192
|
+
accepted.append(str(compute(context, task)))
|
|
193
|
+
if any(answers.name(a) == mine for a in accepted):
|
|
194
|
+
return Result(CORRECT, "Right.")
|
|
195
|
+
return Result(INCORRECT, "Not right.")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def footprint_of(context: Context, task: Task):
|
|
199
|
+
"""The footprint the block asks for: of the log, or of a net (``of: net.pnml``)."""
|
|
200
|
+
from ..mining.footprint import footprint_of_log, footprint_of_net
|
|
201
|
+
source = task.get("of")
|
|
202
|
+
if source and source.lower().endswith(".pnml"):
|
|
203
|
+
return footprint_of_net(context.net(source))
|
|
204
|
+
return footprint_of_log(context.simple_log(source if source and source != "log" else None))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _footprint_pairs(task: Task, footprint) -> list[tuple[str, str]]:
|
|
208
|
+
"""The cells the block asks about: all of them, or those in ``pairs:``."""
|
|
209
|
+
if not task.get("pairs"):
|
|
210
|
+
return [(a, b) for a in footprint.activities for b in footprint.activities]
|
|
211
|
+
known = {answers.name(x): x for x in footprint.activities}
|
|
212
|
+
pairs = []
|
|
213
|
+
for item in answers.parse(task.get("pairs")):
|
|
214
|
+
if isinstance(item, tuple) and len(item) == 2 and all(x in known for x in item):
|
|
215
|
+
pairs.append((known[item[0]], known[item[1]]))
|
|
216
|
+
else:
|
|
217
|
+
raise TaskError("“pairs:” must list pairs of activities, like (a, b), (b, c)")
|
|
218
|
+
return pairs
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _check_footprint(context, task, answer) -> Result:
|
|
222
|
+
footprint = footprint_of(context, task)
|
|
223
|
+
wrong, blank = [], 0
|
|
224
|
+
pairs = _footprint_pairs(task, footprint)
|
|
225
|
+
for a, b in pairs:
|
|
226
|
+
mine = answer.get(f"{a}\t{b}") if isinstance(answer, dict) else None
|
|
227
|
+
if not mine:
|
|
228
|
+
blank += 1
|
|
229
|
+
wrong.append((a, b))
|
|
230
|
+
elif mine != footprint.relation(a, b):
|
|
231
|
+
wrong.append((a, b))
|
|
232
|
+
cells = len(pairs)
|
|
233
|
+
if not wrong:
|
|
234
|
+
return Result(CORRECT, f"Right: all {cells} cells.")
|
|
235
|
+
filled_wrong = len(wrong) - blank
|
|
236
|
+
message = f"{cells - len(wrong)} of {cells} cells are right"
|
|
237
|
+
if filled_wrong:
|
|
238
|
+
message += f"; {filled_wrong} {'is' if filled_wrong == 1 else 'are'} wrong (marked)"
|
|
239
|
+
if blank:
|
|
240
|
+
message += f"; {blank} {'is' if blank == 1 else 'are'} still empty"
|
|
241
|
+
return Result(PARTIAL if cells - len(wrong) else INCORRECT, message + ".",
|
|
242
|
+
wrong_cells=wrong, share=(cells - len(wrong)) / cells if cells else 0.0)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _check_trace(context, task, answer) -> Result:
|
|
246
|
+
"""A firing sequence of the net that ends as ``ends:`` says."""
|
|
247
|
+
from ..mining.analysis import reachability_graph
|
|
248
|
+
from ..mining.compare_nets import replayable_prefix
|
|
249
|
+
from .computed import _with_start
|
|
250
|
+
steps = answers.trace(str(answer))
|
|
251
|
+
target = task.get("fits")
|
|
252
|
+
if target:
|
|
253
|
+
net = _model_answer(context, target)
|
|
254
|
+
else:
|
|
255
|
+
net = context.net(task.get("net") or task.get("of"))
|
|
256
|
+
net = _with_start(net)
|
|
257
|
+
path, done = replayable_prefix(net, steps)
|
|
258
|
+
if done < len(steps):
|
|
259
|
+
return Result(INCORRECT, f"The net can do the first {done} step"
|
|
260
|
+
f"{'s' * (done != 1)}, but then “{steps[done]}” is not enabled.")
|
|
261
|
+
marking = net.initial_marking
|
|
262
|
+
for transition in path:
|
|
263
|
+
marking = net.fire(marking, transition)
|
|
264
|
+
ends = (task.get("ends") or "any").lower()
|
|
265
|
+
final = net.final_marking or None
|
|
266
|
+
if ends == "any":
|
|
267
|
+
ok, why = True, "it can be fired"
|
|
268
|
+
elif ends == "final":
|
|
269
|
+
ok, why = final is not None and marking == final, "it ends in the final marking"
|
|
270
|
+
elif ends == "deadlock":
|
|
271
|
+
ok = not net.enabled(marking) and marking != final
|
|
272
|
+
why = "nothing is enabled at its end and the case has not completed"
|
|
273
|
+
elif ends in ("stuck", "no completion", "cannot complete"):
|
|
274
|
+
graph = reachability_graph(net, marking, max_states=50_000)
|
|
275
|
+
ok = final is not None and final not in graph.states and not graph.truncated
|
|
276
|
+
why = "after it, the final marking can no longer be reached"
|
|
277
|
+
elif ends in ("improper", "improper completion"):
|
|
278
|
+
ok = final is not None and all(marking[p] >= n for p, n in final.items()) \
|
|
279
|
+
and marking != final
|
|
280
|
+
why = "it puts a token in the sink while other tokens are left behind"
|
|
281
|
+
else:
|
|
282
|
+
raise TaskError(f"unknown “ends: {ends}” (use any, final, deadlock, stuck or "
|
|
283
|
+
"improper)")
|
|
284
|
+
if ok and task.get("unseen") and _yes_no(task.get("unseen")):
|
|
285
|
+
log = context.simple_log(task.get("log"))
|
|
286
|
+
seen = {tuple(answers.name(a) for a in t) for t in log}
|
|
287
|
+
if tuple(answers.name(s) for s in steps) in seen:
|
|
288
|
+
return Result(INCORRECT, "The net can fire it, but the log already shows this "
|
|
289
|
+
"trace: find one the log never shows.")
|
|
290
|
+
why += " and the log never shows it"
|
|
291
|
+
if ok:
|
|
292
|
+
return Result(CORRECT, f"Right: {why}.")
|
|
293
|
+
return Result(INCORRECT, f"The net can fire it, but it is not what is asked: {why} "
|
|
294
|
+
"does not hold at its end.")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _check_net(context, task, answer) -> Result:
|
|
298
|
+
"""``answer`` is the student's net (a PetriNet)."""
|
|
299
|
+
from ..mining.compare_nets import compare_nets
|
|
300
|
+
from .computed import _soundness, _with_start
|
|
301
|
+
net = answer
|
|
302
|
+
problems: list[str] = []
|
|
303
|
+
comparison = None
|
|
304
|
+
if task.get("sound") is not None and _yes_no(task.get("sound")):
|
|
305
|
+
report = _soundness(net)
|
|
306
|
+
if not report.workflow.is_workflow_net:
|
|
307
|
+
problems.append("it is not a WF-net (" + "; ".join(report.workflow.problems[:1])
|
|
308
|
+
+ ")")
|
|
309
|
+
elif report.sound is False:
|
|
310
|
+
problems.append("it is not sound: " + (report.findings[0] if report.findings
|
|
311
|
+
else "a soundness condition fails"))
|
|
312
|
+
elif report.sound is None:
|
|
313
|
+
problems.append("its soundness could not be decided (too many states)")
|
|
314
|
+
if task.get("fits") is not None:
|
|
315
|
+
from ..mining.conformance.token_replay import token_replay
|
|
316
|
+
source = task.get("fits")
|
|
317
|
+
log = context.simple_log(None if source.lower() in ("log", "yes", "") else source)
|
|
318
|
+
replay = token_replay(_with_start(net), log)
|
|
319
|
+
if replay.fitting_traces < replay.trace_count:
|
|
320
|
+
problems.append(f"it replays {replay.fitting_traces} of the "
|
|
321
|
+
f"{replay.trace_count} traces of the log without problems")
|
|
322
|
+
target = task.get("answer") or task.get("compute")
|
|
323
|
+
if target:
|
|
324
|
+
model = _model_answer(context, target, task)
|
|
325
|
+
if task.get("exact") and _yes_no(task.get("exact")):
|
|
326
|
+
same, why = _isomorphic(net, model)
|
|
327
|
+
if not same:
|
|
328
|
+
problems.append("it is not the same net as the model answer: " + why)
|
|
329
|
+
else:
|
|
330
|
+
comparison = compare_nets(net, model)
|
|
331
|
+
if not comparison.equivalent:
|
|
332
|
+
problems.append("it does not allow the same complete traces as the model answer")
|
|
333
|
+
if not any(task.get(k) for k in ("sound", "fits", "answer", "compute")):
|
|
334
|
+
return Result(UNKNOWN, "Nothing to check this net against: compare it with the "
|
|
335
|
+
"model answer yourself.")
|
|
336
|
+
if problems:
|
|
337
|
+
return Result(INCORRECT, "Not yet: " + "; ".join(problems) + ".",
|
|
338
|
+
comparison=comparison)
|
|
339
|
+
good = []
|
|
340
|
+
if target:
|
|
341
|
+
good.append("the same net as the model answer" if task.get("exact")
|
|
342
|
+
else "same behaviour as the model answer")
|
|
343
|
+
if task.get("sound"):
|
|
344
|
+
good.append("sound")
|
|
345
|
+
if task.get("fits"):
|
|
346
|
+
good.append("replays every trace of the log")
|
|
347
|
+
return Result(CORRECT, "Right: " + ", ".join(good) + ".", comparison=comparison)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _isomorphic(first, second) -> tuple[bool, str]:
|
|
351
|
+
"""Are two nets the same up to the names of places (transitions by label)?"""
|
|
352
|
+
from ..mining.transition_system import TransitionSystem, isomorphic
|
|
353
|
+
if len(first.places) != len(second.places):
|
|
354
|
+
return False, f"it has {len(first.places)} places, the model answer {len(second.places)}"
|
|
355
|
+
if len(first.transitions) != len(second.transitions):
|
|
356
|
+
return False, (f"it has {len(first.transitions)} transitions, the model answer "
|
|
357
|
+
f"{len(second.transitions)}")
|
|
358
|
+
if len(first.arcs) != len(second.arcs):
|
|
359
|
+
return False, f"it has {len(first.arcs)} arcs, the model answer {len(second.arcs)}"
|
|
360
|
+
mine = {answers.name(t.label or "") for t in first.transitions.values()}
|
|
361
|
+
theirs = {answers.name(t.label or "") for t in second.transitions.values()}
|
|
362
|
+
if mine != theirs:
|
|
363
|
+
return False, "its transition labels differ from the model answer's"
|
|
364
|
+
from ..mining.analysis import reachability_graph
|
|
365
|
+
from .computed import _with_start
|
|
366
|
+
graphs = []
|
|
367
|
+
for net in (first, second):
|
|
368
|
+
net = _with_start(net)
|
|
369
|
+
graph = reachability_graph(net, max_states=5_000)
|
|
370
|
+
if graph.truncated or graph.has_omega:
|
|
371
|
+
return True, "" # too big to tell apart: accept the structure
|
|
372
|
+
ts = TransitionSystem()
|
|
373
|
+
for i in range(len(graph.states)):
|
|
374
|
+
ts.add_state(f"s{i}")
|
|
375
|
+
for source, transition, target in graph.edges:
|
|
376
|
+
ts.add_transition(f"s{source}", answers.name(net.transitions[transition].label or "τ"),
|
|
377
|
+
f"s{target}")
|
|
378
|
+
ts.initial = ["s0"]
|
|
379
|
+
graphs.append(ts)
|
|
380
|
+
if not isomorphic(graphs[0], graphs[1]):
|
|
381
|
+
return False, "its reachability graph differs from the model answer's"
|
|
382
|
+
return True, ""
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _model_answer(context: Context, target: str, task: Task | None = None):
|
|
386
|
+
"""``answer.pnml``, or a net discovered from the log (``alpha``,
|
|
387
|
+
``inductive``), or a compute that gives a net (``synchronous product(⟨a, b⟩)``,
|
|
388
|
+
``box(heuristics_miner).net``)."""
|
|
389
|
+
kind = target.strip().casefold()
|
|
390
|
+
if kind in ("alpha", "alpha(log)", "α"):
|
|
391
|
+
return context.alpha.net
|
|
392
|
+
if kind in ("inductive", "inductive(log)", "im"):
|
|
393
|
+
from ..mining.discovery.inductive import inductive_miner
|
|
394
|
+
return inductive_miner(context.simple_log()).net
|
|
395
|
+
if context.files.file(target) is not None or target.lower().endswith(".pnml"):
|
|
396
|
+
return context.net(target)
|
|
397
|
+
from ..mining.petrinet import PetriNet
|
|
398
|
+
from ..mining.processtree import ProcessTree, to_petri_net
|
|
399
|
+
value = compute(context, task or Task("net", "net"), target)
|
|
400
|
+
if isinstance(value, ProcessTree):
|
|
401
|
+
return to_petri_net(value)
|
|
402
|
+
if isinstance(value, notation.Tree):
|
|
403
|
+
raise TaskError("a tree compute does not give a net; use im.tree with a tree block")
|
|
404
|
+
if hasattr(value, "net") and isinstance(value.net, PetriNet):
|
|
405
|
+
return value.net
|
|
406
|
+
if not isinstance(value, PetriNet):
|
|
407
|
+
raise TaskError(f"“{target}” does not give a net")
|
|
408
|
+
return value
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# -- the new blocks -------------------------------------------------------------------------
|
|
412
|
+
def _check_marking(context, task, answer) -> Result:
|
|
413
|
+
right = expected(context, task)
|
|
414
|
+
right = notation.marking(right) if isinstance(right, str) else Counter(
|
|
415
|
+
{answers.name(k): v for k, v in dict(right).items()})
|
|
416
|
+
mine = notation.marking(str(answer))
|
|
417
|
+
if mine == right:
|
|
418
|
+
return Result(CORRECT, "Right.")
|
|
419
|
+
missing = +Counter(right) - Counter(mine)
|
|
420
|
+
extra = +Counter(mine) - Counter(right)
|
|
421
|
+
parts = []
|
|
422
|
+
if extra:
|
|
423
|
+
parts.append(f"{sum(extra.values())} token{'s' * (sum(extra.values()) != 1)} too many")
|
|
424
|
+
if missing:
|
|
425
|
+
parts.append(f"{sum(missing.values())} missing")
|
|
426
|
+
total = sum(right.values()) or 1
|
|
427
|
+
hits = sum((Counter(mine) & Counter(right)).values())
|
|
428
|
+
return Result(PARTIAL if hits and not extra else INCORRECT,
|
|
429
|
+
"Not yet: " + ", ".join(parts) + ".", share=max(0.0, (hits - sum(extra.values())) / total))
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _check_markings(context, task, answer) -> Result:
|
|
433
|
+
right = expected(context, task)
|
|
434
|
+
right = notation.markings(right) if isinstance(right, str) else frozenset(right)
|
|
435
|
+
mine = notation.markings(str(answer))
|
|
436
|
+
if mine == right:
|
|
437
|
+
return Result(CORRECT, f"Right: {len(right)} marking{'s' * (len(right) != 1)}.")
|
|
438
|
+
hits, extra, missing = len(mine & right), len(mine - right), len(right - mine)
|
|
439
|
+
parts = []
|
|
440
|
+
if hits:
|
|
441
|
+
parts.append(f"{hits} of your markings {'is' if hits == 1 else 'are'} right")
|
|
442
|
+
if extra:
|
|
443
|
+
parts.append(f"{extra} {'is' if extra == 1 else 'are'} not reachable")
|
|
444
|
+
if missing:
|
|
445
|
+
parts.append(f"{missing} {'is' if missing == 1 else 'are'} missing")
|
|
446
|
+
return Result(PARTIAL if hits else INCORRECT, "Not yet: " + ", ".join(parts) + ".",
|
|
447
|
+
share=max(0.0, (hits - extra) / len(right)) if right else 0.0)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _check_tuple(context, task, answer) -> Result:
|
|
451
|
+
"""(P, T, F, m0): each part against ``net.P``, ``net.T``, ``net.F``, ``net.m0``."""
|
|
452
|
+
mine = notation.net_tuple(answer)
|
|
453
|
+
context.net(task.get("of") or task.get("net")) # a clear error when it is missing
|
|
454
|
+
from .computed import COMPUTED as C
|
|
455
|
+
right = {"P": answers.normalised(C["net.p"](context, "", task)),
|
|
456
|
+
"T": answers.normalised(C["net.t"](context, "", task)),
|
|
457
|
+
"F": answers.normalised(C["net.f"](context, "", task)),
|
|
458
|
+
"m0": notation.marking_key(C["net.m0"](context, "", task))}
|
|
459
|
+
parts, verdicts, hits = [], {}, 0
|
|
460
|
+
for key in notation.TUPLE_FIELDS:
|
|
461
|
+
ok = mine[key] == right[key]
|
|
462
|
+
verdicts[key] = CORRECT if ok else (INCORRECT if not mine[key] else
|
|
463
|
+
(PARTIAL if (mine[key] & right[key]) else INCORRECT))
|
|
464
|
+
hits += ok
|
|
465
|
+
if not ok:
|
|
466
|
+
label = {"P": "P", "T": "T", "F": "F", "m0": "m₀"}[key]
|
|
467
|
+
if not mine[key]:
|
|
468
|
+
parts.append(f"{label} is empty")
|
|
469
|
+
elif key == "m0":
|
|
470
|
+
parts.append(f"{label} is not the initial marking")
|
|
471
|
+
else:
|
|
472
|
+
extra, missing = len(mine[key] - right[key]), len(right[key] - mine[key])
|
|
473
|
+
parts.append(f"{label}: " + ", ".join(x for x in (
|
|
474
|
+
f"{extra} too many" if extra else "", f"{missing} missing" if missing else "") if x))
|
|
475
|
+
if hits == 4:
|
|
476
|
+
return Result(CORRECT, "Right: P, T, F and m₀ all agree with the net.", parts=verdicts)
|
|
477
|
+
return Result(PARTIAL if hits else INCORRECT, f"Not yet ({hits} of 4 parts right): "
|
|
478
|
+
+ "; ".join(parts) + ".", share=hits / 4, parts=verdicts)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _check_ts(context, task, answer) -> Result:
|
|
482
|
+
from ..mining.transition_system import isomorphic, parse_transition_system
|
|
483
|
+
right = expected(context, task)
|
|
484
|
+
if isinstance(right, str):
|
|
485
|
+
try:
|
|
486
|
+
right = parse_transition_system(right)
|
|
487
|
+
except ValueError as error:
|
|
488
|
+
raise TaskError(f"the block's answer is not a transition system: {error}") from None
|
|
489
|
+
try:
|
|
490
|
+
mine = parse_transition_system(str(answer))
|
|
491
|
+
except ValueError as error:
|
|
492
|
+
return Result(UNKNOWN, f"Could not read your transition system: {error}")
|
|
493
|
+
for ts in (mine, right):
|
|
494
|
+
ts.transitions = [(s, answers.name(e), t) for s, e, t in ts.transitions]
|
|
495
|
+
if isomorphic(mine, right):
|
|
496
|
+
return Result(CORRECT, f"Right: {len(right.reachable())} states and "
|
|
497
|
+
f"{len(right.transitions)} transitions, the same up to names.")
|
|
498
|
+
states, arcs = len(mine.reachable()), len(mine.transitions)
|
|
499
|
+
wanted_states, wanted_arcs = len(right.reachable()), len(right.transitions)
|
|
500
|
+
hints = []
|
|
501
|
+
if states != wanted_states:
|
|
502
|
+
hints.append(f"you have {states} reachable state{'s' * (states != 1)}, it should be {wanted_states}")
|
|
503
|
+
if arcs != wanted_arcs:
|
|
504
|
+
hints.append(f"{arcs} transition{'s' * (arcs != 1)} instead of {wanted_arcs}")
|
|
505
|
+
if not hints:
|
|
506
|
+
hints.append("the numbers agree, but the shape does not (look at which events leave which state)")
|
|
507
|
+
close = states == wanted_states or arcs == wanted_arcs
|
|
508
|
+
return Result(PARTIAL if close else INCORRECT, "Not yet: " + "; ".join(hints) + ".",
|
|
509
|
+
share=0.5 if close else 0.0)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _check_matrix(context, task, answer) -> Result:
|
|
513
|
+
right = expected(context, task)
|
|
514
|
+
right = (notation.matrix(right) if isinstance(right, str) else right).normalised()
|
|
515
|
+
mine = (notation.matrix(answer) if isinstance(answer, str) else _matrix_from_grid(answer)).normalised()
|
|
516
|
+
if set(mine.rows) != set(right.rows) or set(mine.columns) != set(right.columns):
|
|
517
|
+
missing_rows = sorted(set(right.rows) - set(mine.rows))
|
|
518
|
+
missing_cols = sorted(set(right.columns) - set(mine.columns))
|
|
519
|
+
extra = sorted((set(mine.rows) - set(right.rows)) | (set(mine.columns) - set(right.columns)))
|
|
520
|
+
parts = [f"rows missing: {', '.join(missing_rows)}" if missing_rows else "",
|
|
521
|
+
f"columns missing: {', '.join(missing_cols)}" if missing_cols else "",
|
|
522
|
+
f"not in the matrix: {', '.join(extra)}" if extra else ""]
|
|
523
|
+
return Result(INCORRECT, "Not yet: " + "; ".join(p for p in parts if p) + ".")
|
|
524
|
+
wrong = [(r, c) for r in right.rows for c in right.columns
|
|
525
|
+
if abs((mine.cells.get((r, c)) or 0) - (right.cells.get((r, c)) or 0)) > 1e-9]
|
|
526
|
+
cells = len(right.rows) * len(right.columns)
|
|
527
|
+
if not wrong:
|
|
528
|
+
return Result(CORRECT, f"Right: all {cells} cells.")
|
|
529
|
+
return Result(PARTIAL if len(wrong) < cells else INCORRECT,
|
|
530
|
+
f"{cells - len(wrong)} of {cells} cells are right; {len(wrong)} "
|
|
531
|
+
f"{'is' if len(wrong) == 1 else 'are'} wrong (marked).",
|
|
532
|
+
wrong_cells=wrong, share=(cells - len(wrong)) / cells)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _matrix_from_grid(value: dict) -> notation.Matrix:
|
|
536
|
+
"""The matrix editor's value: ``{"rows": [...], "columns": [...], "cells": {"r\\tc": v}}``."""
|
|
537
|
+
rows, columns = list(value.get("rows", [])), list(value.get("columns", []))
|
|
538
|
+
cells = {}
|
|
539
|
+
for key, cell in (value.get("cells") or {}).items():
|
|
540
|
+
r, _, c = key.partition("\t")
|
|
541
|
+
if str(cell).strip() == "":
|
|
542
|
+
continue
|
|
543
|
+
try:
|
|
544
|
+
cells[(r, c)] = answers.number(str(cell))
|
|
545
|
+
except (ValueError, ZeroDivisionError):
|
|
546
|
+
raise AnswerSyntaxError(f"“{cell}” is not a number") from None
|
|
547
|
+
return notation.Matrix(rows, columns, cells)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _check_cut(context, task, answer) -> Result:
|
|
551
|
+
right = expected(context, task)
|
|
552
|
+
right = notation.cut(right) if isinstance(right, str) else right
|
|
553
|
+
operator, groups = right
|
|
554
|
+
groups = tuple(frozenset(answers.name(a) for a in g) for g in groups)
|
|
555
|
+
mine_operator, mine_groups = notation.cut(str(answer))
|
|
556
|
+
ordered = operator in ("→", "↺")
|
|
557
|
+
same = (mine_groups == groups) if ordered else (set(mine_groups) == set(groups))
|
|
558
|
+
if mine_operator == operator and same:
|
|
559
|
+
return Result(CORRECT, f"Right: a {operator} cut into {len(groups)} parts.")
|
|
560
|
+
parts = []
|
|
561
|
+
if mine_operator != operator:
|
|
562
|
+
parts.append("the operator is not the one the Inductive Miner finds first")
|
|
563
|
+
if set(mine_groups) == set(groups) and ordered:
|
|
564
|
+
parts.append("the groups are right but not in this order")
|
|
565
|
+
elif not same:
|
|
566
|
+
hits = len(set(mine_groups) & set(groups))
|
|
567
|
+
parts.append(f"{hits} of the {len(groups)} groups {'is' if hits == 1 else 'are'} right")
|
|
568
|
+
share = 0.5 if (mine_operator == operator or same) else 0.0
|
|
569
|
+
return Result(PARTIAL if share else INCORRECT, "Not yet: " + "; ".join(parts) + ".", share=share)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _check_log(context, task, answer) -> Result:
|
|
573
|
+
right = expected(context, task)
|
|
574
|
+
right = notation.log(right) if isinstance(right, str) else Counter(
|
|
575
|
+
{tuple(answers.name(a) for a in t): n for t, n in dict(right).items()})
|
|
576
|
+
mine = notation.log(str(answer))
|
|
577
|
+
if mine == right:
|
|
578
|
+
return Result(CORRECT, f"Right: {len(right)} variant{'s' * (len(right) != 1)}, "
|
|
579
|
+
f"{sum(right.values())} traces.")
|
|
580
|
+
hits = sum((Counter(mine) & Counter(right)).values())
|
|
581
|
+
total = sum(right.values())
|
|
582
|
+
parts = []
|
|
583
|
+
extra = sum((+(Counter(mine) - Counter(right))).values())
|
|
584
|
+
missing = sum((+(Counter(right) - Counter(mine))).values())
|
|
585
|
+
if extra:
|
|
586
|
+
parts.append(f"{extra} trace{'s' * (extra != 1)} should not be there")
|
|
587
|
+
if missing:
|
|
588
|
+
parts.append(f"{missing} {'is' if missing == 1 else 'are'} missing")
|
|
589
|
+
return Result(PARTIAL if hits else INCORRECT, "Not yet: " + ", ".join(parts) + ".",
|
|
590
|
+
share=max(0.0, (hits - extra) / total) if total else 0.0)
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _check_tree(context, task, answer) -> Result:
|
|
594
|
+
right = expected(context, task)
|
|
595
|
+
right = notation.tree(right) if isinstance(right, str) else right
|
|
596
|
+
mine = notation.tree(str(answer))
|
|
597
|
+
if mine.key() == right.key():
|
|
598
|
+
return Result(CORRECT, "Right: the same tree.")
|
|
599
|
+
if mine.operator == right.operator and len(mine.children) == len(right.children):
|
|
600
|
+
return Result(PARTIAL, "Not yet: the root and its number of children are right, "
|
|
601
|
+
"but something below differs.", share=0.5)
|
|
602
|
+
if {a for a in _leaves(mine)} != {a for a in _leaves(right)}:
|
|
603
|
+
return Result(INCORRECT, "Not yet: the leaves are not the activities of the log.")
|
|
604
|
+
return Result(INCORRECT, "Not yet: the tree differs from the one the miner finds.")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _leaves(tree: notation.Tree) -> list:
|
|
608
|
+
if tree.operator is None:
|
|
609
|
+
return [tree.label]
|
|
610
|
+
return [leaf for child in tree.children for leaf in _leaves(child)]
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _check_replay(context, task, answer) -> Result:
|
|
614
|
+
right = expected(context, task)
|
|
615
|
+
if isinstance(right, str):
|
|
616
|
+
right = notation.replay_table(right)
|
|
617
|
+
mine = notation.replay_table(answer)
|
|
618
|
+
wrong, cells = [], 0
|
|
619
|
+
for trace, row in right.items():
|
|
620
|
+
for column in notation.REPLAY_COLUMNS:
|
|
621
|
+
cells += 1
|
|
622
|
+
value = mine.get(trace, {}).get(column)
|
|
623
|
+
if value is None or abs(value - row[column]) > 1e-9:
|
|
624
|
+
wrong.append(("⟨" + ", ".join(trace) + "⟩", column))
|
|
625
|
+
if not cells:
|
|
626
|
+
raise TaskError("the replay has no traces")
|
|
627
|
+
if not wrong:
|
|
628
|
+
return Result(CORRECT, f"Right: all {cells} figures.")
|
|
629
|
+
return Result(PARTIAL if len(wrong) < cells else INCORRECT,
|
|
630
|
+
f"{cells - len(wrong)} of {cells} figures are right; {len(wrong)} "
|
|
631
|
+
f"{'is' if len(wrong) == 1 else 'are'} wrong or missing (marked).",
|
|
632
|
+
wrong_cells=wrong, share=(cells - len(wrong)) / cells)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _check_alignment(context, task, answer) -> Result:
|
|
636
|
+
moves = notation.alignment(str(answer))
|
|
637
|
+
net = context.net(task.get("of") or task.get("net"))
|
|
638
|
+
trace = tuple(answers.trace(task.get("trace"))) if task.get("trace") else None
|
|
639
|
+
kind, why = alignment_kind(net, moves, trace)
|
|
640
|
+
wanted = (task.get("answer") or "optimal").strip().casefold()
|
|
641
|
+
if kind == wanted:
|
|
642
|
+
return Result(CORRECT, f"Right: {why}.")
|
|
643
|
+
if kind == "sub-optimal" and wanted == "optimal":
|
|
644
|
+
return Result(PARTIAL, f"It is an alignment, but not an optimal one: {why}.", share=0.5)
|
|
645
|
+
return Result(INCORRECT, f"Not yet: it is {kind} ({why}).")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _check_ranking(context, task, answer) -> Result:
|
|
649
|
+
names = task.get("over")
|
|
650
|
+
if not names:
|
|
651
|
+
raise TaskError("a ranking needs “over:” with the names to rank")
|
|
652
|
+
candidates = [n.strip() for n in names.split(",") if n.strip()]
|
|
653
|
+
mine = notation.ranking(answer)
|
|
654
|
+
keys = {answers.name(c.replace(".pnml", "")): c for c in candidates}
|
|
655
|
+
unknown = [m for m in mine if m not in keys]
|
|
656
|
+
if unknown:
|
|
657
|
+
return Result(UNKNOWN, "Not among the names to rank: " + ", ".join(unknown) + ".")
|
|
658
|
+
if len(mine) != len(candidates):
|
|
659
|
+
return Result(UNKNOWN, f"Rank all {len(candidates)} of them.")
|
|
660
|
+
scores = _scores(context, task, candidates)
|
|
661
|
+
by = {answers.name(c.replace(".pnml", "")): scores[c] for c in candidates}
|
|
662
|
+
higher_is_better = (task.get("order") or "high").strip().lower() not in ("low", "ascending", "lowest first")
|
|
663
|
+
for a, b in zip(mine, mine[1:]):
|
|
664
|
+
bad = by[a] < by[b] if higher_is_better else by[a] > by[b]
|
|
665
|
+
if bad:
|
|
666
|
+
right_count = sum(1 for x, y in zip(mine, mine[1:])
|
|
667
|
+
if not (by[x] < by[y] if higher_is_better else by[x] > by[y]))
|
|
668
|
+
return Result(INCORRECT if right_count == 0 else PARTIAL,
|
|
669
|
+
f"Not yet: {keys[a]} is ranked above {keys[b]}, but scores "
|
|
670
|
+
f"{'lower' if higher_is_better else 'higher'}.",
|
|
671
|
+
share=right_count / max(1, len(mine) - 1))
|
|
672
|
+
return Result(CORRECT, "Right: " + " ≥ ".join(f"{keys[m]} ({by[m]:.3g})" for m in mine) + ".")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _scores(context, task, candidates: list[str]) -> dict[str, float]:
|
|
676
|
+
"""``by: fitness`` (the default) of every candidate net on the log."""
|
|
677
|
+
by = (task.get("by") or "fitness").strip()
|
|
678
|
+
found, argument = lookup(by)
|
|
679
|
+
scores = {}
|
|
680
|
+
for candidate in candidates:
|
|
681
|
+
scoped = Task(task.type, task.id, settings={**task.settings, "of": candidate})
|
|
682
|
+
scores[candidate] = float(found(context, argument, scoped))
|
|
683
|
+
return scores
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _check_predict(context, task, answer) -> Result:
|
|
687
|
+
"""``predict``: a set, number, yes/no or text answer whose right value is a
|
|
688
|
+
box's result (``box: alpha_miner``, ``value: net.transitions``)."""
|
|
689
|
+
box_id = task.get("box")
|
|
690
|
+
if not box_id:
|
|
691
|
+
raise TaskError("a predict block needs “box:” (the box to predict)")
|
|
692
|
+
expression = f"box({box_id})" + ("." + task.get("value") if task.get("value") else "")
|
|
693
|
+
scoped = Task(_predict_kind(task), task.id, settings={**task.settings, "compute": expression})
|
|
694
|
+
scoped.options = task.options
|
|
695
|
+
return CHECKERS[scoped.type](context, scoped, answer)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _predict_kind(task: Task) -> str:
|
|
699
|
+
kind = (task.get("as") or "").strip().lower()
|
|
700
|
+
if kind in CHECKERS and kind not in ("predict", "workflow"):
|
|
701
|
+
return kind
|
|
702
|
+
return "set"
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _check_workflow(context, task, answer) -> Result:
|
|
706
|
+
"""``workflow``: the student's workflow (saved beside the question) must
|
|
707
|
+
have the boxes in ``needs:`` and, with ``result:``, a box's value must be
|
|
708
|
+
``answer:`` / ``compute:``."""
|
|
709
|
+
from ..flow.record import load
|
|
710
|
+
from ..flow.runner import Cache, Runner
|
|
711
|
+
path = context.exercise.folder / (answer if isinstance(answer, str) and answer else "my workflow.cpnflow")
|
|
712
|
+
if not path.exists():
|
|
713
|
+
return Result(UNKNOWN, "Build the workflow in the Workflow tab first (it is saved beside "
|
|
714
|
+
"the question).")
|
|
715
|
+
wf, _record = load(path, context.library)
|
|
716
|
+
problems, share, checks = [], 0.0, 0
|
|
717
|
+
needs = [n.strip() for n in (task.get("needs") or "").split(",") if n.strip()]
|
|
718
|
+
if needs:
|
|
719
|
+
checks += 1
|
|
720
|
+
present = {n.box for n in wf.nodes.values()} | {n.box.rsplit(".", 1)[-1] for n in wf.nodes.values()}
|
|
721
|
+
present |= {answers.name(wf.spec(n.id).name) for n in wf.nodes.values()}
|
|
722
|
+
missing = [n for n in needs if n not in present and answers.name(n) not in present]
|
|
723
|
+
if missing:
|
|
724
|
+
problems.append("missing: " + ", ".join(missing))
|
|
725
|
+
else:
|
|
726
|
+
share += 1
|
|
727
|
+
if task.get("result"):
|
|
728
|
+
checks += 1
|
|
729
|
+
run = Runner(context.library, Cache(), folder=context.exercise.folder).run(wf)
|
|
730
|
+
node_name, _, rest = task.get("result").partition(".")
|
|
731
|
+
node = next((n for n in wf.nodes.values()
|
|
732
|
+
if answers.name(wf.title(n.id)) == answers.name(node_name)
|
|
733
|
+
or n.box.rsplit(".", 1)[-1] == node_name or n.id == node_name), None)
|
|
734
|
+
if node is None:
|
|
735
|
+
problems.append(f"no box called “{node_name}” in your workflow")
|
|
736
|
+
else:
|
|
737
|
+
result = run.result(node)
|
|
738
|
+
if result is None or result.status != "done":
|
|
739
|
+
problems.append(f"“{wf.title(node.id)}” did not run: " + (result.error if result else ""))
|
|
740
|
+
else:
|
|
741
|
+
from .computed import follow_path
|
|
742
|
+
value = follow_path(result.value, rest)
|
|
743
|
+
scoped = Task(_result_kind(value), task.id, settings={k: v for k, v in task.settings.items()
|
|
744
|
+
if k in ("answer", "compute", "tolerance", "of")})
|
|
745
|
+
verdict = CHECKERS[scoped.type](context, scoped, _as_text(value))
|
|
746
|
+
if verdict.correct:
|
|
747
|
+
share += 1
|
|
748
|
+
else:
|
|
749
|
+
problems.append(f"the result of “{wf.title(node.id)}” is not right ({verdict.message.rstrip('.')})")
|
|
750
|
+
if not checks:
|
|
751
|
+
return Result(UNKNOWN, "Nothing to check this workflow against: compare it with the "
|
|
752
|
+
"model answer yourself.")
|
|
753
|
+
if problems:
|
|
754
|
+
return Result(PARTIAL if share else INCORRECT, "Not yet: " + "; ".join(problems) + ".",
|
|
755
|
+
share=share / checks)
|
|
756
|
+
return Result(CORRECT, "Right: the workflow has what it needs and its result agrees.")
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _result_kind(value) -> str:
|
|
760
|
+
if isinstance(value, bool):
|
|
761
|
+
return "yesno"
|
|
762
|
+
if isinstance(value, (int, float)):
|
|
763
|
+
return "number"
|
|
764
|
+
if isinstance(value, (set, frozenset, list)):
|
|
765
|
+
return "set"
|
|
766
|
+
return "text"
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _as_text(value) -> str:
|
|
770
|
+
if isinstance(value, (set, frozenset, list)):
|
|
771
|
+
return answers.show(frozenset(answers.normalised(v) for v in value))
|
|
772
|
+
if isinstance(value, bool):
|
|
773
|
+
return "yes" if value else "no"
|
|
774
|
+
return str(value)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
CHECKERS = {"set": _check_set, "number": _check_number, "yesno": _check_yesno,
|
|
778
|
+
"choice": _check_choice, "text": _check_text, "footprint": _check_footprint,
|
|
779
|
+
"trace": _check_trace, "net": _check_net, "marking": _check_marking,
|
|
780
|
+
"markings": _check_markings, "tuple": _check_tuple, "ts": _check_ts,
|
|
781
|
+
"matrix": _check_matrix, "cut": _check_cut, "log": _check_log, "tree": _check_tree,
|
|
782
|
+
"replay": _check_replay, "alignment": _check_alignment, "ranking": _check_ranking,
|
|
783
|
+
"predict": _check_predict, "workflow": _check_workflow}
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
# ---------------------------------------------------------------------------
|
|
787
|
+
# The right answer in words
|
|
788
|
+
# ---------------------------------------------------------------------------
|
|
789
|
+
def show_value(value) -> str:
|
|
790
|
+
"""A computed value in the notation of the course."""
|
|
791
|
+
from ..mining.transition_system import TransitionSystem
|
|
792
|
+
if isinstance(value, bool):
|
|
793
|
+
return "Yes." if value else "No."
|
|
794
|
+
if isinstance(value, float) and value == float("inf"):
|
|
795
|
+
return "∞ (unbounded)"
|
|
796
|
+
if isinstance(value, (int, float)):
|
|
797
|
+
return f"{float(value):.4g}"
|
|
798
|
+
if isinstance(value, notation.Matrix):
|
|
799
|
+
return notation.show_matrix(value)
|
|
800
|
+
if isinstance(value, notation.Tree):
|
|
801
|
+
return str(value)
|
|
802
|
+
if isinstance(value, TransitionSystem):
|
|
803
|
+
return value.to_text()
|
|
804
|
+
if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], str) and value[0] in "→×∧↺":
|
|
805
|
+
return notation.show_cut(value)
|
|
806
|
+
if isinstance(value, Counter):
|
|
807
|
+
if value and all(isinstance(k, tuple) for k in value):
|
|
808
|
+
return notation.show_log(value)
|
|
809
|
+
return notation.show_marking(value)
|
|
810
|
+
if isinstance(value, dict):
|
|
811
|
+
rows = []
|
|
812
|
+
for key, row in value.items():
|
|
813
|
+
if isinstance(row, dict):
|
|
814
|
+
rows.append("⟨" + ", ".join(key) + "⟩: " + " ".join(
|
|
815
|
+
f"{c} = {row[c]:g}" for c in notation.REPLAY_COLUMNS if c in row))
|
|
816
|
+
else:
|
|
817
|
+
rows.append(f"{key}: {row}")
|
|
818
|
+
return "\n".join(rows)
|
|
819
|
+
if isinstance(value, frozenset) and value and all(isinstance(v, frozenset) and all(
|
|
820
|
+
isinstance(x, tuple) and len(x) == 2 and isinstance(x[1], int) for x in v) for v in value):
|
|
821
|
+
return notation.show_markings(value)
|
|
822
|
+
if isinstance(value, (frozenset, set)):
|
|
823
|
+
value = answers.normalised(value)
|
|
824
|
+
return answers.show_set(value)
|
|
825
|
+
if isinstance(value, str):
|
|
826
|
+
value = answers.normalised(value)
|
|
827
|
+
return answers.show_set(answers.as_set(value)) if value.startswith("{") else value
|
|
828
|
+
return str(value)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def model_answer_text(exercise: Exercise, task: Task, context: Context | None = None) -> str:
|
|
832
|
+
"""The right answer in words, for *Show answer* (the block's ``solution`` first)."""
|
|
833
|
+
if task.solution:
|
|
834
|
+
return task.solution
|
|
835
|
+
context = context or Context(exercise)
|
|
836
|
+
try:
|
|
837
|
+
if task.type == "choice":
|
|
838
|
+
if any(o.correct for o in task.options):
|
|
839
|
+
return "; ".join(o.text for o in task.options if o.correct) + "."
|
|
840
|
+
return show_value(compute(context, task)) if task.get("compute") else ""
|
|
841
|
+
if task.type == "text":
|
|
842
|
+
return task.settings.get("answer") or (task.alternatives[0] if task.alternatives
|
|
843
|
+
else (show_value(compute(context, task))
|
|
844
|
+
if task.get("compute") else ""))
|
|
845
|
+
if task.type == "predict":
|
|
846
|
+
value = compute(context, task, f"box({task.get('box')})" +
|
|
847
|
+
("." + task.get("value") if task.get("value") else ""))
|
|
848
|
+
return show_value(value)
|
|
849
|
+
if task.type == "tuple":
|
|
850
|
+
from .computed import COMPUTED as C
|
|
851
|
+
return "\n".join(f"{label} = {show_value(C[key](context, '', task))}" for label, key in
|
|
852
|
+
(("P", "net.p"), ("T", "net.t"), ("F", "net.f"), ("m₀", "net.m0")))
|
|
853
|
+
if task.type == "ranking":
|
|
854
|
+
candidates = [n.strip() for n in (task.get("over") or "").split(",") if n.strip()]
|
|
855
|
+
scores = _scores(context, task, candidates)
|
|
856
|
+
order = sorted(candidates, key=lambda c: -scores[c])
|
|
857
|
+
return " > ".join(f"{c} ({scores[c]:.3g})" for c in order)
|
|
858
|
+
if task.type == "alignment":
|
|
859
|
+
from ..mining.conformance.alignments import align_trace
|
|
860
|
+
from .computed import _with_start
|
|
861
|
+
net = _with_start(context.net(task.get("of") or task.get("net")))
|
|
862
|
+
trace = tuple(answers.trace(task.get("trace"))) if task.get("trace") else ()
|
|
863
|
+
best = align_trace(net, trace)
|
|
864
|
+
return notation.show_alignment((m.log, m.model) for m in best.moves) + \
|
|
865
|
+
f"\ncost {best.cost}"
|
|
866
|
+
if task.type in ("set", "number", "yesno", "marking", "markings", "ts", "matrix", "cut",
|
|
867
|
+
"log", "tree", "replay"):
|
|
868
|
+
value = expected(context, task)
|
|
869
|
+
if isinstance(value, str) and task.type == "set":
|
|
870
|
+
value = answers.as_set(answers.normalised(value))
|
|
871
|
+
if isinstance(value, str) and task.type == "yesno":
|
|
872
|
+
value = _yes_no(value)
|
|
873
|
+
if isinstance(value, str) and task.type == "number":
|
|
874
|
+
return value
|
|
875
|
+
return show_value(value)
|
|
876
|
+
except TaskError as error:
|
|
877
|
+
return f"(The answer could not be worked out: {error}.)"
|
|
878
|
+
return ""
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
# ---------------------------------------------------------------------------
|
|
882
|
+
# For authors: is the pack consistent?
|
|
883
|
+
# ---------------------------------------------------------------------------
|
|
884
|
+
def validate(exercise: Exercise) -> list[str]:
|
|
885
|
+
"""Problems with the exercise's answer blocks (for authors: ``openprocess exercises check``)."""
|
|
886
|
+
problems = []
|
|
887
|
+
if exercise.error:
|
|
888
|
+
problems.append(exercise.error)
|
|
889
|
+
context = Context(exercise, student=exercise.student() or "the author")
|
|
890
|
+
for task in exercise.sheet.tasks:
|
|
891
|
+
where = f"answer block “{task.id}” (line {task.line})" if task.line else \
|
|
892
|
+
f"answer “{task.id}”"
|
|
893
|
+
try:
|
|
894
|
+
if task.get("compute"):
|
|
895
|
+
lookup(task.get("compute"))
|
|
896
|
+
if task.type in ("set", "number", "yesno", "marking", "markings", "ts", "matrix",
|
|
897
|
+
"cut", "log", "tree", "replay") and task.checkable:
|
|
898
|
+
value = expected(context, task)
|
|
899
|
+
if task.type == "yesno":
|
|
900
|
+
_yes_no(value)
|
|
901
|
+
if task.type == "number" and isinstance(value, str):
|
|
902
|
+
answers.number(value)
|
|
903
|
+
if isinstance(value, str):
|
|
904
|
+
reader = {"marking": notation.marking, "markings": notation.markings,
|
|
905
|
+
"matrix": notation.matrix, "cut": notation.cut, "log": notation.log,
|
|
906
|
+
"tree": notation.tree, "replay": notation.replay_table}.get(task.type)
|
|
907
|
+
if reader is not None:
|
|
908
|
+
reader(value)
|
|
909
|
+
elif task.type == "footprint":
|
|
910
|
+
_footprint_pairs(task, footprint_of(context, task))
|
|
911
|
+
elif task.type == "trace":
|
|
912
|
+
if task.get("fits"):
|
|
913
|
+
_model_answer(context, task.get("fits"), task)
|
|
914
|
+
else:
|
|
915
|
+
context.net(task.get("net") or task.get("of"))
|
|
916
|
+
elif task.type == "net":
|
|
917
|
+
if task.get("answer") or task.get("compute"):
|
|
918
|
+
_model_answer(context, task.get("answer") or task.get("compute"), task)
|
|
919
|
+
if task.get("start") and task.get("start").lower() != "empty" \
|
|
920
|
+
and exercise.files.file(task.get("start")) is None:
|
|
921
|
+
raise TaskError(f"the exercise has no file “{task.get('start')}”")
|
|
922
|
+
elif task.type == "tuple":
|
|
923
|
+
context.net(task.get("of") or task.get("net"))
|
|
924
|
+
elif task.type == "alignment":
|
|
925
|
+
context.net(task.get("of") or task.get("net"))
|
|
926
|
+
if task.get("trace"):
|
|
927
|
+
answers.trace(task.get("trace"))
|
|
928
|
+
if task.get("alignment"):
|
|
929
|
+
notation.alignment(task.get("alignment"))
|
|
930
|
+
elif task.type == "ranking":
|
|
931
|
+
candidates = [n.strip() for n in (task.get("over") or "").split(",") if n.strip()]
|
|
932
|
+
if len(candidates) < 2:
|
|
933
|
+
raise TaskError("“over:” must name at least two things to rank")
|
|
934
|
+
_scores(context, task, candidates)
|
|
935
|
+
elif task.type == "predict":
|
|
936
|
+
if not task.get("box"):
|
|
937
|
+
raise TaskError("a predict block needs “box:”")
|
|
938
|
+
compute(context, task, f"box({task.get('box')})" +
|
|
939
|
+
("." + task.get("value") if task.get("value") else ""))
|
|
940
|
+
elif task.type == "workflow":
|
|
941
|
+
if not task.get("needs") and not task.get("result"):
|
|
942
|
+
raise TaskError("a workflow block needs “needs:” or “result:”")
|
|
943
|
+
elif task.type == "choice" and task.get("compute"):
|
|
944
|
+
compute(context, task)
|
|
945
|
+
if task.get("points"):
|
|
946
|
+
float(task.get("points"))
|
|
947
|
+
if not task.checkable and task.type not in ("open",) and not task.solution:
|
|
948
|
+
problems.append(f"{where}: nothing to check against and no solution")
|
|
949
|
+
except (TaskError, AnswerSyntaxError, ValueError) as error:
|
|
950
|
+
problems.append(f"{where}: {error}")
|
|
951
|
+
except Exception as error: # noqa: BLE001 - report, do not crash the listing
|
|
952
|
+
problems.append(f"{where}: {type(error).__name__}: {error}")
|
|
953
|
+
return problems
|