1use std::collections::HashMap;
10
11use ocel::Ocel;
12use serde::Serialize;
13
14use crate::trace;
15
16#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "camelCase")]
30pub struct ReworkMetric {
31 pub activity: String,
32 pub traces: usize,
34 pub extra_occurrences: usize,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize)]
40#[serde(rename_all = "camelCase")]
41pub struct LeadTimeReport {
42 pub object_type: String,
43 pub measured: usize,
45 pub median_secs: f64,
46 pub mean_secs: f64,
47 pub p90_secs: f64,
48 pub rest_median_secs: f64,
50 pub rest_count: usize,
51 pub variants: Vec<VariantLead>,
53 pub rework: Vec<ReworkMetric>,
55}
56
57#[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 let p90 = sorted[(n * 9).div_ceil(10).max(1) - 1] as f64;
72 (median, mean, p90)
73}
74
75#[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 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 #[test]
186 #[allow(clippy::float_cmp)]
188 fn per_variant_distributions_and_rest() {
189 let log = log_from_sequences(&[
190 &["a", "b"], &["a", "b"], &["a", "b", "c", "d"], ]);
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}