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,263 @@
1
+ """Alignments (Carmona, van Dongen, Solti & Weidlich, ch. 7; course reading 5).
2
+
3
+ An **alignment** pairs up a trace with a complete run of the model, move by
4
+ move. Each move is one of:
5
+
6
+ ============= ================= =============================================
7
+ move written meaning
8
+ ============= ================= =============================================
9
+ synchronous ``(a, a)`` the log did a, the model did a -- agreement
10
+ log move ``(a, ≫)`` the log did a, the model could not
11
+ model move ``(≫, a)`` the model had to do a, the log did not
12
+ silent move ``(≫, τ)`` the model fired a τ transition (free)
13
+ ============= ================= =============================================
14
+
15
+ With the **standard cost function** (log and model moves cost 1, synchronous
16
+ and silent moves cost 0), an *optimal* alignment is one with minimal total
17
+ cost: the fewest deviations needed to explain the trace with the model.
18
+
19
+ How it is computed
20
+ ------------------
21
+ Search the **synchronous product**: states are ``(marking, i)`` where ``i``
22
+ is how many events have been explained so far. From each state:
23
+
24
+ * a synchronous move fires a transition labelled ``trace[i]`` and moves to
25
+ ``i + 1``;
26
+ * a log move stays in the marking and moves to ``i + 1``;
27
+ * a model move fires any enabled transition and stays at ``i``.
28
+
29
+ The goal is ``(final marking, len(trace))``. Costs are non-negative, so
30
+ Dijkstra/A* returns an optimal path. As in PM4Py, costs are scaled: a real
31
+ deviation costs 10000 and a τ move costs 1, so that among equally good
32
+ alignments the one with fewest τ moves wins; the reported cost is the
33
+ integer division by 10000.
34
+
35
+ Heuristic: every remaining event whose label does not occur in the model
36
+ *must* become a log move, so their count is a lower bound on the remaining
37
+ cost -- admissible, so A* stays optimal.
38
+
39
+ Fitness
40
+ -------
41
+ ``fitness(σ) = 1 − cost(σ) / (|σ| + cost(⟨⟩))`` where ``cost(⟨⟩)`` is the
42
+ cost of aligning the empty trace -- the length of the shortest visible run of
43
+ the model. The denominator is the worst case: every event a log move plus a
44
+ shortest run of model moves.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import heapq
50
+ import itertools
51
+ from dataclasses import dataclass, field
52
+
53
+ from ..log import SimpleLog
54
+ from ..petrinet import Marking, PetriNet
55
+
56
+ SKIP = "≫"
57
+ DEVIATION = 10_000
58
+ TAU_COST = 1
59
+
60
+
61
+ @dataclass
62
+ class Move:
63
+ log: str # activity, or SKIP
64
+ model: str # label, "τ", or SKIP
65
+ transition: str | None # transition id for model/sync moves
66
+
67
+ @property
68
+ def kind(self) -> str:
69
+ if self.log == SKIP:
70
+ return "silent" if self.model == "τ" else "model"
71
+ if self.model == SKIP:
72
+ return "log"
73
+ return "sync"
74
+
75
+
76
+ @dataclass
77
+ class Alignment:
78
+ trace: tuple[str, ...]
79
+ count: int
80
+ moves: list[Move]
81
+ cost: int # number of deviations (standard cost function)
82
+ fitness: float
83
+ states_visited: int = 0
84
+ #: False if the search gave up (state limit); then moves are empty
85
+ complete: bool = True
86
+
87
+ @property
88
+ def log_moves(self) -> int:
89
+ return sum(1 for m in self.moves if m.kind == "log")
90
+
91
+ @property
92
+ def model_moves(self) -> int:
93
+ return sum(1 for m in self.moves if m.kind == "model")
94
+
95
+
96
+ @dataclass
97
+ class AlignmentResult:
98
+ net: PetriNet
99
+ alignments: list[Alignment] = field(default_factory=list) # per variant
100
+ empty_trace_cost: int = 0
101
+
102
+ @property
103
+ def trace_count(self) -> int:
104
+ return sum(a.count for a in self.alignments)
105
+
106
+ @property
107
+ def average_fitness(self) -> float:
108
+ """Mean of the per-trace fitness values (weighted by frequency)."""
109
+ total = self.trace_count
110
+ return sum(a.fitness * a.count for a in self.alignments) / total if total else 1.0
111
+
112
+ @property
113
+ def log_fitness(self) -> float:
114
+ """1 − total cost / total worst-case cost."""
115
+ cost = sum(a.cost * a.count for a in self.alignments)
116
+ worst = sum((len(a.trace) + self.empty_trace_cost) * a.count for a in self.alignments)
117
+ return 1 - cost / worst if worst else 1.0
118
+
119
+ @property
120
+ def fitting_traces(self) -> int:
121
+ return sum(a.count for a in self.alignments if a.cost == 0 and a.complete)
122
+
123
+ def move_statistics(self) -> tuple[dict[str, int], dict[str, int], dict[str, int]]:
124
+ """(sync, log-move, model-move) counts per activity, frequency-weighted."""
125
+ sync: dict[str, int] = {}
126
+ log_moves: dict[str, int] = {}
127
+ model_moves: dict[str, int] = {}
128
+ for alignment in self.alignments:
129
+ for move in alignment.moves:
130
+ if move.kind == "sync":
131
+ sync[move.log] = sync.get(move.log, 0) + alignment.count
132
+ elif move.kind == "log":
133
+ log_moves[move.log] = log_moves.get(move.log, 0) + alignment.count
134
+ elif move.kind == "model":
135
+ model_moves[move.model] = model_moves.get(move.model, 0) + alignment.count
136
+ return sync, log_moves, model_moves
137
+
138
+
139
+ class _CompiledNet:
140
+ """The net flattened into integer arrays for a fast inner loop.
141
+
142
+ A marking becomes a tuple of token counts indexed by place number, and
143
+ each transition becomes its input requirements plus its effect vector.
144
+ Tuples hash and compare much faster than :class:`Marking` objects, which
145
+ matters because an alignment search can visit hundreds of thousands of
146
+ states.
147
+ """
148
+
149
+ def __init__(self, net: PetriNet) -> None:
150
+ self.places = list(net.places)
151
+ index = {p: i for i, p in enumerate(self.places)}
152
+ self.transitions = list(net.transitions.values())
153
+ self.pre = [tuple((index[p], n) for p, n in net.pre(t.id).items())
154
+ for t in self.transitions]
155
+ self.delta = []
156
+ for t in self.transitions:
157
+ change = [0] * len(self.places)
158
+ for p, n in net.pre(t.id).items():
159
+ change[index[p]] -= n
160
+ for p, n in net.post(t.id).items():
161
+ change[index[p]] += n
162
+ self.delta.append(tuple((i, d) for i, d in enumerate(change) if d))
163
+ self.initial = self.encode(net.initial_marking, index)
164
+ self.final = self.encode(net.final_marking, index)
165
+
166
+ def encode(self, marking: Marking, index: dict[str, int]) -> tuple[int, ...]:
167
+ vector = [0] * len(self.places)
168
+ for p, n in marking.items():
169
+ vector[index[p]] = n
170
+ return tuple(vector)
171
+
172
+ def enabled(self, marking: tuple[int, ...]) -> list[int]:
173
+ return [k for k, pre in enumerate(self.pre)
174
+ if all(marking[i] >= n for i, n in pre)]
175
+
176
+ def fire(self, marking: tuple[int, ...], k: int) -> tuple[int, ...]:
177
+ vector = list(marking)
178
+ for i, d in self.delta[k]:
179
+ vector[i] += d
180
+ return tuple(vector)
181
+
182
+
183
+ def align_trace(net: PetriNet, trace: tuple[str, ...], count: int = 1,
184
+ max_states: int = 500_000, compiled: "_CompiledNet | None" = None) -> Alignment:
185
+ """An optimal alignment of one trace (A* on the synchronous product)."""
186
+ c = compiled or _CompiledNet(net)
187
+ labels = net.labels()
188
+ # Suffix heuristic: events not in the model are unavoidable log moves.
189
+ unavoidable = [0] * (len(trace) + 1)
190
+ for i in range(len(trace) - 1, -1, -1):
191
+ unavoidable[i] = unavoidable[i + 1] + (DEVIATION if trace[i] not in labels else 0)
192
+
193
+ start = (c.initial, 0)
194
+ tie = itertools.count()
195
+ frontier: list = [(unavoidable[0], 0, next(tie), start)]
196
+ best_cost: dict = {start: 0}
197
+ parent: dict = {start: None}
198
+ closed: set = set()
199
+ goal = (c.final, len(trace))
200
+
201
+ while frontier:
202
+ _, cost, _, state = heapq.heappop(frontier)
203
+ if state in closed:
204
+ continue
205
+ closed.add(state)
206
+ if state == goal:
207
+ moves = []
208
+ node = state
209
+ while parent[node] is not None:
210
+ previous, move = parent[node]
211
+ moves.append(move)
212
+ node = previous
213
+ return Alignment(trace, count, moves[::-1], cost // DEVIATION, 0.0, len(closed))
214
+ if len(closed) > max_states:
215
+ break
216
+
217
+ marking, position = state
218
+ enabled = c.enabled(marking)
219
+ successors = []
220
+ if position < len(trace):
221
+ activity = trace[position]
222
+ successors.append(((marking, position + 1), DEVIATION, Move(activity, SKIP, None)))
223
+ for k in enabled:
224
+ t = c.transitions[k]
225
+ if t.label == activity:
226
+ successors.append(((c.fire(marking, k), position + 1), 0,
227
+ Move(activity, activity, t.id)))
228
+ for k in enabled:
229
+ t = c.transitions[k]
230
+ step_cost = TAU_COST if t.label is None else DEVIATION
231
+ successors.append(((c.fire(marking, k), position), step_cost,
232
+ Move(SKIP, "τ" if t.label is None else t.label, t.id)))
233
+
234
+ for successor, step_cost, move in successors:
235
+ if successor in closed:
236
+ continue
237
+ new_cost = cost + step_cost
238
+ if new_cost < best_cost.get(successor, 1 << 62):
239
+ best_cost[successor] = new_cost
240
+ parent[successor] = (state, move)
241
+ heapq.heappush(frontier, (new_cost + unavoidable[successor[1]], new_cost,
242
+ next(tie), successor))
243
+
244
+ return Alignment(trace, count, [], len(trace), 0.0, len(closed), complete=False)
245
+
246
+
247
+ def align_log(net: PetriNet, log: SimpleLog, max_states: int = 500_000,
248
+ progress=None) -> AlignmentResult:
249
+ """Align every variant; fitness per trace uses the empty-trace cost."""
250
+ compiled = _CompiledNet(net)
251
+ empty = align_trace(net, (), 1, max_states, compiled)
252
+ result = AlignmentResult(net, empty_trace_cost=empty.cost)
253
+ variants = sorted(log.items(), key=lambda item: (-item[1], item[0]))
254
+ for number, (trace, count) in enumerate(variants, 1):
255
+ alignment = align_trace(net, trace, count, max_states, compiled)
256
+ worst = len(trace) + empty.cost
257
+ alignment.fitness = 1 - alignment.cost / worst if worst else 1.0
258
+ if not alignment.complete:
259
+ alignment.fitness = 0.0
260
+ result.alignments.append(alignment)
261
+ if progress is not None:
262
+ progress(number, len(variants))
263
+ return result
@@ -0,0 +1,145 @@
1
+ """Precision, generalisation and simplicity -- the other three quality dimensions.
2
+
3
+ Fitness (token replay, alignments) asks "can the model replay the log?".
4
+ Three more dimensions complete the picture (course: the four quality
5
+ criteria):
6
+
7
+ precision
8
+ Does the model allow *only* what the log shows? A flower model fits
9
+ everything but has terrible precision.
10
+ generalisation
11
+ Does the model generalise beyond the examples, rather than overfitting
12
+ exactly the traces seen?
13
+ simplicity
14
+ Is the model as simple as possible (Occam's razor)?
15
+
16
+ Precision: escaping edges (ETConformance, Muñoz-Gama & Carmona)
17
+ ---------------------------------------------------------------
18
+ Walk through every *prefix* of every trace. After prefix ``h``, the log
19
+ continued with some set of activities ``log_next(h)``; the model, in the
20
+ marking reached by replaying ``h``, would *allow* a set ``model_next(h)``.
21
+ Anything the model allows but the log never did is an **escaping edge**::
22
+
23
+ Σ_h freq(h) · |model_next(h) \\ log_next(h)|
24
+ precision = 1 − ─────────────────────────────────────────────
25
+ Σ_h freq(h) · |model_next(h)|
26
+
27
+ Prefixes that do not fit the model are skipped (there is no meaningful
28
+ marking to compare against). ``model_next`` looks through τ transitions.
29
+
30
+ Generalisation (token-based, as in PM4Py)
31
+ -----------------------------------------
32
+ A transition that fired only a few times during replay gives little evidence
33
+ that the model is right about it. With ``n_t`` the number of firings of
34
+ transition t::
35
+
36
+ generalisation = 1 − mean_t ( 1 / sqrt(n_t) )
37
+
38
+ Simplicity (arc degree, as in PM4Py)
39
+ ------------------------------------
40
+ The mean number of arcs per node; a pure sequence has degree 2. Anything
41
+ above that makes the model harder to read::
42
+
43
+ simplicity = 1 / (1 + max(mean degree − 2, 0))
44
+
45
+ These last two are rough proxies -- which is also what the literature says
46
+ about them -- but they make the trade-off between the four dimensions
47
+ visible when comparing models discovered from the same log.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import math
53
+ from collections import Counter, defaultdict, deque
54
+
55
+ from ..log import SimpleLog
56
+ from ..petrinet import Marking, PetriNet
57
+ from .token_replay import ReplayResult, silent_path
58
+
59
+
60
+ def _visible_enabled(net: PetriNet, marking: Marking, max_states: int = 2000) -> set[str]:
61
+ """Labels that can fire next, possibly after τ moves."""
62
+ result: set[str] = set()
63
+ seen = {marking}
64
+ queue = deque([marking])
65
+ while queue and len(seen) < max_states:
66
+ current = queue.popleft()
67
+ for transition in net.enabled(current):
68
+ label = net.transitions[transition].label
69
+ if label is not None:
70
+ result.add(label)
71
+ else:
72
+ successor = net.fire(current, transition)
73
+ if successor not in seen:
74
+ seen.add(successor)
75
+ queue.append(successor)
76
+ return result
77
+
78
+
79
+ def _replay_prefix(net: PetriNet, prefix: tuple[str, ...],
80
+ cache: dict[tuple[str, ...], Marking | None]) -> Marking | None:
81
+ """Marking after replaying ``prefix`` without missing tokens, or None."""
82
+ if prefix in cache:
83
+ return cache[prefix]
84
+ if not prefix:
85
+ cache[prefix] = net.initial_marking
86
+ return net.initial_marking
87
+ before = _replay_prefix(net, prefix[:-1], cache)
88
+ result: Marking | None = None
89
+ if before is not None:
90
+ activity = prefix[-1]
91
+ candidates = net.transitions_with_label(activity)
92
+ for transition in candidates:
93
+ if net.is_enabled(before, transition):
94
+ result = net.fire(before, transition)
95
+ break
96
+ else:
97
+ for transition in candidates:
98
+ path = silent_path(net, before, lambda m, t=transition: net.is_enabled(m, t))
99
+ if path is not None:
100
+ marking = before
101
+ for step in path:
102
+ marking = net.fire(marking, step)
103
+ result = net.fire(marking, transition)
104
+ break
105
+ cache[prefix] = result
106
+ return result
107
+
108
+
109
+ def precision(net: PetriNet, log: SimpleLog) -> float:
110
+ """ETConformance precision with escaping edges."""
111
+ log_next: dict[tuple[str, ...], set[str]] = defaultdict(set)
112
+ frequency: Counter = Counter()
113
+ for trace, count in log.items():
114
+ for i, activity in enumerate(trace):
115
+ prefix = trace[:i]
116
+ log_next[prefix].add(activity)
117
+ frequency[prefix] += count
118
+
119
+ cache: dict[tuple[str, ...], Marking | None] = {}
120
+ escaping = allowed = 0
121
+ for prefix, observed in log_next.items():
122
+ marking = _replay_prefix(net, prefix, cache)
123
+ if marking is None:
124
+ continue
125
+ enabled = _visible_enabled(net, marking)
126
+ allowed += frequency[prefix] * len(enabled)
127
+ escaping += frequency[prefix] * len(enabled - observed)
128
+ return 1.0 if allowed == 0 else 1 - escaping / allowed
129
+
130
+
131
+ def generalisation(replay: ReplayResult) -> float:
132
+ fired, _ = replay.transition_totals()
133
+ net = replay.net
134
+ if not net.transitions:
135
+ return 1.0
136
+ terms = [1 / math.sqrt(fired[t]) if fired[t] else 1.0 for t in net.transitions]
137
+ return 1 - sum(terms) / len(terms)
138
+
139
+
140
+ def simplicity(net: PetriNet) -> float:
141
+ nodes = len(net.places) + len(net.transitions)
142
+ if nodes == 0:
143
+ return 1.0
144
+ mean_degree = 2 * len(net.arcs) / nodes
145
+ return 1 / (1 + max(mean_degree - 2, 0))
@@ -0,0 +1,252 @@
1
+ """Token-based replay (van der Aalst, *Process Mining*, §8.2).
2
+
3
+ Replay each trace on the net and *count tokens*:
4
+
5
+ ``p`` produced
6
+ tokens put into places (the environment first produces the initial
7
+ marking, then every fired transition produces its output tokens);
8
+ ``c`` consumed
9
+ tokens taken out (every fired transition consumes its inputs, and at the
10
+ end the environment consumes the final marking);
11
+ ``m`` missing
12
+ tokens that had to be *created artificially* because the transition for
13
+ the next event was not enabled -- evidence the log did something the
14
+ model did not allow;
15
+ ``r`` remaining
16
+ tokens left behind after the final marking was consumed -- evidence the
17
+ model expected something the log did not do.
18
+
19
+ Fitness of a trace σ on net N::
20
+
21
+ fitness(σ, N) = ½ (1 − m / c) + ½ (1 − r / p)
22
+
23
+ It is 1 exactly when nothing was missing or remaining. For a whole log the
24
+ four counts are summed over all traces (weighted by frequency) before the
25
+ formula is applied.
26
+
27
+ Silent transitions and duplicates
28
+ ---------------------------------
29
+ Events never refer to τ transitions, so the replayer may fire them on its
30
+ own: when the transition for the next event is not enabled, it first looks
31
+ for a (shortest) sequence of silent transitions that enables it. With
32
+ duplicate labels, it prefers a transition that is enabled, then one reachable
33
+ through silent moves, then the one missing the fewest tokens. These are
34
+ heuristics -- token replay is fast but not always optimal, which is why
35
+ alignments exist (see :mod:`alignments`).
36
+
37
+ Diagnostics
38
+ -----------
39
+ Per place we keep the total missing and remaining tokens, and per transition
40
+ how often it fired with missing tokens. The app paints these on the model
41
+ (ProM's "replay" view): a red ``−n`` badge means the log wanted a token here
42
+ that was not there; a ``+n`` badge means a token was left behind.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from collections import Counter, deque
48
+ from dataclasses import dataclass, field
49
+
50
+ from ..log import SimpleLog
51
+ from ..petrinet import Marking, PetriNet
52
+
53
+
54
+ @dataclass
55
+ class TraceReplay:
56
+ trace: tuple[str, ...]
57
+ count: int
58
+ produced: int
59
+ consumed: int
60
+ missing: int
61
+ remaining: int
62
+ #: activities that do not occur in the model at all
63
+ unknown_activities: list[str] = field(default_factory=list)
64
+ #: indices of events whose transition was not enabled
65
+ problem_events: list[int] = field(default_factory=list)
66
+ missing_by_place: Counter = field(default_factory=Counter)
67
+ remaining_by_place: Counter = field(default_factory=Counter)
68
+ fired: Counter = field(default_factory=Counter) # transition id -> times
69
+ forced: Counter = field(default_factory=Counter) # fired with missing tokens
70
+
71
+ @property
72
+ def fitness(self) -> float:
73
+ return _fitness(self.produced, self.consumed, self.missing, self.remaining)
74
+
75
+ @property
76
+ def fits(self) -> bool:
77
+ return self.missing == 0 and self.remaining == 0 and not self.unknown_activities
78
+
79
+
80
+ def _fitness(p: int, c: int, m: int, r: int) -> float:
81
+ part_missing = 1 - m / c if c else 1.0
82
+ part_remaining = 1 - r / p if p else 1.0
83
+ return 0.5 * part_missing + 0.5 * part_remaining
84
+
85
+
86
+ @dataclass
87
+ class ReplayResult:
88
+ net: PetriNet
89
+ traces: list[TraceReplay] # one per variant, most frequent first
90
+
91
+ def _total(self, attribute: str) -> int:
92
+ return sum(getattr(t, attribute) * t.count for t in self.traces)
93
+
94
+ @property
95
+ def produced(self) -> int:
96
+ return self._total("produced")
97
+
98
+ @property
99
+ def consumed(self) -> int:
100
+ return self._total("consumed")
101
+
102
+ @property
103
+ def missing(self) -> int:
104
+ return self._total("missing")
105
+
106
+ @property
107
+ def remaining(self) -> int:
108
+ return self._total("remaining")
109
+
110
+ @property
111
+ def fitness(self) -> float:
112
+ """Log-level fitness from the summed counts."""
113
+ return _fitness(self.produced, self.consumed, self.missing, self.remaining)
114
+
115
+ @property
116
+ def fitting_traces(self) -> int:
117
+ return sum(t.count for t in self.traces if t.fits)
118
+
119
+ @property
120
+ def trace_count(self) -> int:
121
+ return sum(t.count for t in self.traces)
122
+
123
+ def place_totals(self) -> tuple[Counter, Counter]:
124
+ """(missing, remaining) per place, frequency-weighted."""
125
+ missing, remaining = Counter(), Counter()
126
+ for t in self.traces:
127
+ for place, n in t.missing_by_place.items():
128
+ missing[place] += n * t.count
129
+ for place, n in t.remaining_by_place.items():
130
+ remaining[place] += n * t.count
131
+ return missing, remaining
132
+
133
+ def transition_totals(self) -> tuple[Counter, Counter]:
134
+ """(fired, forced) per transition, frequency-weighted."""
135
+ fired, forced = Counter(), Counter()
136
+ for t in self.traces:
137
+ for transition, n in t.fired.items():
138
+ fired[transition] += n * t.count
139
+ for transition, n in t.forced.items():
140
+ forced[transition] += n * t.count
141
+ return fired, forced
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Silent moves
146
+ # ---------------------------------------------------------------------------
147
+ def silent_path(net: PetriNet, marking: Marking, goal, max_states: int = 2000) -> list[str] | None:
148
+ """Shortest sequence of τ transitions from ``marking`` to a marking where
149
+ ``goal(marking)`` holds. ``[]`` if it already holds, ``None`` if none found."""
150
+ if goal(marking):
151
+ return []
152
+ silent = [t for t, tr in net.transitions.items() if tr.silent]
153
+ if not silent:
154
+ return None
155
+ parent: dict[Marking, tuple[Marking, str] | None] = {marking: None}
156
+ queue = deque([marking])
157
+ while queue and len(parent) < max_states:
158
+ current = queue.popleft()
159
+ for transition in silent:
160
+ if not net.is_enabled(current, transition):
161
+ continue
162
+ successor = net.fire(current, transition)
163
+ if successor in parent:
164
+ continue
165
+ parent[successor] = (current, transition)
166
+ if goal(successor):
167
+ path = []
168
+ node = successor
169
+ while parent[node] is not None:
170
+ previous, step = parent[node] # type: ignore[misc]
171
+ path.append(step)
172
+ node = previous
173
+ return path[::-1]
174
+ queue.append(successor)
175
+ return None
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Replay
180
+ # ---------------------------------------------------------------------------
181
+ def replay_trace(net: PetriNet, trace: tuple[str, ...], count: int = 1) -> TraceReplay:
182
+ marking = net.initial_marking
183
+ result = TraceReplay(trace, count, produced=marking.total, consumed=0, missing=0,
184
+ remaining=0)
185
+ by_label: dict[str, list[str]] = {}
186
+ for transition in net.transitions.values():
187
+ if transition.label is not None:
188
+ by_label.setdefault(transition.label, []).append(transition.id)
189
+
190
+ def fire(transition: str, current: Marking) -> Marking:
191
+ pre, post = net.pre(transition), net.post(transition)
192
+ short = Marking({p: n - current[p] for p, n in pre.items() if current[p] < n})
193
+ if short:
194
+ result.missing += short.total
195
+ result.missing_by_place.update(dict(short.items()))
196
+ result.forced[transition] += 1
197
+ current = current + short
198
+ result.consumed += pre.total
199
+ result.produced += post.total
200
+ result.fired[transition] += 1
201
+ return current - pre + post
202
+
203
+ for index, activity in enumerate(trace):
204
+ candidates = by_label.get(activity)
205
+ if not candidates:
206
+ result.unknown_activities.append(activity)
207
+ result.problem_events.append(index)
208
+ continue
209
+ chosen: str | None = next((t for t in candidates if net.is_enabled(marking, t)), None)
210
+ if chosen is None:
211
+ # Try enabling one of the candidates with silent moves.
212
+ best_path = None
213
+ for transition in candidates:
214
+ path = silent_path(net, marking, lambda m, t=transition: net.is_enabled(m, t))
215
+ if path is not None and (best_path is None or len(path) < len(best_path[1])):
216
+ best_path = (transition, path)
217
+ if best_path is not None:
218
+ chosen, path = best_path
219
+ for step in path:
220
+ marking = fire(step, marking)
221
+ if chosen is None:
222
+ # Force the transition missing the fewest tokens.
223
+ chosen = min(candidates, key=lambda t: sum(
224
+ max(0, n - marking[p]) for p, n in net.pre(t).items()))
225
+ result.problem_events.append(index)
226
+ marking = fire(chosen, marking)
227
+
228
+ # Finish: move silently to the final marking if possible, then let the
229
+ # environment consume it.
230
+ final = net.final_marking
231
+ path = silent_path(net, marking, lambda m: m == final)
232
+ if path is None:
233
+ path = silent_path(net, marking, lambda m: m >= final) or []
234
+ for step in path:
235
+ marking = fire(step, marking)
236
+ short = Marking({p: n - marking[p] for p, n in final.items() if marking[p] < n})
237
+ if short:
238
+ result.missing += short.total
239
+ result.missing_by_place.update(dict(short.items()))
240
+ marking = marking + short
241
+ result.consumed += final.total
242
+ leftover = marking - final
243
+ result.remaining = leftover.total
244
+ result.remaining_by_place.update(dict(leftover.items()))
245
+ return result
246
+
247
+
248
+ def token_replay(net: PetriNet, log: SimpleLog) -> ReplayResult:
249
+ """Replay every variant once and weight the results by frequency."""
250
+ traces = [replay_trace(net, trace, count)
251
+ for trace, count in sorted(log.items(), key=lambda item: (-item[1], item[0]))]
252
+ return ReplayResult(net, traces)