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,474 @@
1
+ """The notations of the course, read from what a student types and written back.
2
+
3
+ Every answer box beyond a set or a number has a notation the course already
4
+ uses on paper. This module reads each one leniently and shows it back in
5
+ its canonical form, so a check can compare what was typed with what the
6
+ engine computed:
7
+
8
+ =========== =========================================== ===================
9
+ block type typed read as
10
+ =========== =========================================== ===================
11
+ marking ``[p1, p4²]``, ``[p1, p4^2]``, ``p1 + 2p4`` a multiset of places
12
+ markings ``[p1], [p2, p3], [p4]`` a set of markings
13
+ tuple four fields: P, T, F, m₀ sets, pairs, a marking
14
+ ts ``s0 -a-> s1`` lines (see transition_system) a TransitionSystem
15
+ matrix rows of numbers with row and column headers a named matrix
16
+ cut ``→ {a} {b, c, e} {d}`` an operator and groups
17
+ log ``[<a, b>^2, <a, c>]`` a multiset of traces
18
+ tree ``→(a, ×(∧(b, c), e), d)`` a process tree
19
+ alignment two rows, ``a b ≫ c`` over ``a ≫ d c`` moves (log, model)
20
+ replay a table: p, c, m, r per trace numbers per trace
21
+ ranking ``m2 > m1 > m3`` or one name per line an order of names
22
+ =========== =========================================== ===================
23
+
24
+ Nothing here needs Qt: the same readers serve the app, the command line and
25
+ the tests.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from collections import Counter
32
+ from dataclasses import dataclass, field
33
+
34
+ from . import answers
35
+ from .answers import AnswerSyntaxError
36
+
37
+ SKIP = "≫"
38
+ #: What may stand for ≫ (the "no move") in a typed alignment.
39
+ SKIP_WORDS = {"≫", ">>", "»", "-", "–", "—", "_", "skip", "nomove", "no move"}
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Markings
44
+ # ---------------------------------------------------------------------------
45
+ _COUNT_BEFORE = re.compile(r"^(\d+)\s*[·*x×]?\s*(.+)$")
46
+ _COUNT_AFTER = re.compile(r"^(.+?)\s*(?:\^\s*(\d+)|([²³⁴⁵⁶⁷⁸⁹⁰¹]+)|:\s*(\d+))$")
47
+ _SUPERSCRIPTS = str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹", "0123456789")
48
+
49
+
50
+ def _strip_brackets(text: str) -> str:
51
+ text = text.strip()
52
+ for latex, plain in (("\\{", "{"), ("\\}", "}"), ("\\emptyset", "∅"), ("$", "")):
53
+ text = text.replace(latex, plain)
54
+ text = text.strip()
55
+ if len(text) >= 2 and text[0] in "[{(" and text[-1] in "]})":
56
+ return text[1:-1].strip()
57
+ return text
58
+
59
+
60
+ def marking(text: str) -> Counter:
61
+ """A marking as typed: ``[p1, p4²]``, ``[p1, p4^2]``, ``[p1, 2p4]``,
62
+ ``p1 + 2·p4``, ``{p1: 1, p4: 2}``, ``[]`` or ``∅``. Names are compared
63
+ like other answers (case and spaces do not matter)."""
64
+ body = _strip_brackets(text)
65
+ result: Counter = Counter()
66
+ if body in ("", "∅", "-"):
67
+ return result
68
+ for item in re.split(r"[,+;]", body):
69
+ item = item.strip()
70
+ if not item:
71
+ continue
72
+ count = 1
73
+ after = _COUNT_AFTER.match(item)
74
+ if after and (after.group(2) or after.group(3) or after.group(4)):
75
+ item = after.group(1)
76
+ count = int((after.group(2) or after.group(4) or after.group(3).translate(_SUPERSCRIPTS)))
77
+ else:
78
+ before = _COUNT_BEFORE.match(item)
79
+ if before:
80
+ count, item = int(before.group(1)), before.group(2)
81
+ name = answers.name(item)
82
+ if not name:
83
+ raise AnswerSyntaxError(f"“{item}” is not a place")
84
+ if count:
85
+ result[name] += count
86
+ return result
87
+
88
+
89
+ def show_marking(counts) -> str:
90
+ """``[p1, p4^2]`` (the textbook's notation); ``[]`` when empty."""
91
+ parts = []
92
+ for name in sorted(counts, key=answers.name if all(isinstance(k, str) for k in counts) else str):
93
+ n = counts[name]
94
+ if n:
95
+ parts.append(f"{name}^{n}" if n != 1 else str(name))
96
+ return "[" + ", ".join(parts) + "]"
97
+
98
+
99
+ def marking_key(counts) -> frozenset:
100
+ """A marking as something hashable, for sets of markings."""
101
+ return frozenset((answers.name(k), v) for k, v in counts.items() if v)
102
+
103
+
104
+ def markings(text: str) -> frozenset:
105
+ """A set of markings: ``[p1], [p2, p3], [p4]`` (also inside ``{ }``)."""
106
+ text = text.strip()
107
+ for latex, plain in (("\\{", "{"), ("\\}", "}"), ("$", "")):
108
+ text = text.replace(latex, plain)
109
+ if text.startswith("{") and text.endswith("}") and "[" in text:
110
+ text = text[1:-1]
111
+ found = re.findall(r"\[[^\[\]]*\]", text)
112
+ if not found:
113
+ if text.strip() in ("", "∅", "{}"):
114
+ return frozenset()
115
+ raise AnswerSyntaxError("write each marking in square brackets, like [p1, p2^2]")
116
+ return frozenset(marking_key(marking(m)) for m in found)
117
+
118
+
119
+ def show_markings(keys) -> str:
120
+ shown = sorted(show_marking(dict(k)) for k in keys)
121
+ return ", ".join(shown) if shown else "∅"
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Cuts and process trees
126
+ # ---------------------------------------------------------------------------
127
+ OPERATORS = {"→": "→", "->": "→", "seq": "→", "sequence": "→", "x": "×", "×": "×", "xor": "×",
128
+ "choice": "×", "exclusive": "×", "^": "∧", "∧": "∧", "and": "∧", "parallel": "∧",
129
+ "par": "∧", "concurrent": "∧", "↺": "↺", "loop": "↺", "*": "↺", "redo": "↺"}
130
+
131
+
132
+ def _operator(token: str) -> str:
133
+ key = token.strip().casefold()
134
+ if key in OPERATORS:
135
+ return OPERATORS[key]
136
+ raise AnswerSyntaxError(f"“{token}” is not an operator (use →, ×, ∧ or ↺, or write "
137
+ "sequence, xor, and, loop)")
138
+
139
+
140
+ def cut(text: str) -> tuple[str, tuple[frozenset, ...]]:
141
+ """A cut as typed: ``→ {a} {b, c, e} {d}``, ``sequence: {a}, {b,c,e}, {d}`` or
142
+ ``→({a}, {b, c, e}, {d})``: the operator and its groups, in order."""
143
+ text = text.strip().replace("\\{", "{").replace("\\}", "}").replace("$", "")
144
+ match = re.match(r"^\s*([^\s{(:]+)\s*[:(]?\s*(.*?)\)?\s*$", text, re.S)
145
+ if not match:
146
+ raise AnswerSyntaxError("write the operator first, then the groups in braces")
147
+ operator = _operator(match.group(1))
148
+ groups = re.findall(r"\{[^{}]*\}", match.group(2))
149
+ if not groups:
150
+ raise AnswerSyntaxError("write each group of the cut in braces, like {b, c}")
151
+ parsed = tuple(frozenset(answers.name(n) for n in _strip_brackets(g).split(",") if n.strip())
152
+ for g in groups)
153
+ if any(not g for g in parsed):
154
+ raise AnswerSyntaxError("a group of the cut is empty")
155
+ return operator, parsed
156
+
157
+
158
+ def show_cut(value) -> str:
159
+ operator, groups = value
160
+ return operator + " " + " ".join("{" + ", ".join(sorted(g)) + "}" for g in groups)
161
+
162
+
163
+ @dataclass
164
+ class Tree:
165
+ """A process tree as typed (compared with the engine's, order of the
166
+ children of × and ∧ ignored)."""
167
+ operator: str | None = None
168
+ children: list["Tree"] = field(default_factory=list)
169
+ label: str | None = None
170
+
171
+ def key(self):
172
+ if self.operator is None:
173
+ return ("leaf", self.label)
174
+ inner = [c.key() for c in self.children]
175
+ if self.operator in ("×", "∧"):
176
+ inner = sorted(inner, key=repr)
177
+ return (self.operator, tuple(inner))
178
+
179
+ def __str__(self) -> str:
180
+ if self.operator is None:
181
+ return "τ" if self.label is None else self.label
182
+ return f"{self.operator}(" + ", ".join(map(str, self.children)) + ")"
183
+
184
+
185
+ _TAU = {"τ", "tau", "t", "silent", "skip"}
186
+
187
+
188
+ def tree(text: str) -> Tree:
189
+ """``→(a, ×(∧(b, c), e), d)``; also ``seq(a, xor(and(b, c), e), d)``."""
190
+ text = text.strip().replace("$", "").replace("\\tau", "τ")
191
+ tokens = [t for t in re.findall(r"[(),]|[^(),\s][^(),]*", text)]
192
+ tokens = [t.strip() for t in tokens if t.strip()]
193
+ position = 0
194
+
195
+ def parse() -> Tree:
196
+ nonlocal position
197
+ if position >= len(tokens):
198
+ raise AnswerSyntaxError("the tree ends too soon")
199
+ token = tokens[position]
200
+ position += 1
201
+ if position < len(tokens) and tokens[position] == "(":
202
+ operator = _operator(token)
203
+ position += 1
204
+ children = []
205
+ while True:
206
+ children.append(parse())
207
+ if position >= len(tokens):
208
+ raise AnswerSyntaxError("a “)” is missing")
209
+ if tokens[position] == ",":
210
+ position += 1
211
+ continue
212
+ if tokens[position] == ")":
213
+ position += 1
214
+ break
215
+ raise AnswerSyntaxError("a comma is missing between two children")
216
+ return Tree(operator, children)
217
+ if token in "(),":
218
+ raise AnswerSyntaxError(f"unexpected “{token}”")
219
+ name = answers.name(token)
220
+ return Tree(label=None if name in _TAU else name)
221
+
222
+ result = parse()
223
+ if position < len(tokens):
224
+ raise AnswerSyntaxError(f"unexpected “{tokens[position]}” after the tree")
225
+ return result
226
+
227
+
228
+ def tree_from_engine(node) -> Tree:
229
+ """A :class:`openprocess.mining.processtree.ProcessTree` as a :class:`Tree`."""
230
+ if node.is_leaf:
231
+ return Tree(label=None if node.label is None else answers.name(node.label))
232
+ return Tree(node.operator.value, [tree_from_engine(c) for c in node.children])
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Logs
237
+ # ---------------------------------------------------------------------------
238
+ def log(text: str) -> Counter:
239
+ """A log in the course's notation, names normalised: ``[<a,b>^2, <a,c>]``."""
240
+ from ..mining.log import parse_simple_log
241
+ try:
242
+ parsed = parse_simple_log(text)
243
+ except ValueError as error:
244
+ raise AnswerSyntaxError(str(error)) from None
245
+ return Counter({tuple(answers.name(a) for a in trace): n for trace, n in parsed.items() if n})
246
+
247
+
248
+ def show_log(counts) -> str:
249
+ from ..mining.log import format_simple_log
250
+ return format_simple_log(Counter(counts))
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # Matrices
255
+ # ---------------------------------------------------------------------------
256
+ @dataclass
257
+ class Matrix:
258
+ """Rows and columns by name, with a value in every cell."""
259
+ rows: list[str]
260
+ columns: list[str]
261
+ cells: dict[tuple[str, str], float]
262
+
263
+ def get(self, row: str, column: str):
264
+ return self.cells.get((answers.name(row), answers.name(column)))
265
+
266
+ def normalised(self) -> "Matrix":
267
+ return Matrix([answers.name(r) for r in self.rows], [answers.name(c) for c in self.columns],
268
+ {(answers.name(r), answers.name(c)): v for (r, c), v in self.cells.items()})
269
+
270
+
271
+ def _number(token: str) -> float:
272
+ token = token.strip().replace("−", "-").replace("–", "-")
273
+ if token in ("", ".", "·"):
274
+ return 0.0
275
+ return answers.number(token)
276
+
277
+
278
+ def matrix(text: str) -> Matrix:
279
+ """A matrix typed as rows of numbers with headers::
280
+
281
+ . a b c
282
+ p1 -1 1 0
283
+ p2 0 -1 1
284
+
285
+ The first line names the columns (its first cell may be empty or a dot);
286
+ each other line starts with its row's name. Cells are separated by
287
+ spaces, tabs, commas or ``|`` (a Markdown table works too)."""
288
+ lines = [ln.strip().strip("|") for ln in text.strip().splitlines()]
289
+ lines = [ln for ln in lines if ln.strip() and not re.fullmatch(r"[\s|:\-]+", ln)]
290
+ if len(lines) < 2:
291
+ raise AnswerSyntaxError("write the column names on the first line and one row per line")
292
+
293
+ def split(line: str) -> list[str]:
294
+ if "|" in line:
295
+ return [c.strip() for c in line.split("|")]
296
+ if "\t" in line:
297
+ return [c.strip() for c in line.split("\t")]
298
+ if "," in line:
299
+ return [c.strip() for c in line.split(",")]
300
+ return line.split()
301
+
302
+ header = split(lines[0])
303
+ if header and header[0] in ("", ".", "·", "-", "x", "\\"):
304
+ header = header[1:]
305
+ columns = [answers.name(c) for c in header]
306
+ rows, cells = [], {}
307
+ for line in lines[1:]:
308
+ parts = split(line)
309
+ if len(parts) == len(columns) + 1:
310
+ name, values = answers.name(parts[0]), parts[1:]
311
+ elif len(parts) == len(columns):
312
+ name, values = f"row {len(rows) + 1}", parts
313
+ else:
314
+ raise AnswerSyntaxError(f"the row “{line}” has {len(parts) - 1} values for "
315
+ f"{len(columns)} columns")
316
+ rows.append(name)
317
+ for column, value in zip(columns, values):
318
+ try:
319
+ cells[(name, column)] = _number(value)
320
+ except (ValueError, ZeroDivisionError):
321
+ raise AnswerSyntaxError(f"“{value}” in row {name} is not a number") from None
322
+ return Matrix(rows, columns, cells)
323
+
324
+
325
+ def show_matrix(value: Matrix) -> str:
326
+ rows, columns = value.rows, value.columns
327
+ width = max([len(str(c)) for c in columns] + [len(str(r)) for r in rows] + [3])
328
+
329
+ def fmt(x) -> str:
330
+ return f"{x:g}" if isinstance(x, float) else str(x)
331
+ lines = [" " * (width + 1) + " ".join(str(c).rjust(width) for c in columns)]
332
+ for row in rows:
333
+ lines.append(str(row).ljust(width) + " " +
334
+ " ".join(fmt(value.cells.get((row, c), 0)).rjust(width) for c in columns))
335
+ return "\n".join(lines)
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Alignments and replay tables
340
+ # ---------------------------------------------------------------------------
341
+ def alignment(text: str) -> tuple[list[str], list[str]]:
342
+ """Two rows of moves: the log row over the model row. Cells are
343
+ separated by spaces, ``|`` or commas; ``≫`` (also ``>>`` or ``-``) is no
344
+ move. Returns ``(log moves, model moves)`` with names normalised and
345
+ ``≫`` for no move."""
346
+ text = text.replace("$", "").replace("\\gg", "≫")
347
+ lines = [ln for ln in text.strip().splitlines() if ln.strip()
348
+ and not re.fullmatch(r"[\s|:\-]+", ln)]
349
+ if len(lines) != 2:
350
+ raise AnswerSyntaxError("write the alignment as two lines: the log moves, then the "
351
+ "model moves")
352
+
353
+ def cells(line: str) -> list[str]:
354
+ line = line.strip().strip("|")
355
+ parts = [c.strip() for c in re.split(r"\s*\|\s*|,", line)] if ("|" in line or "," in line) \
356
+ else line.split()
357
+ parts = [p for p in parts if p != ""]
358
+ if parts and answers.name(parts[0]) in ("log", "model", "trace", "net", "l", "m"):
359
+ parts = parts[1:]
360
+ return [SKIP if p.casefold() in SKIP_WORDS else answers.name(p) for p in parts]
361
+ top, bottom = cells(lines[0]), cells(lines[1])
362
+ if len(top) != len(bottom):
363
+ raise AnswerSyntaxError(f"the rows have different lengths ({len(top)} and {len(bottom)} "
364
+ "moves): write ≫ where there is no move")
365
+ if any(a == SKIP and b == SKIP for a, b in zip(top, bottom)):
366
+ raise AnswerSyntaxError("a column with ≫ in both rows is not a move")
367
+ return top, bottom
368
+
369
+
370
+ def show_alignment(moves) -> str:
371
+ """Two aligned rows from ``(log, model)`` pairs."""
372
+ pairs = list(moves)
373
+ width = [max(len(str(a)), len(str(b)), 1) for a, b in pairs]
374
+ top = " ".join(str(a).ljust(w) for (a, _), w in zip(pairs, width))
375
+ bottom = " ".join(str(b).ljust(w) for (_, b), w in zip(pairs, width))
376
+ return top.rstrip() + "\n" + bottom.rstrip()
377
+
378
+
379
+ REPLAY_COLUMNS = ("p", "c", "m", "r")
380
+
381
+
382
+ def replay_table(value) -> dict[str, dict[str, float]]:
383
+ """A replay table as the editor stores it: ``{trace text: {p, c, m, r}}``
384
+ with the trace keys normalised. A string is read as lines of
385
+ ``<a, b, c>: p c m r`` (or ``<a, b, c> | p | c | m | r``)."""
386
+ if isinstance(value, dict):
387
+ result = {}
388
+ for key, row in value.items():
389
+ if not isinstance(row, dict):
390
+ continue
391
+ trace = tuple(answers.trace(str(key)))
392
+ result[trace] = {}
393
+ for column in REPLAY_COLUMNS:
394
+ cell = row.get(column, "")
395
+ if cell not in ("", None):
396
+ try:
397
+ result[trace][column] = answers.number(str(cell))
398
+ except (ValueError, ZeroDivisionError):
399
+ raise AnswerSyntaxError(f"“{cell}” is not a number") from None
400
+ return result
401
+ result = {}
402
+ for line in str(value).strip().splitlines():
403
+ if not line.strip():
404
+ continue
405
+ match = re.match(r"^\s*([<⟨].*?[>⟩])\s*[:|]?\s*(.*)$", line)
406
+ if not match:
407
+ raise AnswerSyntaxError(f"start each line with the trace in ⟨ ⟩: “{line}”")
408
+ numbers = [n for n in re.split(r"[\s|,]+", match.group(2).strip()) if n]
409
+ trace = tuple(answers.trace(match.group(1)))
410
+ result[trace] = {}
411
+ for column, cell in zip(REPLAY_COLUMNS, numbers):
412
+ result[trace][column] = answers.number(cell)
413
+ return result
414
+
415
+
416
+ # ---------------------------------------------------------------------------
417
+ # Rankings
418
+ # ---------------------------------------------------------------------------
419
+ def ranking(value) -> list[str]:
420
+ """An order of names, best first: a list, ``m2 > m1 > m3``, ``m2, m1, m3``
421
+ or one name per line (``1. m2``)."""
422
+ if isinstance(value, list):
423
+ names = [str(v) for v in value]
424
+ else:
425
+ text = str(value).strip()
426
+ if ">" in text:
427
+ names = text.split(">")
428
+ elif "\n" in text:
429
+ names = text.splitlines()
430
+ else:
431
+ names = text.split(",")
432
+ names = [re.sub(r"^\s*\d+[.)]\s*", "", n) for n in names]
433
+ names = [answers.name(n.replace(".pnml", "")) for n in names if n.strip()]
434
+ if len(set(names)) != len(names):
435
+ raise AnswerSyntaxError("a name appears twice in the ranking")
436
+ return names
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # The tuple (P, T, F, m0)
441
+ # ---------------------------------------------------------------------------
442
+ TUPLE_FIELDS = ("P", "T", "F", "m0")
443
+
444
+
445
+ def net_tuple(value) -> dict:
446
+ """The four parts of ``(P, T, F, m0)`` as the editor stores them
447
+ (``{"P": "...", "T": "...", "F": "...", "m0": "..."}``), read: ``P`` and
448
+ ``T`` as sets of names, ``F`` as a set of pairs, ``m0`` as a marking."""
449
+ if isinstance(value, str):
450
+ value = tuple_text(value)
451
+ parts = {k: (value.get(k) or "") for k in TUPLE_FIELDS}
452
+ read = {}
453
+ read["P"] = answers.as_set(parts["P"]) if parts["P"].strip() else frozenset()
454
+ read["T"] = answers.as_set(parts["T"]) if parts["T"].strip() else frozenset()
455
+ arcs = answers.as_set(parts["F"]) if parts["F"].strip() else frozenset()
456
+ pairs = set()
457
+ for item in arcs:
458
+ if isinstance(item, tuple) and len(item) == 2 and all(isinstance(x, str) for x in item):
459
+ pairs.add(item)
460
+ else:
461
+ raise AnswerSyntaxError("write F as pairs, like (p1, a), (a, p2)")
462
+ read["F"] = frozenset(pairs)
463
+ read["m0"] = marking_key(marking(parts["m0"])) if parts["m0"].strip() else frozenset()
464
+ return read
465
+
466
+
467
+ def tuple_text(text: str) -> dict:
468
+ """``P = {p1, p2}; T = {a, b}; F = {(p1, a), …}; m0 = [p1]`` on one or more lines."""
469
+ found = {}
470
+ for key in TUPLE_FIELDS:
471
+ pattern = rf"(?:^|[;\n])\s*{re.escape(key)}\s*[=:]\s*(.*?)(?=(?:[;\n]\s*(?:P|T|F|m0)\s*[=:])|$)"
472
+ match = re.search(pattern, text.replace("m_0", "m0").replace("M0", "m0"), re.S | re.I)
473
+ found[key] = match.group(1).strip() if match else ""
474
+ return found