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
openprocess/ml/lexer.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Tokeniser for the CPN ML subset.
|
|
2
|
+
|
|
3
|
+
CPN ML is a dialect of Standard ML, so the lexical rules are ML's rules. The
|
|
4
|
+
three that trip people up, and that this lexer gets right, are:
|
|
5
|
+
|
|
6
|
+
1. **Negation is written ``~``, not ``-``.** ``~3`` is negative three;
|
|
7
|
+
``3 - 1`` is subtraction. This removes the usual unary/binary minus
|
|
8
|
+
ambiguity entirely, so we can tokenise ``~`` as its own operator.
|
|
9
|
+
|
|
10
|
+
2. **Comments are ``(* ... *)`` and they nest.** ``(* a (* b *) c *)`` is one
|
|
11
|
+
comment. A non-nesting scanner would stop at the first ``*)`` and then
|
|
12
|
+
choke on ``c *)``.
|
|
13
|
+
|
|
14
|
+
3. **Several operators share a prefix.** ``+`` / ``++``, ``-`` / ``--`` /
|
|
15
|
+
``->``, ``@`` / ``@+``, ``<`` / ``<=`` / ``<>``, ``=`` / ``=>``, ``^`` /
|
|
16
|
+
``^^``. We therefore try the longest spellings first.
|
|
17
|
+
|
|
18
|
+
CPN-specific additions to plain SML:
|
|
19
|
+
|
|
20
|
+
* ``` ` ``` (backquote) is the multiset coefficient operator: ``3`x`` means
|
|
21
|
+
three copies of ``x``.
|
|
22
|
+
* ``++`` and ``--`` are multiset union and difference.
|
|
23
|
+
* ``@+`` introduces a time delay on an arc inscription (``e @+ 5``).
|
|
24
|
+
* ``empty`` is the empty multiset literal.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Iterator
|
|
31
|
+
|
|
32
|
+
from .errors import LexError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Words that are reserved and therefore never parsed as identifiers.
|
|
36
|
+
KEYWORDS = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"if", "then", "else",
|
|
39
|
+
"let", "in", "end",
|
|
40
|
+
"val", "fun", "fn",
|
|
41
|
+
"case", "of",
|
|
42
|
+
"andalso", "orelse", "not",
|
|
43
|
+
"div", "mod",
|
|
44
|
+
"true", "false", "nil", "empty",
|
|
45
|
+
"as", "op", "raise", "handle",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Multi-character operators, longest first. Order matters: the scanner takes
|
|
50
|
+
# the first entry that matches, so ``++`` must precede ``+``.
|
|
51
|
+
_MULTI_CHAR_OPERATORS = (
|
|
52
|
+
"@+", # time delay (CPN extension)
|
|
53
|
+
"+++", # timed multiset union
|
|
54
|
+
"---", # timed multiset difference
|
|
55
|
+
"++", # multiset union
|
|
56
|
+
"--", # multiset difference
|
|
57
|
+
"^^", # list append (CPN Tools spelling)
|
|
58
|
+
"::", # list cons
|
|
59
|
+
"<=", ">=", "<>",
|
|
60
|
+
"=>", # match arrow (fn / case)
|
|
61
|
+
"->", # type arrow
|
|
62
|
+
"..", # range, used in `int with 1..10`
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
_SINGLE_CHAR_OPERATORS = frozenset("+-*/=<>^@`~(),;:|[]{}#_.")
|
|
66
|
+
|
|
67
|
+
# Characters that may appear inside an identifier after the first character,
|
|
68
|
+
# in addition to letters and digits. A frozenset (not a string) on purpose:
|
|
69
|
+
# `"" in "_'"` is True in Python, which would make the scan loops run forever
|
|
70
|
+
# at end of input.
|
|
71
|
+
_IDENT_EXTRA_CHARS = frozenset("_'")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Token:
|
|
76
|
+
"""One lexical unit.
|
|
77
|
+
|
|
78
|
+
``kind`` is one of ``ID``, ``KEYWORD``, ``INT``, ``REAL``, ``STRING``,
|
|
79
|
+
``CHAR``, ``OP`` or ``EOF``. ``value`` carries the already-decoded payload
|
|
80
|
+
for literals (a Python ``int``/``float``/``str``) and the raw text for
|
|
81
|
+
everything else. ``position`` is the character offset of the token's first
|
|
82
|
+
character, used to point at the right place in error messages.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
kind: str
|
|
86
|
+
value: object
|
|
87
|
+
position: int
|
|
88
|
+
|
|
89
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
90
|
+
return f"{self.kind}({self.value!r})@{self.position}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Lexer:
|
|
94
|
+
"""Turns an inscription string into a list of :class:`Token`."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, source: str) -> None:
|
|
97
|
+
self.source = source
|
|
98
|
+
self.index = 0
|
|
99
|
+
|
|
100
|
+
# -- low-level cursor helpers -------------------------------------------
|
|
101
|
+
def _peek(self, offset: int = 0) -> str:
|
|
102
|
+
position = self.index + offset
|
|
103
|
+
return self.source[position] if position < len(self.source) else ""
|
|
104
|
+
|
|
105
|
+
def _at_end(self) -> bool:
|
|
106
|
+
return self.index >= len(self.source)
|
|
107
|
+
|
|
108
|
+
# -- skipping ------------------------------------------------------------
|
|
109
|
+
def _skip_whitespace_and_comments(self) -> None:
|
|
110
|
+
"""Advance past any run of whitespace and (nesting) comments."""
|
|
111
|
+
while not self._at_end():
|
|
112
|
+
char = self._peek()
|
|
113
|
+
if char.isspace():
|
|
114
|
+
self.index += 1
|
|
115
|
+
continue
|
|
116
|
+
if char == "(" and self._peek(1) == "*":
|
|
117
|
+
self._skip_comment()
|
|
118
|
+
continue
|
|
119
|
+
break
|
|
120
|
+
|
|
121
|
+
def _skip_comment(self) -> None:
|
|
122
|
+
start = self.index
|
|
123
|
+
depth = 0
|
|
124
|
+
while not self._at_end():
|
|
125
|
+
if self._peek() == "(" and self._peek(1) == "*":
|
|
126
|
+
depth += 1
|
|
127
|
+
self.index += 2
|
|
128
|
+
elif self._peek() == "*" and self._peek(1) == ")":
|
|
129
|
+
depth -= 1
|
|
130
|
+
self.index += 2
|
|
131
|
+
if depth == 0:
|
|
132
|
+
return
|
|
133
|
+
else:
|
|
134
|
+
self.index += 1
|
|
135
|
+
raise LexError("unterminated comment: '(*' without a matching '*)'", start)
|
|
136
|
+
|
|
137
|
+
# -- literal scanners ----------------------------------------------------
|
|
138
|
+
def _scan_number(self) -> Token:
|
|
139
|
+
"""Scan an integer or real literal, honouring ML's ``~`` for negation.
|
|
140
|
+
|
|
141
|
+
``~`` is only treated as part of the literal when it is immediately
|
|
142
|
+
followed by a digit; otherwise it is a standalone negation operator
|
|
143
|
+
applied to an expression, and the parser deals with it.
|
|
144
|
+
"""
|
|
145
|
+
start = self.index
|
|
146
|
+
if self._peek() == "~":
|
|
147
|
+
self.index += 1
|
|
148
|
+
while self._peek().isdigit():
|
|
149
|
+
self.index += 1
|
|
150
|
+
|
|
151
|
+
is_real = False
|
|
152
|
+
# A '.' only starts a fractional part if a digit follows it, so that
|
|
153
|
+
# `#1.x` style selections are not mis-scanned.
|
|
154
|
+
if self._peek() == "." and self._peek(1).isdigit():
|
|
155
|
+
is_real = True
|
|
156
|
+
self.index += 1
|
|
157
|
+
while self._peek().isdigit():
|
|
158
|
+
self.index += 1
|
|
159
|
+
# Exponent, e.g. 1.5E~3
|
|
160
|
+
if self._peek() in ("e", "E") and (
|
|
161
|
+
self._peek(1).isdigit() or (self._peek(1) == "~" and self._peek(2).isdigit())
|
|
162
|
+
):
|
|
163
|
+
is_real = True
|
|
164
|
+
self.index += 1
|
|
165
|
+
if self._peek() == "~":
|
|
166
|
+
self.index += 1
|
|
167
|
+
while self._peek().isdigit():
|
|
168
|
+
self.index += 1
|
|
169
|
+
|
|
170
|
+
text = self.source[start:self.index]
|
|
171
|
+
# ML writes negation as '~'; Python needs '-'.
|
|
172
|
+
python_text = text.replace("~", "-")
|
|
173
|
+
if is_real:
|
|
174
|
+
return Token("REAL", float(python_text), start)
|
|
175
|
+
return Token("INT", int(python_text), start)
|
|
176
|
+
|
|
177
|
+
def _scan_string(self) -> Token:
|
|
178
|
+
"""Scan a double-quoted string with the ML escape sequences."""
|
|
179
|
+
start = self.index
|
|
180
|
+
self.index += 1 # consume the opening quote
|
|
181
|
+
pieces: list[str] = []
|
|
182
|
+
while True:
|
|
183
|
+
if self._at_end():
|
|
184
|
+
raise LexError("unterminated string literal", start)
|
|
185
|
+
char = self._peek()
|
|
186
|
+
if char == '"':
|
|
187
|
+
self.index += 1
|
|
188
|
+
return Token("STRING", "".join(pieces), start)
|
|
189
|
+
if char == "\\":
|
|
190
|
+
self.index += 1
|
|
191
|
+
escape = self._peek()
|
|
192
|
+
self.index += 1
|
|
193
|
+
pieces.append(
|
|
194
|
+
{"n": "\n", "t": "\t", "r": "\r", "\\": "\\", '"': '"'}.get(escape, escape)
|
|
195
|
+
)
|
|
196
|
+
continue
|
|
197
|
+
pieces.append(char)
|
|
198
|
+
self.index += 1
|
|
199
|
+
|
|
200
|
+
def _scan_identifier(self) -> Token:
|
|
201
|
+
"""Scan an alphanumeric identifier or keyword.
|
|
202
|
+
|
|
203
|
+
ML identifiers may contain letters, digits, underscores and primes
|
|
204
|
+
(``x'``), and may be qualified with a structure name (``Int.toString``),
|
|
205
|
+
which we keep as one dotted identifier so the evaluator can look it up
|
|
206
|
+
in the builtin table.
|
|
207
|
+
"""
|
|
208
|
+
start = self.index
|
|
209
|
+
while self._peek().isalnum() or self._peek() in _IDENT_EXTRA_CHARS:
|
|
210
|
+
self.index += 1
|
|
211
|
+
# Qualified name: swallow `.name` runs, but only when a letter follows,
|
|
212
|
+
# so that `1.` or a record selector cannot be absorbed by accident.
|
|
213
|
+
while self._peek() == "." and (self._peek(1).isalpha() or self._peek(1) == "_"):
|
|
214
|
+
self.index += 1
|
|
215
|
+
while self._peek().isalnum() or self._peek() in _IDENT_EXTRA_CHARS:
|
|
216
|
+
self.index += 1
|
|
217
|
+
text = self.source[start:self.index]
|
|
218
|
+
if text in KEYWORDS:
|
|
219
|
+
return Token("KEYWORD", text, start)
|
|
220
|
+
return Token("ID", text, start)
|
|
221
|
+
|
|
222
|
+
# -- the main loop -------------------------------------------------------
|
|
223
|
+
def tokens(self) -> list[Token]:
|
|
224
|
+
"""Scan the whole input and return the token list, ending with EOF."""
|
|
225
|
+
result: list[Token] = []
|
|
226
|
+
while True:
|
|
227
|
+
self._skip_whitespace_and_comments()
|
|
228
|
+
if self._at_end():
|
|
229
|
+
result.append(Token("EOF", None, self.index))
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
start = self.index
|
|
233
|
+
char = self._peek()
|
|
234
|
+
|
|
235
|
+
# Character literal: #"a"
|
|
236
|
+
if char == "#" and self._peek(1) == '"':
|
|
237
|
+
self.index += 1
|
|
238
|
+
string_token = self._scan_string()
|
|
239
|
+
text = str(string_token.value)
|
|
240
|
+
if len(text) != 1:
|
|
241
|
+
raise LexError("character literal must contain exactly one character", start)
|
|
242
|
+
result.append(Token("CHAR", text, start))
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
if char.isdigit() or (char == "~" and self._peek(1).isdigit()):
|
|
246
|
+
result.append(self._scan_number())
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
if char == '"':
|
|
250
|
+
result.append(self._scan_string())
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
if char.isalpha() or char == "_" and (self._peek(1).isalnum() or self._peek(1) == "_"):
|
|
254
|
+
result.append(self._scan_identifier())
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
matched = False
|
|
258
|
+
for operator in _MULTI_CHAR_OPERATORS:
|
|
259
|
+
if self.source.startswith(operator, self.index):
|
|
260
|
+
self.index += len(operator)
|
|
261
|
+
result.append(Token("OP", operator, start))
|
|
262
|
+
matched = True
|
|
263
|
+
break
|
|
264
|
+
if matched:
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
if char in _SINGLE_CHAR_OPERATORS:
|
|
268
|
+
self.index += 1
|
|
269
|
+
result.append(Token("OP", char, start))
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
raise LexError(f"unexpected character {char!r}", start)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def tokenise(source: str) -> list[Token]:
|
|
276
|
+
"""Convenience wrapper: ``tokenise("1 ++ 2")``."""
|
|
277
|
+
return Lexer(source).tokens()
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Multiset (bag) algebra -- the arithmetic that CPN markings are made of.
|
|
2
|
+
|
|
3
|
+
Why multisets are the central data structure
|
|
4
|
+
--------------------------------------------
|
|
5
|
+
In a Coloured Petri Net a place does not hold "3 tokens"; it holds a *multiset
|
|
6
|
+
of coloured tokens*, for example ``2`"a" ++ 1`"b"``. Arc expressions evaluate
|
|
7
|
+
to multisets, and the firing rule is stated entirely in multiset terms:
|
|
8
|
+
|
|
9
|
+
a transition ``t`` is enabled in marking ``M`` under binding ``b`` iff
|
|
10
|
+
for every input place ``p``: E(p,t)<b> <= M(p)
|
|
11
|
+
|
|
12
|
+
firing produces M'(p) = (M(p) -- E(p,t)<b>) ++ E(t,p)<b>
|
|
13
|
+
|
|
14
|
+
So if the multiset operations ``<=``, ``--`` and ``++`` are right, the
|
|
15
|
+
simulator is a very short piece of code. That is the whole reason this module
|
|
16
|
+
is separate and heavily tested.
|
|
17
|
+
|
|
18
|
+
Two classes live here:
|
|
19
|
+
|
|
20
|
+
* :class:`Multiset` -- untimed. A map from value to a strictly positive count.
|
|
21
|
+
* :class:`TimedMultiset` -- timed. A map from ``(value, timestamp)`` to a
|
|
22
|
+
count. Used only for places whose colour set is declared ``timed``.
|
|
23
|
+
|
|
24
|
+
Both are treated as **immutable**: every operator returns a new object. This
|
|
25
|
+
matters for state space analysis, where markings are used as dictionary keys
|
|
26
|
+
and must never mutate after being stored.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Any, Iterable, Iterator
|
|
32
|
+
|
|
33
|
+
from .errors import EvalError
|
|
34
|
+
from .values import format_value, sort_key
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Multiset:
|
|
38
|
+
"""An immutable multiset of CPN values.
|
|
39
|
+
|
|
40
|
+
Internally a ``dict`` from value to count. We maintain one invariant that
|
|
41
|
+
the rest of the code relies on:
|
|
42
|
+
|
|
43
|
+
**no key ever maps to a count of zero.**
|
|
44
|
+
|
|
45
|
+
Keeping zero entries out means equality is just dict equality, the support
|
|
46
|
+
is just ``keys()``, and hashing is straightforward.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
__slots__ = ("_counts", "_hash")
|
|
50
|
+
|
|
51
|
+
def __init__(self, counts: dict[Any, int] | Iterable[tuple[Any, int]] | None = None) -> None:
|
|
52
|
+
cleaned: dict[Any, int] = {}
|
|
53
|
+
if counts is not None:
|
|
54
|
+
items = counts.items() if isinstance(counts, dict) else counts
|
|
55
|
+
for value, count in items:
|
|
56
|
+
if count < 0:
|
|
57
|
+
raise EvalError(
|
|
58
|
+
f"multiset coefficient must not be negative, got {count} "
|
|
59
|
+
f"for {format_value(value)}"
|
|
60
|
+
)
|
|
61
|
+
if count:
|
|
62
|
+
cleaned[value] = cleaned.get(value, 0) + count
|
|
63
|
+
self._counts = cleaned
|
|
64
|
+
self._hash: int | None = None
|
|
65
|
+
|
|
66
|
+
# -- constructors --------------------------------------------------------
|
|
67
|
+
@classmethod
|
|
68
|
+
def _clean(cls, counts: dict[Any, int]) -> "Multiset":
|
|
69
|
+
"""Wrap a dict already known to hold only positive counts.
|
|
70
|
+
|
|
71
|
+
Skips the validation loop of ``__init__``: the algebra below builds
|
|
72
|
+
clean dicts itself, and multiset arithmetic is the simulator's (and
|
|
73
|
+
the state space tool's) hottest code.
|
|
74
|
+
"""
|
|
75
|
+
result = cls.__new__(cls)
|
|
76
|
+
result._counts = counts
|
|
77
|
+
result._hash = None
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def empty() -> "Multiset":
|
|
82
|
+
"""The empty multiset, written ``empty`` in CPN ML."""
|
|
83
|
+
return Multiset()
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def singleton(value: Any, count: int = 1) -> "Multiset":
|
|
87
|
+
"""``count`value`` -- ``count`` copies of one value."""
|
|
88
|
+
return Multiset({value: count})
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def from_values(values: Iterable[Any]) -> "Multiset":
|
|
92
|
+
"""Build from a plain iterable, summing duplicates."""
|
|
93
|
+
counts: dict[Any, int] = {}
|
|
94
|
+
for value in values:
|
|
95
|
+
counts[value] = counts.get(value, 0) + 1
|
|
96
|
+
return Multiset(counts)
|
|
97
|
+
|
|
98
|
+
# -- inspection ----------------------------------------------------------
|
|
99
|
+
def count(self, value: Any) -> int:
|
|
100
|
+
"""Coefficient of ``value``; zero if absent."""
|
|
101
|
+
return self._counts.get(value, 0)
|
|
102
|
+
|
|
103
|
+
def support(self) -> list[Any]:
|
|
104
|
+
"""Distinct values present, in canonical (sorted) order."""
|
|
105
|
+
return sorted(self._counts, key=sort_key)
|
|
106
|
+
|
|
107
|
+
def size(self) -> int:
|
|
108
|
+
"""Total number of tokens, counting multiplicity."""
|
|
109
|
+
return sum(self._counts.values())
|
|
110
|
+
|
|
111
|
+
def is_empty(self) -> bool:
|
|
112
|
+
return not self._counts
|
|
113
|
+
|
|
114
|
+
def __bool__(self) -> bool:
|
|
115
|
+
return bool(self._counts)
|
|
116
|
+
|
|
117
|
+
def __len__(self) -> int:
|
|
118
|
+
return self.size()
|
|
119
|
+
|
|
120
|
+
def __contains__(self, value: Any) -> bool:
|
|
121
|
+
return value in self._counts
|
|
122
|
+
|
|
123
|
+
def items(self) -> Iterator[tuple[Any, int]]:
|
|
124
|
+
"""Iterate ``(value, count)`` pairs in canonical order."""
|
|
125
|
+
for value in self.support():
|
|
126
|
+
yield value, self._counts[value]
|
|
127
|
+
|
|
128
|
+
def expand(self) -> Iterator[Any]:
|
|
129
|
+
"""Iterate individual tokens, repeating values by multiplicity.
|
|
130
|
+
|
|
131
|
+
Used by the binder, which needs to pick concrete tokens out of a place.
|
|
132
|
+
"""
|
|
133
|
+
for value, count in self.items():
|
|
134
|
+
for _ in range(count):
|
|
135
|
+
yield value
|
|
136
|
+
|
|
137
|
+
# -- algebra -------------------------------------------------------------
|
|
138
|
+
def __add__(self, other: "Multiset") -> "Multiset":
|
|
139
|
+
"""``++`` -- multiset union (pointwise sum of coefficients)."""
|
|
140
|
+
if not isinstance(other, Multiset):
|
|
141
|
+
return NotImplemented
|
|
142
|
+
result = dict(self._counts)
|
|
143
|
+
for value, count in other._counts.items():
|
|
144
|
+
result[value] = result.get(value, 0) + count
|
|
145
|
+
return Multiset._clean(result)
|
|
146
|
+
|
|
147
|
+
def __sub__(self, other: "Multiset") -> "Multiset":
|
|
148
|
+
"""``--`` -- multiset difference, truncated at zero.
|
|
149
|
+
|
|
150
|
+
CPN Tools defines ``(m1 -- m2)(c) = max(0, m1(c) - m2(c))``. The
|
|
151
|
+
simulator never relies on the truncation, because it checks
|
|
152
|
+
``other <= self`` first, but user inscriptions may.
|
|
153
|
+
"""
|
|
154
|
+
if not isinstance(other, Multiset):
|
|
155
|
+
return NotImplemented
|
|
156
|
+
result = dict(self._counts)
|
|
157
|
+
for value, count in other._counts.items():
|
|
158
|
+
remaining = result.get(value, 0) - count
|
|
159
|
+
if remaining > 0:
|
|
160
|
+
result[value] = remaining
|
|
161
|
+
else:
|
|
162
|
+
result.pop(value, None)
|
|
163
|
+
return Multiset._clean(result)
|
|
164
|
+
|
|
165
|
+
def __mul__(self, factor: int) -> "Multiset":
|
|
166
|
+
"""Scalar multiplication: every coefficient times ``factor``."""
|
|
167
|
+
if not isinstance(factor, int):
|
|
168
|
+
return NotImplemented
|
|
169
|
+
if factor < 0:
|
|
170
|
+
raise EvalError("cannot multiply a multiset by a negative number")
|
|
171
|
+
if factor == 0:
|
|
172
|
+
return Multiset()
|
|
173
|
+
return Multiset._clean({v: c * factor for v, c in self._counts.items()})
|
|
174
|
+
|
|
175
|
+
__rmul__ = __mul__
|
|
176
|
+
|
|
177
|
+
def __le__(self, other: "Multiset") -> bool:
|
|
178
|
+
"""Multiset inclusion -- **this is the enabling test**.
|
|
179
|
+
|
|
180
|
+
``self <= other`` iff every value occurs at least as often in ``other``
|
|
181
|
+
as it does in ``self``. Note this is a *partial* order: two multisets
|
|
182
|
+
can be incomparable, so ``not (a <= b)`` does not imply ``b <= a``.
|
|
183
|
+
"""
|
|
184
|
+
if not isinstance(other, Multiset):
|
|
185
|
+
return NotImplemented
|
|
186
|
+
return all(count <= other._counts.get(value, 0) for value, count in self._counts.items())
|
|
187
|
+
|
|
188
|
+
def __ge__(self, other: "Multiset") -> bool:
|
|
189
|
+
if not isinstance(other, Multiset):
|
|
190
|
+
return NotImplemented
|
|
191
|
+
return other <= self
|
|
192
|
+
|
|
193
|
+
def __lt__(self, other: "Multiset") -> bool:
|
|
194
|
+
return self <= other and self != other
|
|
195
|
+
|
|
196
|
+
def __gt__(self, other: "Multiset") -> bool:
|
|
197
|
+
return other < self
|
|
198
|
+
|
|
199
|
+
# -- identity ------------------------------------------------------------
|
|
200
|
+
def __eq__(self, other: object) -> bool:
|
|
201
|
+
return isinstance(other, Multiset) and self._counts == other._counts
|
|
202
|
+
|
|
203
|
+
def __hash__(self) -> int:
|
|
204
|
+
# Cached because markings are hashed once per state space node visit.
|
|
205
|
+
if self._hash is None:
|
|
206
|
+
self._hash = hash(frozenset(self._counts.items()))
|
|
207
|
+
return self._hash
|
|
208
|
+
|
|
209
|
+
# -- printing ------------------------------------------------------------
|
|
210
|
+
def __repr__(self) -> str:
|
|
211
|
+
"""CPN Tools syntax: ``2`"a"++1`"b"``; the empty multiset is ``empty``."""
|
|
212
|
+
if not self._counts:
|
|
213
|
+
return "empty"
|
|
214
|
+
return "++".join(f"{count}`{format_value(value)}" for value, count in self.items())
|
|
215
|
+
|
|
216
|
+
__str__ = __repr__
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class TimedMultiset:
|
|
220
|
+
"""A multiset of *timestamped* tokens, for places with a timed colour set.
|
|
221
|
+
|
|
222
|
+
In a timed CPN each token carries a time stamp saying when it becomes
|
|
223
|
+
available. A transition may only consume tokens whose stamp is less than
|
|
224
|
+
or equal to the current model time. We therefore key the underlying dict
|
|
225
|
+
on ``(value, timestamp)`` pairs and expose two extra operations that the
|
|
226
|
+
untimed class does not need:
|
|
227
|
+
|
|
228
|
+
* :meth:`available_at` -- project down to the untimed multiset of tokens
|
|
229
|
+
that are ready at a given clock value;
|
|
230
|
+
* :meth:`earliest_time` -- the smallest timestamp present, which the
|
|
231
|
+
simulator uses to decide how far to advance the clock when no transition
|
|
232
|
+
is enabled now.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
__slots__ = ("_counts", "_hash")
|
|
236
|
+
|
|
237
|
+
def __init__(self, counts: dict[tuple[Any, int], int] | Iterable[tuple[tuple[Any, int], int]] | None = None) -> None:
|
|
238
|
+
cleaned: dict[tuple[Any, int], int] = {}
|
|
239
|
+
if counts is not None:
|
|
240
|
+
items = counts.items() if isinstance(counts, dict) else counts
|
|
241
|
+
for key, count in items:
|
|
242
|
+
if count < 0:
|
|
243
|
+
raise EvalError("timed multiset coefficient must not be negative")
|
|
244
|
+
if count:
|
|
245
|
+
cleaned[key] = cleaned.get(key, 0) + count
|
|
246
|
+
self._counts = cleaned
|
|
247
|
+
self._hash: int | None = None
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def empty() -> "TimedMultiset":
|
|
251
|
+
return TimedMultiset()
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def from_multiset(multiset: Multiset, timestamp: int) -> "TimedMultiset":
|
|
255
|
+
"""Stamp every token of an untimed multiset with the same time.
|
|
256
|
+
|
|
257
|
+
This is how an output arc result becomes tokens in a timed place: the
|
|
258
|
+
arc yields a plain multiset, and the arc's ``@+`` delay expression
|
|
259
|
+
(plus the current clock) supplies the stamp.
|
|
260
|
+
"""
|
|
261
|
+
return TimedMultiset({(value, timestamp): count for value, count in multiset.items()})
|
|
262
|
+
|
|
263
|
+
# -- inspection ----------------------------------------------------------
|
|
264
|
+
def size(self) -> int:
|
|
265
|
+
return sum(self._counts.values())
|
|
266
|
+
|
|
267
|
+
def is_empty(self) -> bool:
|
|
268
|
+
return not self._counts
|
|
269
|
+
|
|
270
|
+
def __bool__(self) -> bool:
|
|
271
|
+
return bool(self._counts)
|
|
272
|
+
|
|
273
|
+
def items(self) -> Iterator[tuple[Any, int, int]]:
|
|
274
|
+
"""Iterate ``(value, timestamp, count)`` in canonical order."""
|
|
275
|
+
for value, timestamp in sorted(self._counts, key=lambda k: (sort_key(k[0]), k[1])):
|
|
276
|
+
yield value, timestamp, self._counts[(value, timestamp)]
|
|
277
|
+
|
|
278
|
+
def available_at(self, clock: int) -> Multiset:
|
|
279
|
+
"""Untimed multiset of the tokens whose stamp has been reached."""
|
|
280
|
+
ready: dict[Any, int] = {}
|
|
281
|
+
for (value, timestamp), count in self._counts.items():
|
|
282
|
+
if timestamp <= clock:
|
|
283
|
+
ready[value] = ready.get(value, 0) + count
|
|
284
|
+
return Multiset(ready)
|
|
285
|
+
|
|
286
|
+
def earliest_time(self) -> int | None:
|
|
287
|
+
"""Smallest timestamp in the place, or ``None`` if the place is empty."""
|
|
288
|
+
if not self._counts:
|
|
289
|
+
return None
|
|
290
|
+
return min(timestamp for _, timestamp in self._counts)
|
|
291
|
+
|
|
292
|
+
def next_time_after(self, clock: int) -> int | None:
|
|
293
|
+
"""Smallest timestamp strictly greater than ``clock``.
|
|
294
|
+
|
|
295
|
+
The simulator asks every place this question to compute the next
|
|
296
|
+
interesting model time when nothing is enabled at the current one.
|
|
297
|
+
"""
|
|
298
|
+
future = [t for _, t in self._counts if t > clock]
|
|
299
|
+
return min(future) if future else None
|
|
300
|
+
|
|
301
|
+
# -- algebra -------------------------------------------------------------
|
|
302
|
+
def add(self, other: "TimedMultiset") -> "TimedMultiset":
|
|
303
|
+
result = dict(self._counts)
|
|
304
|
+
for key, count in other._counts.items():
|
|
305
|
+
result[key] = result.get(key, 0) + count
|
|
306
|
+
return TimedMultiset(result)
|
|
307
|
+
|
|
308
|
+
def remove_available(self, wanted: Multiset, clock: int) -> "TimedMultiset":
|
|
309
|
+
"""Consume ``wanted`` tokens, taking the *oldest* available ones first.
|
|
310
|
+
|
|
311
|
+
CPN Tools removes tokens with the smallest timestamp first. This is
|
|
312
|
+
not merely a convention: it keeps timed simulation deterministic with
|
|
313
|
+
respect to which stamps remain in the place, which matters when a
|
|
314
|
+
later transition's enabling depends on those stamps. Raises
|
|
315
|
+
:class:`EvalError` if not enough tokens are available at ``clock`` --
|
|
316
|
+
the caller is expected to have checked enabling first.
|
|
317
|
+
"""
|
|
318
|
+
result = dict(self._counts)
|
|
319
|
+
for value, needed in wanted.items():
|
|
320
|
+
# Candidate stamps for this value that have already been reached,
|
|
321
|
+
# oldest first.
|
|
322
|
+
stamps = sorted(t for (v, t) in result if v == value and t <= clock)
|
|
323
|
+
for timestamp in stamps:
|
|
324
|
+
if needed == 0:
|
|
325
|
+
break
|
|
326
|
+
key = (value, timestamp)
|
|
327
|
+
take = min(needed, result[key])
|
|
328
|
+
result[key] -= take
|
|
329
|
+
if result[key] == 0:
|
|
330
|
+
del result[key]
|
|
331
|
+
needed -= take
|
|
332
|
+
if needed:
|
|
333
|
+
raise EvalError(
|
|
334
|
+
f"cannot remove {wanted.count(value)}`{format_value(value)} at time "
|
|
335
|
+
f"{clock}: only {wanted.count(value) - needed} available"
|
|
336
|
+
)
|
|
337
|
+
return TimedMultiset(result)
|
|
338
|
+
|
|
339
|
+
# -- identity ------------------------------------------------------------
|
|
340
|
+
def __eq__(self, other: object) -> bool:
|
|
341
|
+
return isinstance(other, TimedMultiset) and self._counts == other._counts
|
|
342
|
+
|
|
343
|
+
def __hash__(self) -> int:
|
|
344
|
+
if self._hash is None:
|
|
345
|
+
self._hash = hash(frozenset(self._counts.items()))
|
|
346
|
+
return self._hash
|
|
347
|
+
|
|
348
|
+
def __repr__(self) -> str:
|
|
349
|
+
"""CPN Tools syntax: ``2`"a"@[0,5]``.
|
|
350
|
+
|
|
351
|
+
We print one term per (value, timestamp) pair, which is unambiguous and
|
|
352
|
+
matches what CPN Tools shows in the marking tooltip for simple cases.
|
|
353
|
+
"""
|
|
354
|
+
if not self._counts:
|
|
355
|
+
return "empty"
|
|
356
|
+
return "++".join(
|
|
357
|
+
f"{count}`{format_value(value)}@[{timestamp}]" for value, timestamp, count in self.items()
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
__str__ = __repr__
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class TimedTokens:
|
|
364
|
+
"""Tokens with a time attached: the value of ``x@+5``, ``x@5`` or ``+++``.
|
|
365
|
+
|
|
366
|
+
CPN Tools writes timed multisets as terms joined by ``+++``, each term
|
|
367
|
+
carrying either a *delay* (``1`x@+5``: available 5 time units after the
|
|
368
|
+
transition fires) or an absolute *time stamp* (``1`x@5``, mostly in
|
|
369
|
+
initial markings). ``groups`` keeps one ``(tokens, time, absolute)``
|
|
370
|
+
entry per term; :meth:`stamped` turns them into concrete time stamps once
|
|
371
|
+
the clock is known.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
__slots__ = ("groups",)
|
|
375
|
+
|
|
376
|
+
def __init__(self, groups: Iterable[tuple[Multiset, Any, bool]]) -> None:
|
|
377
|
+
self.groups: tuple[tuple[Multiset, Any, bool], ...] = tuple(groups)
|
|
378
|
+
|
|
379
|
+
def __add__(self, other: "TimedTokens") -> "TimedTokens":
|
|
380
|
+
"""``+++``: the terms of both."""
|
|
381
|
+
if not isinstance(other, TimedTokens):
|
|
382
|
+
return NotImplemented
|
|
383
|
+
return TimedTokens(self.groups + other.groups)
|
|
384
|
+
|
|
385
|
+
def untimed(self) -> Multiset:
|
|
386
|
+
"""All the tokens, without their times."""
|
|
387
|
+
result = Multiset.empty()
|
|
388
|
+
for tokens, _time, _absolute in self.groups:
|
|
389
|
+
result = result + tokens
|
|
390
|
+
return result
|
|
391
|
+
|
|
392
|
+
def delayed(self, delay: Any) -> "TimedTokens":
|
|
393
|
+
"""``(...)@+d`` around tokens that already have a delay: add ``d``."""
|
|
394
|
+
return TimedTokens((tokens, time if absolute else time + delay, absolute)
|
|
395
|
+
for tokens, time, absolute in self.groups)
|
|
396
|
+
|
|
397
|
+
def scaled(self, factor: int) -> "TimedTokens":
|
|
398
|
+
return TimedTokens((tokens * factor, time, absolute)
|
|
399
|
+
for tokens, time, absolute in self.groups)
|
|
400
|
+
|
|
401
|
+
def stamped(self, clock: Any) -> list[tuple[Multiset, Any]]:
|
|
402
|
+
"""``(tokens, time stamp)`` per term, delays counted from ``clock``."""
|
|
403
|
+
return [(tokens, time if absolute else clock + time)
|
|
404
|
+
for tokens, time, absolute in self.groups]
|
|
405
|
+
|
|
406
|
+
def __eq__(self, other: object) -> bool:
|
|
407
|
+
return isinstance(other, TimedTokens) and self.groups == other.groups
|
|
408
|
+
|
|
409
|
+
def __hash__(self) -> int:
|
|
410
|
+
return hash(self.groups)
|
|
411
|
+
|
|
412
|
+
def __repr__(self) -> str:
|
|
413
|
+
terms = [f"{tokens}@{'' if absolute else '+'}{time}"
|
|
414
|
+
for tokens, time, absolute in self.groups]
|
|
415
|
+
return "+++".join(terms) if terms else "empty"
|
|
416
|
+
|
|
417
|
+
__str__ = __repr__
|