openprocess 0.7.0__py3-none-any.whl

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