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,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__