openprocess 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. cpnpy/__init__.py +46 -0
  2. openprocess/__init__.py +57 -0
  3. openprocess/analysis/__init__.py +0 -0
  4. openprocess/analysis/state_space.py +521 -0
  5. openprocess/analysis/state_space_process.py +251 -0
  6. openprocess/cli.py +742 -0
  7. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/answer.pnml +31 -0
  8. openprocess/exercises/1 Petri nets/Exercise 1.1 Order handling/question.md +38 -0
  9. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/answer.pnml +27 -0
  10. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/net.pnml +29 -0
  11. openprocess/exercises/2 Soundness/Exercise 2.1 Spot the flaw/question.md +65 -0
  12. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/log.txt +1 -0
  13. openprocess/exercises/3 Discovery/Exercise 3.1 The alpha-algorithm/question.md +67 -0
  14. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/question.md +75 -0
  15. openprocess/exercises/4 Regions/Exercise 4.1 Regions of a transition system/ts.txt +5 -0
  16. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/net.pnml +27 -0
  17. openprocess/exercises/5 Markings/Exercise 5.1 Markings and matrices/question.md +65 -0
  18. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/log.txt +1 -0
  19. openprocess/exercises/6 Inductive Miner/Exercise 6.1 Cuts and trees/question.md +62 -0
  20. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/log.txt +1 -0
  21. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m1.pnml +36 -0
  22. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m2.pnml +28 -0
  23. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/m3.pnml +30 -0
  24. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/net.pnml +36 -0
  25. openprocess/exercises/7 Conformance/Exercise 7.1 Replay, alignments and workflows/question.md +69 -0
  26. openprocess/exercises/pack.md +14 -0
  27. openprocess/flow/__init__.py +50 -0
  28. openprocess/flow/box.py +466 -0
  29. openprocess/flow/boxes/__init__.py +7 -0
  30. openprocess/flow/boxes/check.py +119 -0
  31. openprocess/flow/boxes/compare.py +16 -0
  32. openprocess/flow/boxes/cpn.py +53 -0
  33. openprocess/flow/boxes/discover.py +124 -0
  34. openprocess/flow/boxes/filter.py +80 -0
  35. openprocess/flow/boxes/input.py +124 -0
  36. openprocess/flow/boxes/output.py +52 -0
  37. openprocess/flow/boxes/predict.py +186 -0
  38. openprocess/flow/boxes/science.py +159 -0
  39. openprocess/flow/boxes/sweeps.py +18 -0
  40. openprocess/flow/convert.py +187 -0
  41. openprocess/flow/datasets.py +198 -0
  42. openprocess/flow/explain.py +115 -0
  43. openprocess/flow/library.py +222 -0
  44. openprocess/flow/record.py +385 -0
  45. openprocess/flow/runner.py +357 -0
  46. openprocess/flow/sweep.py +92 -0
  47. openprocess/flow/types.py +290 -0
  48. openprocess/flow/workflow.py +628 -0
  49. openprocess/gui/__init__.py +0 -0
  50. openprocess/gui/app.py +90 -0
  51. openprocess/gui/arc_editing.py +295 -0
  52. openprocess/gui/canvas.py +1414 -0
  53. openprocess/gui/flow/__init__.py +8 -0
  54. openprocess/gui/flow/canvas.py +854 -0
  55. openprocess/gui/flow/page.py +972 -0
  56. openprocess/gui/flow/templates.py +131 -0
  57. openprocess/gui/flow/viewers.py +665 -0
  58. openprocess/gui/items.py +1275 -0
  59. openprocess/gui/learn/answer_boxes.py +978 -0
  60. openprocess/gui/learn/concealment.py +91 -0
  61. openprocess/gui/learn/mode.py +1181 -0
  62. openprocess/gui/panning.py +241 -0
  63. openprocess/gui/resources/openprocess-icon.png +0 -0
  64. openprocess/gui/studio/__init__.py +1 -0
  65. openprocess/gui/studio/__main__.py +3 -0
  66. openprocess/gui/studio/app.py +4031 -0
  67. openprocess/gui/studio/charts.py +115 -0
  68. openprocess/gui/studio/compare_page.py +487 -0
  69. openprocess/gui/studio/cpn_page.py +1858 -0
  70. openprocess/gui/studio/definition_view.py +284 -0
  71. openprocess/gui/studio/derivation_view.py +421 -0
  72. openprocess/gui/studio/documents.py +152 -0
  73. openprocess/gui/studio/dotted_chart.py +1401 -0
  74. openprocess/gui/studio/file_dialogs.py +143 -0
  75. openprocess/gui/studio/filter_dialog.py +247 -0
  76. openprocess/gui/studio/graph_builders.py +176 -0
  77. openprocess/gui/studio/graph_view.py +682 -0
  78. openprocess/gui/studio/instances.py +413 -0
  79. openprocess/gui/studio/log_editor.py +675 -0
  80. openprocess/gui/studio/log_page.py +800 -0
  81. openprocess/gui/studio/markdown_view.py +127 -0
  82. openprocess/gui/studio/mathtext.py +260 -0
  83. openprocess/gui/studio/ml_highlighter.py +75 -0
  84. openprocess/gui/studio/model_page.py +760 -0
  85. openprocess/gui/studio/net_comparison.py +124 -0
  86. openprocess/gui/studio/notes_overlay.py +275 -0
  87. openprocess/gui/studio/petri_page.py +844 -0
  88. openprocess/gui/studio/regions_view.py +502 -0
  89. openprocess/gui/studio/sidebar.py +149 -0
  90. openprocess/gui/studio/style.py +503 -0
  91. openprocess/gui/studio/tool_icons.py +134 -0
  92. openprocess/gui/studio/updates.py +439 -0
  93. openprocess/gui/studio/widgets.py +899 -0
  94. openprocess/gui/studio/workers.py +60 -0
  95. openprocess/gui/studio/workspace.py +447 -0
  96. openprocess/gui/theme.py +394 -0
  97. openprocess/gui/tidy.py +86 -0
  98. openprocess/io/__init__.py +0 -0
  99. openprocess/io/cpn_reader.py +389 -0
  100. openprocess/io/cpn_writer.py +357 -0
  101. openprocess/learn/__init__.py +23 -0
  102. openprocess/learn/answers.py +188 -0
  103. openprocess/learn/checks.py +953 -0
  104. openprocess/learn/computed.py +1180 -0
  105. openprocess/learn/context.py +145 -0
  106. openprocess/learn/exam.py +169 -0
  107. openprocess/learn/exercise-packs.md +325 -0
  108. openprocess/learn/importer.py +216 -0
  109. openprocess/learn/notation.py +474 -0
  110. openprocess/learn/pack.py +511 -0
  111. openprocess/learn/sheet.py +296 -0
  112. openprocess/mining/__init__.py +73 -0
  113. openprocess/mining/analysis.py +689 -0
  114. openprocess/mining/columns.py +282 -0
  115. openprocess/mining/compare_nets.py +246 -0
  116. openprocess/mining/conformance/__init__.py +0 -0
  117. openprocess/mining/conformance/alignments.py +263 -0
  118. openprocess/mining/conformance/quality.py +145 -0
  119. openprocess/mining/conformance/token_replay.py +252 -0
  120. openprocess/mining/csv_import.py +222 -0
  121. openprocess/mining/definitions.py +584 -0
  122. openprocess/mining/dfg.py +187 -0
  123. openprocess/mining/discovery/__init__.py +0 -0
  124. openprocess/mining/discovery/alpha.py +168 -0
  125. openprocess/mining/discovery/heuristics.py +332 -0
  126. openprocess/mining/discovery/inductive.py +477 -0
  127. openprocess/mining/discovery/state_regions.py +62 -0
  128. openprocess/mining/filtering.py +237 -0
  129. openprocess/mining/footprint.py +183 -0
  130. openprocess/mining/invariants.py +191 -0
  131. openprocess/mining/layout.py +279 -0
  132. openprocess/mining/log.py +364 -0
  133. openprocess/mining/petrinet.py +354 -0
  134. openprocess/mining/playout.py +75 -0
  135. openprocess/mining/pm4py_bridge.py +82 -0
  136. openprocess/mining/pnml.py +223 -0
  137. openprocess/mining/processtree.py +216 -0
  138. openprocess/mining/regions.py +476 -0
  139. openprocess/mining/stats.py +160 -0
  140. openprocess/mining/structure.py +374 -0
  141. openprocess/mining/transition_system.py +409 -0
  142. openprocess/mining/xes.py +399 -0
  143. openprocess/ml/__init__.py +0 -0
  144. openprocess/ml/ast_nodes.py +332 -0
  145. openprocess/ml/builtins.py +364 -0
  146. openprocess/ml/colorsets.py +522 -0
  147. openprocess/ml/errors.py +60 -0
  148. openprocess/ml/evaluator.py +754 -0
  149. openprocess/ml/lexer.py +277 -0
  150. openprocess/ml/multiset.py +417 -0
  151. openprocess/ml/parser.py +737 -0
  152. openprocess/ml/values.py +319 -0
  153. openprocess/model/__init__.py +0 -0
  154. openprocess/model/declarations.py +617 -0
  155. openprocess/model/examples.py +98 -0
  156. openprocess/model/net.py +701 -0
  157. openprocess/model/plain.py +192 -0
  158. openprocess/references.py +280 -0
  159. openprocess/sim/__init__.py +0 -0
  160. openprocess/sim/binding.py +620 -0
  161. openprocess/sim/export.py +66 -0
  162. openprocess/sim/simulator.py +315 -0
  163. openprocess/teaching/__init__.py +4 -0
  164. openprocess/teaching/answers.py +4 -0
  165. openprocess/teaching/checks.py +5 -0
  166. openprocess/teaching/pack.py +4 -0
  167. openprocess/teaching/sheet.py +4 -0
  168. openprocess-0.7.0.dist-info/METADATA +927 -0
  169. openprocess-0.7.0.dist-info/RECORD +173 -0
  170. openprocess-0.7.0.dist-info/WHEEL +5 -0
  171. openprocess-0.7.0.dist-info/entry_points.txt +6 -0
  172. openprocess-0.7.0.dist-info/licenses/LICENSE +21 -0
  173. openprocess-0.7.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,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