openprocess 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cpnpy/__init__.py +46 -0
- openprocess/__init__.py +57 -0
- openprocess/analysis/__init__.py +0 -0
- openprocess/analysis/state_space.py +521 -0
- openprocess/analysis/state_space_process.py +251 -0
- openprocess/cli.py +742 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
- openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
- openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
- openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
- openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
- openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
- openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
- openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
- openprocess/exercises/pack.md +14 -0
- openprocess/flow/__init__.py +50 -0
- openprocess/flow/box.py +466 -0
- openprocess/flow/boxes/__init__.py +7 -0
- openprocess/flow/boxes/check.py +119 -0
- openprocess/flow/boxes/compare.py +16 -0
- openprocess/flow/boxes/cpn.py +53 -0
- openprocess/flow/boxes/discover.py +124 -0
- openprocess/flow/boxes/filter.py +80 -0
- openprocess/flow/boxes/input.py +124 -0
- openprocess/flow/boxes/output.py +52 -0
- openprocess/flow/boxes/predict.py +186 -0
- openprocess/flow/boxes/science.py +159 -0
- openprocess/flow/boxes/sweeps.py +18 -0
- openprocess/flow/convert.py +187 -0
- openprocess/flow/datasets.py +198 -0
- openprocess/flow/explain.py +115 -0
- openprocess/flow/library.py +222 -0
- openprocess/flow/record.py +385 -0
- openprocess/flow/runner.py +357 -0
- openprocess/flow/sweep.py +92 -0
- openprocess/flow/types.py +290 -0
- openprocess/flow/workflow.py +628 -0
- openprocess/gui/__init__.py +0 -0
- openprocess/gui/app.py +90 -0
- openprocess/gui/arc_editing.py +295 -0
- openprocess/gui/canvas.py +1414 -0
- openprocess/gui/flow/__init__.py +8 -0
- openprocess/gui/flow/canvas.py +854 -0
- openprocess/gui/flow/page.py +972 -0
- openprocess/gui/flow/templates.py +131 -0
- openprocess/gui/flow/viewers.py +665 -0
- openprocess/gui/items.py +1275 -0
- openprocess/gui/learn/answer_boxes.py +978 -0
- openprocess/gui/learn/concealment.py +91 -0
- openprocess/gui/learn/mode.py +1181 -0
- openprocess/gui/panning.py +241 -0
- openprocess/gui/resources/openprocess-icon.png +0 -0
- openprocess/gui/studio/__init__.py +1 -0
- openprocess/gui/studio/__main__.py +3 -0
- openprocess/gui/studio/app.py +4031 -0
- openprocess/gui/studio/charts.py +115 -0
- openprocess/gui/studio/compare_page.py +487 -0
- openprocess/gui/studio/cpn_page.py +1858 -0
- openprocess/gui/studio/definition_view.py +284 -0
- openprocess/gui/studio/derivation_view.py +421 -0
- openprocess/gui/studio/documents.py +152 -0
- openprocess/gui/studio/dotted_chart.py +1401 -0
- openprocess/gui/studio/file_dialogs.py +143 -0
- openprocess/gui/studio/filter_dialog.py +247 -0
- openprocess/gui/studio/graph_builders.py +176 -0
- openprocess/gui/studio/graph_view.py +682 -0
- openprocess/gui/studio/instances.py +413 -0
- openprocess/gui/studio/log_editor.py +675 -0
- openprocess/gui/studio/log_page.py +800 -0
- openprocess/gui/studio/markdown_view.py +127 -0
- openprocess/gui/studio/mathtext.py +260 -0
- openprocess/gui/studio/ml_highlighter.py +75 -0
- openprocess/gui/studio/model_page.py +760 -0
- openprocess/gui/studio/net_comparison.py +124 -0
- openprocess/gui/studio/notes_overlay.py +275 -0
- openprocess/gui/studio/petri_page.py +844 -0
- openprocess/gui/studio/regions_view.py +502 -0
- openprocess/gui/studio/sidebar.py +149 -0
- openprocess/gui/studio/style.py +503 -0
- openprocess/gui/studio/tool_icons.py +134 -0
- openprocess/gui/studio/updates.py +439 -0
- openprocess/gui/studio/widgets.py +899 -0
- openprocess/gui/studio/workers.py +60 -0
- openprocess/gui/studio/workspace.py +447 -0
- openprocess/gui/theme.py +394 -0
- openprocess/gui/tidy.py +86 -0
- openprocess/io/__init__.py +0 -0
- openprocess/io/cpn_reader.py +389 -0
- openprocess/io/cpn_writer.py +357 -0
- openprocess/learn/__init__.py +23 -0
- openprocess/learn/answers.py +188 -0
- openprocess/learn/checks.py +953 -0
- openprocess/learn/computed.py +1180 -0
- openprocess/learn/context.py +145 -0
- openprocess/learn/exam.py +169 -0
- openprocess/learn/exercise-packs.md +325 -0
- openprocess/learn/importer.py +216 -0
- openprocess/learn/notation.py +474 -0
- openprocess/learn/pack.py +511 -0
- openprocess/learn/sheet.py +296 -0
- openprocess/mining/__init__.py +73 -0
- openprocess/mining/analysis.py +689 -0
- openprocess/mining/columns.py +282 -0
- openprocess/mining/compare_nets.py +246 -0
- openprocess/mining/conformance/__init__.py +0 -0
- openprocess/mining/conformance/alignments.py +263 -0
- openprocess/mining/conformance/quality.py +145 -0
- openprocess/mining/conformance/token_replay.py +252 -0
- openprocess/mining/csv_import.py +222 -0
- openprocess/mining/definitions.py +584 -0
- openprocess/mining/dfg.py +187 -0
- openprocess/mining/discovery/__init__.py +0 -0
- openprocess/mining/discovery/alpha.py +168 -0
- openprocess/mining/discovery/heuristics.py +332 -0
- openprocess/mining/discovery/inductive.py +477 -0
- openprocess/mining/discovery/state_regions.py +62 -0
- openprocess/mining/filtering.py +237 -0
- openprocess/mining/footprint.py +183 -0
- openprocess/mining/invariants.py +191 -0
- openprocess/mining/layout.py +279 -0
- openprocess/mining/log.py +364 -0
- openprocess/mining/petrinet.py +354 -0
- openprocess/mining/playout.py +75 -0
- openprocess/mining/pm4py_bridge.py +82 -0
- openprocess/mining/pnml.py +223 -0
- openprocess/mining/processtree.py +216 -0
- openprocess/mining/regions.py +476 -0
- openprocess/mining/stats.py +160 -0
- openprocess/mining/structure.py +374 -0
- openprocess/mining/transition_system.py +409 -0
- openprocess/mining/xes.py +399 -0
- openprocess/ml/__init__.py +0 -0
- openprocess/ml/ast_nodes.py +332 -0
- openprocess/ml/builtins.py +364 -0
- openprocess/ml/colorsets.py +522 -0
- openprocess/ml/errors.py +60 -0
- openprocess/ml/evaluator.py +754 -0
- openprocess/ml/lexer.py +277 -0
- openprocess/ml/multiset.py +417 -0
- openprocess/ml/parser.py +737 -0
- openprocess/ml/values.py +319 -0
- openprocess/model/__init__.py +0 -0
- openprocess/model/declarations.py +617 -0
- openprocess/model/examples.py +98 -0
- openprocess/model/net.py +701 -0
- openprocess/model/plain.py +192 -0
- openprocess/references.py +280 -0
- openprocess/sim/__init__.py +0 -0
- openprocess/sim/binding.py +620 -0
- openprocess/sim/export.py +66 -0
- openprocess/sim/simulator.py +315 -0
- openprocess/teaching/__init__.py +4 -0
- openprocess/teaching/answers.py +4 -0
- openprocess/teaching/checks.py +5 -0
- openprocess/teaching/pack.py +4 -0
- openprocess/teaching/sheet.py +4 -0
- openprocess-0.7.0.dist-info/METADATA +927 -0
- openprocess-0.7.0.dist-info/RECORD +173 -0
- openprocess-0.7.0.dist-info/WHEEL +5 -0
- openprocess-0.7.0.dist-info/entry_points.txt +6 -0
- openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
- openprocess-0.7.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Transition systems: the first phase of two-phase discovery.
|
|
2
|
+
|
|
3
|
+
A **transition system** is ``TS = (S, E, T, s_in)``: states ``S``, events
|
|
4
|
+
``E``, transitions ``T ⊆ S × E × S`` and an initial state ``s_in``. It is the
|
|
5
|
+
simplest model of a process: the states are drawn as circles, every
|
|
6
|
+
transition as an arrow labelled with its event. It has no concurrency, so a
|
|
7
|
+
process where *a* and *b* can happen in either order needs a diamond.
|
|
8
|
+
|
|
9
|
+
From an event log (van der Aalst, *Process Mining*, Section 7.4.1)
|
|
10
|
+
-----------------------------------------------------------------
|
|
11
|
+
Each event in a trace happens in a *state*, and the **state function**
|
|
12
|
+
decides what that state is. It looks at the events before it (the
|
|
13
|
+
*prefix*), the events still to come (the *postfix*), or both, and abstracts
|
|
14
|
+
them in two steps:
|
|
15
|
+
|
|
16
|
+
1. the **horizon** ``k`` keeps only the last ``k`` events of the prefix (the
|
|
17
|
+
first ``k`` of the postfix), or all of them;
|
|
18
|
+
2. the **representation** turns that into a state: the **sequence** itself
|
|
19
|
+
(``⟨a,d⟩``), the **multiset** (``[a, d^2]``, order forgotten) or the
|
|
20
|
+
**set** (``{a,d}``, order and frequency forgotten).
|
|
21
|
+
|
|
22
|
+
Every trace then walks from state to state, one event at a time, and the
|
|
23
|
+
transition system is the union of those walks. Coarser abstractions (a
|
|
24
|
+
set, a short horizon) give fewer states and generalise more; the sequence
|
|
25
|
+
with no horizon gives a tree that allows exactly the log.
|
|
26
|
+
|
|
27
|
+
The prefix of the first event is empty, so with the prefix every trace starts
|
|
28
|
+
in the same state. The postfix of the first event is the whole trace, so
|
|
29
|
+
the postfix gives each distinct trace its own start state.
|
|
30
|
+
|
|
31
|
+
Typed transition systems
|
|
32
|
+
------------------------
|
|
33
|
+
Exercises often start from a transition system rather than a log.
|
|
34
|
+
:func:`parse_transition_system` reads one written as text::
|
|
35
|
+
|
|
36
|
+
s0 -a-> s1, s0 -b-> s2
|
|
37
|
+
s1 -c-> s3
|
|
38
|
+
initial: s0
|
|
39
|
+
|
|
40
|
+
Everything here is pure standard library, like the rest of
|
|
41
|
+
:mod:`openprocess.mining`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import re
|
|
47
|
+
from collections import Counter, deque
|
|
48
|
+
from dataclasses import dataclass, field
|
|
49
|
+
|
|
50
|
+
from .log import SimpleLog
|
|
51
|
+
|
|
52
|
+
#: The parts of a trace the state function can look at.
|
|
53
|
+
DIRECTIONS = ("prefix", "postfix", "both")
|
|
54
|
+
#: How the events in view become a state.
|
|
55
|
+
REPRESENTATIONS = ("sequence", "multiset", "set")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class TransitionSystem:
|
|
60
|
+
"""``(S, E, T, s_in)`` with states named by strings.
|
|
61
|
+
|
|
62
|
+
``initial`` is a list because a transition system built from the
|
|
63
|
+
postfix of a log has one start state per distinct trace; region theory
|
|
64
|
+
needs exactly one (:attr:`initial_state`). ``final`` lists the states
|
|
65
|
+
in which a trace of the log ended (empty for a typed system unless given).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
states: list[str] = field(default_factory=list)
|
|
69
|
+
transitions: list[tuple[str, str, str]] = field(default_factory=list)
|
|
70
|
+
initial: list[str] = field(default_factory=list)
|
|
71
|
+
final: list[str] = field(default_factory=list)
|
|
72
|
+
name: str = "Transition system"
|
|
73
|
+
#: For a transition system built from a log: each variant (sequence,
|
|
74
|
+
#: number of cases) with the states it passes through, in order.
|
|
75
|
+
traces: list[tuple[tuple[str, ...], int, list[str]]] = field(default_factory=list)
|
|
76
|
+
#: How it was made, e.g. ``"set of the prefix, last 2 events"``.
|
|
77
|
+
abstraction: str = ""
|
|
78
|
+
|
|
79
|
+
# -- construction --------------------------------------------------------------------
|
|
80
|
+
def add_state(self, state: str) -> str:
|
|
81
|
+
if state not in self._state_set():
|
|
82
|
+
self.states.append(state)
|
|
83
|
+
self._states_cache.add(state)
|
|
84
|
+
return state
|
|
85
|
+
|
|
86
|
+
def add_transition(self, source: str, event: str, target: str) -> None:
|
|
87
|
+
self.add_state(source)
|
|
88
|
+
self.add_state(target)
|
|
89
|
+
step = (source, event, target)
|
|
90
|
+
if step not in self._transition_set():
|
|
91
|
+
self.transitions.append(step)
|
|
92
|
+
self._transitions_cache.add(step)
|
|
93
|
+
|
|
94
|
+
def _state_set(self) -> set[str]:
|
|
95
|
+
cache = getattr(self, "_states_cache", None)
|
|
96
|
+
if cache is None or len(cache) != len(self.states):
|
|
97
|
+
self._states_cache = set(self.states)
|
|
98
|
+
return self._states_cache
|
|
99
|
+
|
|
100
|
+
def _transition_set(self) -> set[tuple[str, str, str]]:
|
|
101
|
+
cache = getattr(self, "_transitions_cache", None)
|
|
102
|
+
if cache is None or len(cache) != len(self.transitions):
|
|
103
|
+
self._transitions_cache = set(self.transitions)
|
|
104
|
+
return self._transitions_cache
|
|
105
|
+
|
|
106
|
+
# -- queries -------------------------------------------------------------------------
|
|
107
|
+
@property
|
|
108
|
+
def events(self) -> list[str]:
|
|
109
|
+
"""The events, in the order they first appear."""
|
|
110
|
+
seen: dict[str, None] = {}
|
|
111
|
+
for _, event, _ in self.transitions:
|
|
112
|
+
seen.setdefault(event, None)
|
|
113
|
+
return list(seen)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def initial_state(self) -> str | None:
|
|
117
|
+
"""``s_in``, when there is exactly one initial state."""
|
|
118
|
+
return self.initial[0] if len(self.initial) == 1 else None
|
|
119
|
+
|
|
120
|
+
def transitions_of(self, event: str) -> list[tuple[str, str]]:
|
|
121
|
+
"""``(source, target)`` of every transition labelled ``event``."""
|
|
122
|
+
return [(s, t) for s, e, t in self.transitions if e == event]
|
|
123
|
+
|
|
124
|
+
def successors(self, state: str) -> list[tuple[str, str]]:
|
|
125
|
+
return [(e, t) for s, e, t in self.transitions if s == state]
|
|
126
|
+
|
|
127
|
+
def reachable(self) -> set[str]:
|
|
128
|
+
"""The states reachable from the initial state(s)."""
|
|
129
|
+
seen = set(self.initial)
|
|
130
|
+
queue = deque(self.initial)
|
|
131
|
+
outgoing: dict[str, list[str]] = {}
|
|
132
|
+
for s, _, t in self.transitions:
|
|
133
|
+
outgoing.setdefault(s, []).append(t)
|
|
134
|
+
while queue:
|
|
135
|
+
state = queue.popleft()
|
|
136
|
+
for target in outgoing.get(state, []):
|
|
137
|
+
if target not in seen:
|
|
138
|
+
seen.add(target)
|
|
139
|
+
queue.append(target)
|
|
140
|
+
return seen
|
|
141
|
+
|
|
142
|
+
def summary(self) -> str:
|
|
143
|
+
def count(number: int, noun: str) -> str:
|
|
144
|
+
return f"{number} {noun}{'' if number == 1 else 's'}"
|
|
145
|
+
return (f"{count(len(self.states), 'state')}, {count(len(self.events), 'event')}, "
|
|
146
|
+
f"{count(len(self.transitions), 'transition')}")
|
|
147
|
+
|
|
148
|
+
def to_text(self) -> str:
|
|
149
|
+
"""The notation :func:`parse_transition_system` reads."""
|
|
150
|
+
lines = [f"{s} -{e}-> {t}" for s, e, t in self.transitions]
|
|
151
|
+
lonely = [s for s in self.states
|
|
152
|
+
if not any(s in (a, b) for a, _, b in self.transitions)]
|
|
153
|
+
if lonely:
|
|
154
|
+
lines.append("states: " + ", ".join(lonely))
|
|
155
|
+
if self.initial:
|
|
156
|
+
lines.append("initial: " + ", ".join(self.initial))
|
|
157
|
+
if self.final:
|
|
158
|
+
lines.append("final: " + ", ".join(self.final))
|
|
159
|
+
return "\n".join(lines)
|
|
160
|
+
|
|
161
|
+
def __repr__(self) -> str:
|
|
162
|
+
return f"<TransitionSystem {self.name!r}: {self.summary()}>"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# From an event log
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
def describe_abstraction(direction: str = "prefix", representation: str = "sequence",
|
|
169
|
+
horizon: int | None = None) -> str:
|
|
170
|
+
"""Words for a state function, e.g. ``"set of the prefix, last 2 events"``."""
|
|
171
|
+
if direction == "both":
|
|
172
|
+
part = "prefix and postfix"
|
|
173
|
+
else:
|
|
174
|
+
part = direction
|
|
175
|
+
if horizon is None:
|
|
176
|
+
window = "no horizon"
|
|
177
|
+
else:
|
|
178
|
+
noun = "event" if horizon == 1 else "events"
|
|
179
|
+
window = (f"last {horizon} {noun}" if direction == "prefix" else
|
|
180
|
+
f"next {horizon} {noun}" if direction == "postfix" else
|
|
181
|
+
f"{horizon} {noun} each way")
|
|
182
|
+
return f"{representation} of the {part}, {window}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def state_label(events: tuple[str, ...], representation: str) -> str:
|
|
186
|
+
"""How a state is written: ``⟨a,d⟩``, ``[a,d^2]`` or ``{a,d}``."""
|
|
187
|
+
if representation == "sequence":
|
|
188
|
+
return "⟨" + ",".join(events) + "⟩"
|
|
189
|
+
if representation == "multiset":
|
|
190
|
+
counts = Counter(events)
|
|
191
|
+
return "[" + ",".join(a if counts[a] == 1 else f"{a}^{counts[a]}"
|
|
192
|
+
for a in sorted(counts)) + "]"
|
|
193
|
+
if representation == "set":
|
|
194
|
+
return "{" + ",".join(sorted(set(events))) + "}"
|
|
195
|
+
raise ValueError(f"unknown representation {representation!r}; use one of "
|
|
196
|
+
+ ", ".join(REPRESENTATIONS))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def transition_system_from_log(log: SimpleLog, direction: str = "prefix",
|
|
200
|
+
representation: str = "sequence",
|
|
201
|
+
horizon: int | None = None,
|
|
202
|
+
name: str = "Transition system") -> TransitionSystem:
|
|
203
|
+
"""Build a transition system from a simple log with a state function.
|
|
204
|
+
|
|
205
|
+
``direction`` is ``"prefix"``, ``"postfix"`` or ``"both"``;
|
|
206
|
+
``representation`` is ``"sequence"``, ``"multiset"`` or ``"set"``;
|
|
207
|
+
``horizon`` is how many events to keep (``None``: all).
|
|
208
|
+
"""
|
|
209
|
+
if direction not in DIRECTIONS:
|
|
210
|
+
raise ValueError(f"unknown direction {direction!r}; use one of {', '.join(DIRECTIONS)}")
|
|
211
|
+
if horizon is not None and horizon < 1:
|
|
212
|
+
horizon = None
|
|
213
|
+
|
|
214
|
+
def view(trace: tuple[str, ...], position: int) -> str:
|
|
215
|
+
before, after = trace[:position], trace[position:]
|
|
216
|
+
if horizon is not None:
|
|
217
|
+
before, after = before[-horizon:] if horizon else (), after[:horizon]
|
|
218
|
+
if direction == "prefix":
|
|
219
|
+
return state_label(before, representation)
|
|
220
|
+
if direction == "postfix":
|
|
221
|
+
return state_label(after, representation)
|
|
222
|
+
return f"({state_label(before, representation)}, {state_label(after, representation)})"
|
|
223
|
+
|
|
224
|
+
ts = TransitionSystem(name=name, abstraction=describe_abstraction(direction, representation,
|
|
225
|
+
horizon))
|
|
226
|
+
variants = sorted(log.items(), key=lambda item: (-item[1], item[0]))
|
|
227
|
+
for trace, count in variants:
|
|
228
|
+
walk = [view(trace, position) for position in range(len(trace) + 1)]
|
|
229
|
+
if walk[0] not in ts.initial:
|
|
230
|
+
ts.initial.append(walk[0])
|
|
231
|
+
ts.add_state(walk[0])
|
|
232
|
+
for position, event in enumerate(trace):
|
|
233
|
+
ts.add_transition(walk[position], event, walk[position + 1])
|
|
234
|
+
if walk[-1] not in ts.final:
|
|
235
|
+
ts.final.append(walk[-1])
|
|
236
|
+
ts.traces.append((trace, count, walk))
|
|
237
|
+
return ts
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Typed transition systems
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# A state is a word, or a bracketed abstraction such as {a,d}, [a^2,b] or ⟨a,b⟩.
|
|
244
|
+
_STATE = r"(\{[^{}]*\}|\[[^\[\]]*\]|⟨[^⟨⟩]*⟩|\([^()]*\)|[^\s,;{}\[\]⟨⟩()]+)"
|
|
245
|
+
_EVENT = r"([^\s,;\-–—→>]+(?:\s+[^\s,;\-–—→>]+)*?)"
|
|
246
|
+
_ARROW = re.compile(_STATE + r"\s*(?:-+|–|—)\s*" + _EVENT + r"\s*(?:-+>|→|–>|—>)\s*" + _STATE)
|
|
247
|
+
_LATEX = re.compile(_STATE + r"\s*\\xrightarrow\{([^{}]+)\}\s*" + _STATE)
|
|
248
|
+
_ROLE = re.compile(r"^\s*(initial|init|start|final|end|states?)\s*[:=]\s*(.*)$", re.IGNORECASE)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _clean_state(text: str) -> str:
|
|
252
|
+
# s_0 and s_{0} (LaTeX) are both s0.
|
|
253
|
+
text = text.strip()
|
|
254
|
+
match = re.fullmatch(r"([A-Za-z]+)_\{?(\w+)\}?", text)
|
|
255
|
+
return match.group(1) + match.group(2) if match else text
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def parse_states(text: str) -> list[str]:
|
|
259
|
+
"""States written as a list: ``s0, s2`` or ``{a}, {a,d}`` (braces kept together)."""
|
|
260
|
+
return [_clean_state(m.group(0)) for m in re.finditer(_STATE, text)]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
_state_list = parse_states
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def parse_transition_system(text: str, name: str = "Transition system") -> TransitionSystem:
|
|
267
|
+
"""Read a transition system written as text.
|
|
268
|
+
|
|
269
|
+
Transitions are written ``source -event-> target`` (also ``--a-->``,
|
|
270
|
+
``–a→``, or LaTeX's ``s_0 \\xrightarrow{a} s_1``), separated by commas,
|
|
271
|
+
semicolons or new lines. States may be words (``s0``, ``s_1``) or
|
|
272
|
+
abstractions as the log-based construction writes them (``{a,d}``,
|
|
273
|
+
``[a^2]``, ``⟨a,b⟩``). Optional lines::
|
|
274
|
+
|
|
275
|
+
initial: s0 (default: the source of the first transition)
|
|
276
|
+
final: s7, s8
|
|
277
|
+
states: s9 (states without transitions)
|
|
278
|
+
|
|
279
|
+
Raises ``ValueError`` when no transition is found.
|
|
280
|
+
"""
|
|
281
|
+
ts = TransitionSystem(name=name)
|
|
282
|
+
initial: list[str] | None = None
|
|
283
|
+
final: list[str] = []
|
|
284
|
+
found = False
|
|
285
|
+
for line in text.replace(";", "\n").splitlines():
|
|
286
|
+
line = line.split("#", 1)[0] if not line.lstrip().startswith("#") else ""
|
|
287
|
+
role = _ROLE.match(line)
|
|
288
|
+
if role:
|
|
289
|
+
word, states = role.group(1).lower(), _state_list(role.group(2))
|
|
290
|
+
if word in ("initial", "init", "start"):
|
|
291
|
+
initial = (initial or []) + states
|
|
292
|
+
elif word in ("final", "end"):
|
|
293
|
+
final += states
|
|
294
|
+
else:
|
|
295
|
+
for state in states:
|
|
296
|
+
ts.add_state(state)
|
|
297
|
+
continue
|
|
298
|
+
steps = list(_ARROW.finditer(line)) + list(_LATEX.finditer(line))
|
|
299
|
+
steps.sort(key=lambda m: m.start())
|
|
300
|
+
for step in steps:
|
|
301
|
+
source, event, target = (_clean_state(step.group(1)), step.group(2).strip(),
|
|
302
|
+
_clean_state(step.group(3)))
|
|
303
|
+
ts.add_transition(source, event, target)
|
|
304
|
+
found = True
|
|
305
|
+
if not found:
|
|
306
|
+
raise ValueError("No transitions found. Write them as s0 -a-> s1, one per line or "
|
|
307
|
+
"separated by commas.")
|
|
308
|
+
for state in (initial or []) + final:
|
|
309
|
+
ts.add_state(state)
|
|
310
|
+
ts.initial = initial if initial else [ts.transitions[0][0]]
|
|
311
|
+
ts.final = final
|
|
312
|
+
return ts
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Isomorphism
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
def isomorphic(first: TransitionSystem, second: TransitionSystem,
|
|
319
|
+
reachable_only: bool = True) -> bool:
|
|
320
|
+
"""Are the two transition systems the same up to renaming states?
|
|
321
|
+
|
|
322
|
+
Events must match exactly; initial states must map to initial states.
|
|
323
|
+
With ``reachable_only`` only the parts reachable from the initial states
|
|
324
|
+
are compared (what a net's reachability graph can show).
|
|
325
|
+
"""
|
|
326
|
+
def prepare(ts: TransitionSystem):
|
|
327
|
+
keep = ts.reachable() if reachable_only else set(ts.states)
|
|
328
|
+
states = [s for s in ts.states if s in keep]
|
|
329
|
+
edges = sorted({(s, e, t) for s, e, t in ts.transitions if s in keep})
|
|
330
|
+
return states, edges, set(ts.initial)
|
|
331
|
+
|
|
332
|
+
states1, edges1, init1 = prepare(first)
|
|
333
|
+
states2, edges2, init2 = prepare(second)
|
|
334
|
+
if len(states1) != len(states2) or len(edges1) != len(edges2) or len(init1) != len(init2):
|
|
335
|
+
return False
|
|
336
|
+
if Counter(e for _, e, _ in edges1) != Counter(e for _, e, _ in edges2):
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
def refine(states, edges, init):
|
|
340
|
+
"""Colour refinement: states that cannot be told apart get one colour."""
|
|
341
|
+
out: dict[str, list] = {s: [] for s in states}
|
|
342
|
+
into: dict[str, list] = {s: [] for s in states}
|
|
343
|
+
for s, e, t in edges:
|
|
344
|
+
out[s].append((e, t))
|
|
345
|
+
into[t].append((e, s))
|
|
346
|
+
colour = {s: (s in init,) for s in states}
|
|
347
|
+
for _ in range(len(states) + 1):
|
|
348
|
+
new = {s: (colour[s], tuple(sorted((e, colour[t]) for e, t in out[s])),
|
|
349
|
+
tuple(sorted((e, colour[p]) for e, p in into[s]))) for s in states}
|
|
350
|
+
if len(set(new.values())) == len(set(colour.values())):
|
|
351
|
+
break
|
|
352
|
+
colour = new
|
|
353
|
+
return colour, out
|
|
354
|
+
|
|
355
|
+
# Refine both together so the colours are comparable.
|
|
356
|
+
joined_states = [("1", s) for s in states1] + [("2", s) for s in states2]
|
|
357
|
+
joined_edges = [(("1", s), e, ("1", t)) for s, e, t in edges1] + \
|
|
358
|
+
[(("2", s), e, ("2", t)) for s, e, t in edges2]
|
|
359
|
+
joined_init = {("1", s) for s in init1} | {("2", s) for s in init2}
|
|
360
|
+
colour, out = refine(joined_states, joined_edges, joined_init)
|
|
361
|
+
if Counter(colour[("1", s)] for s in states1) != Counter(colour[("2", s)] for s in states2):
|
|
362
|
+
return False
|
|
363
|
+
edge_set2 = set(joined_edges)
|
|
364
|
+
candidates = {s: [t for t in states2 if colour[("2", t)] == colour[("1", s)]] for s in states1}
|
|
365
|
+
order = sorted(states1, key=lambda s: len(candidates[s]))
|
|
366
|
+
mapping: dict[str, str] = {}
|
|
367
|
+
used: set[str] = set()
|
|
368
|
+
|
|
369
|
+
def consistent(s: str, t: str) -> bool:
|
|
370
|
+
for e, s2 in out[("1", s)]:
|
|
371
|
+
mapped = mapping.get(s2[1]) if s2[1] != s else t
|
|
372
|
+
if mapped is not None and (("2", t), e, ("2", mapped)) not in edge_set2:
|
|
373
|
+
return False
|
|
374
|
+
return True
|
|
375
|
+
|
|
376
|
+
def search(index: int) -> bool:
|
|
377
|
+
if index == len(order):
|
|
378
|
+
return all((("2", mapping[s]), e, ("2", mapping[t])) in edge_set2
|
|
379
|
+
for s, e, t in edges1)
|
|
380
|
+
s = order[index]
|
|
381
|
+
for t in candidates[s]:
|
|
382
|
+
if t in used:
|
|
383
|
+
continue
|
|
384
|
+
mapping[s] = t
|
|
385
|
+
used.add(t)
|
|
386
|
+
if consistent(s, t) and search(index + 1):
|
|
387
|
+
return True
|
|
388
|
+
del mapping[s]
|
|
389
|
+
used.discard(t)
|
|
390
|
+
return False
|
|
391
|
+
|
|
392
|
+
return search(0)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def transition_system_of_graph(graph, name: str | None = None) -> TransitionSystem:
|
|
396
|
+
"""A net's reachability graph as a transition system labelled by activities.
|
|
397
|
+
|
|
398
|
+
States are named ``m0, m1, …`` (``m0`` the initial marking); silent
|
|
399
|
+
transitions keep the label ``τ``.
|
|
400
|
+
"""
|
|
401
|
+
net = graph.net
|
|
402
|
+
ts = TransitionSystem(name=name or f"Reachability graph of {net.name}")
|
|
403
|
+
for index in range(len(graph.states)):
|
|
404
|
+
ts.add_state(f"m{index}")
|
|
405
|
+
for source, transition, target in graph.edges:
|
|
406
|
+
label = net.transitions[transition].label
|
|
407
|
+
ts.add_transition(f"m{source}", label if label is not None else "τ", f"m{target}")
|
|
408
|
+
ts.initial = ["m0"] if graph.states else []
|
|
409
|
+
return ts
|