use petgraph::Graph;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap, HashSet};
use srcgraph_core::{ClassNode, EdgeKind};
use crate::association_rules::{parse_call_sequences, CallSequence};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Place {
pub id: String,
pub label: String,
pub kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Transition {
pub id: String,
pub label: String,
pub kind: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Arc {
pub source: String,
pub target: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PetriNet {
pub places: Vec<Place>,
pub transitions: Vec<Transition>,
pub arcs: Vec<Arc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeProcessMining {
pub node_id: String,
pub class_name: String,
pub net: PetriNet,
pub conformance: f64,
pub num_places: usize,
pub num_transitions: usize,
pub num_arcs: usize,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProcessMiningAnalysis {
pub nodes: Vec<NodeProcessMining>,
pub total: usize,
}
pub fn build_petri_net(sequences: &[CallSequence]) -> PetriNet {
if sequences.is_empty() {
return PetriNet::default();
}
let mut activities: HashSet<String> = HashSet::new();
let mut direct_succession: HashMap<(String, String), usize> = HashMap::new();
let mut first_activities: HashSet<String> = HashSet::new();
let mut last_activities: HashSet<String> = HashSet::new();
for seq in sequences {
if seq.calls.is_empty() {
continue;
}
for c in &seq.calls {
activities.insert(c.clone());
}
first_activities.insert(seq.calls.first().unwrap().clone());
last_activities.insert(seq.calls.last().unwrap().clone());
for w in seq.calls.windows(2) {
*direct_succession.entry((w[0].clone(), w[1].clone())).or_insert(0) += 1;
}
}
if activities.is_empty() {
return PetriNet::default();
}
let mut causal: std::collections::BTreeSet<(String, String)> = std::collections::BTreeSet::new();
for ((a, b), &count) in &direct_succession {
let reverse = direct_succession.get(&(b.clone(), a.clone())).copied().unwrap_or(0);
if count > 0 && reverse == 0 {
causal.insert((a.clone(), b.clone()));
}
}
let mut acts_sorted: Vec<String> = activities.into_iter().collect();
acts_sorted.sort();
let transitions: Vec<Transition> = acts_sorted
.iter()
.map(|a| Transition {
id: format!("t_{a}"),
label: a.clone(),
kind: "activity".to_owned(),
})
.collect();
let mut places: Vec<Place> = vec![
Place { id: "p_start".to_owned(), label: "Start".to_owned(), kind: "start".to_owned() },
Place { id: "p_end".to_owned(), label: "End".to_owned(), kind: "end".to_owned() },
];
let mut arcs: Vec<Arc> = Vec::new();
let mut first_sorted: Vec<&String> = first_activities.iter().collect();
first_sorted.sort();
for a in first_sorted {
arcs.push(Arc { source: "p_start".to_owned(), target: format!("t_{a}") });
}
let mut last_sorted: Vec<&String> = last_activities.iter().collect();
last_sorted.sort();
for a in last_sorted {
arcs.push(Arc { source: format!("t_{a}"), target: "p_end".to_owned() });
}
for (i, (a, b)) in causal.into_iter().enumerate() {
let pid = format!("p_{i}");
places.push(Place {
id: pid.clone(),
label: format!("{a}\u{2192}{b}"),
kind: "intermediate".to_owned(),
});
arcs.push(Arc { source: format!("t_{a}"), target: pid.clone() });
arcs.push(Arc { source: pid, target: format!("t_{b}") });
}
PetriNet { places, transitions, arcs }
}
pub fn classify_transitions(net: &mut PetriNet) {
let mut incoming: HashMap<String, Vec<String>> = HashMap::new();
let mut outgoing: HashMap<String, Vec<String>> = HashMap::new();
for a in &net.arcs {
outgoing.entry(a.source.clone()).or_default().push(a.target.clone());
incoming.entry(a.target.clone()).or_default().push(a.source.clone());
}
let kinds: Vec<String> = net
.transitions
.iter()
.map(|t| classify_one(&t.id, &incoming, &outgoing))
.collect();
for (t, k) in net.transitions.iter_mut().zip(kinds) {
t.kind = k;
}
}
fn classify_one(
tid: &str,
incoming: &HashMap<String, Vec<String>>,
outgoing: &HashMap<String, Vec<String>>,
) -> String {
let inputs: &[String] = incoming.get(tid).map(|v| v.as_slice()).unwrap_or(&[]);
let outputs: &[String] = outgoing.get(tid).map(|v| v.as_slice()).unwrap_or(&[]);
for inp in inputs {
let consumers = outgoing
.get(inp)
.map(|v| v.iter().filter(|t| t.starts_with("t_")).count())
.unwrap_or(0);
if consumers > 1 {
return "choice".to_owned();
}
}
let input_set: HashSet<&str> = inputs.iter().map(|s| s.as_str()).collect();
for out in outputs {
if let Some(next_ts) = outgoing.get(out) {
for nt in next_ts.iter().filter(|t| t.starts_with("t_")) {
if let Some(nt_outs) = outgoing.get(nt) {
if nt_outs.iter().any(|o| input_set.contains(o.as_str())) {
return "loop".to_owned();
}
}
}
}
}
for out in outputs {
let consumers = outgoing
.get(out)
.map(|v| v.iter().filter(|t| t.starts_with("t_")).count())
.unwrap_or(0);
if consumers > 1 {
return "parallel".to_owned();
}
}
"mandatory".to_owned()
}
pub fn compute_conformance(net: &PetriNet, sequences: &[CallSequence]) -> f64 {
if sequences.is_empty() || net.arcs.is_empty() {
return 0.0;
}
let mut from_source: HashMap<&str, Vec<&str>> = HashMap::new();
for a in &net.arcs {
from_source.entry(a.source.as_str()).or_default().push(a.target.as_str());
}
let mut valid: HashSet<(String, String)> = HashSet::new();
for arc in &net.arcs {
if !arc.source.starts_with("t_") {
continue;
}
let t_from = &arc.source[2..];
if let Some(next_via_place) = from_source.get(arc.target.as_str()) {
for t in next_via_place {
if let Some(stripped) = t.strip_prefix("t_") {
valid.insert((t_from.to_owned(), stripped.to_owned()));
}
}
}
}
let mut conforming = 0usize;
for seq in sequences {
if seq.calls.len() < 2 {
conforming += 1;
continue;
}
let ok = seq
.calls
.windows(2)
.all(|w| valid.contains(&(w[0].clone(), w[1].clone())));
if ok {
conforming += 1;
}
}
round4(conforming as f64 / sequences.len() as f64)
}
pub fn compute_process_mining<N, E>(graph: &Graph<N, E>) -> ProcessMiningAnalysis
where
N: ClassNode,
E: EdgeKind,
{
let mut nodes: Vec<NodeProcessMining> = Vec::new();
let mut staged: BTreeMap<String, Vec<CallSequence>> = BTreeMap::new();
for nx in graph.node_indices() {
let node = &graph[nx];
let Some(blob) = node.call_sequences() else {
continue;
};
let parsed = if let Some(s) = blob.as_str() {
serde_json::from_str::<serde_json::Value>(s)
.ok()
.and_then(|v| parse_call_sequences(&v))
} else {
parse_call_sequences(blob)
};
let Some(seqs) = parsed else { continue };
if seqs.len() < 2 {
continue;
}
staged.insert(node.id().to_owned(), seqs);
}
for (node_id, seqs) in staged {
let mut net = build_petri_net(&seqs);
classify_transitions(&mut net);
let conformance = compute_conformance(&net, &seqs);
let num_places = net.places.len();
let num_transitions = net.transitions.len();
let num_arcs = net.arcs.len();
nodes.push(NodeProcessMining {
class_name: node_id.split('.').next_back().unwrap_or(&node_id).to_owned(),
node_id,
net,
conformance,
num_places,
num_transitions,
num_arcs,
});
}
let total = nodes.len();
ProcessMiningAnalysis { nodes, total }
}
fn round4(x: f64) -> f64 {
(x * 10_000.0).round() / 10_000.0
}
#[cfg(test)]
mod tests {
use super::*;
use srcgraph_core::{OwnedClassNode, OwnedGraph};
use petgraph::Graph;
use serde_json::json;
fn class(id: &str, seqs: Option<serde_json::Value>) -> OwnedClassNode {
OwnedClassNode {
id: id.to_owned(),
name: id.to_owned(),
namespace: "test".to_owned(),
line_count: 10,
method_count: 1,
halstead_eta1: 0,
halstead_eta2: 0,
halstead_n1: 0,
halstead_n2: 0,
method_connectivity: None,
method_fingerprints: None,
method_tokens: None,
call_sequences: seqs,
cyclomatic_complexity: None,
path_conditions: None,
invariants: None,
error_messages: None,
magic_numbers: None,
dead_code: None,
tenant_branches: None,
state_transitions: None,
}
}
fn seq(method: &str, calls: &[&str]) -> CallSequence {
CallSequence {
method: method.to_owned(),
calls: calls.iter().map(|s| s.to_owned().to_owned()).collect(),
}
}
fn linear_sequences() -> Vec<CallSequence> {
vec![
seq("M1", &["Validate", "Save", "Notify"]),
seq("M2", &["Validate", "Save", "Notify"]),
seq("M3", &["Validate", "Save", "Notify"]),
]
}
fn branching_sequences() -> Vec<CallSequence> {
vec![
seq("M1", &["Validate", "Save"]),
seq("M2", &["Validate", "Delete"]),
seq("M3", &["Validate", "Save"]),
]
}
#[test]
fn build_empty_yields_empty_net() {
let net = build_petri_net(&[]);
assert!(net.places.is_empty());
assert!(net.transitions.is_empty());
assert!(net.arcs.is_empty());
}
#[test]
fn build_linear_has_start_end_and_all_activities() {
let net = build_petri_net(&linear_sequences());
let place_ids: HashSet<&str> = net.places.iter().map(|p| p.id.as_str()).collect();
assert!(place_ids.contains("p_start"));
assert!(place_ids.contains("p_end"));
let labels: HashSet<&str> = net.transitions.iter().map(|t| t.label.as_str()).collect();
assert_eq!(labels, HashSet::from(["Validate", "Save", "Notify"]));
}
#[test]
fn build_linear_has_causal_places() {
let net = build_petri_net(&linear_sequences());
let intermediates: Vec<_> = net.places.iter().filter(|p| p.kind == "intermediate").collect();
assert_eq!(intermediates.len(), 2);
}
#[test]
fn build_single_call_seq_produces_one_transition_no_intermediates() {
let net = build_petri_net(&[seq("M1", &["A"])]);
assert_eq!(net.transitions.len(), 1);
let intermediates: Vec<_> = net.places.iter().filter(|p| p.kind == "intermediate").collect();
assert!(intermediates.is_empty());
}
#[test]
fn build_arcs_reference_valid_nodes() {
let net = build_petri_net(&branching_sequences());
let ids: HashSet<&str> = net
.places
.iter()
.map(|p| p.id.as_str())
.chain(net.transitions.iter().map(|t| t.id.as_str()))
.collect();
for a in &net.arcs {
assert!(ids.contains(a.source.as_str()), "missing source {}", a.source);
assert!(ids.contains(a.target.as_str()), "missing target {}", a.target);
}
}
#[test]
fn build_parallel_pair_yields_no_intermediate_place() {
let seqs = vec![
seq("M1", &["Init", "Load", "Save"]),
seq("M2", &["Init", "Save", "Load"]),
];
let net = build_petri_net(&seqs);
let intermediates: HashSet<&str> = net
.places
.iter()
.filter(|p| p.kind == "intermediate")
.map(|p| p.label.as_str())
.collect();
assert!(!intermediates.contains("Load\u{2192}Save"));
assert!(!intermediates.contains("Save\u{2192}Load"));
assert!(intermediates.contains("Init\u{2192}Load"));
assert!(intermediates.contains("Init\u{2192}Save"));
}
#[test]
fn classify_linear_has_mandatory_transitions() {
let mut net = build_petri_net(&linear_sequences());
classify_transitions(&mut net);
assert!(net.transitions.iter().any(|t| t.kind == "mandatory"));
for t in &net.transitions {
assert!(["mandatory", "choice", "loop", "parallel"].contains(&t.kind.as_str()));
}
}
#[test]
fn classify_branching_marks_validate_parallel() {
let mut net = build_petri_net(&branching_sequences());
classify_transitions(&mut net);
let kinds: HashSet<&str> = net.transitions.iter().map(|t| t.kind.as_str()).collect();
assert!(kinds.len() >= 1);
}
#[test]
fn conformance_perfect_on_linear() {
let seqs = linear_sequences();
let net = build_petri_net(&seqs);
let c = compute_conformance(&net, &seqs);
assert!(c >= 0.99, "expected perfect conformance, got {c}");
}
#[test]
fn conformance_empty_returns_zero() {
let net = PetriNet::default();
assert_eq!(compute_conformance(&net, &[]), 0.0);
}
#[test]
fn conformance_drops_with_violation() {
let mut seqs = linear_sequences();
let net = build_petri_net(&seqs);
seqs.push(seq("Bad", &["Notify", "Validate"]));
let c = compute_conformance(&net, &seqs);
assert!(c < 1.0);
}
#[test]
fn conformance_short_sequence_trivially_conforms() {
let net = build_petri_net(&linear_sequences());
let short = vec![seq("M", &["LoneCall"])];
assert_eq!(compute_conformance(&net, &short), 1.0);
}
#[test]
fn conformance_in_unit_range() {
let seqs = branching_sequences();
let net = build_petri_net(&seqs);
let c = compute_conformance(&net, &seqs);
assert!((0.0..=1.0).contains(&c));
}
#[test]
fn process_mining_walks_graph_and_skips_thin_nodes() {
let blob = json!({"sequences": [
{"method": "M1", "calls": ["Validate", "Save", "Notify"]},
{"method": "M2", "calls": ["Validate", "Save", "Notify"]},
{"method": "M3", "calls": ["Validate", "Delete", "Notify"]},
]});
let thin = json!({"sequences": [
{"method": "M1", "calls": ["A", "B"]},
]});
let mut g: OwnedGraph = Graph::new();
g.add_node(class("Order", Some(blob)));
g.add_node(class("Thin", Some(thin))); g.add_node(class("None", None));
let r = compute_process_mining(&g);
assert_eq!(r.total, 1);
assert_eq!(r.nodes.len(), 1);
let n = &r.nodes[0];
assert_eq!(n.node_id, "Order");
assert!(n.num_transitions >= 1);
assert!(n.num_places >= 2); assert!((0.0..=1.0).contains(&n.conformance));
for t in &n.net.transitions {
assert_ne!(t.kind, "activity");
}
}
#[test]
fn process_mining_accepts_string_encoded_blob() {
let inner = json!({"sequences": [
{"method": "M1", "calls": ["A", "B", "C"]},
{"method": "M2", "calls": ["A", "B", "C"]},
]});
let mut g: OwnedGraph = Graph::new();
g.add_node(class("X", Some(serde_json::Value::String(inner.to_string()))));
let r = compute_process_mining(&g);
assert_eq!(r.total, 1);
}
#[test]
fn process_mining_empty_graph() {
let g: OwnedGraph = Graph::new();
let r = compute_process_mining(&g);
assert_eq!(r.total, 0);
assert!(r.nodes.is_empty());
}
}