1use std::collections::HashMap;
21
22use ocel::Ocel;
23use serde::Serialize;
24
25use crate::alpha::PetriNet;
26use crate::inductive::ProcessTree;
27use crate::replay::{compile, Compiled};
28use crate::trace;
29
30#[derive(Debug, Clone, PartialEq, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct PrecisionReport {
34 pub object_type: String,
35 pub precision: f64,
37 pub allowed: usize,
39 pub escaping: usize,
41 pub truncated_traces: usize,
43}
44
45pub(crate) struct State {
47 pub(crate) visits: usize,
48 pub(crate) observed: Vec<bool>,
49}
50
51pub(crate) fn visit_states(
55 sequences: &[(Vec<u16>, usize)],
56 activities: usize,
57 valid: impl Fn(&[u16]) -> bool,
58) -> (HashMap<Vec<u16>, State>, usize) {
59 let mut states: HashMap<Vec<u16>, State> = HashMap::new();
60 let mut truncated = 0usize;
61 for (sequence, count) in sequences {
62 let mut fully_replayed = true;
63 for i in 0..sequence.len() {
64 let prefix = &sequence[..i];
65 if !valid(prefix) {
66 fully_replayed = false;
67 break;
68 }
69 let state = states.entry(prefix.to_vec()).or_insert_with(|| State {
70 visits: 0,
71 observed: vec![false; activities],
72 });
73 state.visits += count;
74 state.observed[sequence[i] as usize] = true;
75 }
76 if !fully_replayed {
77 truncated += count;
78 }
79 }
80 (states, truncated)
81}
82
83pub(crate) fn score(
88 object_type: &str,
89 states: &HashMap<Vec<u16>, State>,
90 truncated_traces: usize,
91 allowed_of: impl Fn(&[u16]) -> (Vec<bool>, usize),
92) -> PrecisionReport {
93 let mut allowed_sum = 0usize;
94 let mut escaping_sum = 0usize;
95 for (prefix, state) in states {
96 let (allowed, unlogged) = allowed_of(prefix);
97 let allowed_count = allowed.iter().filter(|&&a| a).count() + unlogged;
98 let escaping_count = allowed
99 .iter()
100 .zip(&state.observed)
101 .filter(|&(&a, &o)| a && !o)
102 .count()
103 + unlogged;
104 allowed_sum += state.visits * allowed_count;
105 escaping_sum += state.visits * escaping_count;
106 }
107 let precision = if allowed_sum == 0 {
108 1.0
109 } else {
110 1.0 - ratio(escaping_sum, allowed_sum)
111 };
112 PrecisionReport {
113 object_type: object_type.to_owned(),
114 precision,
115 allowed: allowed_sum,
116 escaping: escaping_sum,
117 truncated_traces,
118 }
119}
120
121#[allow(clippy::cast_precision_loss)]
123fn ratio(num: usize, den: usize) -> f64 {
124 num as f64 / den as f64
125}
126
127pub(crate) fn sequences_of(traces: &trace::Traces<'_>) -> Vec<(Vec<u16>, usize)> {
128 let mut index: HashMap<Vec<u16>, usize> = HashMap::new();
129 let mut sequences: Vec<(Vec<u16>, usize)> = Vec::new();
130 for steps in &traces.steps {
131 if steps.is_empty() {
132 continue;
133 }
134 let sequence: Vec<u16> = steps.iter().map(|&(a, _)| a).collect();
135 if let Some(&at) = index.get(&sequence) {
136 sequences[at].1 += 1;
137 } else {
138 index.insert(sequence.clone(), sequences.len());
139 sequences.push((sequence, 1));
140 }
141 }
142 sequences
143}
144
145#[must_use]
148pub fn tree_precision(log: &Ocel, object_type: &str, tree: &ProcessTree) -> PrecisionReport {
149 let traces = trace::build(log, object_type);
150 let mut intern: HashMap<String, u16> = traces
151 .activity_names
152 .iter()
153 .enumerate()
154 .map(|(id, &name)| {
155 (
156 name.to_owned(),
157 u16::try_from(id).expect("checked in build"),
158 )
159 })
160 .collect();
161 let mut compiled = Compiled::new();
162 let (root, _) = compile(tree, &mut intern, &mut compiled);
163 let logged = traces.activity_names.len();
164 let symbols = intern.len();
166
167 let sequences = sequences_of(&traces);
168 let (states, truncated) = visit_states(&sequences, logged, |prefix| {
169 compiled.prefix_ok(root, prefix)
170 });
171 score(object_type, &states, truncated, |prefix| {
172 let mut allowed = vec![false; logged];
173 let mut unlogged = 0usize;
174 let mut extended = Vec::with_capacity(prefix.len() + 1);
175 extended.extend_from_slice(prefix);
176 extended.push(0);
177 #[allow(clippy::needless_range_loop)] for id in 0..symbols {
179 *extended.last_mut().expect("just pushed") =
180 u16::try_from(id).expect("interned as u16");
181 if compiled.prefix_ok(root, &extended) {
182 if id < logged {
183 allowed[id] = true;
184 } else {
185 unlogged += 1;
186 }
187 }
188 }
189 (allowed, unlogged)
190 })
191}
192
193#[must_use]
196pub fn net_precision(log: &Ocel, object_type: &str, net: &PetriNet) -> PrecisionReport {
197 let traces = trace::build(log, object_type);
198 let activities = traces.activity_names.len();
199
200 let transition_of: HashMap<&str, usize> = net
201 .transitions
202 .iter()
203 .enumerate()
204 .map(|(i, name)| (name.as_str(), i))
205 .collect();
206 let mut consumes: Vec<Vec<usize>> = vec![Vec::new(); net.transitions.len()];
207 let mut produces: Vec<Vec<usize>> = vec![Vec::new(); net.transitions.len()];
208 for (place_index, place) in net.places.iter().enumerate() {
209 for name in &place.outputs {
210 if let Some(&t) = transition_of.get(name.as_str()) {
211 consumes[t].push(place_index);
212 }
213 }
214 for name in &place.inputs {
215 if let Some(&t) = transition_of.get(name.as_str()) {
216 produces[t].push(place_index);
217 }
218 }
219 }
220 let activity_to_transition: Vec<Option<usize>> = traces
221 .activity_names
222 .iter()
223 .map(|name| transition_of.get(name).copied())
224 .collect();
225 let sources: Vec<usize> = (0..net.places.len())
226 .filter(|&p| net.places[p].inputs.is_empty())
227 .collect();
228
229 let marking_after = |prefix: &[u16]| -> Option<Vec<usize>> {
231 if net.places.is_empty() {
232 return None;
233 }
234 let mut marking = vec![0usize; net.places.len()];
235 for &p in &sources {
236 marking[p] = 1;
237 }
238 for &a in prefix {
239 let t = activity_to_transition[a as usize]?;
240 for &p in &consumes[t] {
241 if marking[p] == 0 {
242 return None;
243 }
244 marking[p] -= 1;
245 }
246 for &p in &produces[t] {
247 marking[p] += 1;
248 }
249 }
250 Some(marking)
251 };
252
253 let logged_transition: Vec<bool> = {
255 let mut logged = vec![false; net.transitions.len()];
256 for t in activity_to_transition.iter().flatten() {
257 logged[*t] = true;
258 }
259 logged
260 };
261
262 let sequences = sequences_of(&traces);
263 let (states, truncated) = visit_states(&sequences, activities, |prefix| {
264 marking_after(prefix).is_some()
265 });
266 score(object_type, &states, truncated, |prefix| {
267 let mut allowed = vec![false; activities];
268 let mut unlogged = 0usize;
269 let Some(marking) = marking_after(prefix) else {
270 return (allowed, unlogged);
271 };
272 let enabled = |t: usize| consumes[t].iter().all(|&p| marking[p] > 0);
275 for (id, slot) in allowed.iter_mut().enumerate() {
276 if let Some(t) = activity_to_transition[id] {
277 *slot = enabled(t);
278 }
279 }
280 for (t, &is_logged) in logged_transition.iter().enumerate() {
281 if !is_logged && enabled(t) {
282 unlogged += 1;
283 }
284 }
285 (allowed, unlogged)
286 })
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::test_util::log_from_sequences;
293 use crate::{alpha, inductive, ProcessTree};
294
295 fn approx(a: f64, b: f64) {
296 assert!((a - b).abs() < 1e-9, "{a} != {b}");
297 }
298
299 #[test]
300 fn a_linear_model_of_a_linear_log_is_perfectly_precise() {
301 let log = log_from_sequences(&[&["a", "b", "c"], &["a", "b", "c"]]);
302 let tree = inductive(&log, "case", 0.0);
303 let report = tree_precision(&log, "case", &tree);
304 approx(report.precision, 1.0);
305 assert_eq!(report.truncated_traces, 0);
306 }
307
308 #[test]
309 fn a_flower_is_much_less_precise_than_a_sequence() {
310 let sequences: Vec<&[&str]> = vec![&["a", "b", "c"]; 5];
311 let log = log_from_sequences(&sequences);
312 let sequence = inductive(&log, "case", 0.0);
313 let flower = ProcessTree::Loop {
314 children: vec![
315 ProcessTree::Tau,
316 ProcessTree::Activity { label: "a".into() },
317 ProcessTree::Activity { label: "b".into() },
318 ProcessTree::Activity { label: "c".into() },
319 ],
320 };
321 let strict = tree_precision(&log, "case", &sequence);
322 let loose = tree_precision(&log, "case", &flower);
323 approx(strict.precision, 1.0);
324 approx(loose.precision, 1.0 / 3.0);
326 }
327
328 #[test]
329 fn exclusive_choice_counts_the_unchosen_branch_as_escaping() {
330 let log = log_from_sequences(&[&["s", "a"], &["s", "a"]]);
332 let tree = ProcessTree::Sequence {
333 children: vec![
334 ProcessTree::Activity { label: "s".into() },
335 ProcessTree::Exclusive {
336 children: vec![
337 ProcessTree::Activity { label: "a".into() },
338 ProcessTree::Activity { label: "b".into() },
339 ],
340 },
341 ],
342 };
343 let report = tree_precision(&log, "case", &tree);
344 assert_eq!(report.allowed, 6);
347 assert_eq!(report.escaping, 2);
348 approx(report.precision, 1.0 - 2.0 / 6.0);
349 assert!(!log_has_activity(&log, "b"));
353 }
354
355 fn log_has_activity(log: &ocel::Ocel, name: &str) -> bool {
356 log.events.iter().any(|e| e.event_type == name)
357 }
358
359 #[test]
360 fn deviating_trace_stops_contributing_and_is_counted() {
361 let log = log_from_sequences(&[&["a", "b"], &["b", "a"]]);
362 let tree = ProcessTree::Sequence {
363 children: vec![
364 ProcessTree::Activity { label: "a".into() },
365 ProcessTree::Activity { label: "b".into() },
366 ],
367 };
368 let report = tree_precision(&log, "case", &tree);
369 assert_eq!(report.truncated_traces, 1);
370 approx(report.precision, 1.0);
372 }
373
374 #[test]
375 fn net_precision_matches_tree_semantics_on_a_structured_log() {
376 let sequences: &[&[&str]] = &[
377 &["a", "b", "c", "d"],
378 &["a", "c", "b", "d"],
379 &["a", "e", "d"],
380 ];
381 let log = log_from_sequences(sequences);
382 let net = alpha(&log, "case");
383 let report = net_precision(&log, "case", &net);
384 assert_eq!(report.truncated_traces, 0);
385 assert!(report.precision > 0.5 && report.precision <= 1.0);
386 }
387}