ocel-mine 0.1.0

Object-centric process mining for OCEL 2.0: variants, DFG/OC-DFG, discovery (alpha, inductive, heuristics), replay fitness, lead times
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! Per-type directly-follows graphs and the object-centric overlay.
//!
//! Edges are computed **within one object type only** (ADR 0001): a naive
//! flattened DFG double-counts shared events and manufactures orderings across
//! unrelated lifecycles. The OC-DFG is the overlay of per-type DFGs — every
//! edge keeps its object type, and activity nodes carry per-type annotations
//! plus an honest total (an event touching several included types counts once).

use std::collections::{HashMap, HashSet};

use ocel::Ocel;
use serde::Serialize;

use crate::trace;

/// An activity node of one type's DFG.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DfgNode {
    pub activity: String,
    /// Trace steps with this activity.
    pub events: usize,
    /// Distinct objects touching the activity.
    pub objects: usize,
    /// Traces starting here.
    pub starts: usize,
    /// Traces ending here.
    pub ends: usize,
}

/// A directly-follows edge of one type's DFG.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DfgEdge {
    pub from: String,
    pub to: String,
    /// Directly-follows occurrences.
    pub frequency: usize,
    /// Distinct objects exhibiting the edge.
    pub objects: usize,
    /// Median gap between the two events, in seconds.
    pub median_secs: f64,
    /// Mean gap between the two events, in seconds.
    pub mean_secs: f64,
}

/// The directly-follows graph of one object type.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Dfg {
    pub object_type: String,
    pub objects: usize,
    pub with_events: usize,
    /// Nodes sorted by descending event count, ties by activity name.
    pub nodes: Vec<DfgNode>,
    /// Edges sorted by descending frequency, ties by (from, to).
    pub edges: Vec<DfgEdge>,
}

struct NodeAgg {
    events: usize,
    objects: usize,
    last_slot: usize,
    starts: usize,
    ends: usize,
}

struct EdgeAgg {
    frequency: usize,
    objects: usize,
    last_slot: usize,
    gaps: Vec<i64>,
}

// Gap seconds of realistic logs are far below 2^53, so f64 stats are exact
// enough; the lossy usize/i64 → f64 casts here are intentional.
#[allow(clippy::cast_precision_loss)]
fn gap_stats(gaps: &mut [i64]) -> (f64, f64) {
    gaps.sort_unstable();
    let n = gaps.len();
    let median = if n % 2 == 1 {
        gaps[n / 2] as f64
    } else {
        (gaps[n / 2 - 1] + gaps[n / 2]) as f64 / 2.0
    };
    let mean = gaps.iter().sum::<i64>() as f64 / n as f64;
    (median, mean)
}

/// Compute the directly-follows graph for `object_type`.
#[must_use]
pub fn dfg(log: &Ocel, object_type: &str) -> Dfg {
    let traces = trace::build(log, object_type);

    let mut nodes: Vec<NodeAgg> = (0..traces.activity_names.len())
        .map(|_| NodeAgg {
            events: 0,
            objects: 0,
            last_slot: usize::MAX,
            starts: 0,
            ends: 0,
        })
        .collect();
    let mut edges: HashMap<u32, EdgeAgg> = HashMap::new();

    let mut with_events = 0usize;
    for (slot, steps) in traces.steps.iter().enumerate() {
        let (Some(&(first, _)), Some(&(last, _))) = (steps.first(), steps.last()) else {
            continue;
        };
        with_events += 1;
        nodes[first as usize].starts += 1;
        nodes[last as usize].ends += 1;
        for &(activity, _) in steps {
            let node = &mut nodes[activity as usize];
            node.events += 1;
            if node.last_slot != slot {
                node.last_slot = slot;
                node.objects += 1;
            }
        }
        for pair in steps.windows(2) {
            let (from, from_time) = pair[0];
            let (to, to_time) = pair[1];
            let key = (u32::from(from) << 16) | u32::from(to);
            let agg = edges.entry(key).or_insert_with(|| EdgeAgg {
                frequency: 0,
                objects: 0,
                last_slot: usize::MAX,
                gaps: Vec::new(),
            });
            agg.frequency += 1;
            if agg.last_slot != slot {
                agg.last_slot = slot;
                agg.objects += 1;
            }
            agg.gaps.push((to_time - from_time).num_seconds());
        }
    }

    let mut nodes: Vec<DfgNode> = nodes
        .into_iter()
        .enumerate()
        .filter(|(_, agg)| agg.events > 0)
        .map(|(id, agg)| DfgNode {
            activity: traces.activity_names[id].to_owned(),
            events: agg.events,
            objects: agg.objects,
            starts: agg.starts,
            ends: agg.ends,
        })
        .collect();
    nodes.sort_unstable_by(|a, b| {
        b.events
            .cmp(&a.events)
            .then_with(|| a.activity.cmp(&b.activity))
    });

    let mut edges: Vec<DfgEdge> = edges
        .into_iter()
        .map(|(key, mut agg)| {
            let (median_secs, mean_secs) = gap_stats(&mut agg.gaps);
            DfgEdge {
                from: traces.activity_names[(key >> 16) as usize].to_owned(),
                to: traces.activity_names[(key & 0xffff) as usize].to_owned(),
                frequency: agg.frequency,
                objects: agg.objects,
                median_secs,
                mean_secs,
            }
        })
        .collect();
    edges.sort_unstable_by(|a, b| {
        b.frequency
            .cmp(&a.frequency)
            .then_with(|| (a.from.as_str(), a.to.as_str()).cmp(&(b.from.as_str(), b.to.as_str())))
    });

    Dfg {
        object_type: object_type.to_owned(),
        objects: traces.object_ids.len(),
        with_events,
        nodes,
        edges,
    }
}

/// Per-type annotation of one activity in the OC-DFG.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OcTypeCount {
    pub object_type: String,
    pub events: usize,
    pub objects: usize,
    pub starts: usize,
    pub ends: usize,
}

/// One activity of the OC-DFG with its per-type annotations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OcActivity {
    pub activity: String,
    /// Events with this activity touching at least one included type,
    /// counted once even when they touch several types (convergence-honest).
    pub events: usize,
    pub per_type: Vec<OcTypeCount>,
}

/// A per-type edge of the OC-DFG overlay.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OcDfgEdge {
    pub object_type: String,
    #[serde(flatten)]
    pub edge: DfgEdge,
}

/// The object-centric DFG: per-type DFGs overlaid, nothing flattened.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct OcDfg {
    pub object_types: Vec<String>,
    /// Activities sorted by descending total event count, ties by name.
    pub activities: Vec<OcActivity>,
    /// All per-type edges, sorted by descending frequency.
    pub edges: Vec<OcDfgEdge>,
}

/// Compute the OC-DFG overlay for `object_types`.
#[must_use]
pub fn oc_dfg(log: &Ocel, object_types: &[&str]) -> OcDfg {
    let included: HashSet<&str> = log
        .objects
        .iter()
        .filter(|o| object_types.contains(&o.object_type.as_str()))
        .map(|o| o.id.as_str())
        .collect();
    let mut event_totals: HashMap<&str, usize> = HashMap::new();
    for event in &log.events {
        if event
            .relationships
            .iter()
            .any(|r| included.contains(r.object_id.as_str()))
        {
            *event_totals.entry(event.event_type.as_str()).or_insert(0) += 1;
        }
    }

    let mut activities: HashMap<String, Vec<OcTypeCount>> = HashMap::new();
    let mut edges: Vec<OcDfgEdge> = Vec::new();
    for &object_type in object_types {
        let graph = dfg(log, object_type);
        for node in graph.nodes {
            activities
                .entry(node.activity)
                .or_default()
                .push(OcTypeCount {
                    object_type: object_type.to_owned(),
                    events: node.events,
                    objects: node.objects,
                    starts: node.starts,
                    ends: node.ends,
                });
        }
        edges.extend(graph.edges.into_iter().map(|edge| OcDfgEdge {
            object_type: object_type.to_owned(),
            edge,
        }));
    }

    let mut activities: Vec<OcActivity> = activities
        .into_iter()
        .map(|(activity, per_type)| OcActivity {
            events: event_totals.get(activity.as_str()).copied().unwrap_or(0),
            activity,
            per_type,
        })
        .collect();
    activities.sort_unstable_by(|a, b| {
        b.events
            .cmp(&a.events)
            .then_with(|| a.activity.cmp(&b.activity))
    });
    edges.sort_unstable_by_key(|e| std::cmp::Reverse(e.edge.frequency));

    OcDfg {
        object_types: object_types.iter().map(|&t| t.to_owned()).collect(),
        activities,
        edges,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{TimeZone, Utc};
    use ocel::{Event, EventType, Object, ObjectType, Relationship};

    fn rel(object_id: &str) -> Relationship {
        Relationship {
            object_id: object_id.into(),
            qualifier: "q".into(),
        }
    }

    fn event(id: &str, event_type: &str, minute: u32, objects: &[&str]) -> Event {
        Event {
            id: id.into(),
            event_type: event_type.into(),
            time: Utc.with_ymd_and_hms(2026, 1, 1, 9, minute, 0).unwrap(),
            attributes: vec![],
            relationships: objects.iter().map(|o| rel(o)).collect(),
        }
    }

    fn log(events: Vec<Event>, objects: &[(&str, &str)]) -> Ocel {
        let mut builder = Ocel::builder();
        for name in ["created", "changed", "closed"] {
            builder.add_event_type(EventType {
                name: name.into(),
                attributes: vec![],
            });
        }
        for type_name in ["task", "user"] {
            builder.add_object_type(ObjectType {
                name: type_name.into(),
                attributes: vec![],
            });
        }
        for (id, type_name) in objects {
            builder.add_object(Object {
                id: (*id).into(),
                object_type: (*type_name).into(),
                attributes: vec![],
                relationships: vec![],
            });
        }
        for e in events {
            builder.add_event(e);
        }
        builder.build().expect("valid log")
    }

    #[test]
    // whole-second gaps are exactly representable in f64
    #[allow(clippy::float_cmp)]
    fn aggregates_edges_with_durations() {
        let log = log(
            vec![
                event("e1", "created", 0, &["a"]),
                event("e2", "changed", 1, &["a"]),
                event("e3", "closed", 3, &["a"]),
                event("e4", "created", 10, &["b"]),
                event("e5", "changed", 13, &["b"]),
                event("e6", "closed", 14, &["b"]),
                event("e7", "created", 20, &["c"]),
                event("e8", "closed", 21, &["c"]),
            ],
            &[("a", "task"), ("b", "task"), ("c", "task")],
        );
        let graph = dfg(&log, "task");
        assert_eq!(graph.objects, 3);
        assert_eq!(graph.with_events, 3);

        let created = graph
            .nodes
            .iter()
            .find(|n| n.activity == "created")
            .unwrap();
        assert_eq!((created.events, created.objects), (3, 3));
        assert_eq!((created.starts, created.ends), (3, 0));
        let closed = graph.nodes.iter().find(|n| n.activity == "closed").unwrap();
        assert_eq!((closed.starts, closed.ends), (0, 3));

        let created_changed = graph
            .edges
            .iter()
            .find(|e| e.from == "created" && e.to == "changed")
            .unwrap();
        assert_eq!(created_changed.frequency, 2);
        assert_eq!(created_changed.objects, 2);
        // gaps: 60s and 180s
        assert_eq!(created_changed.median_secs, 120.0);
        assert_eq!(created_changed.mean_secs, 120.0);

        let created_closed = graph
            .edges
            .iter()
            .find(|e| e.from == "created" && e.to == "closed")
            .unwrap();
        assert_eq!(created_closed.frequency, 1);
        assert_eq!(created_closed.median_secs, 60.0);
    }

    #[test]
    fn oc_dfg_counts_shared_events_once() {
        let log = log(
            vec![
                event("e1", "created", 0, &["a", "u"]),
                event("e2", "closed", 1, &["a", "u"]),
            ],
            &[("a", "task"), ("u", "user")],
        );
        let graph = oc_dfg(&log, &["task", "user"]);
        let created = graph
            .activities
            .iter()
            .find(|a| a.activity == "created")
            .unwrap();
        // one event touching both types counts once
        assert_eq!(created.events, 1);
        assert_eq!(created.per_type.len(), 2);
        // one created->closed edge per type
        assert_eq!(graph.edges.len(), 2);
        assert!(graph
            .edges
            .iter()
            .all(|e| e.edge.from == "created" && e.edge.to == "closed"));
    }

    #[test]
    fn empty_type_yields_empty_graph() {
        let log = log(vec![], &[]);
        let graph = dfg(&log, "task");
        assert_eq!(graph.objects, 0);
        assert!(graph.nodes.is_empty());
        assert!(graph.edges.is_empty());
    }
}