Skip to main content

ocel_mine/
precision.rs

1//! ETC-style precision: how much behavior the model allows beyond the log.
2//!
3//! For every prefix state the log visits (deduplicated, weighted by visits):
4//! `allowed` = activities the model enables after that prefix, `observed` =
5//! activities that actually follow in the log, `escaping` = allowed −
6//! observed. Precision = 1 − Σ w·|escaping| / Σ w·|allowed| (Muñoz-Gama &
7//! Carmona's escaping-edges measure, the same family as `PM4Py`'s
8//! `precision_token_based_replay`).
9//!
10//! States are taken *before* each event of each trace (the empty prefix
11//! included, the end-of-trace state excluded), mirroring `PM4Py`. Traces stop
12//! contributing at their first non-replayable event (`truncated_traces`
13//! reports how many stopped early) — `PM4Py`'s measure does the same, and the
14//! numbers match it exactly on the official log (orders/items × noise 0/0.2,
15//! alpha on orders — including the 95.4%-fit items model: 0.352038 both).
16//!
17//! Read together with fitness: a flower model is 100% fit and very
18//! imprecise; a linear model of the top variant is precise and unfit.
19
20use 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/// Escaping-edges precision of a model over one object type's traces.
31#[derive(Debug, Clone, PartialEq, Serialize)]
32#[serde(rename_all = "camelCase")]
33pub struct PrecisionReport {
34    pub object_type: String,
35    /// 1 − escaping/allowed (1.0 when the model allows nothing anywhere).
36    pub precision: f64,
37    /// Σ visits·|allowed| over visited prefix states.
38    pub allowed: usize,
39    /// Σ visits·|escaping| over visited prefix states.
40    pub escaping: usize,
41    /// Traces that stopped contributing at a non-replayable event.
42    pub truncated_traces: usize,
43}
44
45/// One visited prefix state: how often, and what followed in the log.
46pub(crate) struct State {
47    pub(crate) visits: usize,
48    pub(crate) observed: Vec<bool>,
49}
50
51/// Walk every trace, recording each valid prefix state (before each event)
52/// with its visit count and observed next activities. `valid` decides how far
53/// a trace keeps contributing.
54pub(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
83/// `allowed_of` returns the allowed activities in the *logged* alphabet plus
84/// the count of allowed model moves outside it (never observed by
85/// definition, so always escaping) — enabled transitions the log never
86/// exercises still widen the model, exactly as in `PM4Py`'s measure.
87pub(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// counts fit in f64 exactly for realistic logs; intentional cast
122#[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/// Escaping-edges precision of a process tree (exact prefix semantics via the
146/// same ownership routing as [`crate::tree_replay`]).
147#[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    // compile() interns model-only labels after the logged ones
165    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)] // id doubles as the symbol value
178        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/// Escaping-edges precision of an alpha net: allowed = transitions enabled at
194/// the marking each prefix reaches (the net has no silent transitions).
195#[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    // marking after replaying `prefix`, or None on a missing token
230    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    // transitions whose label never occurs in the traces
254    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        // no input places (e.g. a disconnected alpha self-loop) = fires
273        // freely, same as in net_replay — allowed everywhere
274        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        // flower: every state allows all 3 activities, the log follows 1
325        approx(loose.precision, 1.0 / 3.0);
326    }
327
328    #[test]
329    fn exclusive_choice_counts_the_unchosen_branch_as_escaping() {
330        // model allows a|b after start; the log only ever takes a
331        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        // states: ε (allows s, observed s) and [s] (allows a+b, observed a),
345        // each visited twice: 2·1 + 2·2
346        assert_eq!(report.allowed, 6);
347        assert_eq!(report.escaping, 2);
348        approx(report.precision, 1.0 - 2.0 / 6.0);
349        // NOTE: b never appears in the log, so it is not in the trace
350        // alphabet — allowed_of can only see logged activities. Guard that
351        // assumption explicitly:
352        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        // the fit trace visits ε and [a]; each allows exactly the observed
371        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}