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
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)
|
openprocess/__init__.py
ADDED
|
@@ -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)}"
|