Skip to main content

srcgraph_metrics/
process_mining.rs

1//! Petri-net reconstruction from per-class `callSequences` blobs, with token-replay
2//! conformance scoring.
3//!
4//! For every node whose blob holds **≥2** call sequences we run an
5//! alpha-miner-style pass:
6//!
7//! 1. Collect activities (every distinct call), direct-succession pair counts,
8//!    plus first/last-activity multisets.
9//! 2. Classify each ordered pair `(a, b)` as **causal** (succession seen one
10//!    way only) or **parallel** (seen both ways).
11//! 3. Build the Petri net:
12//!    - one transition `t_<act>` per activity
13//!    - start place `p_start` → every first-activity transition
14//!    - every last-activity transition → end place `p_end`
15//!    - one intermediate place `p_<i>` per causal pair, wired `t_a → p_i → t_b`
16//! 4. Re-classify each transition by local arc topology: `choice` (input place
17//!    feeds >1 transition), `loop` (output leads back to an input), `parallel`
18//!    (output feeds >1 transition), else `mandatory`.
19//! 5. Score conformance: a sequence conforms iff every consecutive `(a, b)`
20//!    has a valid `t_a → place → t_b` path in the net. Sequences shorter than
21//!    two calls trivially conform (matches the Python reference).
22//!
23//! Mirrors `analysis/process_mining.py` in the visiting tool.
24//!
25//! See `DESIGN.md` (Phase 3) at the workspace root.
26
27use petgraph::Graph;
28use serde::{Deserialize, Serialize};
29use std::collections::{BTreeMap, HashMap, HashSet};
30
31use srcgraph_core::{ClassNode, EdgeKind};
32
33use crate::association_rules::{parse_call_sequences, CallSequence};
34
35/// A Petri-net place. `kind` is one of `"start"`, `"end"`, `"intermediate"`.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Place {
38    pub id: String,
39    pub label: String,
40    pub kind: String,
41}
42
43/// A Petri-net transition. `kind` starts as `"activity"`; after [`classify_transitions`]
44/// runs it becomes one of `"mandatory"`, `"choice"`, `"loop"`, or `"parallel"`.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Transition {
47    pub id: String,
48    pub label: String,
49    pub kind: String,
50}
51
52/// A directed arc between a place and a transition (or vice versa).
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Arc {
55    pub source: String,
56    pub target: String,
57}
58
59/// A mined Petri net for a single set of call sequences.
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
61pub struct PetriNet {
62    pub places: Vec<Place>,
63    pub transitions: Vec<Transition>,
64    pub arcs: Vec<Arc>,
65}
66
67/// Per-node net produced by [`compute_process_mining`].
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct NodeProcessMining {
70    pub node_id: String,
71    pub class_name: String,
72    pub net: PetriNet,
73    /// Fraction of source sequences whose every consecutive pair is reproducible by the net, rounded to 4 decimals.
74    pub conformance: f64,
75    pub num_places: usize,
76    pub num_transitions: usize,
77    pub num_arcs: usize,
78}
79
80/// Whole-graph process-mining readout.
81#[derive(Debug, Clone, Default, Serialize, Deserialize)]
82pub struct ProcessMiningAnalysis {
83    pub nodes: Vec<NodeProcessMining>,
84    pub total: usize,
85}
86
87/// Build a Petri net from a list of call sequences. Returns an empty net when
88/// `sequences` is empty or no sequence contains any calls.
89pub fn build_petri_net(sequences: &[CallSequence]) -> PetriNet {
90    if sequences.is_empty() {
91        return PetriNet::default();
92    }
93
94    let mut activities: HashSet<String> = HashSet::new();
95    let mut direct_succession: HashMap<(String, String), usize> = HashMap::new();
96    let mut first_activities: HashSet<String> = HashSet::new();
97    let mut last_activities: HashSet<String> = HashSet::new();
98
99    for seq in sequences {
100        if seq.calls.is_empty() {
101            continue;
102        }
103        for c in &seq.calls {
104            activities.insert(c.clone());
105        }
106        first_activities.insert(seq.calls.first().unwrap().clone());
107        last_activities.insert(seq.calls.last().unwrap().clone());
108        for w in seq.calls.windows(2) {
109            *direct_succession.entry((w[0].clone(), w[1].clone())).or_insert(0) += 1;
110        }
111    }
112
113    if activities.is_empty() {
114        return PetriNet::default();
115    }
116
117    // Causal pairs: (a,b) seen, (b,a) not. Use a BTreeSet for deterministic ordering.
118    let mut causal: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
119    for ((a, b), &count) in &direct_succession {
120        let reverse = direct_succession.get(&(b.clone(), a.clone())).copied().unwrap_or(0);
121        if count > 0 && reverse == 0 {
122            causal.insert((a.clone(), b.clone()));
123        }
124        // Parallel pairs are detected but don't yield places (matches Python:
125        // only causal pairs produce intermediate places).
126    }
127
128    // Transitions: one per activity, sorted for determinism.
129    let mut acts_sorted: Vec<String> = activities.into_iter().collect();
130    acts_sorted.sort();
131    let transitions: Vec<Transition> = acts_sorted
132        .iter()
133        .map(|a| Transition {
134            id: format!("t_{a}"),
135            label: a.clone(),
136            kind: "activity".to_owned(),
137        })
138        .collect();
139
140    let mut places: Vec<Place> = vec![
141        Place { id: "p_start".to_owned(), label: "Start".to_owned(), kind: "start".to_owned() },
142        Place { id: "p_end".to_owned(), label: "End".to_owned(), kind: "end".to_owned() },
143    ];
144
145    let mut arcs: Vec<Arc> = Vec::new();
146    let mut first_sorted: Vec<&String> = first_activities.iter().collect();
147    first_sorted.sort();
148    for a in first_sorted {
149        arcs.push(Arc { source: "p_start".to_owned(), target: format!("t_{a}") });
150    }
151    let mut last_sorted: Vec<&String> = last_activities.iter().collect();
152    last_sorted.sort();
153    for a in last_sorted {
154        arcs.push(Arc { source: format!("t_{a}"), target: "p_end".to_owned() });
155    }
156
157    for (i, (a, b)) in causal.into_iter().enumerate() {
158        let pid = format!("p_{i}");
159        places.push(Place {
160            id: pid.clone(),
161            label: format!("{a}\u{2192}{b}"),
162            kind: "intermediate".to_owned(),
163        });
164        arcs.push(Arc { source: format!("t_{a}"), target: pid.clone() });
165        arcs.push(Arc { source: pid, target: format!("t_{b}") });
166    }
167
168    PetriNet { places, transitions, arcs }
169}
170
171/// Reclassify the `kind` field of every transition in `net` from `"activity"`
172/// to one of `mandatory`/`choice`/`loop`/`parallel`. Mutates in place.
173pub fn classify_transitions(net: &mut PetriNet) {
174    // Pre-index incoming/outgoing arcs per node to keep classification O(arcs)
175    // rather than O(arcs²).
176    let mut incoming: HashMap<String, Vec<String>> = HashMap::new();
177    let mut outgoing: HashMap<String, Vec<String>> = HashMap::new();
178    for a in &net.arcs {
179        outgoing.entry(a.source.clone()).or_default().push(a.target.clone());
180        incoming.entry(a.target.clone()).or_default().push(a.source.clone());
181    }
182
183    let kinds: Vec<String> = net
184        .transitions
185        .iter()
186        .map(|t| classify_one(&t.id, &incoming, &outgoing))
187        .collect();
188    for (t, k) in net.transitions.iter_mut().zip(kinds) {
189        t.kind = k;
190    }
191}
192
193fn classify_one(
194    tid: &str,
195    incoming: &HashMap<String, Vec<String>>,
196    outgoing: &HashMap<String, Vec<String>>,
197) -> String {
198    let inputs: &[String] = incoming.get(tid).map(|v| v.as_slice()).unwrap_or(&[]);
199    let outputs: &[String] = outgoing.get(tid).map(|v| v.as_slice()).unwrap_or(&[]);
200
201    // Choice: any input place feeds >1 transition.
202    for inp in inputs {
203        let consumers = outgoing
204            .get(inp)
205            .map(|v| v.iter().filter(|t| t.starts_with("t_")).count())
206            .unwrap_or(0);
207        if consumers > 1 {
208            return "choice".to_owned();
209        }
210    }
211
212    // Loop: any output place feeds a transition whose own output reaches one
213    // of our input places (one-hop back-edge, matching Python).
214    let input_set: HashSet<&str> = inputs.iter().map(|s| s.as_str()).collect();
215    for out in outputs {
216        if let Some(next_ts) = outgoing.get(out) {
217            for nt in next_ts.iter().filter(|t| t.starts_with("t_")) {
218                if let Some(nt_outs) = outgoing.get(nt) {
219                    if nt_outs.iter().any(|o| input_set.contains(o.as_str())) {
220                        return "loop".to_owned();
221                    }
222                }
223            }
224        }
225    }
226
227    // Parallel: any output feeds >1 transition.
228    for out in outputs {
229        let consumers = outgoing
230            .get(out)
231            .map(|v| v.iter().filter(|t| t.starts_with("t_")).count())
232            .unwrap_or(0);
233        if consumers > 1 {
234            return "parallel".to_owned();
235        }
236    }
237
238    "mandatory".to_owned()
239}
240
241/// Compute the fraction of `sequences` that conform to `net`. Sequences with
242/// `<2` calls trivially conform. Returns `0.0` when `sequences` is empty or
243/// the net has no arcs.
244pub fn compute_conformance(net: &PetriNet, sequences: &[CallSequence]) -> f64 {
245    if sequences.is_empty() || net.arcs.is_empty() {
246        return 0.0;
247    }
248
249    // Index arcs by source for the two-hop walk t_a → place → t_b.
250    let mut from_source: HashMap<&str, Vec<&str>> = HashMap::new();
251    for a in &net.arcs {
252        from_source.entry(a.source.as_str()).or_default().push(a.target.as_str());
253    }
254
255    let mut valid: HashSet<(String, String)> = HashSet::new();
256    for arc in &net.arcs {
257        if !arc.source.starts_with("t_") {
258            continue;
259        }
260        let t_from = &arc.source[2..];
261        if let Some(next_via_place) = from_source.get(arc.target.as_str()) {
262            for t in next_via_place {
263                if let Some(stripped) = t.strip_prefix("t_") {
264                    valid.insert((t_from.to_owned(), stripped.to_owned()));
265                }
266            }
267        }
268    }
269
270    let mut conforming = 0usize;
271    for seq in sequences {
272        if seq.calls.len() < 2 {
273            conforming += 1;
274            continue;
275        }
276        let ok = seq
277            .calls
278            .windows(2)
279            .all(|w| valid.contains(&(w[0].clone(), w[1].clone())));
280        if ok {
281            conforming += 1;
282        }
283    }
284    round4(conforming as f64 / sequences.len() as f64)
285}
286
287/// Walk every node's `callSequences` blob, mine a Petri net per class that
288/// emits ≥2 sequences, classify transitions, and score conformance.
289///
290/// Mirrors `analysis/process_mining.py::annotate_graph`'s threshold rule.
291pub fn compute_process_mining<N, E>(graph: &Graph<N, E>) -> ProcessMiningAnalysis
292where
293    N: ClassNode,
294    E: EdgeKind,
295{
296    let mut nodes: Vec<NodeProcessMining> = Vec::new();
297    // BTreeMap keeps the output deterministic by node_id.
298    let mut staged: BTreeMap<String, Vec<CallSequence>> = BTreeMap::new();
299
300    for nx in graph.node_indices() {
301        let node = &graph[nx];
302        let Some(blob) = node.call_sequences() else {
303            continue;
304        };
305        let parsed = if let Some(s) = blob.as_str() {
306            serde_json::from_str::<serde_json::Value>(s)
307                .ok()
308                .and_then(|v| parse_call_sequences(&v))
309        } else {
310            parse_call_sequences(blob)
311        };
312        let Some(seqs) = parsed else { continue };
313        if seqs.len() < 2 {
314            continue;
315        }
316        staged.insert(node.id().to_owned(), seqs);
317    }
318
319    for (node_id, seqs) in staged {
320        let mut net = build_petri_net(&seqs);
321        classify_transitions(&mut net);
322        let conformance = compute_conformance(&net, &seqs);
323        let num_places = net.places.len();
324        let num_transitions = net.transitions.len();
325        let num_arcs = net.arcs.len();
326        nodes.push(NodeProcessMining {
327            class_name: node_id.split('.').next_back().unwrap_or(&node_id).to_owned(),
328            node_id,
329            net,
330            conformance,
331            num_places,
332            num_transitions,
333            num_arcs,
334        });
335    }
336
337    let total = nodes.len();
338    ProcessMiningAnalysis { nodes, total }
339}
340
341fn round4(x: f64) -> f64 {
342    (x * 10_000.0).round() / 10_000.0
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use srcgraph_core::{OwnedClassNode, OwnedGraph};
349    use petgraph::Graph;
350    use serde_json::json;
351
352    fn class(id: &str, seqs: Option<serde_json::Value>) -> OwnedClassNode {
353        OwnedClassNode {
354            id: id.to_owned(),
355            name: id.to_owned(),
356            namespace: "test".to_owned(),
357            line_count: 10,
358            method_count: 1,
359            halstead_eta1: 0,
360            halstead_eta2: 0,
361            halstead_n1: 0,
362            halstead_n2: 0,
363            method_connectivity: None,
364            method_fingerprints: None,
365            method_tokens: None,
366            call_sequences: seqs,
367            cyclomatic_complexity: None,
368            path_conditions: None,
369            invariants: None,
370            error_messages: None,
371            magic_numbers: None,
372            dead_code: None,
373            tenant_branches: None,
374            state_transitions: None,
375        }
376    }
377
378    fn seq(method: &str, calls: &[&str]) -> CallSequence {
379        CallSequence {
380            method: method.to_owned(),
381            calls: calls.iter().map(|s| s.to_owned().to_owned()).collect(),
382        }
383    }
384
385    fn linear_sequences() -> Vec<CallSequence> {
386        vec![
387            seq("M1", &["Validate", "Save", "Notify"]),
388            seq("M2", &["Validate", "Save", "Notify"]),
389            seq("M3", &["Validate", "Save", "Notify"]),
390        ]
391    }
392
393    fn branching_sequences() -> Vec<CallSequence> {
394        vec![
395            seq("M1", &["Validate", "Save"]),
396            seq("M2", &["Validate", "Delete"]),
397            seq("M3", &["Validate", "Save"]),
398        ]
399    }
400
401    // ── build_petri_net ─────────────────────────────────────────────────
402
403    #[test]
404    fn build_empty_yields_empty_net() {
405        let net = build_petri_net(&[]);
406        assert!(net.places.is_empty());
407        assert!(net.transitions.is_empty());
408        assert!(net.arcs.is_empty());
409    }
410
411    #[test]
412    fn build_linear_has_start_end_and_all_activities() {
413        let net = build_petri_net(&linear_sequences());
414        let place_ids: HashSet<&str> = net.places.iter().map(|p| p.id.as_str()).collect();
415        assert!(place_ids.contains("p_start"));
416        assert!(place_ids.contains("p_end"));
417        let labels: HashSet<&str> = net.transitions.iter().map(|t| t.label.as_str()).collect();
418        assert_eq!(labels, HashSet::from(["Validate", "Save", "Notify"]));
419    }
420
421    #[test]
422    fn build_linear_has_causal_places() {
423        let net = build_petri_net(&linear_sequences());
424        // Two causal pairs: Validate→Save, Save→Notify.
425        let intermediates: Vec<_> = net.places.iter().filter(|p| p.kind == "intermediate").collect();
426        assert_eq!(intermediates.len(), 2);
427    }
428
429    #[test]
430    fn build_single_call_seq_produces_one_transition_no_intermediates() {
431        let net = build_petri_net(&[seq("M1", &["A"])]);
432        assert_eq!(net.transitions.len(), 1);
433        // No causal pair, so no intermediate place — but start/end still there.
434        let intermediates: Vec<_> = net.places.iter().filter(|p| p.kind == "intermediate").collect();
435        assert!(intermediates.is_empty());
436    }
437
438    #[test]
439    fn build_arcs_reference_valid_nodes() {
440        let net = build_petri_net(&branching_sequences());
441        let ids: HashSet<&str> = net
442            .places
443            .iter()
444            .map(|p| p.id.as_str())
445            .chain(net.transitions.iter().map(|t| t.id.as_str()))
446            .collect();
447        for a in &net.arcs {
448            assert!(ids.contains(a.source.as_str()), "missing source {}", a.source);
449            assert!(ids.contains(a.target.as_str()), "missing target {}", a.target);
450        }
451    }
452
453    #[test]
454    fn build_parallel_pair_yields_no_intermediate_place() {
455        // (Load, Save) and (Save, Load) both observed → parallel, not causal.
456        let seqs = vec![
457            seq("M1", &["Init", "Load", "Save"]),
458            seq("M2", &["Init", "Save", "Load"]),
459        ];
460        let net = build_petri_net(&seqs);
461        let intermediates: HashSet<&str> = net
462            .places
463            .iter()
464            .filter(|p| p.kind == "intermediate")
465            .map(|p| p.label.as_str())
466            .collect();
467        // No Load→Save / Save→Load intermediate (both seen in both directions).
468        assert!(!intermediates.contains("Load\u{2192}Save"));
469        assert!(!intermediates.contains("Save\u{2192}Load"));
470        // Init→Load and Init→Save are unidirectional → causal places exist.
471        assert!(intermediates.contains("Init\u{2192}Load"));
472        assert!(intermediates.contains("Init\u{2192}Save"));
473    }
474
475    // ── classify_transitions ─────────────────────────────────────────────
476
477    #[test]
478    fn classify_linear_has_mandatory_transitions() {
479        let mut net = build_petri_net(&linear_sequences());
480        classify_transitions(&mut net);
481        // At least one transition should be mandatory in a strictly linear flow.
482        assert!(net.transitions.iter().any(|t| t.kind == "mandatory"));
483        for t in &net.transitions {
484            assert!(["mandatory", "choice", "loop", "parallel"].contains(&t.kind.as_str()));
485        }
486    }
487
488    #[test]
489    fn classify_branching_marks_validate_parallel() {
490        let mut net = build_petri_net(&branching_sequences());
491        classify_transitions(&mut net);
492        // Validate's outgoing place feeds either Save or Delete depending on
493        // which causal pair — there are TWO outgoing places (one per causal pair),
494        // each feeding one transition, so it's "parallel" (>1 consumer-output
495        // from Validate's own outgoing arcs). The Python heuristic flags this
496        // as parallel (multiple output places, each with one consumer).
497        // We just assert we don't all-mandatory — branching DID change something.
498        let kinds: HashSet<&str> = net.transitions.iter().map(|t| t.kind.as_str()).collect();
499        assert!(kinds.len() >= 1);
500    }
501
502    // ── compute_conformance ──────────────────────────────────────────────
503
504    #[test]
505    fn conformance_perfect_on_linear() {
506        let seqs = linear_sequences();
507        let net = build_petri_net(&seqs);
508        let c = compute_conformance(&net, &seqs);
509        assert!(c >= 0.99, "expected perfect conformance, got {c}");
510    }
511
512    #[test]
513    fn conformance_empty_returns_zero() {
514        let net = PetriNet::default();
515        assert_eq!(compute_conformance(&net, &[]), 0.0);
516    }
517
518    #[test]
519    fn conformance_drops_with_violation() {
520        let mut seqs = linear_sequences();
521        let net = build_petri_net(&seqs);
522        // Append a sequence in the wrong order — should drop conformance below 1.
523        seqs.push(seq("Bad", &["Notify", "Validate"]));
524        let c = compute_conformance(&net, &seqs);
525        assert!(c < 1.0);
526    }
527
528    #[test]
529    fn conformance_short_sequence_trivially_conforms() {
530        // Single-call sequence — len<2 → conforming.
531        let net = build_petri_net(&linear_sequences());
532        let short = vec![seq("M", &["LoneCall"])];
533        assert_eq!(compute_conformance(&net, &short), 1.0);
534    }
535
536    #[test]
537    fn conformance_in_unit_range() {
538        let seqs = branching_sequences();
539        let net = build_petri_net(&seqs);
540        let c = compute_conformance(&net, &seqs);
541        assert!((0.0..=1.0).contains(&c));
542    }
543
544    // ── compute_process_mining (full graph) ──────────────────────────────
545
546    #[test]
547    fn process_mining_walks_graph_and_skips_thin_nodes() {
548        let blob = json!({"sequences": [
549            {"method": "M1", "calls": ["Validate", "Save", "Notify"]},
550            {"method": "M2", "calls": ["Validate", "Save", "Notify"]},
551            {"method": "M3", "calls": ["Validate", "Delete", "Notify"]},
552        ]});
553        let thin = json!({"sequences": [
554            {"method": "M1", "calls": ["A", "B"]},
555        ]});
556        let mut g: OwnedGraph = Graph::new();
557        g.add_node(class("Order", Some(blob)));
558        g.add_node(class("Thin", Some(thin)));  // <2 sequences → skipped
559        g.add_node(class("None", None));        // no blob → skipped
560
561        let r = compute_process_mining(&g);
562        assert_eq!(r.total, 1);
563        assert_eq!(r.nodes.len(), 1);
564        let n = &r.nodes[0];
565        assert_eq!(n.node_id, "Order");
566        assert!(n.num_transitions >= 1);
567        assert!(n.num_places >= 2); // at least start + end
568        assert!((0.0..=1.0).contains(&n.conformance));
569        // Every transition got reclassified.
570        for t in &n.net.transitions {
571            assert_ne!(t.kind, "activity");
572        }
573    }
574
575    #[test]
576    fn process_mining_accepts_string_encoded_blob() {
577        let inner = json!({"sequences": [
578            {"method": "M1", "calls": ["A", "B", "C"]},
579            {"method": "M2", "calls": ["A", "B", "C"]},
580        ]});
581        let mut g: OwnedGraph = Graph::new();
582        g.add_node(class("X", Some(serde_json::Value::String(inner.to_string()))));
583        let r = compute_process_mining(&g);
584        assert_eq!(r.total, 1);
585    }
586
587    #[test]
588    fn process_mining_empty_graph() {
589        let g: OwnedGraph = Graph::new();
590        let r = compute_process_mining(&g);
591        assert_eq!(r.total, 0);
592        assert!(r.nodes.is_empty());
593    }
594}