ocelescope-module-alignments 0.1.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.
File without changes
@@ -0,0 +1,69 @@
1
+ from typing import Any, Dict, List, Optional, Tuple, Union
2
+
3
+ from ocelescope import PetriNet
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class VariantAlignmentResult(BaseModel):
8
+ activities: List[str] # The variant's activity sequence
9
+ frequency: int # How many traces follow this variant
10
+ result: Union[Dict[str, Any]] # The alignment result or error for this variant
11
+
12
+
13
+ class FitnessResult(BaseModel):
14
+ average_fitness: float # Average trace fitness (across all traces)
15
+ log_fitness: float # Log fitness, as the total computed fitness (summing up the costs for all traces)
16
+ perfectly_fitting_frac: float # Fraction of traces that perfectly fit (i.e., have an alignment cost of `0`)
17
+ total_costs: int # The total cost, summed up from all traces
18
+
19
+
20
+ class CostFunction(BaseModel):
21
+ log_move_cost: int # Default cost for a log move (log event not matched by model)
22
+ model_move_cost: int # Default cost for a model move (visible transition fires without matching log event)
23
+ silent_move_cost: int # Default cost for a silent/tau move
24
+ sync_move_cost: int # Default cost for a synchronous move
25
+
26
+
27
+ class AlignmentOptions(BaseModel):
28
+ cost_fn: CostFunction # Cost function for alignment moves
29
+ max_states: Optional[Optional[int]] # Maximum number of states to visit before aborting (per trace).
30
+
31
+
32
+ class TransitionStat(BaseModel):
33
+ sync_fires: int
34
+ model_fires: int
35
+
36
+
37
+ class Aggregated(BaseModel):
38
+ total_traces: int
39
+ transition_stats: Dict[str, TransitionStat]
40
+ log_move_counts: Dict[str, int]
41
+
42
+
43
+ class NetPlace(BaseModel):
44
+ id: str
45
+
46
+
47
+ class NetTransition(BaseModel):
48
+ id: str
49
+ label: str | None
50
+
51
+
52
+ class NetArc(BaseModel):
53
+ nodes: Tuple[str, str]
54
+ weight: int
55
+
56
+
57
+ class ResponseNet(BaseModel):
58
+ places: List[NetPlace]
59
+ transitions: List[NetTransition]
60
+ arcs: List[NetArc]
61
+ initial_marking: Dict[str, int]
62
+ final_marking: Dict[str, int]
63
+
64
+
65
+ class AlignmentsResponse(BaseModel):
66
+ net: ResponseNet
67
+ variant_alignments: List[VariantAlignmentResult]
68
+ fitness: FitnessResult
69
+ aggregated: Aggregated
@@ -0,0 +1,14 @@
1
+ from fastapi import FastAPI
2
+ from ocelescope_backend.app.modules import Module, ModuleMeta
3
+ from packaging.version import Version
4
+
5
+ from ocelescope_module_alignments.routes import router
6
+
7
+
8
+ class Alignments(Module):
9
+ meta = ModuleMeta(key="alignments", version=Version("1.0"))
10
+
11
+ def create_app(self) -> FastAPI:
12
+ app = FastAPI(title="Case-Centric Alignments", version=str(self.meta.version))
13
+ app.include_router(router)
14
+ return app
@@ -0,0 +1,45 @@
1
+ from typing import cast
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+ from ocelescope import PetriNet
5
+ from ocelescope_backend.app.dependencies import ApiOcel, ApiSession
6
+ from ocelescope_backend.app.sse_manager import ErrorNotification, sse_manager
7
+ from pydantic import BaseModel
8
+
9
+ from ocelescope_module_alignments.models.alignment import AlignmentsResponse
10
+ from ocelescope_module_alignments.utils.compute_alignments import (
11
+ AlignmentComputationError,
12
+ compute_alignments,
13
+ )
14
+
15
+ router = APIRouter()
16
+
17
+
18
+ @router.get("/{ocel_id}/alignments", operation_id="get_alignments")
19
+ def get_alignments(
20
+ ocel: ApiOcel, session: ApiSession, object_type: str, resource_id: str | None = None
21
+ ) -> AlignmentsResponse:
22
+ try:
23
+ if resource_id:
24
+ resource_store = cast(PetriNet, session.get_resource(resource_id))
25
+ petri_net = PetriNet(**resource_store.data)
26
+ else:
27
+ petri_net = None
28
+
29
+ return compute_alignments(ocel, object_type, petri_net)
30
+ except AlignmentComputationError as e:
31
+ sse_manager.send_safe(
32
+ session_id=session.id,
33
+ message=ErrorNotification(
34
+ type="error",
35
+ title="Alignment computation failed",
36
+ message=str(e),
37
+ trace=e.trace,
38
+ ),
39
+ )
40
+ raise HTTPException(status_code=422, detail=str(e)) from e
41
+
42
+
43
+ @router.get("/{ocel_id}/objects/types", operation_id="objectTypes")
44
+ def get_object_types(ocel: ApiOcel) -> list[str]:
45
+ return ocel.objects.types
@@ -0,0 +1,245 @@
1
+ import traceback
2
+ import uuid
3
+ from typing import Dict, List, Optional, Tuple
4
+
5
+ import polars
6
+ import r4pm
7
+ from ocelescope import OCEL, PetriNet
8
+ from ocelescope_module_alignments.models.alignment import (
9
+ Aggregated,
10
+ AlignmentOptions,
11
+ AlignmentsResponse,
12
+ FitnessResult,
13
+ NetArc,
14
+ NetPlace,
15
+ NetTransition,
16
+ ResponseNet,
17
+ TransitionStat,
18
+ VariantAlignmentResult,
19
+ )
20
+ from r4pm.bindings.core.event_data.object_centric.utils.flatten import flatten_ocel_on
21
+ from r4pm.bindings.discovery.case_centric.alphappp.full import PetriNet as R4PMPetriNet
22
+
23
+
24
+ class AlignmentComputationError(Exception):
25
+ def __init__(self, message: str, trace: str | None = None):
26
+ super().__init__(message)
27
+ self.trace = trace or message
28
+
29
+
30
+ def to_r4pm_format(petri_net: PetriNet) -> R4PMPetriNet:
31
+ place_ids = {p.name: str(uuid.uuid4()) for p in petri_net.places}
32
+ transition_ids = {t.name: str(uuid.uuid4()) for t in petri_net.transitions}
33
+
34
+ def node_id(name: str) -> str:
35
+ return place_ids.get(name) or transition_ids[name]
36
+
37
+ places = {pid: {"id": pid} for pid in place_ids.values()}
38
+
39
+ transitions = {
40
+ tid: {"id": tid, "label": t.label} for t, tid in ((t, transition_ids[t.name]) for t in petri_net.transitions)
41
+ }
42
+
43
+ arcs = [
44
+ {
45
+ "from_to": {
46
+ "type": "PlaceTransition" if arc.source in place_ids else "TransitionPlace",
47
+ "nodes": [node_id(arc.source), node_id(arc.target)],
48
+ },
49
+ "weight": arc.weight,
50
+ }
51
+ for arc in petri_net.arcs
52
+ ]
53
+
54
+ initial_marking = {place_ids[name]: count for name, count in petri_net.initial_marking.items()}
55
+ final_marking = {place_ids[name]: count for name, count in petri_net.final_marking.items()}
56
+
57
+ return {
58
+ "places": places,
59
+ "transitions": transitions,
60
+ "arcs": arcs,
61
+ "initial_marking": initial_marking,
62
+ "final_markings": [final_marking] if final_marking else [],
63
+ }
64
+
65
+
66
+ def flatten(petri_net: PetriNet, object_type: str) -> PetriNet:
67
+ place_names = {p.name for p in petri_net.places if p.object_type == object_type}
68
+ places_flatten = [p for p in petri_net.places if p.name in place_names]
69
+
70
+ arcs_flatten = []
71
+ connected_transition_names = set()
72
+ for arc in petri_net.arcs:
73
+ source_is_place = arc.source in place_names
74
+ target_is_place = arc.target in place_names
75
+ if not (source_is_place or target_is_place):
76
+ continue
77
+ arcs_flatten.append(arc)
78
+ if not source_is_place:
79
+ connected_transition_names.add(arc.source)
80
+ if not target_is_place:
81
+ connected_transition_names.add(arc.target)
82
+
83
+ transitions_flatten = [t for t in petri_net.transitions if t.name in connected_transition_names]
84
+
85
+ initial_marking_flatten = {name: count for name, count in petri_net.initial_marking.items() if name in place_names}
86
+ final_marking_flatten = {name: count for name, count in petri_net.final_marking.items() if name in place_names}
87
+
88
+ return PetriNet(
89
+ places=places_flatten,
90
+ transitions=transitions_flatten,
91
+ arcs=arcs_flatten,
92
+ initial_marking=initial_marking_flatten,
93
+ final_marking=final_marking_flatten,
94
+ )
95
+
96
+
97
+ def convert_net(petri_net: R4PMPetriNet) -> ResponseNet:
98
+ places = [NetPlace(id=p_id) for p_id in petri_net["places"].keys()]
99
+ transitions = [NetTransition(id=t_id, label=t.get("label")) for t_id, t in petri_net["transitions"].items()]
100
+ arcs = [
101
+ NetArc(
102
+ nodes=(arc["from_to"]["nodes"][0], arc["from_to"]["nodes"][1]),
103
+ weight=arc["weight"],
104
+ )
105
+ for arc in petri_net["arcs"]
106
+ ]
107
+ initial_marking = petri_net.get("initial_marking") or {}
108
+ final_markings = petri_net.get("final_markings") or []
109
+ final_marking = final_markings[0] if final_markings else {}
110
+
111
+ return ResponseNet(
112
+ places=places,
113
+ transitions=transitions,
114
+ arcs=arcs,
115
+ initial_marking=initial_marking,
116
+ final_marking=final_marking,
117
+ )
118
+
119
+
120
+ def is_flattened(petri_net: PetriNet) -> bool:
121
+ object_types = set()
122
+ for place in petri_net.places:
123
+ object_types.add(place.object_type)
124
+ if len(object_types) > 1:
125
+ return False
126
+
127
+ return len(object_types) == 1
128
+
129
+
130
+ def preprocessing(ocel: OCEL, object_type: str, petri_net: PetriNet | None) -> Tuple[str, R4PMPetriNet]:
131
+ config = {
132
+ "absolute_df_clean_thresh": 1,
133
+ "balance_thresh": 0.2,
134
+ "fitness_thresh": 0.2,
135
+ "log_repair_loop_df_thresh_rel": 0.2,
136
+ "log_repair_skip_df_thresh_rel": 0.2,
137
+ "relative_df_clean_thresh": 0.2,
138
+ "replay_thresh": 0.2,
139
+ }
140
+ event_log_df = ocel.sql(f"""
141
+ SELECT
142
+ rel."ocel:oid" as "case:concept:name",
143
+ ev."ocel:timestamp" as "time:timestamp",
144
+ ev."ocel:activity" as "concept:name"
145
+ FROM
146
+ e2o rel
147
+ RIGHT JOIN (
148
+ SELECT
149
+ *
150
+ FROM
151
+ objects
152
+ WHERE
153
+ "ocel:type" = '{object_type}'
154
+ ) ob USING ("ocel:oid")
155
+ LEFT JOIN (
156
+ SELECT
157
+ *
158
+ FROM
159
+ events
160
+ ) ev USING ("ocel:eid")
161
+ ORDER BY
162
+ ("time:timestamp", "case:concept:name", "concept:name")
163
+ """).pl()
164
+
165
+ log_id = r4pm.import_item_from_df("EventLog", event_log_df)
166
+ proj_id = r4pm.bindings.log_to_activity_projection(log_id)
167
+ if petri_net:
168
+ if is_flattened(petri_net):
169
+ process_model = to_r4pm_format(petri_net)
170
+ else:
171
+ process_model = to_r4pm_format(flatten(petri_net, object_type))
172
+ else:
173
+ process_model = r4pm.bindings.discover_alphaplusplusplus(proj_id, config=config)
174
+ return proj_id, process_model
175
+
176
+
177
+ def variant_alignment(
178
+ proj_id: str,
179
+ process_model: R4PMPetriNet,
180
+ options: Optional["AlignmentOptions"] = None,
181
+ ) -> List[VariantAlignmentResult]:
182
+ raw_result = r4pm.bindings.align_variants(process_model, proj_id, options)
183
+ return [VariantAlignmentResult(**r) for r in raw_result]
184
+
185
+
186
+ def compute_fitness(alignments: List[VariantAlignmentResult], process_model: R4PMPetriNet) -> FitnessResult:
187
+ fitness = r4pm.bindings.compute_fitness([a.model_dump() for a in alignments], process_model)
188
+ return FitnessResult(**fitness)
189
+
190
+
191
+ def compute_aggregated(
192
+ variant_alignments: List[VariantAlignmentResult],
193
+ ) -> Aggregated:
194
+ total_traces = sum(v.frequency for v in variant_alignments)
195
+ transition_stats: Dict[str, Dict[str, int]] = {}
196
+ log_move_counts: Dict[str, int] = {}
197
+
198
+ for v in variant_alignments:
199
+ result = v.result
200
+ if "Ok" not in result:
201
+ continue
202
+ freq = v.frequency
203
+ for move in result["Ok"]["moves"]:
204
+ if "SyncMove" in move:
205
+ t = move["SyncMove"]["transition"]
206
+ stats = transition_stats.setdefault(t, {"sync_fires": 0, "model_fires": 0})
207
+ stats["sync_fires"] += freq
208
+ elif "ModelMove" in move:
209
+ t = move["ModelMove"]["transition"]
210
+ stats = transition_stats.setdefault(t, {"sync_fires": 0, "model_fires": 0})
211
+ stats["model_fires"] += freq
212
+ elif "LogMove" in move:
213
+ idx = move["LogMove"]["trace_event_index"]
214
+ activity = v.activities[idx]
215
+ log_move_counts[activity] = log_move_counts.get(activity, 0) + freq
216
+
217
+ return Aggregated(
218
+ total_traces=total_traces,
219
+ transition_stats={k: TransitionStat(**v) for k, v in transition_stats.items()},
220
+ log_move_counts=log_move_counts,
221
+ )
222
+
223
+
224
+ def compute_alignments(ocel: OCEL, object_type: str, petri_net: PetriNet | None):
225
+ (proj_id, process_model) = preprocessing(ocel, object_type, petri_net)
226
+ options = {
227
+ "cost_fn": {
228
+ "log_move_cost": 1,
229
+ "model_move_cost": 1,
230
+ "silent_move_cost": 0,
231
+ "sync_move_cost": 0,
232
+ }
233
+ }
234
+ try:
235
+ alignments = variant_alignment(proj_id, process_model, options)
236
+ fitness = compute_fitness(alignments, process_model)
237
+ except Exception as e:
238
+ raise AlignmentComputationError(e, traceback.format_exc()) from e
239
+ aggregated = compute_aggregated(alignments)
240
+ return AlignmentsResponse(
241
+ net=convert_net(process_model),
242
+ variant_alignments=alignments,
243
+ fitness=fitness,
244
+ aggregated=aggregated,
245
+ )
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.3
2
+ Name: ocelescope-module-alignments
3
+ Version: 0.1.0
4
+ Summary: case-centric Alignments
5
+ Author: Uy Sa Huynh
6
+ Requires-Dist: ocelescope-backend>=0.10.1
7
+ Requires-Dist: ocelescope-module-filter>=0.10.1,<0.11.0
8
+ Requires-Dist: ocelescope-module-ocel>=0.10.1,<0.11.0
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # ocelescope-module-case-centric-alignments
13
+
14
+ An [Ocelescope](https://www.ocelescope.org) backend module that computes
15
+ case-centric alignments for object-centric event logs.
16
+
17
+ For a selected object type, the log is aligned against a Petri net resource,
18
+ or against a model discovered with Alpha+++ if none is given.
19
+
20
+ ## Installation
21
+
22
+ ```sh
23
+ pip install ocelescope-module-alignments
24
+ ```
25
+
26
+ Install it next to `ocelescope-backend`. The module is picked up automatically
27
+ and served under `/modules/alignments/v1`.
@@ -0,0 +1,9 @@
1
+ ocelescope_module_alignments/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ ocelescope_module_alignments/models/alignment.py,sha256=2cN-EwwIGqSFPrmh_pUpPM1AHyLxVIajMDnRC7T8Dm8,2074
3
+ ocelescope_module_alignments/module.py,sha256=0nRc0q-SyvGwPAWGcMsSI06AL9-8E7OAy2tnpxLQbEA,455
4
+ ocelescope_module_alignments/routes.py,sha256=yBEHz-wMo7a5D0Fl59M3kvF5E-6hY7CH5MFGcT9ao64,1521
5
+ ocelescope_module_alignments/utils/compute_alignments.py,sha256=wMj5YEKl8WhWnzFdH42HuOajBVhy2BEXuvIpZzS4P3w,8520
6
+ ocelescope_module_alignments-0.1.0.dist-info/WHEEL,sha256=9IUYslYuG_asT4WlWvfk_K76nveuLvSTKDoLeL0cV-Q,80
7
+ ocelescope_module_alignments-0.1.0.dist-info/entry_points.txt,sha256=6xlcopjxD9w_YbtiWryb2rvxPq0rUJdaRgoTEfoqx6s,101
8
+ ocelescope_module_alignments-0.1.0.dist-info/METADATA,sha256=gJdROBXiRy6fKwFVMx14j-w8j9IVbMpESNdsKYo1g34,847
9
+ ocelescope_module_alignments-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [ocelescope_backend.modules]
2
+ caseCentricAlignments = ocelescope_module_alignments.module:Alignments
3
+