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
cpnpy/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """``cpnpy``: the old name of :mod:`openprocess` (CPNpy, up to 0.6).
2
+
3
+ ``import cpnpy`` and ``from cpnpy.flow import box`` keep working: every
4
+ ``cpnpy.*`` module is the ``openprocess.*`` module under its old name, so a
5
+ box file or an exercise pack written for CPNpy runs unchanged. New code
6
+ should import ``openprocess``; this shim warns once per process.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ import importlib.abc
13
+ import importlib.machinery
14
+ import sys
15
+ import warnings
16
+
17
+ import openprocess as _new
18
+
19
+ __version__ = _new.__version__
20
+ __all__ = list(getattr(_new, "__all__", []))
21
+
22
+
23
+ class _Alias(importlib.abc.MetaPathFinder, importlib.abc.Loader):
24
+ """``cpnpy.x.y`` resolves to the already-imported ``openprocess.x.y``."""
25
+
26
+ def find_spec(self, name, path=None, target=None):
27
+ if name == __name__ or not name.startswith(__name__ + "."):
28
+ return None
29
+ return importlib.machinery.ModuleSpec(name, self)
30
+
31
+ def create_module(self, spec):
32
+ module = importlib.import_module(_new.__name__ + spec.name[len(__name__):])
33
+ sys.modules[spec.name] = module
34
+ return module
35
+
36
+ def exec_module(self, module):
37
+ pass
38
+
39
+
40
+ sys.meta_path.insert(0, _Alias())
41
+ warnings.warn("cpnpy is now openprocess: import openprocess instead (cpnpy keeps working for now)",
42
+ DeprecationWarning, stacklevel=2)
43
+
44
+
45
+ def __getattr__(name):
46
+ return getattr(_new, name)
@@ -0,0 +1,57 @@
1
+ """OpenProcess -- Coloured Petri Nets, Petri nets and process mining, on macOS, Windows and Linux.
2
+
3
+ A from-scratch implementation of the modelling, simulation and analysis parts
4
+ of CPN Tools / CPN IDE in pure Python, with a PySide6 desktop application.
5
+
6
+ Quick start::
7
+
8
+ from openprocess import read_cpn, Simulator, StateSpace
9
+
10
+ net = read_cpn("model.cpn")
11
+ print(net.errors) # [] means it compiled cleanly
12
+
13
+ simulator = Simulator(net, seed=0)
14
+ simulator.run(100)
15
+ print(simulator.marking.describe(net))
16
+
17
+ print(StateSpace(net).generate().report())
18
+
19
+ Package layout
20
+ --------------
21
+ ``openprocess.ml``
22
+ The CPN ML subset: values, multisets, colour sets, lexer, parser, evaluator.
23
+ ``openprocess.model``
24
+ The net data model and the declaration compiler.
25
+ ``openprocess.io``
26
+ Reading and writing CPN Tools ``.cpn`` XML.
27
+ ``openprocess.sim``
28
+ Binding search and the simulator.
29
+ ``openprocess.analysis``
30
+ State space generation and the properties derived from it.
31
+ ``openprocess.mining``
32
+ Process mining: event logs (XES/CSV), discovery (α, Inductive Miner,
33
+ Heuristics), conformance (token replay, alignments, precision), P/T nets,
34
+ PNML, soundness. See ``openprocess/mining/__init__.py``.
35
+ ``openprocess.gui``
36
+ The PySide6 desktop applications (optional; need the ``gui`` extra):
37
+ the CPN editor (``openprocess.gui.app``) and OpenProcess Studio (``openprocess.gui.studio``).
38
+ """
39
+
40
+ from .analysis.state_space import StateSpace
41
+ from .io.cpn_reader import parse_cpn, read_cpn
42
+ from .io.cpn_writer import to_xml_string, write_cpn
43
+ from .ml.multiset import Multiset, TimedMultiset
44
+ from .model.net import Arc, CPNet, Marking, Page, Place, Transition
45
+ from .sim.binding import BindingElement
46
+ from .sim.simulator import Simulator
47
+
48
+ #: The one place the version number is written (pyproject.toml, the update
49
+ #: check and packaging/build.py read it from here). A release is tagged
50
+ #: "v" + this, e.g. v0.2.0.
51
+ __version__ = "0.7.0"
52
+
53
+ __all__ = [
54
+ "Arc", "BindingElement", "CPNet", "Marking", "Multiset", "Page", "Place",
55
+ "Simulator", "StateSpace", "TimedMultiset", "Transition",
56
+ "parse_cpn", "read_cpn", "to_xml_string", "write_cpn",
57
+ ]
File without changes
@@ -0,0 +1,521 @@
1
+ """State space (reachability graph) generation and analysis.
2
+
3
+ What a state space is
4
+ ---------------------
5
+ Simulation shows you *one* behaviour of a model. The state space shows you
6
+ *all* of them: every marking reachable from the initial one, and every binding
7
+ element leading between them. From that graph you can prove properties rather
8
+ than sample them -- that the model never deadlocks, that a place never holds
9
+ more than three tokens, that every transition can eventually occur.
10
+
11
+ The graph
12
+ ---------
13
+ * **Node**: a :class:`State`, i.e. a marking together with the model clock (the
14
+ clock matters, because the same marking at two different times can behave
15
+ differently in a timed net).
16
+ * **Arc**: a binding element that leads from one state to another.
17
+
18
+ Generation is a plain breadth-first search with a visited set. Because
19
+ markings are hashable and immutable, the visited set is an ordinary Python
20
+ ``set`` and equality does the work.
21
+
22
+ The properties we compute, and how
23
+ ----------------------------------
24
+ ``Best integer bounds``
25
+ For each place, the largest and smallest number of tokens it holds across
26
+ all reachable markings. Direct scan.
27
+
28
+ ``Best upper multiset bounds``
29
+ The smallest multiset that dominates every reachable marking of a place,
30
+ i.e. the pointwise maximum of the coefficients. Also a direct scan.
31
+
32
+ ``Dead markings``
33
+ Nodes with no outgoing arcs. These are the states the model can get stuck
34
+ in; an empty list is the usual definition of "the model does not deadlock".
35
+
36
+ ``Dead / live transitions``
37
+ Computed from the **strongly connected component** (SCC) graph. Condensing
38
+ each SCC to a single node turns the state space into a DAG, and the DAG's
39
+ *terminal* components (no arcs leaving them) are the sets of states the
40
+ system ends up cycling within forever. A transition is
41
+ :term:`live` exactly when it occurs somewhere inside **every** terminal
42
+ component -- meaning that no matter where the system settles, that
43
+ transition can still happen again. A transition is *dead* when it occurs
44
+ on no arc at all.
45
+
46
+ ``Home markings``
47
+ A marking you can always get back to. In SCC terms: if the condensed
48
+ graph has exactly one terminal component, every state in it is a home
49
+ marking, because every path eventually reaches that component. If there is
50
+ more than one, no home markings exist.
51
+
52
+ Tarjan's algorithm is used for the SCCs, written iteratively so that a state
53
+ space of a hundred thousand nodes does not blow the Python recursion limit.
54
+
55
+ Bounding the search
56
+ -------------------
57
+ Many interesting models have infinite state spaces. :meth:`StateSpace.generate`
58
+ therefore takes ``max_nodes``; hitting the cap sets :attr:`StateSpace.partial`,
59
+ and the report says so, because properties computed from a partial state space
60
+ are *not* proofs.
61
+ """
62
+
63
+ from __future__ import annotations
64
+
65
+ from collections import deque
66
+
67
+ from dataclasses import dataclass, field
68
+ from typing import Any, Iterable, Iterator
69
+
70
+ from ..ml.multiset import Multiset, TimedMultiset
71
+ from ..model.net import CPNet, Marking, Place, Transition
72
+ from ..sim.binding import BindingElement
73
+ from ..sim.simulator import Simulator
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class State:
78
+ """A node of the state space: a marking plus the model clock."""
79
+
80
+ marking: Marking
81
+ time: int = 0
82
+
83
+ def __hash__(self) -> int:
84
+ return hash((self.marking, self.time))
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class StateArc:
89
+ """An arc of the state space: one binding element, from one state to another."""
90
+
91
+ source: int
92
+ target: int
93
+ binding: BindingElement
94
+
95
+
96
+ class StateSpace:
97
+ """The reachability graph of a model, plus the analyses over it."""
98
+
99
+ def __init__(self, net: CPNet) -> None:
100
+ self.net = net
101
+ #: Node index -> state. Index 0 is always the initial state.
102
+ self.states: list[State] = []
103
+ #: State -> node index, for the visited check.
104
+ self.index_of: dict[State, int] = {}
105
+ #: Outgoing arcs per node index.
106
+ self.arcs: dict[int, list[StateArc]] = {}
107
+ #: True if generation stopped at ``max_nodes`` rather than exhausting.
108
+ self.partial = False
109
+ #: True if generation was stopped by ``should_stop`` (the user).
110
+ self.cancelled = False
111
+ #: Nodes whose successors were all computed and kept. In a partial
112
+ #: state space the others are the unexplored frontier: they have no
113
+ #: outgoing arcs *yet*, which must not be mistaken for deadlock.
114
+ self.complete: set[int] = set()
115
+
116
+ # =======================================================================
117
+ # Generation
118
+ # =======================================================================
119
+ def generate(self, initial: Marking | None = None, max_nodes: int = 10_000,
120
+ max_arcs: int = 200_000, should_stop=None, progress=None) -> "StateSpace":
121
+ """Breadth-first exploration from ``initial`` (the model's if omitted).
122
+
123
+ The simulator is used in its *pure* mode: we call
124
+ :meth:`~openprocess.sim.simulator.Simulator.fire` with an explicit marking and
125
+ clock, so the simulator's own state is never touched and exploration
126
+ does not disturb an interactive session.
127
+
128
+ ``should_stop`` (a no-argument callable) is polled regularly; when it
129
+ returns true, exploration stops and the result is marked ``partial``
130
+ and ``cancelled``. ``progress(nodes, arcs)`` is called at the same
131
+ points. Both exist for the GUI, which runs this in a worker thread.
132
+ """
133
+ import time
134
+ simulator = Simulator(self.net, initial)
135
+ start = State(simulator.marking, 0)
136
+ self.states = [start]
137
+ self.index_of = {start: 0}
138
+ self.arcs = {}
139
+ self.partial = False
140
+
141
+ self.cancelled = False
142
+ self.complete = set()
143
+ queue: deque[int] = deque([0])
144
+ arc_count = 0
145
+ processed = 0
146
+
147
+ while queue:
148
+ processed += 1
149
+ if processed % 5 == 0:
150
+ if progress is not None:
151
+ progress(len(self.states), arc_count)
152
+ if should_stop is not None and should_stop():
153
+ self.cancelled = self.partial = True
154
+ break
155
+ # Let other threads (the window) run: pure-Python exploration
156
+ # would otherwise hold the interpreter lock almost all the time.
157
+ time.sleep(0)
158
+ current_index = queue.popleft()
159
+ current = self.states[current_index]
160
+ self.arcs.setdefault(current_index, [])
161
+ dropped = stopped = False
162
+
163
+ for binding, successor in self._successors(simulator, current):
164
+ if successor not in self.index_of:
165
+ if len(self.states) >= max_nodes:
166
+ self.partial = dropped = True
167
+ continue
168
+ self.index_of[successor] = len(self.states)
169
+ self.states.append(successor)
170
+ queue.append(self.index_of[successor])
171
+ self.arcs[current_index].append(
172
+ StateArc(current_index, self.index_of[successor], binding)
173
+ )
174
+ arc_count += 1
175
+ if arc_count >= max_arcs:
176
+ self.partial = stopped = True
177
+ queue.clear()
178
+ break
179
+ if not (dropped or stopped):
180
+ self.complete.add(current_index)
181
+ if dropped:
182
+ # The node limit is reached: expanding the rest of the queue
183
+ # could only add arcs between known nodes, at the full cost of
184
+ # computing every successor. Stop here, as CPN Tools does.
185
+ break
186
+
187
+ return self
188
+
189
+ def _successors(self, simulator: Simulator,
190
+ state: State) -> Iterator[tuple[BindingElement, State]]:
191
+ """All (binding element, next state) pairs out of one state.
192
+
193
+ Mirrors the simulator's own stepping rule, including the time jump: if
194
+ nothing is enabled at the state's clock but tokens are stamped for the
195
+ future, the clock advances and we look again. Without that, every
196
+ timed model would appear to deadlock.
197
+ """
198
+ simulator.marking = state.marking
199
+ simulator.clock = state.time
200
+
201
+ enabled = simulator.all_enabled()
202
+ if not enabled:
203
+ # Let time pass, one token-release moment at a time, until
204
+ # something is enabled -- like Simulator.step. Stopping after the
205
+ # first jump would call a state dead when the token released
206
+ # first enables nothing but a later one does.
207
+ while not enabled and simulator.advance_time():
208
+ enabled = simulator.all_enabled()
209
+ # The time jump is itself a state change, so we model it as
210
+ # firing from the *advanced* state.
211
+ advanced = simulator.clock
212
+ for element in enabled:
213
+ marking, clock = simulator.fire(element, state.marking, advanced)
214
+ yield element, State(marking, clock)
215
+ return
216
+
217
+ for element in enabled:
218
+ marking, clock = simulator.fire(element, state.marking, state.time)
219
+ yield element, State(marking, clock)
220
+
221
+ # =======================================================================
222
+ # Basic statistics
223
+ # =======================================================================
224
+ @property
225
+ def node_count(self) -> int:
226
+ return len(self.states)
227
+
228
+ @property
229
+ def arc_count(self) -> int:
230
+ return sum(len(arcs) for arcs in self.arcs.values())
231
+
232
+ # =======================================================================
233
+ # Boundedness
234
+ # =======================================================================
235
+ def integer_bounds(self) -> dict[str, tuple[int, int]]:
236
+ """Place id -> ``(lower, upper)`` token counts over all reachable states."""
237
+ bounds: dict[str, tuple[int, int]] = {}
238
+ for place in self.net.all_places():
239
+ key = self.net.marking_key(place.id)
240
+ counts = [state.marking.get(key).size() for state in self.states]
241
+ bounds[place.id] = (min(counts), max(counts)) if counts else (0, 0)
242
+ return bounds
243
+
244
+ def upper_multiset_bounds(self) -> dict[str, Multiset]:
245
+ """Place id -> the pointwise maximum multiset over all reachable states.
246
+
247
+ This is CPN Tools' "best upper multiset bound": the smallest multiset
248
+ ``B`` with ``M(p) <= B`` for every reachable ``M``. It is strictly more
249
+ informative than the integer bound, because it says *which* colours can
250
+ be present and how many of each.
251
+ """
252
+ if getattr(self, "_multiset_bounds", None) is not None and \
253
+ self._multiset_bounds[0] == len(self.states):
254
+ return self._multiset_bounds[1]
255
+ result: dict[str, Multiset] = {}
256
+ for place in self.net.all_places():
257
+ key = self.net.marking_key(place.id)
258
+ maximum: dict[Any, int] = {}
259
+ # Successive states share the multiset objects of places a firing
260
+ # did not touch, so each distinct object only needs one look.
261
+ seen: set[int] = set()
262
+ for state in self.states:
263
+ tokens = state.marking.get(key)
264
+ if id(tokens) in seen:
265
+ continue
266
+ seen.add(id(tokens))
267
+ if isinstance(tokens, TimedMultiset):
268
+ tokens = tokens.available_at(10**12) # ignore stamps here
269
+ for value, count in tokens._counts.items():
270
+ if count > maximum.get(value, 0):
271
+ maximum[value] = count
272
+ result[place.id] = Multiset(maximum)
273
+ self._multiset_bounds = (len(self.states), result)
274
+ return result
275
+
276
+ # =======================================================================
277
+ # Strongly connected components (Tarjan, iterative)
278
+ # =======================================================================
279
+ def strongly_connected_components(self) -> list[list[int]]:
280
+ """Partition the nodes into SCCs.
281
+
282
+ Iterative Tarjan: the explicit stack replaces recursion so that deep
283
+ state spaces do not hit Python's recursion limit. Components are
284
+ returned in reverse topological order, which is what Tarjan produces
285
+ naturally.
286
+ """
287
+ index_counter = 0
288
+ indices: dict[int, int] = {}
289
+ low_links: dict[int, int] = {}
290
+ on_stack: set[int] = set()
291
+ stack: list[int] = []
292
+ components: list[list[int]] = []
293
+
294
+ for root in range(len(self.states)):
295
+ if root in indices:
296
+ continue
297
+ # Each work item is (node, iterator over its successors).
298
+ work: list[tuple[int, Iterator[int]]] = [
299
+ (root, iter(self._successor_indices(root)))
300
+ ]
301
+ indices[root] = low_links[root] = index_counter
302
+ index_counter += 1
303
+ stack.append(root)
304
+ on_stack.add(root)
305
+
306
+ while work:
307
+ node, successors = work[-1]
308
+ advanced = False
309
+ for successor in successors:
310
+ if successor not in indices:
311
+ indices[successor] = low_links[successor] = index_counter
312
+ index_counter += 1
313
+ stack.append(successor)
314
+ on_stack.add(successor)
315
+ work.append((successor, iter(self._successor_indices(successor))))
316
+ advanced = True
317
+ break
318
+ if successor in on_stack:
319
+ low_links[node] = min(low_links[node], indices[successor])
320
+ if advanced:
321
+ continue
322
+
323
+ work.pop()
324
+ if work:
325
+ parent = work[-1][0]
326
+ low_links[parent] = min(low_links[parent], low_links[node])
327
+ if low_links[node] == indices[node]:
328
+ component: list[int] = []
329
+ while True:
330
+ member = stack.pop()
331
+ on_stack.discard(member)
332
+ component.append(member)
333
+ if member == node:
334
+ break
335
+ components.append(component)
336
+
337
+ return components
338
+
339
+ def _successor_indices(self, node: int) -> list[int]:
340
+ return [arc.target for arc in self.arcs.get(node, [])]
341
+
342
+ def terminal_components(self) -> list[list[int]]:
343
+ """SCCs with no arcs leaving them -- where the system ends up."""
344
+ components = self.strongly_connected_components()
345
+ component_of: dict[int, int] = {}
346
+ for number, component in enumerate(components):
347
+ for node in component:
348
+ component_of[node] = number
349
+
350
+ terminal: list[list[int]] = []
351
+ for number, component in enumerate(components):
352
+ members = set(component)
353
+ if all(
354
+ component_of[arc.target] == number
355
+ for node in component
356
+ for arc in self.arcs.get(node, [])
357
+ ):
358
+ terminal.append(component)
359
+ return terminal
360
+
361
+ # =======================================================================
362
+ # Liveness and home properties
363
+ # =======================================================================
364
+ def dead_markings(self) -> list[int]:
365
+ """Nodes from which nothing can happen -- the deadlocks.
366
+
367
+ Only fully explored nodes count: in a partial state space a frontier
368
+ node has no arcs simply because it was never expanded.
369
+ """
370
+ return [index for index in sorted(self.complete) if not self.arcs.get(index)]
371
+
372
+ @property
373
+ def unexplored_count(self) -> int:
374
+ """Nodes found but not (fully) expanded -- zero for a full state space."""
375
+ return len(self.states) - len(self.complete)
376
+
377
+ def occurring_transitions(self) -> set[str]:
378
+ """Transition ids that label at least one arc."""
379
+ return {
380
+ arc.binding.transition_id
381
+ for arcs in self.arcs.values()
382
+ for arc in arcs
383
+ }
384
+
385
+ def dead_transitions(self) -> list[Transition]:
386
+ """Transitions that can never occur from the initial marking."""
387
+ occurring = self.occurring_transitions()
388
+ return [
389
+ transition
390
+ for transition in self.net.all_transitions()
391
+ if not transition.is_substitution and transition.id not in occurring
392
+ ]
393
+
394
+ def live_transitions(self) -> list[Transition]:
395
+ """Transitions that can always eventually occur again.
396
+
397
+ A transition is live iff it labels an arc inside **every** terminal
398
+ SCC. Intuition: whichever cycle the system ultimately settles into,
399
+ this transition still occurs within it.
400
+ """
401
+ terminals = self.terminal_components()
402
+ if not terminals:
403
+ return []
404
+
405
+ # The transitions occurring inside each terminal component.
406
+ occurring: list[set[str]] = []
407
+ for component in terminals:
408
+ members = set(component)
409
+ occurring.append({
410
+ arc.binding.transition_id
411
+ for node in component
412
+ for arc in self.arcs.get(node, [])
413
+ if arc.target in members
414
+ })
415
+ return [
416
+ transition for transition in self.net.all_transitions()
417
+ if not transition.is_substitution
418
+ and all(transition.id in inside for inside in occurring)
419
+ ]
420
+
421
+ def home_markings(self) -> list[int]:
422
+ """Markings reachable from every reachable marking.
423
+
424
+ Exists iff the condensed graph has exactly one terminal SCC, in which
425
+ case every state in that SCC is a home marking.
426
+ """
427
+ terminals = self.terminal_components()
428
+ if len(terminals) != 1:
429
+ return []
430
+ return sorted(terminals[0])
431
+
432
+ # =======================================================================
433
+ # Reporting
434
+ # =======================================================================
435
+ def report(self, max_listed: int = 10) -> str:
436
+ """A textual report modelled on CPN Tools' state space report."""
437
+ lines: list[str] = []
438
+ add = lines.append
439
+
440
+ add("=" * 66)
441
+ add(f" State space report for: {self.net.name}")
442
+ add("=" * 66)
443
+ add("")
444
+ add("Statistics")
445
+ add("----------")
446
+ add(f" Nodes: {self.node_count}")
447
+ add(f" Arcs: {self.arc_count}")
448
+ status = ("PARTIAL (stopped by the user)" if self.cancelled else
449
+ "PARTIAL (node/arc limit reached)" if self.partial else "Full")
450
+ add(f" Status: {status}")
451
+ if self.partial:
452
+ add(" WARNING: the properties below describe only the explored")
453
+ add(" fragment and are not proofs about the whole model.")
454
+ add("")
455
+
456
+ add("Boundedness Properties")
457
+ add("----------------------")
458
+ add(" Best Integer Bounds Upper Lower")
459
+ bounds = self.integer_bounds()
460
+ for place in self.net.all_places():
461
+ lower, upper = bounds[place.id]
462
+ add(f" {place.name:<24}{upper:<11}{lower}")
463
+ add("")
464
+ add(" Best Upper Multiset Bounds")
465
+ multiset_bounds = self.upper_multiset_bounds() # computed once
466
+ for place in self.net.all_places():
467
+ add(f" {place.name:<24}{multiset_bounds[place.id]}")
468
+ add("")
469
+
470
+ add("Home Properties")
471
+ add("---------------")
472
+ home = [] if self.partial else self.home_markings()
473
+ if self.partial:
474
+ add(" Home Markings: not determined (needs the full state space)")
475
+ elif not home:
476
+ add(" Home Markings: None")
477
+ elif len(home) == self.node_count:
478
+ add(" Home Markings: All")
479
+ else:
480
+ shown = ", ".join(str(node) for node in home[:max_listed])
481
+ more = "" if len(home) <= max_listed else f", ... ({len(home)} total)"
482
+ add(f" Home Markings: [{shown}{more}]")
483
+ add("")
484
+
485
+ add("Liveness Properties")
486
+ add("-------------------")
487
+ dead = self.dead_markings()
488
+ if self.partial:
489
+ add(f" Unexplored nodes: {self.unexplored_count} (not counted as dead)")
490
+ if not dead:
491
+ add(" Dead Markings: None" + (" among the explored nodes" if self.partial else ""))
492
+ else:
493
+ shown = ", ".join(str(node) for node in dead[:max_listed])
494
+ more = "" if len(dead) <= max_listed else f", ... ({len(dead)} total)"
495
+ add(f" Dead Markings: [{shown}{more}]")
496
+
497
+ dead_transitions = self.dead_transitions()
498
+ caption = ("Transitions not seen in the explored part" if self.partial
499
+ else "Dead Transition Instances")
500
+ add(f" {caption}: "
501
+ f"{', '.join(t.name for t in dead_transitions) if dead_transitions else 'None'}")
502
+
503
+ if self.partial:
504
+ add(" Live Transition Instances: not determined (needs the full state space)")
505
+ else:
506
+ live = self.live_transitions()
507
+ add(f" Live Transition Instances: "
508
+ f"{', '.join(t.name for t in live) if live else 'None'}")
509
+ add("")
510
+ add("Fairness Properties")
511
+ add("-------------------")
512
+ add(" Not computed by this implementation.")
513
+ add("")
514
+ return "\n".join(lines)
515
+
516
+ # -- convenience ---------------------------------------------------------
517
+ def describe_state(self, index: int) -> str:
518
+ """Human-readable dump of one node."""
519
+ state = self.states[index]
520
+ header = f"Node {index}" + (f" (time {state.time})" if state.time else "")
521
+ return f"{header}\n{state.marking.describe(self.net)}"