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,282 @@
1
+ """A columnar store for large logs: one array per attribute, no objects.
2
+
3
+ A million-event log read as :class:`~.log.Trace` and :class:`~.log.Event`
4
+ objects costs a million dicts. The :class:`ColumnStore` keeps one array
5
+ per attribute instead -- activities, resources and lifecycles as codes into
6
+ a table of strings, timestamps as seconds since the epoch -- and the
7
+ events of a case are a slice ``offsets[i]:offsets[i+1]``.
8
+
9
+ :class:`LazyTraces` makes a store look like the list of traces every view
10
+ in the app reads: ``log.traces[i]`` builds the ``Trace`` with its events on
11
+ first access and keeps it; ``len``, iteration and slicing work; and the
12
+ first *edit* (append, insert, delete) materialises everything, so the
13
+ editor keeps working. The things discovery needs most -- the activity
14
+ sequences, the directly-follows counts, the event count -- are read from
15
+ the arrays without building any object (:meth:`LazyTraces.sequences`).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from array import array
21
+ from collections.abc import MutableSequence
22
+ from datetime import datetime, timedelta, timezone
23
+ from typing import Any, Iterator
24
+
25
+ #: Standard keys kept in typed arrays; everything else goes to ``extra``.
26
+ KEY_NAME = "concept:name"
27
+ KEY_TIME = "time:timestamp"
28
+ KEY_LIFECYCLE = "lifecycle:transition"
29
+ KEY_RESOURCE = "org:resource"
30
+
31
+
32
+ class Codes:
33
+ """Strings as small integers, with the table to read them back."""
34
+
35
+ __slots__ = ("values", "_index", "codes")
36
+
37
+ def __init__(self) -> None:
38
+ self.values: list[str] = []
39
+ self._index: dict[str, int] = {}
40
+ self.codes = array("i")
41
+
42
+ def add(self, value: str | None) -> None:
43
+ if value is None:
44
+ self.codes.append(-1)
45
+ return
46
+ code = self._index.get(value)
47
+ if code is None:
48
+ code = self._index[value] = len(self.values)
49
+ self.values.append(value)
50
+ self.codes.append(code)
51
+
52
+ def get(self, index: int) -> str | None:
53
+ code = self.codes[index]
54
+ return None if code < 0 else self.values[code]
55
+
56
+ def __len__(self) -> int:
57
+ return len(self.codes)
58
+
59
+
60
+ class ColumnStore:
61
+ """The columns of a log. Fill it event by event, case by case."""
62
+
63
+ def __init__(self) -> None:
64
+ self.case_ids: list[str] = []
65
+ self.case_attributes: list[dict[str, Any]] = []
66
+ self.offsets = array("q", [0])
67
+ self.activity = Codes()
68
+ self.lifecycle = Codes()
69
+ self.resource = Codes()
70
+ self.micros = array("q") # microseconds since the epoch; NO_TIME when absent
71
+ self.offset_minutes = array("i") # the timestamp's UTC offset
72
+ #: Other event attributes: key -> list of values (None where absent).
73
+ self.extra: dict[str, list[Any]] = {}
74
+ #: Event attribute keys in the order they first appeared, so a trace
75
+ #: built from the columns writes its attributes in the file's order.
76
+ self.key_order: list[str] = []
77
+ self._known: set[str] = set()
78
+
79
+ # -- filling -------------------------------------------------------------------
80
+ def add_event(self, attributes: dict[str, Any]) -> None:
81
+ """One event from its attribute dict (the reader has a faster path)."""
82
+ self.add(attributes.get(KEY_NAME), attributes.get(KEY_TIME), attributes.get(KEY_LIFECYCLE),
83
+ attributes.get(KEY_RESOURCE),
84
+ [(k, v) for k, v in attributes.items() if k not in _STANDARD])
85
+
86
+ def add(self, activity, stamp, lifecycle, resource, extras: list[tuple[str, Any]],
87
+ order: list[str] | None = None) -> None:
88
+ if order is not None:
89
+ for key in order:
90
+ if key not in self._known:
91
+ self._known.add(key)
92
+ self.key_order.append(key)
93
+ self.activity.add(_text(activity))
94
+ self.lifecycle.add(_text(lifecycle))
95
+ self.resource.add(_text(resource))
96
+ if isinstance(stamp, datetime):
97
+ self.micros.append(_micros(stamp))
98
+ offset = stamp.utcoffset()
99
+ self.offset_minutes.append(int(offset.total_seconds() // 60) if offset is not None else 0)
100
+ else:
101
+ self.micros.append(NO_TIME)
102
+ self.offset_minutes.append(0)
103
+ position = len(self.micros) - 1
104
+ if extras:
105
+ for key, value in extras:
106
+ column = self.extra.get(key)
107
+ if column is None:
108
+ column = self.extra[key] = [None] * position
109
+ column.append(value)
110
+ if len(self.extra) != len(extras):
111
+ for column in self.extra.values():
112
+ if len(column) <= position:
113
+ column.append(None)
114
+
115
+ def end_case(self, attributes: dict[str, Any]) -> None:
116
+ self.case_attributes.append(attributes)
117
+ self.case_ids.append(_text(attributes.get(KEY_NAME)) or "")
118
+ self.offsets.append(len(self.micros))
119
+
120
+ # -- reading -------------------------------------------------------------------
121
+ @property
122
+ def case_count(self) -> int:
123
+ return len(self.case_ids)
124
+
125
+ @property
126
+ def event_count(self) -> int:
127
+ return len(self.micros)
128
+
129
+ def timestamp(self, index: int) -> datetime | None:
130
+ micros = self.micros[index]
131
+ if micros == NO_TIME:
132
+ return None
133
+ zone = timezone(timedelta(minutes=self.offset_minutes[index]))
134
+ return _EPOCH.astimezone(zone) + timedelta(microseconds=micros)
135
+
136
+ def event_attributes(self, index: int) -> dict[str, Any]:
137
+ """The attributes of one event, as the object reader would give them."""
138
+ attributes: dict[str, Any] = {}
139
+ activity = self.activity.get(index)
140
+ if activity is not None:
141
+ attributes[KEY_NAME] = activity
142
+ stamp = self.timestamp(index)
143
+ if stamp is not None:
144
+ attributes[KEY_TIME] = stamp
145
+ lifecycle = self.lifecycle.get(index)
146
+ if lifecycle is not None:
147
+ attributes[KEY_LIFECYCLE] = lifecycle
148
+ resource = self.resource.get(index)
149
+ if resource is not None:
150
+ attributes[KEY_RESOURCE] = resource
151
+ for key, column in self.extra.items():
152
+ value = column[index]
153
+ if value is not None:
154
+ attributes[key] = value
155
+ if self.key_order:
156
+ ordered = {key: attributes[key] for key in self.key_order if key in attributes}
157
+ ordered.update({k: v for k, v in attributes.items() if k not in ordered})
158
+ return ordered
159
+ return attributes
160
+
161
+ def trace(self, case: int):
162
+ from .log import Event, Trace
163
+ start, end = self.offsets[case], self.offsets[case + 1]
164
+ return Trace(dict(self.case_attributes[case]),
165
+ [Event(self.event_attributes(i)) for i in range(start, end)])
166
+
167
+ def has_lifecycle_pairs(self) -> bool:
168
+ seen = {v.lower() for v in self.lifecycle.values}
169
+ return {"start", "complete"} <= seen
170
+
171
+ def sequences(self, keys: tuple[str, ...], lifecycles: frozenset[str] | None) -> list[tuple[str, ...]]:
172
+ """Activity sequences under a classifier, straight from the arrays."""
173
+ activity, lifecycle, resource = self.activity, self.lifecycle, self.resource
174
+ columns = []
175
+ for key in keys:
176
+ if key == KEY_NAME:
177
+ columns.append((activity.codes, activity.values))
178
+ elif key == KEY_LIFECYCLE:
179
+ columns.append((lifecycle.codes, lifecycle.values))
180
+ elif key == KEY_RESOURCE:
181
+ columns.append((resource.codes, resource.values))
182
+ else:
183
+ extra = self.extra.get(key)
184
+ columns.append((None, extra))
185
+ wanted = None if lifecycles is None else {v.lower() for v in lifecycles}
186
+ out = []
187
+ offsets = self.offsets
188
+ for case in range(len(self.case_ids)):
189
+ labels = []
190
+ for i in range(offsets[case], offsets[case + 1]):
191
+ if wanted is not None:
192
+ code = lifecycle.codes[i]
193
+ if code >= 0 and lifecycle.values[code].lower() not in wanted:
194
+ continue
195
+ parts = []
196
+ for codes, values in columns:
197
+ if codes is None:
198
+ value = values[i] if values is not None else None
199
+ parts.append("" if value is None else str(value))
200
+ else:
201
+ code = codes[i]
202
+ parts.append("" if code < 0 else values[code])
203
+ labels.append("+".join(parts))
204
+ out.append(tuple(labels))
205
+ return out
206
+
207
+
208
+ _STANDARD = frozenset((KEY_NAME, KEY_TIME, KEY_LIFECYCLE, KEY_RESOURCE))
209
+ NO_TIME = -(1 << 62)
210
+ _EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
211
+
212
+
213
+ def _micros(stamp: datetime) -> int:
214
+ delta = stamp - _EPOCH
215
+ return (delta.days * 86400 + delta.seconds) * 1_000_000 + delta.microseconds
216
+
217
+
218
+ def _text(value) -> str | None:
219
+ return None if value is None else str(value)
220
+
221
+
222
+ class LazyTraces(MutableSequence):
223
+ """The traces of a log, built from a :class:`ColumnStore` on demand."""
224
+
225
+ def __init__(self, store: ColumnStore) -> None:
226
+ self.store = store
227
+ self._built: list = [None] * store.case_count
228
+ self._list: list | None = None # set once the log was edited
229
+
230
+ # -- the fast paths -------------------------------------------------------------
231
+ @property
232
+ def modified(self) -> bool:
233
+ return self._list is not None
234
+
235
+ @property
236
+ def event_count(self) -> int:
237
+ if self._list is not None:
238
+ return sum(len(t) for t in self._list)
239
+ return self.store.event_count
240
+
241
+ def sequences(self, keys: tuple[str, ...], lifecycles: frozenset[str] | None):
242
+ return self.store.sequences(keys, lifecycles) if self._list is None else None
243
+
244
+ def materialise(self) -> list:
245
+ """Every trace as an object; from then on this is a plain list."""
246
+ if self._list is None:
247
+ self._list = [self[i] for i in range(len(self))]
248
+ self._built = []
249
+ return self._list
250
+
251
+ # -- the sequence protocol ------------------------------------------------------
252
+ def __len__(self) -> int:
253
+ return len(self._list) if self._list is not None else self.store.case_count
254
+
255
+ def __getitem__(self, index):
256
+ if self._list is not None:
257
+ return self._list[index]
258
+ if isinstance(index, slice):
259
+ return [self[i] for i in range(*index.indices(len(self)))]
260
+ if index < 0:
261
+ index += len(self)
262
+ trace = self._built[index]
263
+ if trace is None:
264
+ trace = self._built[index] = self.store.trace(index)
265
+ return trace
266
+
267
+ def __iter__(self) -> Iterator:
268
+ if self._list is not None:
269
+ return iter(self._list)
270
+ return (self[i] for i in range(len(self)))
271
+
272
+ def __setitem__(self, index, value) -> None:
273
+ self.materialise()[index] = value
274
+
275
+ def __delitem__(self, index) -> None:
276
+ del self.materialise()[index]
277
+
278
+ def insert(self, index: int, value) -> None:
279
+ self.materialise().insert(index, value)
280
+
281
+ def __repr__(self) -> str:
282
+ return f"<LazyTraces {len(self)} cases, {self.event_count} events>"
@@ -0,0 +1,246 @@
1
+ """Compare two Petri nets on behaviour, not on how they are drawn.
2
+
3
+ Two nets *behave the same* when they have the same **complete traces**:
4
+ the sequences of visible labels of the firing sequences that lead from the
5
+ initial marking to the final marking. Silent (τ) transitions leave no
6
+ trace, and transitions are matched by label, so a net laid out differently
7
+ from another -- or with other place names, or an extra τ -- still matches.
8
+
9
+ How
10
+ ---
11
+ Each net is turned into an automaton over labels: its states are markings,
12
+ τ-steps are free moves, and a state accepts when it is the final marking.
13
+ Removing the τ-steps and making the automaton deterministic (the subset
14
+ construction) gives, for each sequence of labels, the set of markings it can
15
+ lead to. Both automata are explored together, breadth-first, so the first
16
+ sequence found that one accepts and the other does not is a **shortest
17
+ differing trace**.
18
+
19
+ * For **bounded** nets both reachability graphs are finite, so this is
20
+ exact: when nothing differs, the languages are equal.
21
+ * For **unbounded** nets (or ones too large to explore) only traces up to a
22
+ length limit are compared, and the result says so.
23
+
24
+ When does a net *end*? At its final marking if it has one; for a WF-net
25
+ without one, at one token in the sink; otherwise in any marking where
26
+ nothing is enabled. A net with no tokens that is a WF-net starts from one
27
+ token in its source.
28
+
29
+ Labels are compared after trimming spaces and ignoring case; a mapping can
30
+ rename one net's labels to the other's (``register`` → ``Register request``).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from collections import deque
36
+ from dataclasses import dataclass, field
37
+
38
+ from .analysis import check_workflow_net, reachability_graph
39
+ from .petrinet import Marking, PetriNet
40
+
41
+ #: How many differing traces to collect each way.
42
+ EXAMPLES = 3
43
+
44
+
45
+ def normalise(label: str) -> str:
46
+ return " ".join(label.split()).casefold()
47
+
48
+
49
+ @dataclass
50
+ class NetComparison:
51
+ #: True when the complete traces are the same (up to the length limit
52
+ #: when :attr:`exact` is False).
53
+ equivalent: bool
54
+ #: True when the comparison covers every trace (both nets bounded).
55
+ exact: bool
56
+ #: Shortest traces the first net (yours) allows and the second does not.
57
+ only_first: list[tuple[str, ...]] = field(default_factory=list)
58
+ #: Shortest traces the second net (the answer) allows and the first does not.
59
+ only_second: list[tuple[str, ...]] = field(default_factory=list)
60
+ #: Traces compared up to this many labels (None when exact).
61
+ max_length: int | None = None
62
+ #: Visible labels that occur in only one of the nets (after normalising
63
+ #: and the mapping).
64
+ labels_only_first: list[str] = field(default_factory=list)
65
+ labels_only_second: list[str] = field(default_factory=list)
66
+ notes: list[str] = field(default_factory=list)
67
+
68
+ def summary(self, first: str = "yours", second: str = "the answer") -> str:
69
+ if self.equivalent:
70
+ scope = "" if self.exact else f" (traces up to {self.max_length} steps compared)"
71
+ return f"Same behaviour{scope}: every complete trace of one is a trace of the other."
72
+ return f"Differs: {first} and {second} do not allow the same complete traces."
73
+
74
+
75
+ def _start_and_end(net: PetriNet) -> tuple[Marking, Marking | None, list[str]]:
76
+ """The initial marking, the final marking (None: any dead marking), and notes."""
77
+ notes = []
78
+ initial, final = net.initial_marking, net.final_marking or None
79
+ workflow = check_workflow_net(net)
80
+ if not initial and workflow.is_workflow_net:
81
+ initial = Marking({workflow.source: 1})
82
+ notes.append(f"{net.name} has no tokens: it starts from one token in its source place.")
83
+ if final is None and workflow.is_workflow_net:
84
+ final = Marking({workflow.sink: 1})
85
+ if final is None:
86
+ notes.append(f"{net.name} has no final marking: its traces end where nothing is enabled.")
87
+ return initial, final, notes
88
+
89
+
90
+ class _Automaton:
91
+ """The net as a (lazily explored) automaton over normalised labels."""
92
+
93
+ def __init__(self, net: PetriNet, mapping: dict[str, str] | None = None,
94
+ closure_limit: int = 5_000) -> None:
95
+ self.net = net
96
+ self.initial, self.final, self.notes = _start_and_end(net)
97
+ mapping = {normalise(k): normalise(v) for k, v in (mapping or {}).items()}
98
+ self.label: dict[str, str | None] = {}
99
+ self.spelling: dict[str, str] = {}
100
+ for transition in net.transitions.values():
101
+ if transition.label is None or not transition.label.strip():
102
+ self.label[transition.id] = None
103
+ continue
104
+ key = normalise(transition.label)
105
+ key = mapping.get(key, key)
106
+ self.label[transition.id] = key
107
+ self.spelling.setdefault(key, transition.label.strip())
108
+ self.closure_limit = closure_limit
109
+ self.overflow = False
110
+ self._closures: dict[Marking, frozenset[Marking]] = {}
111
+
112
+ def labels(self) -> set[str]:
113
+ return {label for label in self.label.values() if label is not None}
114
+
115
+ def closure(self, markings) -> frozenset[Marking]:
116
+ """Every marking reachable by τ-steps alone."""
117
+ seen = set(markings)
118
+ queue = deque(markings)
119
+ while queue:
120
+ marking = queue.popleft()
121
+ for transition in self.net.enabled(marking):
122
+ if self.label[transition] is None:
123
+ following = self.net.fire(marking, transition)
124
+ if following not in seen:
125
+ if len(seen) >= self.closure_limit:
126
+ self.overflow = True
127
+ return frozenset(seen)
128
+ seen.add(following)
129
+ queue.append(following)
130
+ return frozenset(seen)
131
+
132
+ def start(self) -> frozenset[Marking]:
133
+ return self.closure([self.initial])
134
+
135
+ def step(self, state: frozenset[Marking], label: str) -> frozenset[Marking]:
136
+ following = []
137
+ for marking in state:
138
+ for transition in self.net.enabled(marking):
139
+ if self.label[transition] == label:
140
+ following.append(self.net.fire(marking, transition))
141
+ return self.closure(following) if following else frozenset()
142
+
143
+ def accepts(self, state: frozenset[Marking]) -> bool:
144
+ if self.final is not None:
145
+ return self.final in state
146
+ return any(not self.net.enabled(m) for m in state)
147
+
148
+
149
+ def _bounded(net: PetriNet, initial: Marking, max_states: int) -> bool:
150
+ graph = reachability_graph(net, initial, max_states=max_states)
151
+ return not graph.truncated and not graph.has_omega
152
+
153
+
154
+ def compare_nets(first: PetriNet, second: PetriNet, mapping: dict[str, str] | None = None,
155
+ max_states: int = 20_000, max_length: int = 12,
156
+ max_pairs: int = 200_000) -> NetComparison:
157
+ """Compare the complete traces of ``first`` (yours) and ``second`` (the answer).
158
+
159
+ ``mapping`` renames labels of ``first`` to labels of ``second``. Exact
160
+ when both nets are bounded with at most ``max_states`` reachable
161
+ markings; otherwise traces of up to ``max_length`` labels are compared.
162
+ """
163
+ one, two = _Automaton(first, mapping), _Automaton(second)
164
+ exact = _bounded(first, one.initial, max_states) and _bounded(second, two.initial,
165
+ max_states)
166
+ labels = sorted(one.labels() | two.labels())
167
+ result = NetComparison(True, exact, max_length=None if exact else max_length)
168
+ result.notes = one.notes + two.notes
169
+ result.labels_only_first = sorted(one.spelling[x] for x in one.labels() - two.labels())
170
+ result.labels_only_second = sorted(two.spelling[x] for x in two.labels() - one.labels())
171
+
172
+ def spell(trace: tuple[str, ...], mine: bool) -> tuple[str, ...]:
173
+ """Each net's traces in its own spelling."""
174
+ first, second = (one, two) if mine else (two, one)
175
+ return tuple(first.spelling.get(x) or second.spelling.get(x, x) for x in trace)
176
+
177
+ start = (one.start(), two.start())
178
+ seen = {start}
179
+ queue: deque[tuple[tuple[frozenset, frozenset], tuple[str, ...]]] = deque([(start, ())])
180
+ while queue:
181
+ (a, b), trace = queue.popleft()
182
+ accept_a, accept_b = one.accepts(a), two.accepts(b)
183
+ if accept_a and not accept_b and len(result.only_first) < EXAMPLES:
184
+ result.only_first.append(spell(trace, True))
185
+ if accept_b and not accept_a and len(result.only_second) < EXAMPLES:
186
+ result.only_second.append(spell(trace, False))
187
+ if len(result.only_first) >= EXAMPLES and len(result.only_second) >= EXAMPLES:
188
+ break
189
+ if not exact and len(trace) >= max_length:
190
+ continue
191
+ for label in labels:
192
+ following = (one.step(a, label), two.step(b, label))
193
+ if not following[0] and not following[1]:
194
+ continue
195
+ if following not in seen:
196
+ if len(seen) >= max_pairs:
197
+ result.exact = False
198
+ result.max_length = len(trace)
199
+ result.notes.append("The comparison stopped early: the nets have too "
200
+ "many states.")
201
+ queue.clear()
202
+ break
203
+ seen.add(following)
204
+ queue.append((following, trace + (label,)))
205
+ if one.overflow or two.overflow:
206
+ result.exact = False
207
+ result.max_length = result.max_length or max_length
208
+ result.notes.append("Long runs of silent steps were cut short.")
209
+ result.equivalent = not result.only_first and not result.only_second
210
+ return result
211
+
212
+
213
+ def replayable_prefix(net: PetriNet, trace, mapping: dict[str, str] | None = None
214
+ ) -> tuple[list[str], int]:
215
+ """Transition ids that replay as much of ``trace`` (labels) on ``net`` as
216
+ possible, τ-steps included, and how many labels they cover."""
217
+ automaton = _Automaton(net, mapping)
218
+ wanted = [normalise(x) for x in trace]
219
+ # Breadth-first over (marking, labels done), remembering how we got there.
220
+ start = (automaton.initial, 0)
221
+ parent: dict[tuple[Marking, int], tuple[tuple[Marking, int], str] | None] = {start: None}
222
+ queue = deque([start])
223
+ best = start
224
+ while queue and len(parent) < 50_000:
225
+ marking, done = queue.popleft()
226
+ if done > best[1] or (done == len(wanted) and best[1] == done and
227
+ automaton.final is not None and marking == automaton.final):
228
+ best = (marking, done)
229
+ for transition in net.enabled(marking):
230
+ label = automaton.label[transition]
231
+ if label is None:
232
+ node = (net.fire(marking, transition), done)
233
+ elif done < len(wanted) and label == wanted[done]:
234
+ node = (net.fire(marking, transition), done + 1)
235
+ else:
236
+ continue
237
+ if node not in parent:
238
+ parent[node] = ((marking, done), transition)
239
+ queue.append(node)
240
+ path: list[str] = []
241
+ node = best
242
+ while parent[node] is not None:
243
+ previous, transition = parent[node] # type: ignore[misc]
244
+ path.append(transition)
245
+ node = previous
246
+ return path[::-1], best[1]
File without changes