openprocess 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- openprocess-0.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,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")]
|