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,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
|