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,620 @@
1
+ """Binding search: deciding which transitions can fire, and with what values.
2
+
3
+ The problem
4
+ -----------
5
+ A transition's arc inscriptions contain free variables. ``(x, n)`` on an input
6
+ arc does not say *which* token to take -- it says "take any token whose value
7
+ fits this shape, and let ``x`` and ``n`` be its parts". Before we can fire
8
+ anything we must find every assignment of values to variables such that the
9
+ consumed tokens are actually present and the guard holds. Such an assignment
10
+ is a **binding**, and a transition plus a binding is a **binding element**.
11
+
12
+ The naive method -- enumerate the Cartesian product of every variable's colour
13
+ set and test each combination -- is correct but hopeless: three variables over
14
+ a 100-element colour set is a million candidates per transition per step.
15
+
16
+ The method used here
17
+ --------------------
18
+ **Pattern-directed search with backtracking.** We read each input arc
19
+ inscription as a *pattern* and match it against the tokens that are really in
20
+ the place, which yields the variable values directly instead of guessing them.
21
+
22
+ Concretely, an arc inscription is first decomposed into terms::
23
+
24
+ 2`(x, n) ++ 1`y -> [ (2, (x,n)), (1, y) ]
25
+
26
+ Each term becomes one *demand*: "take this many tokens of this shape from this
27
+ place". Demands are then satisfied one at a time, depth first:
28
+
29
+ 1. Pick the next unsatisfied demand.
30
+ 2. If its value expression is a pattern, try each distinct value still
31
+ available in that place, matching to extend the binding; recurse.
32
+ 3. If it is not a pattern (``1`(n+1)``, say), it can only be *evaluated*, so we
33
+ defer it until its variables are bound by other demands, then check that
34
+ enough copies are present.
35
+ 4. When every demand is satisfied, enumerate any variables that are still free
36
+ -- ones appearing only in the guard, the output arcs, or the time
37
+ expression -- over their declared colour sets.
38
+ 5. Evaluate the guard. If it holds, we have a binding element.
39
+
40
+ Backtracking happens naturally: if a choice at step 2 leads to a dead end
41
+ further down, the loop simply tries the next candidate value.
42
+
43
+ Consumption bookkeeping
44
+ -----------------------
45
+ Two demands may draw on the same place -- either two terms of one inscription,
46
+ or two arcs from the same place to the same transition. A ``remaining``
47
+ multiset per place is threaded through the search so that a token cannot be
48
+ counted twice.
49
+
50
+ Limits, stated honestly
51
+ -----------------------
52
+ Step 4 needs a *finite* colour set. A variable that appears only on an output
53
+ arc and is typed ``INT`` cannot be enumerated, and we raise a clear error
54
+ naming the variable rather than looping forever. In practice such variables
55
+ are either bound by an input arc or declared over a finite colour set, which is
56
+ also what CPN Tools requires.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import itertools
62
+ from dataclasses import dataclass, field
63
+ from typing import Any, Iterator, Sequence
64
+
65
+ from ..ml.ast_nodes import (
66
+ App, BinOp, CaseExpr, Coefficient, Delay, EmptyMultiset, Expr, FnExpr,
67
+ IfExpr, LetExpr, ListExpr, Literal, RecordExpr, Selector, TupleExpr, UnOp,
68
+ Var,
69
+ )
70
+ from ..ml.colorsets import InfiniteColourSetError
71
+ from ..ml.errors import CPNMLError, EvalError
72
+ from ..ml.evaluator import ConstructorFunction, Environment, Evaluator, to_multiset
73
+ from ..ml.multiset import Multiset, TimedMultiset
74
+ from ..ml.parser import expression_to_pattern
75
+ from ..ml.values import Constructor, format_value
76
+ from ..model.net import Arc, CPNet, Marking, Place, Transition
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Free variable collection
81
+ # ---------------------------------------------------------------------------
82
+ def free_variables(expression: Expr | None, declared: set[str]) -> set[str]:
83
+ """Names from ``declared`` that occur in ``expression``.
84
+
85
+ We intersect with the model's declared variables rather than collecting
86
+ every identifier, because an inscription also mentions function names,
87
+ constructors and constants, none of which are variables to bind. CPN
88
+ requires variables to be declared, so this is exact rather than heuristic.
89
+ """
90
+ if expression is None:
91
+ return set()
92
+
93
+ found: set[str] = set()
94
+
95
+ def visit(node: Any) -> None:
96
+ if isinstance(node, Var):
97
+ if node.name in declared:
98
+ found.add(node.name)
99
+ return
100
+ if isinstance(node, (Literal, EmptyMultiset, Selector)):
101
+ return
102
+ if isinstance(node, (TupleExpr, ListExpr)):
103
+ for item in node.items:
104
+ visit(item)
105
+ return
106
+ if isinstance(node, RecordExpr):
107
+ for _name, value in node.fields:
108
+ visit(value)
109
+ return
110
+ if isinstance(node, Coefficient):
111
+ visit(node.count)
112
+ visit(node.value)
113
+ return
114
+ if isinstance(node, Delay):
115
+ visit(node.base)
116
+ visit(node.delay)
117
+ return
118
+ if isinstance(node, App):
119
+ visit(node.function)
120
+ visit(node.argument)
121
+ return
122
+ if isinstance(node, BinOp):
123
+ visit(node.left)
124
+ visit(node.right)
125
+ return
126
+ if isinstance(node, UnOp):
127
+ visit(node.operand)
128
+ return
129
+ if isinstance(node, IfExpr):
130
+ visit(node.condition)
131
+ visit(node.then_branch)
132
+ visit(node.else_branch)
133
+ return
134
+ if isinstance(node, LetExpr):
135
+ for declaration in node.declarations:
136
+ visit(getattr(declaration, "expression", None))
137
+ for _patterns, body in getattr(declaration, "clauses", ()):
138
+ visit(body)
139
+ visit(node.body)
140
+ return
141
+ if isinstance(node, CaseExpr):
142
+ visit(node.scrutinee)
143
+ for _pattern, body in node.rules:
144
+ visit(body)
145
+ return
146
+ if isinstance(node, FnExpr):
147
+ for _pattern, body in node.rules:
148
+ visit(body)
149
+ return
150
+
151
+ visit(expression)
152
+ return found
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Decomposing an arc inscription into demands
157
+ # ---------------------------------------------------------------------------
158
+ @dataclass
159
+ class Demand:
160
+ """One "take *count* tokens shaped like *value* out of *place*" obligation."""
161
+
162
+ place_id: str
163
+ count: Expr
164
+ value: Expr
165
+ #: The pattern form of ``value``, or ``None`` if it is not a pattern.
166
+ pattern: Any = None
167
+
168
+ @property
169
+ def is_pattern(self) -> bool:
170
+ return self.pattern is not None
171
+
172
+
173
+ def split_terms(expression: Expr) -> list[tuple[Expr, Expr]] | None:
174
+ """Split ``2`a ++ 1`b`` into ``[(2, a), (1, b)]``.
175
+
176
+ Returns ``None`` when the inscription is not a plain sum of coefficient
177
+ terms -- for example when it contains ``--``, an ``if``, or a function call
178
+ producing a multiset. Such an inscription cannot be turned into demands and
179
+ is handled by the fallback path (evaluate, then check inclusion).
180
+ """
181
+ terms: list[tuple[Expr, Expr]] = []
182
+
183
+ def visit(node: Expr) -> bool:
184
+ if isinstance(node, BinOp) and node.operator == "++":
185
+ return visit(node.left) and visit(node.right)
186
+ if isinstance(node, Coefficient):
187
+ terms.append((node.count, node.value))
188
+ return True
189
+ if isinstance(node, EmptyMultiset):
190
+ return True
191
+ # A bare value is one token: `x` means `1`x`.
192
+ if isinstance(node, (Var, Literal, TupleExpr, ListExpr, RecordExpr, App)):
193
+ terms.append((Literal(1, node.position), node))
194
+ return True
195
+ # `h :: t` (take a list token apart) is one token as well.
196
+ if isinstance(node, BinOp) and node.operator == "::":
197
+ terms.append((Literal(1, node.position), node))
198
+ return True
199
+ return False
200
+
201
+ return terms if visit(expression) else None
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Binding elements
206
+ # ---------------------------------------------------------------------------
207
+ @dataclass(frozen=True)
208
+ class BindingElement:
209
+ """A transition together with a concrete assignment to its variables.
210
+
211
+ This is the unit the simulator fires and the unit a state space arc is
212
+ labelled with, so it must be hashable -- hence the tuple of sorted pairs
213
+ rather than a dict.
214
+ """
215
+
216
+ transition_id: str
217
+ assignments: tuple[tuple[str, Any], ...]
218
+
219
+ @staticmethod
220
+ def create(transition: Transition, bindings: dict[str, Any]) -> "BindingElement":
221
+ return BindingElement(
222
+ transition.id, tuple(sorted(bindings.items(), key=lambda pair: pair[0]))
223
+ )
224
+
225
+ def as_dict(self) -> dict[str, Any]:
226
+ return dict(self.assignments)
227
+
228
+ def describe(self, net: CPNet) -> str:
229
+ """``T1 <x = 3, y = "a">`` -- CPN Tools' notation for a binding element."""
230
+ transition = net.find_transition(self.transition_id)
231
+ name = transition.name if transition else self.transition_id
232
+ if not self.assignments:
233
+ return name
234
+ inner = ", ".join(f"{k} = {format_value(v)}" for k, v in self.assignments)
235
+ return f"{name} <{inner}>"
236
+
237
+ def __repr__(self) -> str:
238
+ inner = ", ".join(f"{k}={format_value(v)}" for k, v in self.assignments)
239
+ return f"<{self.transition_id} {inner}>"
240
+
241
+
242
+ class Binder:
243
+ """Computes the enabled binding elements of a transition in a marking."""
244
+
245
+ def __init__(self, net: CPNet, max_bindings_per_transition: int = 5000) -> None:
246
+ self.net = net
247
+ self.evaluator: Evaluator = net.evaluator
248
+ self.declared_variables = set(net.declarations.variables)
249
+ #: Safety valve. A model with a badly constrained variable can produce
250
+ #: an enormous number of bindings; we stop rather than hang, and the
251
+ #: simulator surfaces this as a warning.
252
+ self.max_bindings = max_bindings_per_transition
253
+ self.truncated = False
254
+ #: transition id -> (demand templates, opaque arcs); see bindings()
255
+ self._analysis: dict[str, tuple] = {}
256
+ #: cache for variables_in(): arc/guard expression id -> variable names
257
+ self._variables: dict[int, set[str]] = {}
258
+ self._keep_alive: list = []
259
+
260
+ def identifier_role(self, name: str) -> str:
261
+ """Classify an identifier for pattern conversion.
262
+
263
+ Order matters. A declared ``var`` is a variable even if capitalised.
264
+ Otherwise, an identifier bound in the global environment to a
265
+ constructor value or a constructor function is a constructor; anything
266
+ else bound there is an ML value or function, which cannot appear in a
267
+ pattern. An unbound identifier is treated as a variable, which keeps
268
+ partially-declared models usable in the editor.
269
+ """
270
+ if name in self.declared_variables:
271
+ return "variable"
272
+ if self.evaluator.globals.contains(name):
273
+ bound = self.evaluator.globals.lookup(name)
274
+ if isinstance(bound, (Constructor, ConstructorFunction)):
275
+ return "constructor"
276
+ return "other"
277
+ return "variable"
278
+
279
+ def _free(self, expression) -> set[str]:
280
+ """Cached :func:`free_variables` (expressions are immutable)."""
281
+ if expression is None:
282
+ return set()
283
+ key = id(expression)
284
+ found = self._variables.get(key)
285
+ if found is None:
286
+ found = free_variables(expression, self.declared_variables)
287
+ self._variables[key] = found
288
+ self._keep_alive.append(expression) # ids stay valid while cached
289
+ return found
290
+
291
+ def _analyse_inputs(self, input_arcs):
292
+ demands: list[Demand] = []
293
+ opaque_arcs: list[Arc] = []
294
+ for arc in input_arcs:
295
+ if arc.expression_ast is None:
296
+ continue
297
+ terms = split_terms(arc.expression_ast)
298
+ if terms is None:
299
+ opaque_arcs.append(arc)
300
+ continue
301
+ # Demands are keyed by the marking key, not the raw place id, so
302
+ # that two places in the same fusion set draw on one shared pool.
303
+ place_key = self.net.marking_key(arc.place_id)
304
+ for count_expression, value_expression in terms:
305
+ pattern = None
306
+ try:
307
+ pattern = expression_to_pattern(self.inline_constants(value_expression),
308
+ self.identifier_role)
309
+ except CPNMLError:
310
+ pattern = None
311
+ demands.append(Demand(place_key, count_expression, value_expression, pattern))
312
+ return tuple(demands), tuple(opaque_arcs)
313
+
314
+ def inline_constants(self, node: Expr) -> Expr:
315
+ """Replace named ML *values* (``val PNONE = (~1,~1,~1)``) by literals.
316
+
317
+ An input inscription such as ``(nr, false, PNONE)`` is a perfectly
318
+ good pattern -- PNONE is just a fixed value -- but a bare name in a
319
+ pattern would otherwise be taken as a function and make the whole
320
+ arc un-matchable, leaving ``nr`` to be enumerated over an infinite
321
+ colour set. Functions and constructors are left untouched.
322
+ """
323
+ from dataclasses import replace
324
+ from ..ml.builtins import Builtin, PartialApplication
325
+ from ..ml.evaluator import Closure, CurriedFunction
326
+
327
+ if isinstance(node, Var):
328
+ if node.name in self.declared_variables or not self.evaluator.globals.contains(node.name):
329
+ return node
330
+ value = self.evaluator.globals.lookup(node.name)
331
+ if isinstance(value, (Closure, CurriedFunction, Builtin, PartialApplication, Constructor,
332
+ ConstructorFunction)) or callable(value):
333
+ return node
334
+ return Literal(value, node.position)
335
+ if isinstance(node, (TupleExpr, ListExpr)):
336
+ return replace(node, items=tuple(self.inline_constants(i) for i in node.items))
337
+ if isinstance(node, BinOp) and node.operator == "::":
338
+ return replace(node, left=self.inline_constants(node.left),
339
+ right=self.inline_constants(node.right))
340
+ if isinstance(node, RecordExpr):
341
+ return replace(node, fields=tuple((n, self.inline_constants(v)) for n, v in node.fields))
342
+ if isinstance(node, App):
343
+ return replace(node, argument=self.inline_constants(node.argument))
344
+ return node
345
+
346
+ # -- available tokens ----------------------------------------------------
347
+ def available(self, place: Place, marking: Marking, clock: int) -> Multiset:
348
+ """Tokens of ``place`` that may be consumed at time ``clock``.
349
+
350
+ For an untimed place this is simply its marking. For a timed place it
351
+ is the subset whose time stamp has been reached -- tokens stamped for
352
+ the future are present but not yet usable, which is exactly what makes
353
+ a timed net advance its clock instead of deadlocking.
354
+ """
355
+ tokens = marking.get(self.net.marking_key(place.id))
356
+ if isinstance(tokens, TimedMultiset):
357
+ return tokens.available_at(clock)
358
+ return tokens
359
+
360
+ # -- the search ----------------------------------------------------------
361
+ def bindings(self, transition: Transition, marking: Marking,
362
+ clock: int = 0) -> list[BindingElement]:
363
+ """All enabled binding elements for ``transition``, in canonical order."""
364
+ page = self.net.page_of(transition)
365
+ if page is None:
366
+ return []
367
+
368
+ input_arcs = [a for a in page.arcs_of(transition) if a.is_input]
369
+
370
+ # Split the input arcs into ones we can turn into demands and ones we
371
+ # can only evaluate once the variables are known. This analysis
372
+ # depends only on the inscriptions, not on the marking, so it is done
373
+ # once per transition and cached (the Binder is rebuilt whenever the
374
+ # model is recompiled).
375
+ cached = self._analysis.get(transition.id)
376
+ if cached is None:
377
+ cached = self._analyse_inputs(input_arcs)
378
+ self._analysis[transition.id] = cached
379
+ template, opaque_arcs = cached
380
+ demands = list(template)
381
+
382
+ # Order demands so that the most constrained come first: fewer distinct
383
+ # candidate values means less backtracking. Non-pattern demands go
384
+ # last, since they need other demands to have bound their variables.
385
+ def demand_cost(demand: Demand) -> tuple[int, int]:
386
+ place = self.net.find_place(demand.place_id)
387
+ distinct = len(self.available(place, marking, clock).support()) if place else 0
388
+ return (0 if demand.is_pattern else 1, distinct)
389
+
390
+ demands.sort(key=demand_cost)
391
+
392
+ remaining: dict[str, Multiset] = {}
393
+ for demand in demands:
394
+ if demand.place_id not in remaining:
395
+ place = self.net.find_place(demand.place_id)
396
+ remaining[demand.place_id] = (
397
+ self.available(place, marking, clock) if place else Multiset.empty()
398
+ )
399
+ for arc in opaque_arcs:
400
+ key = self.net.marking_key(arc.place_id)
401
+ if key not in remaining:
402
+ place = self.net.find_place(arc.place_id)
403
+ remaining[key] = (
404
+ self.available(place, marking, clock) if place else Multiset.empty()
405
+ )
406
+
407
+ results: list[BindingElement] = []
408
+ self.truncated = False
409
+ self.evaluator.set_model_time(clock)
410
+ self._search(transition, demands, 0, {}, remaining, opaque_arcs, marking, clock, results)
411
+ return results
412
+
413
+ def _search(self, transition: Transition, demands: Sequence[Demand], index: int,
414
+ bindings: dict[str, Any], remaining: dict[str, Multiset],
415
+ opaque_arcs: Sequence[Arc], marking: Marking, clock: int,
416
+ results: list[BindingElement], stalled: int = 0) -> None:
417
+ """Depth-first satisfaction of the demand list.
418
+
419
+ ``stalled`` counts how many demands in a row have been postponed
420
+ because they could not be evaluated yet (see :meth:`_postpone`).
421
+ """
422
+ if len(results) >= self.max_bindings:
423
+ self.truncated = True
424
+ return
425
+
426
+ if index == len(demands):
427
+ self._finish(transition, bindings, remaining, opaque_arcs, marking, clock, results)
428
+ return
429
+
430
+ demand = demands[index]
431
+ pool = remaining.get(demand.place_id, Multiset.empty())
432
+
433
+ # How many tokens does this term want? The coefficient may itself
434
+ # mention variables, in which case it must already be bound.
435
+ try:
436
+ count = self._evaluate(demand.count, bindings)
437
+ except CPNMLError:
438
+ # Cannot evaluate yet -- postpone by moving this demand to the end.
439
+ self._postpone(transition, demands, index, bindings, remaining,
440
+ opaque_arcs, marking, clock, results, stalled)
441
+ return
442
+
443
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
444
+ return
445
+ if count == 0:
446
+ self._search(transition, demands, index + 1, bindings, remaining,
447
+ opaque_arcs, marking, clock, results)
448
+ return
449
+
450
+ if demand.is_pattern:
451
+ free = self._free(demand.value) - set(bindings)
452
+ if not free:
453
+ # Fully determined: evaluate it and check availability directly.
454
+ self._consume_concrete(transition, demands, index, bindings, remaining,
455
+ opaque_arcs, marking, clock, results,
456
+ demand, count)
457
+ return
458
+ # Try every distinct value with enough copies left.
459
+ for candidate, available_count in pool.items():
460
+ if available_count < count:
461
+ continue
462
+ trial = dict(bindings)
463
+ if not self.evaluator.match(demand.pattern, candidate, trial,
464
+ self.evaluator.globals):
465
+ continue
466
+ updated = dict(remaining)
467
+ updated[demand.place_id] = pool - Multiset.singleton(candidate, count)
468
+ self._search(transition, demands, index + 1, trial, updated,
469
+ opaque_arcs, marking, clock, results)
470
+ return
471
+
472
+ # Not a pattern: it can only be evaluated.
473
+ self._consume_concrete(transition, demands, index, bindings, remaining,
474
+ opaque_arcs, marking, clock, results, demand, count,
475
+ stalled)
476
+
477
+ def _postpone(self, transition: Transition, demands: Sequence[Demand], index: int,
478
+ bindings: dict[str, Any], remaining: dict[str, Multiset],
479
+ opaque_arcs: Sequence[Arc], marking: Marking, clock: int,
480
+ results: list[BindingElement], stalled: int) -> None:
481
+ """Move an unevaluable demand to the end, or unblock the search.
482
+
483
+ A demand such as ``1`(n+1)`` cannot be evaluated until ``n`` is bound.
484
+ Usually a later demand binds it, so the demand is rotated to the back.
485
+ When *every* remaining demand has been rotated without progress, no
486
+ demand will ever bind the missing variables (they occur only in these
487
+ expressions, a guard or an output arc). Then, as CPN Tools does, the
488
+ missing variables are enumerated over their colour sets and the search
489
+ continues with them bound. Rotating for ever instead used to recurse
490
+ until Python gave up.
491
+ """
492
+ if stalled + 1 < len(demands) - index:
493
+ reordered = list(demands)
494
+ reordered.append(reordered.pop(index))
495
+ self._search(transition, reordered, index, bindings, remaining,
496
+ opaque_arcs, marking, clock, results, stalled + 1)
497
+ return
498
+ missing: set[str] = set()
499
+ for demand in demands[index:]:
500
+ missing |= self._free(demand.count) | self._free(demand.value)
501
+ unbound = sorted(missing - set(bindings))
502
+ if not unbound:
503
+ return # stuck on an evaluation error, not on a variable
504
+ for combination in itertools.product(*self._domains(transition, unbound)):
505
+ if len(results) >= self.max_bindings:
506
+ self.truncated = True
507
+ return
508
+ trial = dict(bindings)
509
+ trial.update(zip(unbound, combination))
510
+ self._search(transition, demands, index, trial, remaining,
511
+ opaque_arcs, marking, clock, results)
512
+
513
+ def _domains(self, transition: Transition, names: Sequence[str]) -> list[list[Any]]:
514
+ """The values each of ``names`` ranges over, from its colour set."""
515
+ domains: list[list[Any]] = []
516
+ for name in names:
517
+ colour_set = self.net.declarations.variable_colour_set(name)
518
+ if colour_set is None:
519
+ raise EvalError(
520
+ f"variable '{name}' on transition '{transition.name}' is not declared"
521
+ )
522
+ try:
523
+ domains.append(list(colour_set.members()))
524
+ except InfiniteColourSetError:
525
+ raise EvalError(
526
+ f"variable '{name}' on transition '{transition.name}' is not "
527
+ f"determined by any input arc, and its colour set "
528
+ f"'{colour_set.name}' cannot be enumerated. Bind it on an input "
529
+ f"arc, or give it a finite colour set."
530
+ )
531
+ return domains
532
+
533
+ def _consume_concrete(self, transition: Transition, demands: Sequence[Demand],
534
+ index: int, bindings: dict[str, Any],
535
+ remaining: dict[str, Multiset], opaque_arcs: Sequence[Arc],
536
+ marking: Marking, clock: int, results: list[BindingElement],
537
+ demand: Demand, count: int, stalled: int = 0) -> None:
538
+ """Handle a demand whose value expression is fully determined."""
539
+ try:
540
+ value = self._evaluate(demand.value, bindings)
541
+ except CPNMLError:
542
+ # Still has unbound variables and is not a pattern: defer it.
543
+ self._postpone(transition, demands, index, bindings, remaining,
544
+ opaque_arcs, marking, clock, results, stalled)
545
+ return
546
+
547
+ pool = remaining.get(demand.place_id, Multiset.empty())
548
+ wanted = value * count if isinstance(value, Multiset) else Multiset.singleton(value, count)
549
+ if not (wanted <= pool):
550
+ return
551
+ updated = dict(remaining)
552
+ updated[demand.place_id] = pool - wanted
553
+ self._search(transition, demands, index + 1, bindings, updated,
554
+ opaque_arcs, marking, clock, results)
555
+
556
+ def _finish(self, transition: Transition, bindings: dict[str, Any],
557
+ remaining: dict[str, Multiset], opaque_arcs: Sequence[Arc],
558
+ marking: Marking, clock: int, results: list[BindingElement]) -> None:
559
+ """All demands satisfied: bind leftover variables, then check the guard."""
560
+ page = self.net.page_of(transition)
561
+ assert page is not None
562
+
563
+ # Variables that appear anywhere on this transition but are still free.
564
+ needed: set[str] = set()
565
+ for arc in page.arcs_of(transition):
566
+ needed |= self._free(arc.expression_ast)
567
+ needed |= self._free(transition.guard_ast)
568
+ needed |= self._free(transition.time_ast)
569
+ unbound = sorted(needed - set(bindings))
570
+
571
+ if not unbound:
572
+ self._check_and_emit(transition, bindings, remaining, opaque_arcs,
573
+ marking, clock, results)
574
+ return
575
+
576
+ # Enumerate the remaining variables over their colour sets.
577
+ for combination in itertools.product(*self._domains(transition, unbound)):
578
+ if len(results) >= self.max_bindings:
579
+ self.truncated = True
580
+ return
581
+ trial = dict(bindings)
582
+ trial.update(zip(unbound, combination))
583
+ self._check_and_emit(transition, trial, remaining, opaque_arcs,
584
+ marking, clock, results)
585
+
586
+ def _check_and_emit(self, transition: Transition, bindings: dict[str, Any],
587
+ remaining: dict[str, Multiset], opaque_arcs: Sequence[Arc],
588
+ marking: Marking, clock: int, results: list[BindingElement]) -> None:
589
+ """Final validation: opaque input arcs, then the guard."""
590
+ # Any arc we could not decompose is now fully evaluable.
591
+ for arc in opaque_arcs:
592
+ try:
593
+ wanted = to_multiset(self._evaluate(arc.expression_ast, bindings))
594
+ except CPNMLError:
595
+ return
596
+ key = self.net.marking_key(arc.place_id)
597
+ pool = remaining.get(key, Multiset.empty())
598
+ if not (wanted <= pool):
599
+ return
600
+ remaining = dict(remaining)
601
+ remaining[key] = pool - wanted
602
+
603
+ if transition.guard_ast is not None:
604
+ try:
605
+ guard = self._evaluate(transition.guard_ast, bindings)
606
+ except CPNMLError:
607
+ return
608
+ if guard is not True:
609
+ return
610
+
611
+ element = BindingElement.create(transition, bindings)
612
+ if element not in results:
613
+ results.append(element)
614
+
615
+ # -- evaluation helper ---------------------------------------------------
616
+ def _evaluate(self, expression: Expr | None, bindings: dict[str, Any]) -> Any:
617
+ if expression is None:
618
+ return None
619
+ environment = Environment(bindings, self.evaluator.globals)
620
+ return self.evaluator.evaluate(expression, environment)
@@ -0,0 +1,66 @@
1
+ """A simulation's firing history as an event log.
2
+
3
+ One event per firing that binds the *case variable*: the activity is the
4
+ transition's name, the case is the variable's value, and the timestamp is
5
+ a start time plus the model time. The CPN page's *Export as event log…*
6
+ and the *Simulate CPN* box both use this.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timedelta, timezone
12
+
13
+ from ..ml.values import format_value
14
+ from ..mining.log import KEY_LIFECYCLE, KEY_NAME, KEY_TIME, Event, EventLog, Trace
15
+ from ..model.net import CPNet
16
+
17
+ UNITS = {"seconds": 1, "minutes": 60, "hours": 3600, "days": 86400}
18
+
19
+
20
+ def simulation_to_log(net: CPNet, records, case_variable: str, unit: str = "minutes",
21
+ start: datetime | None = None) -> EventLog:
22
+ """Turn a simulator's firing history into an event log.
23
+
24
+ Firings at the same model time keep their firing order. Every other
25
+ bound variable becomes an event attribute ``cpn:<name>``.
26
+ """
27
+ start = start or datetime(2024, 1, 1, 9, 0, tzinfo=timezone.utc)
28
+ seconds = UNITS[unit]
29
+ traces: dict[str, Trace] = {}
30
+ for record in records:
31
+ values = dict(record.binding.assignments)
32
+ if case_variable not in values:
33
+ continue
34
+ case = format_value(values[case_variable])
35
+ transition = net.find_transition(record.binding.transition_id)
36
+ activity = transition.name if transition else record.binding.transition_id
37
+ trace = traces.get(case)
38
+ if trace is None:
39
+ trace = traces[case] = Trace({KEY_NAME: case})
40
+ event = Event({KEY_NAME: activity,
41
+ KEY_TIME: start + timedelta(seconds=float(record.time) * seconds),
42
+ KEY_LIFECYCLE: "complete"})
43
+ for name, value in record.binding.assignments:
44
+ if name != case_variable:
45
+ event.attributes[f"cpn:{name}"] = format_value(value)
46
+ trace.events.append(event)
47
+ log = EventLog(attributes={KEY_NAME: f"{net.name} simulation"})
48
+ log.traces.extend(traces.values())
49
+ return log
50
+
51
+
52
+ def case_variables(net: CPNet) -> list[str]:
53
+ """Variable names bound by some transition, candidates for the case id."""
54
+ names: list[str] = []
55
+ for transition in net.all_transitions():
56
+ for arc in net.page_of(transition).arcs_of(transition) if net.page_of(transition) else []:
57
+ for token in _identifiers(arc.expression_text):
58
+ if token not in names:
59
+ names.append(token)
60
+ return names
61
+
62
+
63
+ def _identifiers(text: str) -> list[str]:
64
+ import re
65
+ return [t for t in re.findall(r"[A-Za-z_][A-Za-z0-9_']*", text or "")
66
+ if t not in ("if", "then", "else", "andalso", "orelse", "not", "let", "in", "end", "case", "of", "fn")]