Skip to main content

ocel_mine/
metrics.rs

1//! Lead-time metrics.
2//!
3//! A trace's lead time is the gap between its first and last event. Medians
4//! are never added across edges — paths are *measured*: every variant gets its
5//! own distribution, and the happy path (most frequent variant) is compared
6//! against the measured rest. Rework counts activities repeating within a
7//! trace.
8
9use std::collections::HashMap;
10
11use ocel::Ocel;
12use serde::Serialize;
13
14use crate::trace;
15
16/// Lead-time distribution of one variant.
17#[derive(Debug, Clone, PartialEq, Serialize)]
18#[serde(rename_all = "camelCase")]
19pub struct VariantLead {
20    pub activities: Vec<String>,
21    pub count: usize,
22    pub median_secs: f64,
23    pub mean_secs: f64,
24    pub p90_secs: f64,
25}
26
27/// An activity that repeats within traces.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ReworkMetric {
31    pub activity: String,
32    /// Traces where the activity occurs more than once.
33    pub traces: usize,
34    /// Occurrences beyond the first, summed over those traces.
35    pub extra_occurrences: usize,
36}
37
38/// Lead-time metrics of one object type.
39#[derive(Debug, Clone, PartialEq, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct LeadTimeReport {
42    pub object_type: String,
43    /// Traces measured (objects with at least one event).
44    pub measured: usize,
45    pub median_secs: f64,
46    pub mean_secs: f64,
47    pub p90_secs: f64,
48    /// Median over traces NOT following the most frequent variant.
49    pub rest_median_secs: f64,
50    pub rest_count: usize,
51    /// Per-variant distributions, sorted by descending count (ties by sequence).
52    pub variants: Vec<VariantLead>,
53    /// Rework, sorted by descending affected traces (ties by activity).
54    pub rework: Vec<ReworkMetric>,
55}
56
57// Lead times in seconds fit f64 exactly for realistic logs; intentional casts.
58#[allow(clippy::cast_precision_loss)]
59fn stats(sorted: &[i64]) -> (f64, f64, f64) {
60    let n = sorted.len();
61    if n == 0 {
62        return (0.0, 0.0, 0.0);
63    }
64    let median = if n % 2 == 1 {
65        sorted[n / 2] as f64
66    } else {
67        (sorted[n / 2 - 1] + sorted[n / 2]) as f64 / 2.0
68    };
69    let mean = sorted.iter().sum::<i64>() as f64 / n as f64;
70    // nearest-rank percentile
71    let p90 = sorted[(n * 9).div_ceil(10).max(1) - 1] as f64;
72    (median, mean, p90)
73}
74
75/// Compute lead-time metrics for `object_type`.
76#[must_use]
77pub fn lead_times(log: &Ocel, object_type: &str) -> LeadTimeReport {
78    let traces = trace::build(log, object_type);
79
80    let mut per_variant: HashMap<Vec<u16>, Vec<i64>> = HashMap::new();
81    let mut all: Vec<i64> = Vec::new();
82    let mut rework_agg: HashMap<u16, (usize, usize)> = HashMap::new();
83    let mut occurrences: HashMap<u16, usize> = HashMap::new();
84    for steps in &traces.steps {
85        let (Some(&(_, first)), Some(&(_, last))) = (steps.first(), steps.last()) else {
86            continue;
87        };
88        let lead = (last - first).num_seconds();
89        all.push(lead);
90        let sequence: Vec<u16> = steps.iter().map(|&(a, _)| a).collect();
91        per_variant.entry(sequence).or_default().push(lead);
92
93        occurrences.clear();
94        for &(activity, _) in steps {
95            *occurrences.entry(activity).or_insert(0) += 1;
96        }
97        for (&activity, &count) in &occurrences {
98            if count >= 2 {
99                let entry = rework_agg.entry(activity).or_insert((0, 0));
100                entry.0 += 1;
101                entry.1 += count - 1;
102            }
103        }
104    }
105    all.sort_unstable();
106    let (median_secs, mean_secs, p90_secs) = stats(&all);
107
108    let name = |id: u16| traces.activity_names[id as usize].to_owned();
109    let mut keyed: Vec<(Vec<u16>, VariantLead)> = per_variant
110        .into_iter()
111        .map(|(sequence, mut leads)| {
112            leads.sort_unstable();
113            let (median, mean, p90) = stats(&leads);
114            let lead = VariantLead {
115                activities: sequence.iter().map(|&a| name(a)).collect(),
116                count: leads.len(),
117                median_secs: median,
118                mean_secs: mean,
119                p90_secs: p90,
120            };
121            (sequence, lead)
122        })
123        .collect();
124    keyed.sort_unstable_by(|a, b| {
125        b.1.count
126            .cmp(&a.1.count)
127            .then_with(|| a.1.activities.cmp(&b.1.activities))
128    });
129
130    let (rest_median_secs, rest_count) = if let Some((top_key, _)) = keyed.first() {
131        let top_len = top_key.len();
132        let top_key = top_key.clone();
133        // rest = every trace not following the top variant; recompute leads
134        let mut rest: Vec<i64> = traces
135            .steps
136            .iter()
137            .filter(|steps| {
138                !steps.is_empty()
139                    && (steps.len() != top_len
140                        || steps.iter().zip(&top_key).any(|(&(a, _), &k)| a != k))
141            })
142            .map(|steps| (steps[steps.len() - 1].1 - steps[0].1).num_seconds())
143            .collect();
144        rest.sort_unstable();
145        let (median, _, _) = stats(&rest);
146        (median, rest.len())
147    } else {
148        (0.0, 0)
149    };
150    let variants: Vec<VariantLead> = keyed.into_iter().map(|(_, lead)| lead).collect();
151
152    let mut rework: Vec<ReworkMetric> = rework_agg
153        .into_iter()
154        .map(|(activity, (traces_hit, extra))| ReworkMetric {
155            activity: name(activity),
156            traces: traces_hit,
157            extra_occurrences: extra,
158        })
159        .collect();
160    rework.sort_unstable_by(|a, b| {
161        b.traces
162            .cmp(&a.traces)
163            .then_with(|| a.activity.cmp(&b.activity))
164    });
165
166    LeadTimeReport {
167        object_type: object_type.to_owned(),
168        measured: all.len(),
169        median_secs,
170        mean_secs,
171        p90_secs,
172        rest_median_secs,
173        rest_count,
174        variants,
175        rework,
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::test_util::log_from_sequences;
183
184    // helper spacing: events are 1 minute apart within a sequence
185    #[test]
186    // whole-minute leads are exactly representable in f64
187    #[allow(clippy::float_cmp)]
188    fn per_variant_distributions_and_rest() {
189        let log = log_from_sequences(&[
190            &["a", "b"],           // lead 60s
191            &["a", "b"],           // lead 60s
192            &["a", "b", "c", "d"], // lead 180s
193        ]);
194        let report = lead_times(&log, "case");
195        assert_eq!(report.measured, 3);
196        assert_eq!(report.median_secs, 60.0);
197        assert_eq!(report.variants[0].activities, ["a", "b"]);
198        assert_eq!(report.variants[0].count, 2);
199        assert_eq!(report.variants[0].median_secs, 60.0);
200        assert_eq!(report.rest_count, 1);
201        assert_eq!(report.rest_median_secs, 180.0);
202    }
203
204    #[test]
205    fn rework_counts_repeats() {
206        let log = log_from_sequences(&[&["a", "b", "b", "b", "c"], &["a", "c"]]);
207        let report = lead_times(&log, "case");
208        assert_eq!(report.rework.len(), 1);
209        assert_eq!(report.rework[0].activity, "b");
210        assert_eq!(report.rework[0].traces, 1);
211        assert_eq!(report.rework[0].extra_occurrences, 2);
212    }
213
214    #[test]
215    fn empty_type_is_all_zero() {
216        let log = log_from_sequences(&[]);
217        let report = lead_times(&log, "case");
218        assert_eq!(report.measured, 0);
219        assert!(report.variants.is_empty());
220        assert!(report.rework.is_empty());
221    }
222}