use crate::error::{codes, wasm_err};
use crate::models::{parse_timestamp_ms, AttributeValue, EventLog};
use crate::state::{get_or_init_state, StoredObject};
use crate::utilities::to_js;
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use wasm_bindgen::prelude::*;
const DEFAULT_CORRELATION_THRESHOLD_SECS: f64 = 86_400.0;
const CONCEPT_NAME_ATTR: &str = "concept:name";
const TIMESTAMP_ATTR: &str = "time:timestamp";
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CorrelationConfig {
pub correlation_threshold: f64,
pub min_edge_frequency: u32,
}
impl Default for CorrelationConfig {
fn default() -> Self {
CorrelationConfig {
correlation_threshold: DEFAULT_CORRELATION_THRESHOLD_SECS,
min_edge_frequency: 1,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CorrelationResult {
pub edges: Vec<(String, String, u32)>,
pub start_activities: Vec<(String, u32)>,
pub end_activities: Vec<(String, u32)>,
pub num_traces: usize,
}
#[wasm_bindgen]
pub fn discover_correlation(
eventlog_handle: &str,
activity_key: &str,
timestamp_key: &str,
threshold: f64,
) -> Result<JsValue, JsValue> {
let cfg = CorrelationConfig {
correlation_threshold: if threshold > 0.0 {
threshold
} else {
DEFAULT_CORRELATION_THRESHOLD_SECS
},
min_edge_frequency: 1,
};
let result = get_or_init_state().with_object(eventlog_handle, |obj| match obj {
Some(StoredObject::EventLog(log)) => {
let result = mine_correlation(log, activity_key, timestamp_key, &cfg);
to_js(&result)
}
Some(_) => Err(wasm_err(codes::INVALID_HANDLE, "Object is not an EventLog")),
None => Err(wasm_err(codes::INVALID_HANDLE, "EventLog not found")),
})?;
Ok(result)
}
pub fn mine_correlation(
log: &EventLog,
activity_key: &str,
timestamp_key: &str,
cfg: &CorrelationConfig,
) -> CorrelationResult {
let indexed = parse_and_sort(log, activity_key, timestamp_key);
if indexed.len() < 2 {
return empty_result();
}
let mut act_map: BTreeMap<String, (Vec<i64>, Vec<i64>)> = BTreeMap::new();
for ie in &indexed {
let (end_ts, start_ts) = act_map.entry(ie.activity.clone()).or_default();
end_ts.push(ie.end_time);
start_ts.push(ie.start_time);
}
let activities: Vec<String> = act_map.keys().cloned().collect();
let n = activities.len();
if n < 1 {
return empty_result();
}
if n < 2 {
return CorrelationResult {
edges: Vec::new(),
start_activities: activities
.iter()
.map(|a| {
(
a.clone(),
u32::try_from(act_map[a].0.len())
.expect("activity occurrence count fits u32"),
)
})
.collect(),
end_activities: activities
.iter()
.map(|a| {
(
a.clone(),
u32::try_from(act_map[a].0.len())
.expect("activity occurrence count fits u32"),
)
})
.collect(),
num_traces: estimate_trace_count(&indexed, cfg),
};
}
let act_counts: Vec<usize> = activities.iter().map(|a| act_map[a].0.len()).collect();
let ps = compute_ps_matrix(&activities, &act_map);
let dur = compute_duration_matrix(&activities, &act_map, cfg);
let edge_freq = resolve_edges(&act_counts, &ps, &dur);
let mut out_deg: FxHashMap<String, u32> = FxHashMap::default();
let mut in_deg: FxHashMap<String, u32> = FxHashMap::default();
let mut edges: Vec<(String, String, u32)> = Vec::new();
for (&(i, j), &freq) in &edge_freq {
if freq < cfg.min_edge_frequency {
continue;
}
let src = &activities[i];
let tgt = &activities[j];
*out_deg.entry(src.clone()).or_default() += freq;
*in_deg.entry(tgt.clone()).or_default() += freq;
edges.push((src.clone(), tgt.clone(), freq));
}
let start_activities: Vec<(String, u32)> = activities
.iter()
.enumerate()
.filter(|(_, a)| in_deg.get(*a).copied().unwrap_or(0) == 0)
.map(|(i, a)| {
(
a.clone(),
u32::try_from(act_counts[i]).expect("activity count fits u32"),
)
})
.collect();
let end_activities: Vec<(String, u32)> = activities
.iter()
.enumerate()
.filter(|(_, a)| out_deg.get(*a).copied().unwrap_or(0) == 0)
.map(|(i, a)| {
(
a.clone(),
u32::try_from(act_counts[i]).expect("activity count fits u32"),
)
})
.collect();
edges.sort_unstable();
CorrelationResult {
edges,
start_activities,
end_activities,
num_traces: estimate_trace_count(&indexed, cfg),
}
}
struct IndexedEvent {
index: usize,
activity: String,
end_time: i64,
start_time: i64,
}
fn parse_and_sort(log: &EventLog, activity_key: &str, timestamp_key: &str) -> Vec<IndexedEvent> {
let mut parsed = Vec::new();
let mut idx = 0usize;
for trace in &log.traces {
for event in &trace.events {
let activity = match event
.attributes
.get(activity_key)
.and_then(|v| v.as_string())
{
Some(a) => a.to_owned(),
None => continue,
};
let ts_ms = match event.attributes.get(timestamp_key) {
Some(AttributeValue::Date(s)) => parse_timestamp_ms(s),
_ => continue,
};
let Some(ts_ms) = ts_ms else {
continue;
};
let ts_secs = ts_ms / 1000;
parsed.push(IndexedEvent {
index: idx,
activity,
end_time: ts_secs,
start_time: ts_secs,
});
idx += 1;
}
}
parsed.sort_unstable_by_key(|ie| (ie.start_time, ie.end_time, ie.index));
parsed
}
fn compute_ps_matrix(
activities: &[String],
act_map: &BTreeMap<String, (Vec<i64>, Vec<i64>)>,
) -> Vec<Vec<f64>> {
let n = activities.len();
let mut ps = vec![vec![0.0f64; n]; n];
for i in 0..n {
let ai = &act_map[&activities[i]].0; if ai.is_empty() {
continue;
}
for j in 0..n {
if i == j {
continue;
}
let aj = &act_map[&activities[j]].1; if aj.is_empty() {
continue;
}
let count = ai
.iter()
.filter(|t| aj.partition_point(|&x| x <= **t) < aj.len())
.count();
ps[i][j] = count as f64 / (ai.len() * aj.len()) as f64;
}
}
ps
}
fn compute_duration_matrix(
activities: &[String],
act_map: &BTreeMap<String, (Vec<i64>, Vec<i64>)>,
cfg: &CorrelationConfig,
) -> Vec<Vec<f64>> {
let n = activities.len();
let thr = cfg.correlation_threshold as i64;
let mut dur = vec![vec![0.0f64; n]; n];
for i in 0..n {
let ai = &act_map[&activities[i]].0;
if ai.is_empty() {
continue;
}
for j in 0..n {
if i == j {
continue;
}
let aj = &act_map[&activities[j]].1;
if aj.is_empty() {
continue;
}
dur[i][j] = greedy_fifo_avg(ai, aj, thr).min(greedy_lifo_avg(ai, aj, thr));
}
}
dur
}
fn greedy_fifo_avg(ai: &[i64], aj: &[i64], thr: i64) -> f64 {
let mut matches = Vec::new();
let mut z = 0;
for &t in ai {
while z < aj.len() {
if t < aj[z] {
let d = aj[z] - t;
if d <= thr {
matches.push(d);
}
z += 1;
break;
}
z += 1;
}
}
avg(&matches)
}
fn greedy_lifo_avg(ai: &[i64], aj: &[i64], thr: i64) -> f64 {
let mut matches = Vec::new();
let mut k = ai.len() as isize - 1;
for z in (0..aj.len()).rev() {
while k >= 0 {
let k_usize = k as usize;
if k_usize >= ai.len() {
k -= 1;
continue;
}
if ai[k_usize] < aj[z] {
let d = aj[z] - ai[k_usize];
if d <= thr {
matches.push(d);
}
k -= 1;
break;
}
k -= 1;
}
}
avg(&matches)
}
fn avg(v: &[i64]) -> f64 {
if v.is_empty() {
0.0
} else {
v.iter().sum::<i64>() as f64 / v.len() as f64
}
}
fn resolve_edges(
act_counts: &[usize],
ps: &[Vec<f64>],
dur: &[Vec<f64>],
) -> FxHashMap<(usize, usize), u32> {
let n = act_counts.len();
let mut candidates: Vec<(f64, usize, usize)> = Vec::new();
for i in 0..n {
for j in 0..n {
if i == j || ps[i][j] <= 0.0 {
continue;
}
let mc = act_counts[i].min(act_counts[j]);
if mc == 0 {
continue;
}
candidates.push((dur[i][j] / ps[i][j] / mc as f64, i, j));
}
}
candidates.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
let mut out_rem: Vec<u32> = act_counts
.iter()
.map(|&c| u32::try_from(c).expect("occurrence count fits u32"))
.collect();
let mut in_rem: Vec<u32> = act_counts
.iter()
.map(|&c| u32::try_from(c).expect("occurrence count fits u32"))
.collect();
let mut edge_freq: FxHashMap<(usize, usize), u32> = FxHashMap::default();
for (_cost, i, j) in candidates {
if out_rem[i] == 0 || in_rem[j] == 0 {
continue;
}
if ps[j][i] >= ps[i][j] * 0.8 {
continue;
}
let assign = out_rem[i].min(in_rem[j]);
out_rem[i] -= assign;
in_rem[j] -= assign;
*edge_freq.entry((i, j)).or_default() += assign;
}
edge_freq
}
fn estimate_trace_count(indexed: &[IndexedEvent], cfg: &CorrelationConfig) -> usize {
if indexed.is_empty() {
return 0;
}
let thr = cfg.correlation_threshold as i64;
let mut count = 1;
for w in indexed.windows(2) {
if w[1].start_time - w[0].end_time > thr {
count += 1;
}
}
count
}
fn empty_result() -> CorrelationResult {
CorrelationResult {
edges: Vec::new(),
start_activities: Vec::new(),
end_activities: Vec::new(),
num_traces: 0,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{AttributeValue, Event, EventLog, Trace};
use std::collections::{BTreeMap, HashMap};
fn make_log(events: &[(&str, &str)]) -> EventLog {
let trace_events: Vec<Event> = events
.iter()
.map(|(activity, ts)| {
let mut attrs = BTreeMap::new();
attrs.insert(
CONCEPT_NAME_ATTR.to_owned(),
AttributeValue::String((*activity).to_owned()),
);
attrs.insert(
TIMESTAMP_ATTR.to_owned(),
AttributeValue::Date((*ts).to_owned()),
);
Event { attributes: attrs }
})
.collect();
EventLog {
attributes: BTreeMap::new(),
traces: vec![Trace {
attributes: BTreeMap::new(),
events: trace_events,
}],
}
}
fn make_multi_trace_log(traces: &[Vec<(&str, &str)>]) -> EventLog {
EventLog {
attributes: BTreeMap::new(),
traces: traces
.iter()
.map(|events| Trace {
attributes: BTreeMap::new(),
events: events
.iter()
.map(|(activity, ts)| {
let mut attrs = BTreeMap::new();
attrs.insert(
CONCEPT_NAME_ATTR.to_owned(),
AttributeValue::String((*activity).to_owned()),
);
attrs.insert(
TIMESTAMP_ATTR.to_owned(),
AttributeValue::Date((*ts).to_owned()),
);
Event { attributes: attrs }
})
.collect(),
})
.collect(),
}
}
#[test]
fn correlation_discovers_clear_temporal_pattern() {
let log = make_log(&[
("A", "2024-01-01T00:00:00Z"),
("B", "2024-01-01T00:00:01Z"),
("C", "2024-01-01T00:00:02Z"),
("A", "2024-01-01T00:00:10Z"),
("B", "2024-01-01T00:00:11Z"),
("C", "2024-01-01T00:00:12Z"),
("A", "2024-01-01T00:00:20Z"),
("B", "2024-01-01T00:00:21Z"),
("C", "2024-01-01T00:00:22Z"),
]);
let cfg = CorrelationConfig {
correlation_threshold: 5.0,
min_edge_frequency: 1,
};
let result = mine_correlation(&log, CONCEPT_NAME_ATTR, TIMESTAMP_ATTR, &cfg);
assert!(!result.edges.is_empty(), "Expected non-empty DFG edges");
let ab = result.edges.iter().find(|(s, t, _)| s == "A" && t == "B");
assert!(
ab.is_some(),
"Expected A -> B edge, got: {:?}",
result.edges
);
assert!(ab.unwrap().2 >= 2);
let bc = result.edges.iter().find(|(s, t, _)| s == "B" && t == "C");
assert!(bc.is_some(), "Expected B -> C edge");
assert!(
result.start_activities.iter().any(|(a, _)| a == "A"),
"A should be start"
);
assert!(
result.end_activities.iter().any(|(a, _)| a == "C"),
"C should be end"
);
assert_eq!(result.num_traces, 3);
}
#[test]
fn correlation_empty_input() {
let log = EventLog {
attributes: BTreeMap::new(),
traces: vec![Trace {
attributes: BTreeMap::new(),
events: Vec::new(),
}],
};
let result = mine_correlation(
&log,
CONCEPT_NAME_ATTR,
TIMESTAMP_ATTR,
&CorrelationConfig::default(),
);
assert!(result.edges.is_empty() && result.start_activities.is_empty());
assert_eq!(result.num_traces, 0);
}
#[test]
fn correlation_single_activity_no_edges() {
let log = make_log(&[("A", "2024-01-01T00:00:00Z"), ("A", "2024-01-01T00:00:01Z")]);
let result = mine_correlation(
&log,
CONCEPT_NAME_ATTR,
TIMESTAMP_ATTR,
&CorrelationConfig::default(),
);
assert!(result.edges.is_empty());
assert_eq!(result.num_traces, 1);
}
#[test]
fn correlation_no_timestamps_returns_empty() {
let log = EventLog {
attributes: BTreeMap::new(),
traces: vec![Trace {
attributes: BTreeMap::new(),
events: vec![
{
let mut attrs = BTreeMap::new();
attrs.insert(
CONCEPT_NAME_ATTR.to_owned(),
AttributeValue::String("A".to_owned()),
);
Event { attributes: attrs }
},
{
let mut attrs = BTreeMap::new();
attrs.insert(
CONCEPT_NAME_ATTR.to_owned(),
AttributeValue::String("B".to_owned()),
);
Event { attributes: attrs }
},
],
}],
};
let result = mine_correlation(
&log,
CONCEPT_NAME_ATTR,
TIMESTAMP_ATTR,
&CorrelationConfig::default(),
);
assert!(result.edges.is_empty() && result.num_traces == 0);
}
#[test]
fn correlation_from_log_ignores_case_ids() {
let log = make_multi_trace_log(&[
vec![("A", "2024-01-01T00:00:00Z"), ("B", "2024-01-01T00:00:05Z")],
vec![("A", "2024-01-01T00:01:00Z"), ("B", "2024-01-01T00:01:05Z")],
]);
let result = mine_correlation(
&log,
CONCEPT_NAME_ATTR,
TIMESTAMP_ATTR,
&CorrelationConfig::default(),
);
assert!(result.edges.iter().any(|(s, t, _)| s == "A" && t == "B"));
}
#[test]
fn correlation_min_edge_frequency_filters() {
let log = make_log(&[
("A", "2024-01-01T00:00:00Z"),
("B", "2024-01-01T00:00:01Z"),
("A", "2024-01-01T00:00:10Z"),
("B", "2024-01-01T00:00:11Z"),
("A", "2024-01-01T00:00:20Z"),
("B", "2024-01-01T00:00:21Z"),
]);
let cfg = CorrelationConfig {
correlation_threshold: 3600.0,
min_edge_frequency: 5,
};
let result = mine_correlation(&log, CONCEPT_NAME_ATTR, TIMESTAMP_ATTR, &cfg);
assert!(
result.edges.is_empty(),
"min_edge_frequency=5 should filter all edges"
);
}
#[test]
fn correlation_detects_separate_traces_by_gap() {
let log = make_log(&[
("A", "2024-01-01T00:00:00Z"),
("B", "2024-01-01T00:00:01Z"),
("A", "2024-01-01T02:00:00Z"),
("B", "2024-01-01T02:00:01Z"),
]);
let cfg = CorrelationConfig {
correlation_threshold: 3600.0,
min_edge_frequency: 1,
};
let result = mine_correlation(&log, CONCEPT_NAME_ATTR, TIMESTAMP_ATTR, &cfg);
assert_eq!(
result.num_traces, 2,
"2-hour gap should split into 2 traces"
);
}
#[test]
fn correlation_no_activities_returns_empty() {
let log = EventLog {
attributes: BTreeMap::new(),
traces: vec![Trace {
attributes: BTreeMap::new(),
events: vec![{
let mut attrs = BTreeMap::new();
attrs.insert(
TIMESTAMP_ATTR.to_owned(),
AttributeValue::Date("2024-01-01T00:00:00Z".to_owned()),
);
Event { attributes: attrs }
}],
}],
};
let result = mine_correlation(
&log,
CONCEPT_NAME_ATTR,
TIMESTAMP_ATTR,
&CorrelationConfig::default(),
);
assert!(result.edges.is_empty());
assert_eq!(result.num_traces, 0);
}
}