use wasm_bindgen::prelude::*;
use crate::state::{get_or_init_state, StoredObject};
use crate::error::{wasm_err, codes};
use crate::utilities::to_js_str;
use std::collections::HashSet;
#[wasm_bindgen]
pub fn ablation_study(
log_handle: &str,
activity_key: &str,
target_activities_json: &str,
) -> Result<JsValue, JsValue> {
let targets: Vec<String> = serde_json::from_str(target_activities_json)
.map_err(|e| wasm_err(codes::INVALID_JSON, format!("Invalid target activities JSON: {}", e)))?;
if targets.is_empty() {
return Err(wasm_err(codes::INVALID_INPUT, "Target activities list must not be empty"));
}
let (traces, _attributes) = get_or_init_state().with_event_log(log_handle, |log| {
Ok((log.traces.clone(), log.attributes.clone()))
})?;
let total_traces = traces.len();
let (orig_edges, orig_nodes) = build_dfg(&traces, activity_key);
let orig_edge_count = orig_edges.len();
let orig_node_count = orig_nodes.len();
let orig_fitness = compute_avg_fitness(&traces, activity_key, &orig_edges);
let mut results = Vec::with_capacity(targets.len());
for target in &targets {
let filtered_traces: Vec<_> = traces.iter()
.filter(|trace| {
!trace.events.iter().any(|e| {
e.attributes.get(activity_key)
.and_then(|v| v.as_string())
.map(|a| a == target.as_str())
.unwrap_or(false)
})
})
.cloned()
.collect();
let remaining_traces = filtered_traces.len();
let removed_traces = total_traces - remaining_traces;
if remaining_traces == 0 {
results.push(serde_json::json!({
"activity": target,
"severity": 1.0,
"removed_traces": removed_traces,
"remaining_traces": 0,
"edge_count_change": -(orig_edge_count as f64),
"node_count_change": -(orig_node_count as f64),
"fitness_change": 0.0,
"complexity_delta": 0.0,
"note": "all_traces_removed",
}));
continue;
}
let (ablated_edges, ablated_nodes) = build_dfg(&filtered_traces, activity_key);
let ablated_edge_count = ablated_edges.len();
let ablated_node_count = ablated_nodes.len();
let ablated_fitness = compute_avg_fitness(&filtered_traces, activity_key, &ablated_edges);
let lost_edges: HashSet<(String, String)> = orig_edges.difference(&ablated_edges).cloned().collect();
let new_edges: HashSet<(String, String)> = ablated_edges.difference(&orig_edges).cloned().collect();
let orig_complexity = if orig_node_count > 0 {
orig_edge_count as f64 / orig_node_count as f64
} else {
0.0
};
let ablated_complexity = if ablated_node_count > 0 {
ablated_edge_count as f64 / ablated_node_count as f64
} else {
0.0
};
let complexity_delta = ablated_complexity - orig_complexity;
let trace_impact = removed_traces as f64 / total_traces as f64;
let edge_impact = if orig_edge_count > 0 {
lost_edges.len() as f64 / orig_edge_count as f64
} else {
0.0
};
let severity = (trace_impact * 0.6 + edge_impact * 0.4).min(1.0);
results.push(serde_json::json!({
"activity": target,
"severity": severity,
"removed_traces": removed_traces,
"remaining_traces": remaining_traces,
"original_edges": orig_edge_count,
"ablated_edges": ablated_edge_count,
"edge_count_change": ablated_edge_count as f64 - orig_edge_count as f64,
"original_nodes": orig_node_count,
"ablated_nodes": ablated_node_count,
"node_count_change": ablated_node_count as f64 - orig_node_count as f64,
"lost_edges_count": lost_edges.len(),
"new_edges_count": new_edges.len(),
"original_fitness": orig_fitness,
"ablated_fitness": ablated_fitness,
"fitness_change": ablated_fitness - orig_fitness,
"complexity_delta": complexity_delta,
}));
}
results.sort_by(|a, b| {
b["severity"].as_f64().unwrap_or(0.0)
.total_cmp(&a["severity"].as_f64().unwrap_or(0.0))
.unwrap_or(std::cmp::Ordering::Equal)
});
to_js_str(&serde_json::json!({
"results": results,
"total_traces": total_traces,
"original_edges": orig_edge_count,
"original_nodes": orig_node_count,
"original_fitness": orig_fitness,
"method": "ablation_study",
}))
}
fn build_dfg(
traces: &[crate::models::Trace],
activity_key: &str,
) -> (HashSet<(String, String)>, HashSet<String>) {
let mut edges: HashSet<(String, String)> = HashSet::new();
let mut nodes: HashSet<String> = HashSet::new();
for trace in traces {
let acts: Vec<String> = trace.events.iter()
.filter_map(|e| e.attributes.get(activity_key).and_then(|v| v.as_string()).map(str::to_owned))
.collect();
for act in &acts {
nodes.insert(act.clone());
}
for window in acts.windows(2) {
edges.insert((window[0].clone(), window[1].clone()));
}
}
(edges, nodes)
}
fn compute_avg_fitness(
traces: &[crate::models::Trace],
activity_key: &str,
dfg_edges: &HashSet<(String, String)>,
) -> f64 {
if traces.is_empty() {
return 0.0;
}
let total: f64 = traces.iter().map(|trace| {
let acts: Vec<&str> = trace.events.iter()
.filter_map(|e| e.attributes.get(activity_key).and_then(|v| v.as_string()))
.collect();
if acts.len() <= 1 {
return 1.0;
}
let pairs = acts.len() - 1;
let fit = acts.windows(2)
.filter(|w| dfg_edges.contains(&(w[0].to_owned(), w[1].to_owned())))
.count();
fit as f64 / pairs as f64
}).sum();
total / traces.len() as f64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{EventLog, Trace, Event, AttributeValue};
use std::collections::HashMap;
fn make_test_log(traces: Vec<Vec<&str>>) -> EventLog {
let mut log = EventLog::new();
for activities in traces {
let mut trace = Trace {
attributes: HashMap::new(),
events: Vec::new(),
};
for act in activities {
let mut event = Event {
attributes: HashMap::new(),
};
event.attributes.insert("concept:name".to_string(), AttributeValue::String(act.to_string()));
trace.events.push(event);
}
log.traces.push(trace);
}
log
}
#[test]
fn test_build_dfg_basic() {
let log = make_test_log(vec![
vec!["A", "B", "C"],
vec!["A", "B", "D"],
]);
let (edges, nodes) = build_dfg(&log.traces, "concept:name");
assert_eq!(nodes.len(), 4); assert!(edges.contains(&("A".into(), "B".into())));
assert!(edges.contains(&("B".into(), "C".into())));
assert!(edges.contains(&("B".into(), "D".into())));
assert_eq!(edges.len(), 3);
}
#[test]
fn test_ablation_removes_activity() {
let log = make_test_log(vec![
vec!["A", "B", "C"],
vec!["A", "B", "C"],
vec!["A", "X", "C"],
]);
let filtered: Vec<_> = log.traces.iter()
.filter(|trace| {
!trace.events.iter().any(|e| {
e.attributes.get("concept:name")
.and_then(|v| v.as_string())
.map(|a| a == "X")
.unwrap_or(false)
})
})
.cloned()
.collect();
assert_eq!(filtered.len(), 2);
}
#[test]
fn test_ablation_severity_critical() {
let log = make_test_log(vec![
vec!["A", "B", "C"],
vec!["A", "B", "C"],
vec!["A", "B", "C"],
]);
let filtered: Vec<_> = log.traces.iter()
.filter(|trace| {
!trace.events.iter().any(|e| {
e.attributes.get("concept:name")
.and_then(|v| v.as_string())
.map(|a| a == "B")
.unwrap_or(false)
})
})
.cloned()
.collect();
assert_eq!(filtered.len(), 0);
}
#[test]
fn test_compute_avg_fitness_perfect() {
let log = make_test_log(vec![
vec!["A", "B", "C"],
vec!["A", "B", "C"],
]);
let (edges, _) = build_dfg(&log.traces, "concept:name");
let fitness = compute_avg_fitness(&log.traces, "concept:name", &edges);
assert!((fitness - 1.0).abs() < 1e-9);
}
}