1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct Place {
38 pub id: String,
39 pub label: String,
40 pub kind: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Transition {
47 pub id: String,
48 pub label: String,
49 pub kind: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct Arc {
55 pub source: String,
56 pub target: String,
57}
58
59#[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#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct NodeProcessMining {
70 pub node_id: String,
71 pub class_name: String,
72 pub net: PetriNet,
73 pub conformance: f64,
75 pub num_places: usize,
76 pub num_transitions: usize,
77 pub num_arcs: usize,
78}
79
80#[derive(Debug, Clone, Default, Serialize, Deserialize)]
82pub struct ProcessMiningAnalysis {
83 pub nodes: Vec<NodeProcessMining>,
84 pub total: usize,
85}
86
87pub 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 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 }
127
128 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
171pub fn classify_transitions(net: &mut PetriNet) {
174 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 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 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 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
241pub fn compute_conformance(net: &PetriNet, sequences: &[CallSequence]) -> f64 {
245 if sequences.is_empty() || net.arcs.is_empty() {
246 return 0.0;
247 }
248
249 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
287pub 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 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 #[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 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 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 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 assert!(!intermediates.contains("Load\u{2192}Save"));
469 assert!(!intermediates.contains("Save\u{2192}Load"));
470 assert!(intermediates.contains("Init\u{2192}Load"));
472 assert!(intermediates.contains("Init\u{2192}Save"));
473 }
474
475 #[test]
478 fn classify_linear_has_mandatory_transitions() {
479 let mut net = build_petri_net(&linear_sequences());
480 classify_transitions(&mut net);
481 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 let kinds: HashSet<&str> = net.transitions.iter().map(|t| t.kind.as_str()).collect();
499 assert!(kinds.len() >= 1);
500 }
501
502 #[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 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 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 #[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))); g.add_node(class("None", None)); 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); assert!((0.0..=1.0).contains(&n.conformance));
569 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}