Skip to main content

ocel_mine/
stats.rs

1//! Per-type descriptive statistics.
2//!
3//! `type_stats` gives consumers the numbers to judge which object type is the
4//! most "case-like" (a workable median trace length, high events coverage)
5//! without re-deriving trace semantics themselves.
6
7use ocel::Ocel;
8use serde::Serialize;
9
10use crate::trace;
11
12/// Descriptive statistics of one object type.
13#[derive(Debug, Clone, PartialEq, Serialize)]
14#[serde(rename_all = "camelCase")]
15pub struct TypeStats {
16    pub object_type: String,
17    /// Objects of the type in the log.
18    pub objects: usize,
19    /// Objects with at least one linked event.
20    pub with_events: usize,
21    /// Median trace length over objects with events (0 when none).
22    pub median_trace_len: f64,
23    /// Median first→last event span (seconds) over objects with at least two
24    /// events; 0 when none.
25    pub median_active_span_secs: f64,
26    /// Distinct activities appearing in this type's traces. Cross-cutting
27    /// participants (actors, reference masters) touch (nearly) every
28    /// activity in the log; a case notion selects the coherent subset that
29    /// is its lifecycle — compare against the log's activity count to tell
30    /// them apart.
31    pub activity_types: usize,
32}
33
34// Trace lengths and second counts are far below 2^53; the midpoint math is
35// exact enough.
36#[allow(clippy::cast_precision_loss)]
37fn median(sorted: &[i64]) -> f64 {
38    let n = sorted.len();
39    if n == 0 {
40        return 0.0;
41    }
42    if n % 2 == 1 {
43        sorted[n / 2] as f64
44    } else {
45        (sorted[n / 2 - 1] + sorted[n / 2]) as f64 / 2.0
46    }
47}
48
49/// Compute [`TypeStats`] for every object type in the log, sorted by
50/// descending object count (ties by name).
51#[must_use]
52pub fn type_stats(log: &Ocel) -> Vec<TypeStats> {
53    let mut names: Vec<&str> = log.object_types.iter().map(|t| t.name.as_str()).collect();
54    for object in &log.objects {
55        if !names.contains(&object.object_type.as_str()) {
56            names.push(object.object_type.as_str());
57        }
58    }
59
60    let mut out: Vec<TypeStats> = names
61        .iter()
62        .map(|&name| {
63            let traces = trace::build(log, name);
64            let mut lengths: Vec<i64> = traces
65                .steps
66                .iter()
67                .map(Vec::len)
68                .filter(|&len| len > 0)
69                .map(|len| i64::try_from(len).expect("trace length fits i64"))
70                .collect();
71            lengths.sort_unstable();
72            let mut spans: Vec<i64> = traces
73                .steps
74                .iter()
75                .filter(|steps| steps.len() >= 2)
76                .map(|steps| {
77                    let first = steps[0].1;
78                    let last = steps[steps.len() - 1].1;
79                    (last - first).num_seconds()
80                })
81                .collect();
82            spans.sort_unstable();
83            let mut seen: Vec<bool> = vec![false; traces.activity_names.len()];
84            for steps in &traces.steps {
85                for &(activity, _) in steps {
86                    seen[usize::from(activity)] = true;
87                }
88            }
89            TypeStats {
90                object_type: name.to_owned(),
91                objects: traces.object_ids.len(),
92                with_events: lengths.len(),
93                median_trace_len: median(&lengths),
94                median_active_span_secs: median(&spans),
95                activity_types: seen.iter().filter(|&&s| s).count(),
96            }
97        })
98        .collect();
99    out.sort_unstable_by(|a, b| {
100        b.objects
101            .cmp(&a.objects)
102            .then_with(|| a.object_type.cmp(&b.object_type))
103    });
104    out
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use crate::test_util::log_from_sequences;
111
112    #[test]
113    fn medians_and_coverage() {
114        // traces of length 2, 3, 4 -> median 3; events are 1min apart, so
115        // the active spans are 60, 120, 180 seconds -> median 120
116        let log = log_from_sequences(&[&["a", "b"], &["a", "b", "c"], &["a", "b", "c", "d"]]);
117        let stats = type_stats(&log);
118        let case = stats.iter().find(|s| s.object_type == "case").unwrap();
119        assert_eq!(case.objects, 3);
120        assert_eq!(case.with_events, 3);
121        assert!((case.median_trace_len - 3.0).abs() < f64::EPSILON);
122        assert!((case.median_active_span_secs - 120.0).abs() < f64::EPSILON);
123    }
124
125    #[test]
126    fn single_event_objects_do_not_drag_the_span_down() {
127        // many one-event objects (span undefined) around one long-lived one
128        let log = log_from_sequences(&[&["a"], &["a"], &["a"], &["a", "b", "c", "d", "e"]]);
129        let stats = type_stats(&log);
130        let case = stats.iter().find(|s| s.object_type == "case").unwrap();
131        assert!((case.median_active_span_secs - 240.0).abs() < f64::EPSILON);
132    }
133
134    #[test]
135    fn activity_alphabet_counts_distinct_activities_across_traces() {
136        let log = log_from_sequences(&[&["a", "b"], &["c"], &["a"]]);
137        let stats = type_stats(&log);
138        let case = stats.iter().find(|s| s.object_type == "case").unwrap();
139        assert_eq!(case.activity_types, 3);
140    }
141
142    #[test]
143    fn empty_log_yields_declared_types_with_zeros() {
144        let log = log_from_sequences(&[]);
145        let stats = type_stats(&log);
146        assert_eq!(stats.len(), 1); // "case" is declared by the helper
147        assert_eq!(stats[0].with_events, 0);
148        assert!(stats[0].median_trace_len.abs() < f64::EPSILON);
149    }
150}