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,754 @@
1
+ """The CPN ML evaluator: turns an AST plus an environment into a value.
2
+
3
+ Scope and lookup
4
+ ----------------
5
+ Environments form a chain (:class:`Environment`), so a ``let`` or a function
6
+ call pushes a small frame rather than copying the whole global table. Lookup
7
+ walks the chain outwards. The outermost frame holds the standard basis from
8
+ :mod:`openprocess.ml.builtins` plus everything the model declares.
9
+
10
+ Functions
11
+ ---------
12
+ User functions are :class:`Closure` objects capturing the environment in which
13
+ they were declared. ``fun`` is made recursive by binding the function's own
14
+ name inside the closure's environment *before* the body ever runs, which is the
15
+ standard trick and avoids a separate ``letrec`` construct.
16
+
17
+ Identifier resolution
18
+ ---------------------
19
+ CPN ML has no syntactic marker distinguishing a variable from a nullary
20
+ constructor: ``red`` could be either. We resolve at lookup time -- an
21
+ identifier bound in the environment is whatever it is bound to, and an unbound
22
+ capitalised identifier is treated as a nullary constructor. This matches how
23
+ declarations work in practice, because enumeration constants are injected into
24
+ the global environment when a colour set is compiled.
25
+
26
+ Errors
27
+ ------
28
+ Anything the modeller can get wrong raises a subclass of
29
+ :class:`~openprocess.ml.errors.CPNMLError` carrying the source position, so the editor
30
+ can point at the failing token. Pattern-match failure raises
31
+ :class:`~openprocess.ml.errors.MatchError` specifically, because the binder catches
32
+ that one and treats it as "this token does not fit", not as an error.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import random
38
+ from typing import Any, Iterable, Mapping, Sequence
39
+
40
+ from .ast_nodes import (
41
+ App, BinOp, CaseExpr, Coefficient, Decl, Delay, EmptyMultiset, Expr,
42
+ FnExpr, FunDecl, IfExpr, LetExpr, ListExpr, Literal, PAs, PCons,
43
+ PConstructor, PList, PLiteral, PRecord, PTuple, PVar, PWildcard, Pattern,
44
+ RecordExpr, Selector, TupleExpr, UnOp, ValDecl, Var,
45
+ )
46
+ import sys
47
+
48
+ from .builtins import Builtin, PartialApplication, make_builtins
49
+
50
+ # The evaluator is recursive, and ML code recurses over lists: a function such
51
+ # as ``insertB2F`` walking a 120-passenger queue nests a few thousand Python
52
+ # frames. Python's default limit (1000) made that fail with "maximum
53
+ # recursion depth exceeded", so raise it to a level real models need.
54
+ if sys.getrecursionlimit() < 20_000:
55
+ sys.setrecursionlimit(20_000)
56
+ from .errors import EvalError, MatchError
57
+ from .multiset import Multiset, TimedTokens
58
+ from .values import UNIT, Constructor, MLList, Record, Unit, format_value, sort_key
59
+
60
+
61
+ class Environment:
62
+ """A lexical scope: a small dict plus a pointer to the enclosing scope."""
63
+
64
+ __slots__ = ("bindings", "parent")
65
+
66
+ def __init__(self, bindings: Mapping[str, Any] | None = None,
67
+ parent: "Environment | None" = None) -> None:
68
+ self.bindings: dict[str, Any] = dict(bindings or {})
69
+ self.parent = parent
70
+
71
+ def lookup(self, name: str) -> Any:
72
+ scope: Environment | None = self
73
+ while scope is not None:
74
+ if name in scope.bindings:
75
+ return scope.bindings[name]
76
+ scope = scope.parent
77
+ raise KeyError(name)
78
+
79
+ def contains(self, name: str) -> bool:
80
+ scope: Environment | None = self
81
+ while scope is not None:
82
+ if name in scope.bindings:
83
+ return True
84
+ scope = scope.parent
85
+ return False
86
+
87
+ def define(self, name: str, value: Any) -> None:
88
+ self.bindings[name] = value
89
+
90
+ def child(self, bindings: Mapping[str, Any] | None = None) -> "Environment":
91
+ return Environment(bindings, self)
92
+
93
+
94
+ class Closure:
95
+ """A user-defined function value.
96
+
97
+ ``rules`` is the list of ``(pattern, body)`` alternatives. A curried
98
+ ``fun f x y = ...`` is desugared by the evaluator into nested closures, so
99
+ every closure here is unary, matching ML's actual semantics.
100
+ """
101
+
102
+ __slots__ = ("rules", "environment", "name")
103
+
104
+ def __init__(self, rules: Sequence[tuple[Pattern, Expr]],
105
+ environment: Environment, name: str | None = None) -> None:
106
+ self.rules = tuple(rules)
107
+ self.environment = environment
108
+ self.name = name
109
+
110
+ def __repr__(self) -> str:
111
+ return f"fn {self.name}" if self.name else "fn"
112
+
113
+
114
+ class CurriedFunction:
115
+ """A multi-argument ``fun`` with several clauses, e.g.
116
+
117
+ ``fun insert x [] = [x] | insert x (h::t) = ...``
118
+
119
+ ML matches the *whole argument sequence* against each clause in turn, so
120
+ the clause cannot be chosen from the first argument alone -- both clauses
121
+ above accept any ``x``. This value therefore collects arguments one at a
122
+ time (it is still curried: ``insert x`` is a valid partial application),
123
+ and only when all of them have arrived does it try the clauses.
124
+ """
125
+
126
+ __slots__ = ("clauses", "environment", "name", "arity", "arguments")
127
+
128
+ def __init__(self, clauses, environment: "Environment", name: str | None,
129
+ arity: int, arguments: tuple = ()) -> None:
130
+ self.clauses = clauses
131
+ self.environment = environment
132
+ self.name = name
133
+ self.arity = arity
134
+ self.arguments = arguments
135
+
136
+ def __repr__(self) -> str:
137
+ return f"fn {self.name}" if self.name else "fn"
138
+
139
+
140
+ class ConstructorFunction:
141
+ """The value of a union constructor that expects an argument.
142
+
143
+ ``colset U = union Car:CARS + ...`` makes ``Car`` a function from a ``CARS``
144
+ value to a ``U`` value. Applying it produces a
145
+ :class:`~openprocess.ml.values.Constructor`.
146
+ """
147
+
148
+ __slots__ = ("name",)
149
+
150
+ def __init__(self, name: str) -> None:
151
+ self.name = name
152
+
153
+ def __repr__(self) -> str:
154
+ return f"fn {self.name}"
155
+
156
+
157
+ class Evaluator:
158
+ """Evaluates CPN ML expressions.
159
+
160
+ One evaluator instance serves a whole model. It owns the global
161
+ environment (standard basis + model declarations) and the random number
162
+ generator, so seeding it makes a simulation reproducible.
163
+ """
164
+
165
+ def __init__(self, seed: int | None = None) -> None:
166
+ self.rng = random.Random(seed)
167
+ # ``make_builtins`` needs to call back into ``apply`` for higher-order
168
+ # functions like List.map, hence passing the bound method.
169
+ self.globals = Environment(make_builtins(self.rng, self.apply))
170
+ # Set by the simulator so that inscriptions can call ``intTime()``.
171
+ self._model_time: int = 0
172
+ self.globals.define("intTime", Builtin("intTime", lambda _v: self._model_time))
173
+ self.globals.define("time", Builtin("time", lambda _v: self._model_time))
174
+ # The option type: `SOME x` / `NONE` (what Int.fromString returns).
175
+ self.globals.define("SOME", ConstructorFunction("SOME"))
176
+ self.globals.define("NONE", Constructor("NONE"))
177
+
178
+ # -- integration points --------------------------------------------------
179
+ def set_model_time(self, value: int) -> None:
180
+ """Tell inscriptions what the simulator's clock currently reads."""
181
+ self._model_time = value
182
+
183
+ # =======================================================================
184
+ # Expression evaluation
185
+ # =======================================================================
186
+ def evaluate(self, expression: Expr, environment: Environment | None = None) -> Any:
187
+ """Evaluate ``expression`` in ``environment`` (globals if omitted)."""
188
+ env = environment if environment is not None else self.globals
189
+
190
+ if isinstance(expression, Literal):
191
+ return expression.value
192
+
193
+ if isinstance(expression, Var):
194
+ return self._lookup_identifier(expression, env)
195
+
196
+ if isinstance(expression, TupleExpr):
197
+ return tuple(self.evaluate(item, env) for item in expression.items)
198
+
199
+ if isinstance(expression, ListExpr):
200
+ return MLList(self.evaluate(item, env) for item in expression.items)
201
+
202
+ if isinstance(expression, RecordExpr):
203
+ return Record(tuple((name, self.evaluate(value, env)) for name, value in expression.fields))
204
+
205
+ if isinstance(expression, Selector):
206
+ return Builtin(f"#{expression.field_name}", lambda v, f=expression.field_name: _project(v, f))
207
+
208
+ if isinstance(expression, EmptyMultiset):
209
+ return Multiset.empty()
210
+
211
+ if isinstance(expression, Coefficient):
212
+ return self._evaluate_coefficient(expression, env)
213
+
214
+ if isinstance(expression, Delay):
215
+ # `tokens @+ delay`: tokens that become available `delay` time
216
+ # units after the transition fires (see evaluate_timed_arc).
217
+ base = self.evaluate(expression.base, env)
218
+ delay = _expect_time(self.evaluate(expression.delay, env), expression.position)
219
+ if isinstance(base, TimedTokens):
220
+ return base.delayed(delay)
221
+ return TimedTokens([(to_multiset(base), delay, False)])
222
+
223
+ if isinstance(expression, UnOp):
224
+ return self._evaluate_unary(expression, env)
225
+
226
+ if isinstance(expression, BinOp):
227
+ return self._evaluate_binary(expression, env)
228
+
229
+ if isinstance(expression, IfExpr):
230
+ condition = self.evaluate(expression.condition, env)
231
+ if not isinstance(condition, bool):
232
+ raise EvalError(
233
+ f"'if' condition must be a boolean, got {format_value(condition)}",
234
+ expression.position,
235
+ )
236
+ branch = expression.then_branch if condition else expression.else_branch
237
+ return self.evaluate(branch, env)
238
+
239
+ if isinstance(expression, LetExpr):
240
+ local = env.child()
241
+ for declaration in expression.declarations:
242
+ self.execute_declaration(declaration, local)
243
+ return self.evaluate(expression.body, local)
244
+
245
+ if isinstance(expression, CaseExpr):
246
+ subject = self.evaluate(expression.scrutinee, env)
247
+ for pattern, body in expression.rules:
248
+ bindings: dict[str, Any] = {}
249
+ if self.match(pattern, subject, bindings, env):
250
+ return self.evaluate(body, env.child(bindings))
251
+ raise MatchError(
252
+ f"no case alternative matches {format_value(subject)}", expression.position
253
+ )
254
+
255
+ if isinstance(expression, FnExpr):
256
+ return Closure(expression.rules, env)
257
+
258
+ if isinstance(expression, App):
259
+ function = self.evaluate(expression.function, env)
260
+ argument = self.evaluate(expression.argument, env)
261
+ return self.apply(function, argument)
262
+
263
+ raise AssertionError(f"unhandled expression node {type(expression).__name__}")
264
+
265
+ def evaluate_arc(self, expression: Expr, environment: Environment,
266
+ clock: int = 0) -> tuple[Multiset, int]:
267
+ """Evaluate an arc inscription to ``(tokens, timestamp)``.
268
+
269
+ The result is coerced to a multiset, so a bare ``x`` means ``1`x``
270
+ exactly as CPN Tools reads it. The time stamp is that of the first
271
+ timed term (``x @+ d`` gives ``clock + d``), or the clock itself;
272
+ :meth:`evaluate_timed_arc` keeps the time of every term.
273
+ """
274
+ groups = self.evaluate_timed_arc(expression, environment, clock)
275
+ tokens = Multiset.empty()
276
+ for part, _stamp in groups:
277
+ tokens = tokens + part
278
+ return tokens, (groups[0][1] if groups else clock)
279
+
280
+ def evaluate_timed_arc(self, expression: Expr, environment: Environment,
281
+ clock: Any = 0) -> list[tuple[Multiset, Any]]:
282
+ """Evaluate an arc inscription to ``[(tokens, time stamp), ...]``.
283
+
284
+ ``1`x@+5 +++ 1`y@+3`` gives two groups, stamped ``clock + 5`` and
285
+ ``clock + 3``; an untimed inscription gives one group stamped
286
+ ``clock``, meaning the tokens are available immediately. Delays may
287
+ be real (``normal(5.0, 1.0)``); model time is kept as a number of
288
+ either kind, like CPN Tools' real-time setting.
289
+ """
290
+ value = self.evaluate(expression, environment)
291
+ if isinstance(value, TimedTokens):
292
+ return value.stamped(clock)
293
+ return [(to_multiset(value), clock)]
294
+
295
+ # -- identifier resolution ----------------------------------------------
296
+ def _lookup_identifier(self, node: Var, environment: Environment) -> Any:
297
+ try:
298
+ return environment.lookup(node.name)
299
+ except KeyError:
300
+ pass
301
+ # An unbound capitalised identifier is a nullary constructor. This
302
+ # keeps models loading even when a colour set was declared in a block
303
+ # we could not fully compile.
304
+ if node.name[:1].isupper():
305
+ return Constructor(node.name)
306
+ raise EvalError(f"unbound identifier '{node.name}'", node.position)
307
+
308
+ # -- operators -----------------------------------------------------------
309
+ def _evaluate_coefficient(self, node: Coefficient, environment: Environment) -> Multiset:
310
+ count = self.evaluate(node.count, environment)
311
+ if isinstance(count, bool) or not isinstance(count, int):
312
+ raise EvalError(
313
+ f"multiset coefficient must be an integer, got {format_value(count)}",
314
+ node.position,
315
+ )
316
+ if count < 0:
317
+ raise EvalError("multiset coefficient must not be negative", node.position)
318
+ value = self.evaluate(node.value, environment)
319
+ if isinstance(value, Multiset):
320
+ # `2`(1`x ++ 1`y)` scales an existing multiset.
321
+ return value * count
322
+ if isinstance(value, TimedTokens):
323
+ return value.scaled(count)
324
+ return Multiset.singleton(value, count)
325
+
326
+ def _evaluate_unary(self, node: UnOp, environment: Environment) -> Any:
327
+ operand = self.evaluate(node.operand, environment)
328
+ if node.operator == "~":
329
+ if isinstance(operand, bool) or not isinstance(operand, (int, float)):
330
+ raise EvalError(f"'~' expects a number, got {format_value(operand)}", node.position)
331
+ return -operand
332
+ if node.operator == "not":
333
+ if not isinstance(operand, bool):
334
+ raise EvalError(f"'not' expects a boolean, got {format_value(operand)}", node.position)
335
+ return not operand
336
+ raise AssertionError(f"unhandled unary operator {node.operator!r}")
337
+
338
+ def _evaluate_binary(self, node: BinOp, environment: Environment) -> Any:
339
+ operator = node.operator
340
+
341
+ # Short-circuit operators must not evaluate the right side eagerly.
342
+ if operator in ("andalso", "orelse"):
343
+ left = self.evaluate(node.left, environment)
344
+ if not isinstance(left, bool):
345
+ raise EvalError(
346
+ f"'{operator}' expects booleans, got {format_value(left)}", node.position
347
+ )
348
+ if operator == "andalso" and not left:
349
+ return False
350
+ if operator == "orelse" and left:
351
+ return True
352
+ right = self.evaluate(node.right, environment)
353
+ if not isinstance(right, bool):
354
+ raise EvalError(
355
+ f"'{operator}' expects booleans, got {format_value(right)}", node.position
356
+ )
357
+ return right
358
+
359
+ left = self.evaluate(node.left, environment)
360
+ right = self.evaluate(node.right, environment)
361
+ return self._apply_binary(operator, left, right, node.position)
362
+
363
+ def _apply_binary(self, operator: str, left: Any, right: Any, position: int) -> Any:
364
+ # -- equality and ordering ------------------------------------------
365
+ if operator == "=":
366
+ return left == right
367
+ if operator == "<>":
368
+ return left != right
369
+ if operator in ("<", ">", "<=", ">="):
370
+ if isinstance(left, Multiset) and isinstance(right, Multiset):
371
+ # On multisets these are the inclusion orders, which is what a
372
+ # guard like `m <= M(p)` means.
373
+ return {"<": left < right, ">": left > right,
374
+ "<=": left <= right, ">=": left >= right}[operator]
375
+ try:
376
+ left_key, right_key = sort_key(left), sort_key(right)
377
+ except AssertionError:
378
+ raise EvalError(f"cannot compare {format_value(left)} and {format_value(right)}", position)
379
+ return {"<": left_key < right_key, ">": left_key > right_key,
380
+ "<=": left_key <= right_key, ">=": left_key >= right_key}[operator]
381
+
382
+ # -- timed multisets --------------------------------------------------
383
+ if operator == "+++" or (operator == "++" and (isinstance(left, TimedTokens)
384
+ or isinstance(right, TimedTokens))):
385
+ return _as_timed(left) + _as_timed(right)
386
+ if operator == "---":
387
+ # Removing timed tokens: like `--`, regardless of their times.
388
+ return to_multiset(left) - to_multiset(right)
389
+
390
+ # -- multiset algebra ------------------------------------------------
391
+ if operator in ("++", "--"):
392
+ # In CPN ML a multiset *is* a list ('a ms = 'a list), so `++` on
393
+ # two lists is list concatenation and the result is one list
394
+ # value: `lp ++ [p]` appends p to the queue lp.
395
+ if operator == "++" and isinstance(left, MLList) and isinstance(right, MLList):
396
+ return left.append(right)
397
+ # Otherwise coerce bare values to singletons: writing `x ++ y` on
398
+ # an arc is common shorthand for `1`x ++ 1`y`.
399
+ left_ms, right_ms = to_multiset(left), to_multiset(right)
400
+ return left_ms + right_ms if operator == "++" else left_ms - right_ms
401
+
402
+ # -- lists -----------------------------------------------------------
403
+ if operator == "::":
404
+ if not isinstance(right, MLList):
405
+ raise EvalError(f"'::' expects a list on the right, got {format_value(right)}", position)
406
+ return right.cons(left)
407
+ if operator == "@" and not isinstance(right, bool) and isinstance(right, (int, float)):
408
+ # `1`x@5`: tokens with the time stamp 5 (timed initial markings).
409
+ if isinstance(left, TimedTokens):
410
+ return TimedTokens((tokens, right, True) for tokens, _t, _a in left.groups)
411
+ return TimedTokens([(to_multiset(left), right, True)])
412
+ if operator in ("@", "^^"):
413
+ if isinstance(left, MLList) and isinstance(right, MLList):
414
+ return left.append(right)
415
+ if isinstance(left, str) and isinstance(right, str):
416
+ # CPN Tools also uses ^^ for string concatenation in places.
417
+ return left + right
418
+ hint = (" (join timed tokens with +++, e.g. 1`x@0 +++ 1`y@5)"
419
+ if operator == "@" and isinstance(right, Multiset) else "")
420
+ raise EvalError(f"'{operator}' expects two lists, or a time stamp after '@'{hint}",
421
+ position)
422
+
423
+ # -- strings ----------------------------------------------------------
424
+ if operator == "^":
425
+ if isinstance(left, str) and isinstance(right, str):
426
+ return left + right
427
+ raise EvalError(f"'^' expects two strings, got {format_value(left)} and {format_value(right)}", position)
428
+
429
+ # -- arithmetic --------------------------------------------------------
430
+ if operator in ("+", "-", "*", "/", "div", "mod"):
431
+ return self._arithmetic(operator, left, right, position)
432
+
433
+ raise AssertionError(f"unhandled binary operator {operator!r}")
434
+
435
+ @staticmethod
436
+ def _arithmetic(operator: str, left: Any, right: Any, position: int) -> Any:
437
+ for operand in (left, right):
438
+ if isinstance(operand, bool) or not isinstance(operand, (int, float)):
439
+ raise EvalError(
440
+ f"'{operator}' expects numbers, got {format_value(left)} and {format_value(right)}",
441
+ position,
442
+ )
443
+ if operator == "+":
444
+ return left + right
445
+ if operator == "-":
446
+ return left - right
447
+ if operator == "*":
448
+ return left * right
449
+ if operator == "/":
450
+ if float(right) == 0.0:
451
+ raise EvalError("division by zero", position)
452
+ return float(left) / float(right)
453
+ if right == 0:
454
+ raise EvalError(f"'{operator}' by zero", position)
455
+ if operator == "div":
456
+ # ML's `div` truncates towards negative infinity, like Python's //.
457
+ return left // right
458
+ return left % right
459
+
460
+ # -- application ---------------------------------------------------------
461
+ def apply(self, function: Any, argument: Any) -> Any:
462
+ """Apply a function value to one argument."""
463
+ if isinstance(function, Closure):
464
+ for pattern, body in function.rules:
465
+ bindings: dict[str, Any] = {}
466
+ if self.match(pattern, argument, bindings, function.environment):
467
+ return self.evaluate(body, function.environment.child(bindings))
468
+ raise MatchError(
469
+ f"no clause of {function.name or 'this function'} matches "
470
+ f"{format_value(argument)}"
471
+ )
472
+
473
+ if isinstance(function, CurriedFunction):
474
+ arguments = function.arguments + (argument,)
475
+ if len(arguments) < function.arity:
476
+ return CurriedFunction(function.clauses, function.environment, function.name,
477
+ function.arity, arguments)
478
+ for parameters, body in function.clauses:
479
+ bindings: dict[str, Any] = {}
480
+ if all(self.match(pattern, value, bindings, function.environment)
481
+ for pattern, value in zip(parameters, arguments)):
482
+ return self.evaluate(body, function.environment.child(bindings))
483
+ shown = " ".join(format_value(a) for a in arguments)
484
+ raise MatchError(f"no clause of {function.name or 'this function'} matches {shown}")
485
+
486
+ if isinstance(function, ConstructorFunction):
487
+ return Constructor(function.name, argument)
488
+
489
+ if isinstance(function, Builtin):
490
+ if function.arity == 1:
491
+ return function.function(argument)
492
+ return self.apply(PartialApplication(function, ()), argument)
493
+
494
+ if isinstance(function, PartialApplication):
495
+ arguments = function.arguments + (argument,)
496
+ if len(arguments) == function.builtin.arity:
497
+ return function.builtin.function(*arguments)
498
+ return PartialApplication(function.builtin, arguments)
499
+
500
+ raise EvalError(f"{format_value(function) if not callable(function) else function} is not a function")
501
+
502
+ # =======================================================================
503
+ # Pattern matching
504
+ # =======================================================================
505
+ def match(self, pattern: Pattern, value: Any, bindings: dict[str, Any],
506
+ environment: Environment) -> bool:
507
+ """Try to match ``value`` against ``pattern``.
508
+
509
+ On success, fills ``bindings`` with the variables the pattern binds and
510
+ returns ``True``. On failure returns ``False`` -- ``bindings`` may have
511
+ been partially written, so callers must not reuse a failed dictionary.
512
+
513
+ A variable that is *already* in ``bindings`` acts as an equality test
514
+ rather than a fresh binding. This is what makes a non-linear arc
515
+ inscription such as ``(x, x)`` behave correctly, and it is also how the
516
+ binder enforces consistency of one variable across several input arcs.
517
+ """
518
+ if isinstance(pattern, PWildcard):
519
+ return True
520
+
521
+ if isinstance(pattern, PVar):
522
+ existing = bindings.get(pattern.name, _MISSING)
523
+ if existing is _MISSING:
524
+ bindings[pattern.name] = value
525
+ return True
526
+ return bool(existing == value)
527
+
528
+ if isinstance(pattern, PLiteral):
529
+ return bool(pattern.value == value)
530
+
531
+ if isinstance(pattern, PAs):
532
+ if not self.match(pattern.pattern, value, bindings, environment):
533
+ return False
534
+ existing = bindings.get(pattern.name, _MISSING)
535
+ if existing is _MISSING:
536
+ bindings[pattern.name] = value
537
+ return True
538
+ return bool(existing == value)
539
+
540
+ if isinstance(pattern, PTuple):
541
+ if not isinstance(value, tuple) or len(value) != len(pattern.items):
542
+ return False
543
+ return all(
544
+ self.match(sub, item, bindings, environment)
545
+ for sub, item in zip(pattern.items, value)
546
+ )
547
+
548
+ if isinstance(pattern, PList):
549
+ if not isinstance(value, MLList) or len(value) != len(pattern.items):
550
+ return False
551
+ return all(
552
+ self.match(sub, item, bindings, environment)
553
+ for sub, item in zip(pattern.items, value)
554
+ )
555
+
556
+ if isinstance(pattern, PCons):
557
+ if not isinstance(value, MLList) or len(value) == 0:
558
+ return False
559
+ if not self.match(pattern.head, value[0], bindings, environment):
560
+ return False
561
+ return self.match(pattern.tail, MLList(value.items[1:]), bindings, environment)
562
+
563
+ if isinstance(pattern, PRecord):
564
+ if not isinstance(value, Record):
565
+ return False
566
+ if not pattern.open_ended and len(pattern.fields) != len(value.fields):
567
+ return False
568
+ for name, sub in pattern.fields:
569
+ try:
570
+ field_value = value.get(name)
571
+ except KeyError:
572
+ return False
573
+ if not self.match(sub, field_value, bindings, environment):
574
+ return False
575
+ return True
576
+
577
+ if isinstance(pattern, PConstructor):
578
+ return self._match_constructor(pattern, value, bindings, environment)
579
+
580
+ raise AssertionError(f"unhandled pattern node {type(pattern).__name__}")
581
+
582
+ def _match_constructor(self, pattern: PConstructor, value: Any,
583
+ bindings: dict[str, Any], environment: Environment) -> bool:
584
+ """Match ``Tag`` or ``Tag p``.
585
+
586
+ A bare capitalised identifier is genuinely ambiguous in CPN ML: it can
587
+ be a nullary constructor *or* a variable that the modeller chose to
588
+ capitalise. We resolve it the only way that is always safe: if the
589
+ name is bound in the environment to a constructor, treat it as a
590
+ constant; otherwise treat it as a variable binding.
591
+ """
592
+ if pattern.argument is None:
593
+ if environment.contains(pattern.name):
594
+ bound = environment.lookup(pattern.name)
595
+ if isinstance(bound, Constructor):
596
+ return bool(bound == value)
597
+ if isinstance(value, Constructor) and not value.has_argument \
598
+ and value.name == pattern.name:
599
+ return True
600
+ # Fall back to variable semantics.
601
+ existing = bindings.get(pattern.name, _MISSING)
602
+ if existing is _MISSING:
603
+ bindings[pattern.name] = value
604
+ return True
605
+ return bool(existing == value)
606
+
607
+ if not isinstance(value, Constructor) or value.name != pattern.name:
608
+ return False
609
+ if not value.has_argument:
610
+ return False
611
+ return self.match(pattern.argument, value.argument, bindings, environment)
612
+
613
+ # =======================================================================
614
+ # Declarations
615
+ # =======================================================================
616
+ def execute_declaration(self, declaration: Decl, environment: Environment) -> None:
617
+ """Run a ``val`` or ``fun`` declaration, updating ``environment``."""
618
+ if isinstance(declaration, ValDecl):
619
+ value = self.evaluate(declaration.expression, environment)
620
+ bindings: dict[str, Any] = {}
621
+ if not self.match(declaration.pattern, value, bindings, environment):
622
+ raise MatchError(
623
+ f"value {format_value(value)} does not match the pattern in this "
624
+ "'val' declaration",
625
+ declaration.position,
626
+ )
627
+ environment.bindings.update(bindings)
628
+ return
629
+
630
+ if isinstance(declaration, FunDecl):
631
+ self._define_function(declaration, environment)
632
+ return
633
+
634
+ raise AssertionError(f"unhandled declaration node {type(declaration).__name__}")
635
+
636
+ def _define_function(self, declaration: FunDecl, environment: Environment) -> None:
637
+ """Bind a ``fun`` declaration, desugaring currying and enabling recursion.
638
+
639
+ ``fun f x y = body`` becomes a closure over ``x`` whose body is another
640
+ closure over ``y``. We build the outer closure first, bind the name,
641
+ then let the closure's captured environment see that binding -- which
642
+ is what makes ``f`` visible inside its own body.
643
+ """
644
+ arity = len(declaration.clauses[0][0])
645
+ if any(len(parameters) != arity for parameters, _ in declaration.clauses):
646
+ raise EvalError(
647
+ f"all clauses of '{declaration.name}' must take the same number of arguments",
648
+ declaration.position,
649
+ )
650
+
651
+ if arity == 1:
652
+ rules = tuple((parameters[0], body) for parameters, body in declaration.clauses)
653
+ closure = Closure(rules, environment, declaration.name)
654
+ elif len(declaration.clauses) == 1:
655
+ # A single clause: nested ``fn``s are exact and cheapest.
656
+ parameters, body = declaration.clauses[0]
657
+ closure = Closure(((parameters[0], _nest_fn(parameters[1:], body)),),
658
+ environment, declaration.name)
659
+ else:
660
+ # Several clauses: match all arguments together (see CurriedFunction).
661
+ closure = CurriedFunction(tuple(declaration.clauses), environment,
662
+ declaration.name, arity)
663
+
664
+ # Bind before evaluation so the closure can call itself.
665
+ environment.define(declaration.name, closure)
666
+
667
+ # =======================================================================
668
+ # Convenience
669
+ # =======================================================================
670
+ def run_declarations(self, declarations: Iterable[Decl],
671
+ environment: Environment | None = None) -> None:
672
+ env = environment if environment is not None else self.globals
673
+ for declaration in declarations:
674
+ self.execute_declaration(declaration, env)
675
+
676
+
677
+ # Sentinel for "no such binding", so that ``None`` remains a legal bound value.
678
+ _MISSING = object()
679
+
680
+
681
+ def _nest_fn(parameters: Sequence[Pattern], body: Expr) -> Expr:
682
+ """Build ``fn p1 => fn p2 => ... => body`` for a curried function."""
683
+ result = body
684
+ for pattern in reversed(parameters):
685
+ result = FnExpr(((pattern, result),), getattr(pattern, "position", 0))
686
+ return result
687
+
688
+
689
+ def _project(value: Any, field_name: str) -> Any:
690
+ """Implement ``#field`` / ``#n`` projection for records and tuples."""
691
+ if isinstance(value, Record):
692
+ try:
693
+ return value.get(field_name)
694
+ except KeyError:
695
+ raise EvalError(f"record has no field '{field_name}'")
696
+ if isinstance(value, tuple) and field_name.isdigit():
697
+ index = int(field_name) - 1 # ML tuple components are 1-based.
698
+ if 0 <= index < len(value):
699
+ return value[index]
700
+ raise EvalError(f"tuple has no component #{field_name}")
701
+ raise EvalError(f"cannot select #{field_name} from {format_value(value)}")
702
+
703
+
704
+ def expand_lists(tokens: Multiset, colour_set) -> Multiset:
705
+ """CPN Tools' list-to-multiset coercion.
706
+
707
+ When an initial marking or arc expression evaluates to a *list* but the
708
+ place's colour set is not a list colour set, CPN Tools reads the list as
709
+ a multiset: one token per element. ``PLANE_INIT`` (a list of seats) on a
710
+ ``SEAT`` place therefore means "one token per seat", not one list token.
711
+ Tokens that already belong to the colour set are left alone.
712
+ """
713
+ if colour_set is None:
714
+ return tokens
715
+ from .values import MLList
716
+ result = Multiset.empty()
717
+ changed = False
718
+ for token, count in tokens.items():
719
+ if isinstance(token, MLList) and not colour_set.contains(token):
720
+ for item in token.items:
721
+ result = result + Multiset.singleton(item, count)
722
+ changed = True
723
+ else:
724
+ result = result + Multiset.singleton(token, count)
725
+ return result if changed else tokens
726
+
727
+
728
+ def to_multiset(value: Any) -> Multiset:
729
+ """Coerce an arc-expression result to a multiset.
730
+
731
+ CPN Tools treats a bare colour expression on an arc as one token of that
732
+ colour, so ``x`` and ``1`x`` mean the same thing. We implement that
733
+ coercion here, in one place, rather than scattering it through the
734
+ simulator. Timed tokens lose their times (an input arc, for example,
735
+ takes tokens regardless of their stamps).
736
+ """
737
+ if isinstance(value, Multiset):
738
+ return value
739
+ if isinstance(value, TimedTokens):
740
+ return value.untimed()
741
+ return Multiset.singleton(value, 1)
742
+
743
+
744
+ def _as_timed(value: Any) -> TimedTokens:
745
+ """An operand of ``+++``: untimed tokens are available at once (``@+0``)."""
746
+ if isinstance(value, TimedTokens):
747
+ return value
748
+ return TimedTokens([(to_multiset(value), 0, False)])
749
+
750
+
751
+ def _expect_time(value: Any, position: int) -> Any:
752
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
753
+ raise EvalError(f"time delay must be a number, got {format_value(value)}", position)
754
+ return value