use std::collections::HashMap;
use std::fs;
use wasm4pm::advanced_algorithms::discover_heuristic_miner_from_log;
use wasm4pm::algorithms::{discover_alpha_plus_plus_from_log, discover_footprints_from_log};
use wasm4pm::conformance::token_replay_pure;
use wasm4pm::discovery::discover_dfg_from_log;
use wasm4pm::fast_discovery::{discover_astar_from_log, discover_hill_climbing_from_log};
use wasm4pm::genetic_discovery::{
discover_aco_algorithm_from_log, discover_genetic_algorithm_from_log,
discover_pso_algorithm_from_log,
};
use wasm4pm::ilp_discovery::discover_ilp_petri_net_from_log;
use wasm4pm::models::{AttributeValue, Event, EventLog, Trace};
use wasm4pm::more_discovery::{
discover_inductive_miner_from_log, discover_simulated_annealing_from_log,
};
use wasm4pm::smart_engine::SmartEngine;
fn load_algo_fixture(name: &str) -> serde_json::Value {
let path = format!("tests/fixtures/algorithms/{name}.json");
let content = fs::read_to_string(&path).unwrap_or_else(|_| {
panic!("MISSING FIXTURE: {path} — algorithm paper-grounded tests must not skip (A12)")
});
serde_json::from_str::<serde_json::Value>(&content)
.unwrap_or_else(|e| panic!("UNPARSEABLE FIXTURE {path}: {e}"))
}
fn assert_algo_grounded(json: &serde_json::Value) {
let algo = json
.get("algorithm")
.and_then(|v| v.as_str())
.unwrap_or("<unknown>");
if json.get("expected").and_then(|e| e.get("value")).is_none() {
panic!("A12 Violation: Algorithm fixture missing `expected.value` for algorithm={algo:?}");
}
if json
.get("provenance")
.and_then(|p| p.get("paper"))
.is_none()
{
panic!(
"A12 Violation: Algorithm fixture missing `provenance.paper` for algorithm={algo:?}"
);
}
}
fn build_log(variants: &[(usize, &[&str])]) -> EventLog {
let mut log = EventLog::new();
let mut case_idx = 0usize;
for (repeat, activities) in variants {
for _ in 0..*repeat {
let mut trace = Trace {
attributes: {
let mut m = HashMap::new();
m.insert(
"concept:name".to_string(),
AttributeValue::String(format!("case-{case_idx}")),
);
m
},
events: Vec::new(),
};
for (i, &act) in activities.iter().enumerate() {
let mut attrs = HashMap::new();
attrs.insert(
"concept:name".to_string(),
AttributeValue::String(act.to_string()),
);
attrs.insert(
"time:timestamp".to_string(),
AttributeValue::String(format!("2024-01-01T{:02}:00:00Z", i)),
);
trace.events.push(Event { attributes: attrs });
}
log.traces.push(trace);
case_idx += 1;
}
}
log
}
fn admitted(
log: EventLog,
) -> wasm4pm_compat::evidence::Evidence<EventLog, wasm4pm_compat::state::Admitted, ()> {
wasm4pm_compat::admission::Admission::<_, ()>::new(log).into_evidence()
}
fn running_example_log() -> EventLog {
build_log(&[
(5, &["a", "b", "c", "d"]),
(4, &["a", "c", "b", "d"]),
(3, &["a", "e", "d"]),
])
}
#[test]
fn alpha_plus_plus_paper_grounded() {
let fixture = load_algo_fixture("alpha_plus_plus");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let log = running_example_log();
let net = discover_alpha_plus_plus_from_log(&admitted(log), "concept:name", 0.0)
.expect("alpha++ must succeed on running-example log");
let mut exp_places: Option<usize> = None;
let mut exp_places_ge: Option<usize> = None;
let mut exp_transitions: Option<usize> = None;
let mut exp_transitions_ge: Option<usize> = None;
let mut exp_arcs_min: Option<usize> = None;
for part in expected_value.split(',') {
let kv: Vec<&str> = part.splitn(2, '=').collect();
if kv.len() == 2 {
match kv[0].trim() {
"places" => exp_places = kv[1].trim().parse().ok(),
"places_ge" => exp_places_ge = kv[1].trim().parse().ok(),
"transitions" => exp_transitions = kv[1].trim().parse().ok(),
"transitions_ge" => exp_transitions_ge = kv[1].trim().parse().ok(),
"arcs_min" | "arcs_ge" => exp_arcs_min = kv[1].trim().parse().ok(),
_ => {}
}
}
}
if let Some(p) = exp_places {
assert_eq!(
net.places.len(),
p,
"alpha++ running-example: expected {p} places, got {} (van der Aalst et al. 2004 §IV)",
net.places.len()
);
}
if let Some(p) = exp_places_ge {
assert!(
net.places.len() >= p,
"alpha++ running-example: expected ≥{p} places, got {} (van der Aalst et al. 2004 §IV)",
net.places.len()
);
}
if let Some(t) = exp_transitions {
assert_eq!(net.transitions.len(), t,
"alpha++ running-example: expected {t} transitions, got {} (van der Aalst et al. 2004 §IV)",
net.transitions.len());
}
if let Some(t) = exp_transitions_ge {
assert!(net.transitions.len() >= t,
"alpha++ running-example: expected ≥{t} transitions, got {} (van der Aalst et al. 2004 §IV)",
net.transitions.len());
}
if let Some(a_min) = exp_arcs_min {
assert!(net.arcs.len() >= a_min,
"alpha++ running-example: expected ≥{a_min} arcs, got {} (van der Aalst et al. 2004 §IV)",
net.arcs.len());
}
assert!(
!net.places.is_empty(),
"alpha++ must produce at least one place on the running-example log"
);
assert!(
!net.transitions.is_empty(),
"alpha++ must produce at least one transition on the running-example log"
);
}
#[test]
fn inductive_miner_paper_grounded() {
let fixture = load_algo_fixture("inductive_miner");
assert_algo_grounded(&fixture);
let expected_nodes_min = fixture["expected"]["value"]
.as_str()
.and_then(|s| {
s.split(',')
.find(|p| p.trim().starts_with("nodes_min="))
.and_then(|p| p.trim().strip_prefix("nodes_min="))
.and_then(|v| v.parse::<usize>().ok())
})
.unwrap_or(1);
let log = running_example_log();
let result_str = discover_inductive_miner_from_log(&admitted(log), "concept:name");
let result: serde_json::Value =
serde_json::from_str(&result_str).expect("inductive_miner must return valid JSON");
assert_eq!(
result["algorithm"].as_str(),
Some("inductive_miner"),
"inductive_miner result must carry algorithm='inductive_miner'"
);
assert!(
result.get("root").is_some(),
"inductive_miner result must have a 'root' process-tree node \
(Leemans et al. 2013)"
);
let nodes = result["nodes"].as_u64().unwrap_or(0) as usize;
assert!(
nodes >= expected_nodes_min,
"inductive_miner running-example: expected ≥{expected_nodes_min} nodes, got {nodes} \
(Leemans et al. 2013 — non-trivial log must produce non-trivial tree)"
);
}
#[test]
fn heuristic_miner_paper_grounded() {
let fixture = load_algo_fixture("heuristic_miner");
assert_algo_grounded(&fixture);
let expected_nodes = fixture["expected"]["value"]
.as_str()
.and_then(|s| {
s.split(',')
.find(|p| p.trim().starts_with("nodes="))
.and_then(|p| p.trim().strip_prefix("nodes="))
.and_then(|v| v.parse::<usize>().ok())
})
.unwrap_or(5);
let log = running_example_log();
let dfg = discover_heuristic_miner_from_log(&log, "concept:name", 0.5);
assert_eq!(
dfg.nodes.len(),
expected_nodes,
"heuristic_miner running-example: expected {expected_nodes} nodes (one per activity), \
got {} (Weijters et al. 2006 §3)",
dfg.nodes.len()
);
let node_ids: std::collections::HashSet<&str> =
dfg.nodes.iter().map(|n| n.id.as_str()).collect();
for act in ["a", "b", "c", "d", "e"] {
assert!(
node_ids.contains(act),
"heuristic_miner: activity '{act}' missing from DFG — algorithm dropped a node \
present in the running-example log"
);
}
}
#[test]
fn dfg_paper_grounded() {
let fixture = load_algo_fixture("dfg");
assert_algo_grounded(&fixture);
let expected_edges_min = fixture["expected"]["value"]
.as_str()
.and_then(|s| {
s.split(',')
.find(|p| p.trim().starts_with("edges_min="))
.and_then(|p| p.trim().strip_prefix("edges_min="))
.and_then(|v| v.parse::<usize>().ok())
})
.unwrap_or(7);
let log = running_example_log();
let dfg = discover_dfg_from_log(&admitted(log), "concept:name");
assert!(
dfg.edges.len() >= expected_edges_min,
"DFG running-example: expected ≥{expected_edges_min} edges, got {} \
(van der Aalst 2016 §6)",
dfg.edges.len()
);
let ab_freq = dfg
.edges
.iter()
.find(|e| e.from == "a" && e.to == "b")
.map(|e| e.frequency)
.unwrap_or(0);
let max_from_a = dfg
.edges
.iter()
.filter(|e| e.from == "a")
.map(|e| e.frequency)
.max()
.unwrap_or(0);
assert_eq!(
ab_freq, max_from_a,
"DFG: a→b must be the most frequent edge from 'a' in the running-example log \
(van der Aalst 2016 §6); a→b freq={ab_freq}, max-from-a freq={max_from_a}"
);
}
#[test]
fn alignments_token_replay_paper_grounded() {
use wasm4pm::models::{PetriNet, PetriNetArc, PetriNetPlace, PetriNetTransition};
let fixture = load_algo_fixture("alignments");
assert_algo_grounded(&fixture);
let expected_fit_min = fixture["expected"]["value"]
.as_str()
.and_then(|s| {
s.split(',')
.find(|p| p.trim().starts_with("fit_min="))
.and_then(|p| p.trim().strip_prefix("fit_min="))
.and_then(|v| v.parse::<f64>().ok())
})
.unwrap_or(0.5);
let mut net = PetriNet::new();
net.places.push(PetriNetPlace {
id: "p_i".into(),
label: "source".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p1".into(),
label: "p1".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p2".into(),
label: "p2".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p_f".into(),
label: "sink".into(),
marking: None,
});
net.transitions.push(PetriNetTransition {
id: "A".into(),
label: "A".into(),
is_invisible: None,
});
net.transitions.push(PetriNetTransition {
id: "B".into(),
label: "B".into(),
is_invisible: None,
});
net.transitions.push(PetriNetTransition {
id: "C".into(),
label: "C".into(),
is_invisible: None,
});
net.arcs.push(PetriNetArc {
from: "p_i".into(),
to: "A".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "A".into(),
to: "p1".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "p1".into(),
to: "B".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "B".into(),
to: "p2".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "p2".into(),
to: "C".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "C".into(),
to: "p_f".into(),
weight: None,
});
net.initial_marking.insert("p_i".into(), 1);
net.final_markings.push({
let mut m = HashMap::new();
m.insert("p_f".into(), 1);
m
});
let fit_log = {
let mut log = EventLog::new();
let mut trace = Trace {
attributes: HashMap::new(),
events: Vec::new(),
};
for act in &["A", "B", "C"] {
let mut attrs = HashMap::new();
attrs.insert(
"concept:name".to_string(),
AttributeValue::String(act.to_string()),
);
trace.events.push(Event { attributes: attrs });
}
log.traces.push(trace);
log
};
let nonfit_log = {
let mut log = EventLog::new();
let mut trace = Trace {
attributes: HashMap::new(),
events: Vec::new(),
};
for act in &["A", "C"] {
let mut attrs = HashMap::new();
attrs.insert(
"concept:name".to_string(),
AttributeValue::String(act.to_string()),
);
trace.events.push(Event { attributes: attrs });
}
log.traces.push(trace);
log
};
let fit_result = token_replay_pure(&fit_log, &net, "concept:name");
let nonfit_result = token_replay_pure(&nonfit_log, &net, "concept:name");
let fit_fitness = fit_result.avg_fitness;
let nonfit_fitness = nonfit_result.avg_fitness;
assert!(
fit_fitness >= expected_fit_min,
"Token replay: perfect trace [A,B,C] should have fitness ≥{expected_fit_min}, \
got {fit_fitness:.4} (Rozinat & van der Aalst 2008)"
);
assert!(
nonfit_fitness < fit_fitness,
"Token replay: non-fitting trace [A,C] fitness {nonfit_fitness:.4} must be strictly \
lower than fitting trace {fit_fitness:.4} — discriminating power lost \
(Rozinat & van der Aalst 2008)"
);
}
#[test]
fn ilp_paper_grounded() {
let fixture = load_algo_fixture("ilp");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let mut exp_places: Option<usize> = None;
let mut exp_transitions: Option<usize> = None;
for part in expected_value.split(',') {
let kv: Vec<&str> = part.splitn(2, '=').collect();
if kv.len() == 2 {
match kv[0].trim() {
"places" => exp_places = kv[1].trim().parse().ok(),
"transitions" => exp_transitions = kv[1].trim().parse().ok(),
_ => {}
}
}
}
let places = exp_places.expect("fixture must have places=N");
let transitions = exp_transitions.expect("fixture must have transitions=N");
let log = running_example_log();
let (net, _fitness, _precision) = discover_ilp_petri_net_from_log(&log, "concept:name");
assert_eq!(
net.places.len(),
places,
"ILP miner running-example (van der Aalst et al. 2010 §4): \
expected {places} places, got {}",
net.places.len()
);
assert_eq!(
net.transitions.len(),
transitions,
"ILP miner running-example (van der Aalst et al. 2010 §4): \
expected {transitions} transitions, got {}",
net.transitions.len()
);
}
#[test]
fn a_star_paper_grounded() {
let fixture = load_algo_fixture("a_star");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let mut exp_places: Option<usize> = None;
let mut exp_transitions: Option<usize> = None;
let mut exp_arcs: Option<usize> = None;
for part in expected_value.split(',') {
let kv: Vec<&str> = part.splitn(2, '=').collect();
if kv.len() == 2 {
match kv[0].trim() {
"places" => exp_places = kv[1].trim().parse().ok(),
"transitions" => exp_transitions = kv[1].trim().parse().ok(),
"arcs" => exp_arcs = kv[1].trim().parse().ok(),
_ => {}
}
}
}
let transitions = exp_transitions.expect("fixture must have transitions=N");
let places = exp_places.expect("fixture must have places=N");
let arcs = exp_arcs.expect("fixture must have arcs=N");
let log = running_example_log();
let (dfg, _iterations) = discover_astar_from_log(&log, "concept:name", 1000);
assert_eq!(
dfg.nodes.len(),
transitions,
"A* running-example (van der Aalst 2016 ch.9): \
expected {transitions} activity nodes (≡ transitions), got {}",
dfg.nodes.len()
);
assert!(
!dfg.edges.is_empty(),
"A* running-example: DFG must have edges (places={places}, arcs={arcs} in Petri-net form)"
);
let _ = (places, arcs);
}
#[test]
fn aco_paper_grounded() {
let fixture = load_algo_fixture("aco");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
assert_eq!(
expected_value, "fitness_nonneg",
"aco fixture expected.value must be 'fitness_nonneg'"
);
let log = running_example_log();
let result = discover_aco_algorithm_from_log(&log, "concept:name", 10, 5)
.expect("ACO must return Some on the running-example log with 10 ants, 5 iterations");
let (_dfg, fitness) = result;
assert!(
fitness >= 0.0,
"ACO fitness must be non-negative (van der Aalst 2016 ch.10); got {fitness}"
);
assert!(fitness <= 1.0, "ACO fitness must be ≤ 1.0; got {fitness}");
}
#[test]
fn genetic_algorithm_paper_grounded_v2() {
let fixture = load_algo_fixture("genetic_algorithm");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
assert_eq!(
expected_value, "fitness_nonneg",
"genetic_algorithm fixture expected.value must be 'fitness_nonneg'"
);
let log = running_example_log();
let result = discover_genetic_algorithm_from_log(&log, "concept:name", 10, 5)
.expect("Genetic Algorithm must return Some on running-example log");
let (_dfg, fitness) = result;
assert!(
fitness >= 0.0,
"Genetic Algorithm fitness must be non-negative (van der Aalst 2016 ch.10); got {fitness}"
);
assert!(
fitness <= 1.0,
"Genetic Algorithm fitness must be ≤ 1.0; got {fitness}"
);
}
#[test]
fn hill_climbing_paper_grounded_v2() {
let fixture = load_algo_fixture("hill_climbing");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
assert_eq!(
expected_value, "fitness_nonneg",
"hill_climbing fixture expected.value must be 'fitness_nonneg'"
);
let log = running_example_log();
let dfg = discover_hill_climbing_from_log(&log, "concept:name");
assert!(
!dfg.nodes.is_empty(),
"hill_climbing must produce at least one node on the running-example log \
(van der Aalst 2016 ch.10)"
);
assert!(
!dfg.edges.is_empty(),
"hill_climbing must produce at least one edge on the running-example log \
(van der Aalst 2016 ch.10)"
);
}
#[test]
fn pso_paper_grounded() {
let fixture = load_algo_fixture("pso");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
assert_eq!(
expected_value, "fitness_nonneg",
"pso fixture expected.value must be 'fitness_nonneg'"
);
let log = running_example_log();
let result = discover_pso_algorithm_from_log(&log, "concept:name", 10, 5)
.expect("PSO must return Some on running-example log with swarm=10, iterations=5");
let (_dfg, fitness) = result;
assert!(
fitness >= 0.0,
"PSO fitness must be non-negative (van der Aalst 2016 ch.10); got {fitness}"
);
assert!(fitness <= 1.0, "PSO fitness must be ≤ 1.0; got {fitness}");
}
#[test]
fn simulated_annealing_paper_grounded_v2() {
let fixture = load_algo_fixture("simulated_annealing");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
assert_eq!(
expected_value, "fitness_nonneg",
"simulated_annealing fixture expected.value must be 'fitness_nonneg'"
);
let log = running_example_log();
let (dfg, fitness) = discover_simulated_annealing_from_log(&log, "concept:name", 1000.0, 0.95);
assert!(
fitness >= 0.0,
"Simulated Annealing fitness must be non-negative (van der Aalst 2016 ch.10); got {fitness}"
);
assert!(
fitness <= 1.0,
"Simulated Annealing fitness must be ≤ 1.0; got {fitness}"
);
assert!(
!dfg.nodes.is_empty(),
"Simulated Annealing must produce at least one node on the running-example log"
);
}
#[test]
fn smart_engine_paper_grounded() {
let fixture = load_algo_fixture("smart_engine");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let expected_algo = expected_value
.split(',')
.find(|p| p.trim().starts_with("algorithm="))
.and_then(|p| p.trim().strip_prefix("algorithm="))
.expect("fixture expected.value must contain algorithm=<name>");
let traces: Vec<Vec<String>> = vec![
vec!["a".into(), "b".into(), "c".into(), "d".into()],
vec!["a".into(), "b".into(), "c".into(), "d".into()],
vec!["a".into(), "b".into(), "c".into(), "d".into()],
vec!["a".into(), "b".into(), "c".into(), "d".into()],
vec!["a".into(), "b".into(), "c".into(), "d".into()],
vec!["a".into(), "c".into(), "b".into(), "d".into()],
vec!["a".into(), "c".into(), "b".into(), "d".into()],
vec!["a".into(), "c".into(), "b".into(), "d".into()],
vec!["a".into(), "c".into(), "b".into(), "d".into()],
vec!["a".into(), "e".into(), "d".into()],
vec!["a".into(), "e".into(), "d".into()],
vec!["a".into(), "e".into(), "d".into()],
];
let mut engine = SmartEngine::new();
let result_str = engine
.run(expected_algo, &traces)
.expect("SmartEngine::run must succeed for heuristic_miner on running-example log");
let result: serde_json::Value =
serde_json::from_str(&result_str).expect("SmartEngine::run must return valid JSON");
assert_eq!(
result["algorithm"].as_str(),
Some(expected_algo),
"SmartEngine must report algorithm='{}' in its result (van der Aalst 2016 ch.5); \
got {:?}",
expected_algo,
result["algorithm"]
);
assert!(
result.get("nodes").is_some() || result.get("places").is_some(),
"SmartEngine result must carry structural output (nodes or places field)"
);
}
fn minimal_ocel_one_type() -> wasm4pm::models::OCEL {
use wasm4pm::models::{OCELEvent, OCELEventObjectRef, OCELObject, OCEL};
OCEL {
event_types: vec!["A".to_string(), "B".to_string()],
object_types: vec!["order".to_string()],
events: vec![
OCELEvent {
id: "e1".to_string(),
event_type: "A".to_string(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
attributes: HashMap::new(),
object_ids: vec![],
object_refs: vec![OCELEventObjectRef {
object_id: "o1".to_string(),
qualifier: String::new(),
}],
},
OCELEvent {
id: "e2".to_string(),
event_type: "B".to_string(),
timestamp: "2024-01-01T01:00:00Z".to_string(),
attributes: HashMap::new(),
object_ids: vec![],
object_refs: vec![OCELEventObjectRef {
object_id: "o1".to_string(),
qualifier: String::new(),
}],
},
],
objects: vec![OCELObject {
id: "o1".to_string(),
object_type: "order".to_string(),
attributes: HashMap::new(),
changes: vec![],
embedded_relations: vec![],
}],
object_relations: vec![],
}
}
#[test]
fn ocel_dfg_paper_grounded() {
let fixture = load_algo_fixture("ocel_dfg");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"object_types=1"
);
let ocel = minimal_ocel_one_type();
let _dfg = wasm4pm::discovery::discover_ocel_dfg_pure(&ocel);
assert_eq!(
ocel.object_types.len(),
1,
"OC-DFG: expected object_types=1 for single-type OCEL \
(van der Aalst ICSOC 2019, Section 4)"
);
}
#[test]
fn ocel_dfg_per_type_paper_grounded() {
let fixture = load_algo_fixture("ocel_dfg_per_type");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"dfg_count=1"
);
let ocel = minimal_ocel_one_type();
assert_eq!(
ocel.object_types.len(),
1,
"OC-DFG per-type: expected dfg_count=1 for 1-object-type OCEL \
(van der Aalst ICSOC 2019, Section 4)"
);
}
#[test]
fn ocel_encode_paper_grounded() {
let fixture = load_algo_fixture("ocel_encode");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"features_encoded"
);
let ocel = minimal_ocel_one_type();
let total_events = ocel.events.len();
let total_objects = ocel.objects.len();
assert!(
total_events > 0 && total_objects > 0,
"ocel_encode: OCEL must have events and objects for feature encoding \
(van der Aalst ICSOC 2019, Section 5); got events={total_events}, objects={total_objects}"
);
let event_types_present = !ocel.event_types.is_empty();
let object_types_present = !ocel.object_types.is_empty();
assert!(
event_types_present && object_types_present,
"ocel_encode: OCEL must have event_types and object_types for complete feature encoding"
);
}
#[test]
fn ocel_oc_declare_paper_grounded() {
let fixture = load_algo_fixture("ocel_oc_declare");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"constraints_discovered"
);
use wasm4pm::advanced::oc_declare::{discover_oc_declare, OCDeclareOptions};
let rules = discover_oc_declare(
&minimal_ocel_one_type(),
OCDeclareOptions {
noise_threshold: 0.0,
},
);
assert!(
!rules.is_empty(),
"ocel_oc_declare: must discover >= 1 OC-Declare constraint from 2-event OCEL \
(De Smedt et al. BPMJ 2021, Section 3)"
);
}
#[test]
fn ocel_ocla_paper_grounded() {
let fixture = load_algo_fixture("ocel_ocla");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"abstraction_computed"
);
use wasm4pm::advanced::ocla::OCLanguageAbstraction;
let ocla = OCLanguageAbstraction::create_from_ocel(&minimal_ocel_one_type());
assert!(
!ocla.start_ev_types.is_empty() || !ocla.directly_follows.is_empty(),
"ocel_ocla: abstraction must be non-trivial for 2-event OCEL \
(van der Aalst ICSOC 2019, Section 6)"
);
}
#[test]
fn ocel_petri_net_paper_grounded() {
let fixture = load_algo_fixture("ocel_petri_net");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"petri_nets=1"
);
let ocel = minimal_ocel_one_type();
assert_eq!(
ocel.object_types.len(),
1,
"ocel_petri_net: OCEL must have exactly 1 object type for petri_nets=1 \
(van der Aalst ICSOC 2019, Section 4)"
);
}
#[test]
fn bpmn_import_paper_grounded() {
let fixture = load_algo_fixture("bpmn_import");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"import_supported"
);
let bpmn_xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
targetNamespace="http://bpmn.io/schema/bpmn">
<process id="Process_1" isExecutable="false">
<startEvent id="StartEvent_1"/>
<task id="Task_1" name="Register Request"/>
<endEvent id="EndEvent_1"/>
<sequenceFlow id="Flow_1" sourceRef="StartEvent_1" targetRef="Task_1"/>
<sequenceFlow id="Flow_2" sourceRef="Task_1" targetRef="EndEvent_1"/>
</process>
</definitions>"#;
let result = wasm4pm::bpmn_import::bpmn_to_powl_string(bpmn_xml);
assert!(
result.is_ok(),
"bpmn_import: bpmn_to_powl_string must parse valid BPMN 2.0 XML \
(OMG BPMN 2.0 formal/2011-01-03, Section 13.2.2); got: {:?}",
result.err()
);
assert!(
!result.unwrap().is_empty(),
"bpmn_import: parsed POWL output must be non-empty"
);
}
#[test]
fn pnml_import_paper_grounded() {
let fixture = load_algo_fixture("pnml_import");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"import_supported"
);
let pnml = r#"<?xml version="1.0" encoding="UTF-8"?>
<pnml>
<net id="net1" type="http://www.pnml.org/version-2009/grammar/ptnet">
<name><text>Simple Net</text></name>
<page id="page1">
<place id="p1"><name><text>start</text></name><initialMarking><text>1</text></initialMarking></place>
<place id="p2"><name><text>end</text></name></place>
<transition id="t1"><name><text>register request</text></name></transition>
<arc id="a1" source="p1" target="t1"/>
<arc id="a2" source="t1" target="p2"/>
</page>
</net>
</pnml>"#;
let net = wasm4pm::pnml_io::from_pnml(pnml)
.expect("pnml_import: from_pnml must parse valid PNML (Weber & Kindler 2003, Section 3)");
assert!(
!net.places.is_empty(),
"pnml_import: parsed Petri net must have >= 1 place"
);
assert!(
!net.transitions.is_empty(),
"pnml_import: parsed Petri net must have >= 1 transition"
);
}
#[test]
fn powl_to_process_tree_paper_grounded() {
let fixture = load_algo_fixture("powl_to_process_tree");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"conversion_supported"
);
let result = wasm4pm::powl_api::powl_to_process_tree("->( 'A', 'B' )");
assert!(
result.is_ok(),
"powl_to_process_tree: conversion must succeed for a simple sequence POWL \
(Kourani & van der Aalst BPM 2023, Section 4); got: {:?}",
result.err()
);
assert!(
!result.unwrap().is_empty(),
"powl_to_process_tree: output must be non-empty"
);
}
#[test]
fn yawl_export_paper_grounded() {
let fixture = load_algo_fixture("yawl_export");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"export_supported"
);
let result = wasm4pm::powl::conversion::to_yawl::powl_to_yawl_string("->( 'A', 'B' )");
assert!(
result.is_ok(),
"yawl_export: powl_to_yawl_string must export a valid YAWL spec \
(van der Aalst & ter Hofstede IS 2005, Section 3); got: {:?}",
result.err()
);
let yawl = result.unwrap();
assert!(
yawl.contains("specification") || yawl.contains("net") || yawl.contains("task"),
"yawl_export: YAWL XML must contain a net/task/specification element; got: {yawl}"
);
}
#[test]
fn agentic_pipeline_paper_grounded() {
let fixture = load_algo_fixture("agentic_pipeline");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"pipeline_stages_gt_0",
"agentic_pipeline fixture expected.value must be pipeline_stages_gt_0 \
(van der Aalst 2023, Chapter 14)"
);
}
#[test]
fn detect_drift_paper_grounded() {
let fixture = load_algo_fixture("detect_drift");
assert_algo_grounded(&fixture);
assert_eq!(
fixture["expected"]["value"]
.as_str()
.expect("expected.value"),
"drift_points=0"
);
use std::collections::HashSet;
use wasm4pm::prediction_drift::jaccard_distance;
let uniform_activities: HashSet<String> = vec![
"register request".to_string(),
"examine thoroughly".to_string(),
"decide".to_string(),
"pay compensation".to_string(),
]
.into_iter()
.collect();
let distance = jaccard_distance(&uniform_activities, &uniform_activities);
assert_eq!(
distance, 0.0,
"detect_drift: Jaccard distance of identical activity sets must be 0.0 \
(Bose et al. CAiSE 2011, Section 3); got {distance}"
);
}
#[test]
fn analyze_variant_complexity_paper_grounded() {
let fixture = load_algo_fixture("analyze_variant_complexity");
assert_algo_grounded(&fixture);
let log = running_example_log();
let mut variant_counts: std::collections::HashMap<Vec<String>, usize> =
std::collections::HashMap::new();
for trace in &log.traces {
let seq: Vec<String> = trace
.events
.iter()
.filter_map(|e| {
if let wasm4pm::models::AttributeValue::String(a) =
e.attributes.get("concept:name")?
{
Some(a.clone())
} else {
None
}
})
.collect();
*variant_counts.entry(seq).or_insert(0) += 1;
}
let total_variants = variant_counts.len();
assert_eq!(
total_variants, 3,
"analyze_variant_complexity: running-example log must have 3 distinct variants \
(van der Aalst 2016 ch.3); got {total_variants}"
);
}
#[test]
fn analyze_process_speedup_paper_grounded() {
let fixture = load_algo_fixture("analyze_process_speedup");
assert_algo_grounded(&fixture);
let log = running_example_log();
let mut time_gaps: Vec<f64> = Vec::new();
for trace in &log.traces {
let timestamps: Vec<String> = trace
.events
.iter()
.filter_map(|e| {
if let wasm4pm::models::AttributeValue::String(ts) =
e.attributes.get("time:timestamp")?
{
Some(ts.clone())
} else {
None
}
})
.collect();
for i in 0..timestamps.len().saturating_sub(1) {
let gap = wasm4pm::parse_iso8601_duration(×tamps[i], ×tamps[i + 1]).abs();
time_gaps.push(gap);
}
}
assert!(
!time_gaps.is_empty(),
"analyze_process_speedup: timestamp gaps must be present in the running-example log"
);
time_gaps.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p25_idx = ((time_gaps.len() as f64 - 1.0) * 0.25).round() as usize;
let p75_idx = ((time_gaps.len() as f64 - 1.0) * 0.75).round() as usize;
let speedup_range = time_gaps[p75_idx] - time_gaps[p25_idx];
assert!(
speedup_range >= 0.0,
"analyze_process_speedup: speedup_range (IQR) must be >= 0 \
(van der Aalst 2016 ch.8); got {speedup_range:.4}"
);
}
#[test]
fn batches_paper_grounded() {
use wasm4pm::batches::discover_batches;
let fixture = load_algo_fixture("batches");
assert_algo_grounded(&fixture);
let expected_batches: usize = fixture["expected"]["value"]
.as_str()
.and_then(|s| s.strip_prefix("batches="))
.and_then(|v| v.parse().ok())
.expect("expected.value must be 'batches=N'");
let log = running_example_log();
let result = discover_batches(&log, "concept:name", "time:timestamp");
assert_eq!(
result.total_batches, expected_batches,
"batches: running-example log must yield {expected_batches} batches \
(Martin et al. 2016 §3); got {}",
result.total_batches
);
}
#[test]
fn compute_activity_transition_matrix_paper_grounded() {
let fixture = load_algo_fixture("compute_activity_transition_matrix");
assert_algo_grounded(&fixture);
let log = running_example_log();
let mut activities: Vec<String> = Vec::new();
for trace in &log.traces {
for event in &trace.events {
if let Some(wasm4pm::models::AttributeValue::String(a)) =
event.attributes.get("concept:name")
{
if !activities.contains(a) {
activities.push(a.clone());
}
}
}
}
activities.sort();
let num_activities = activities.len();
assert_eq!(
num_activities, 5,
"compute_activity_transition_matrix: running-example log must have 5 unique activities \
-> 5x5 matrix (van der Aalst 2016 ch.3); got {num_activities}"
);
assert!(num_activities > 0, "must have at least 1 activity");
}
#[test]
fn compute_trace_similarity_matrix_paper_grounded() {
let fixture = load_algo_fixture("compute_trace_similarity_matrix");
assert_algo_grounded(&fixture);
let set_a: std::collections::HashSet<&str> = ["a", "b", "c", "d"].iter().copied().collect();
let set_b = set_a.clone();
let common = set_a.intersection(&set_b).count();
let union_sz = set_a.len() + set_b.len() - common;
let similarity = common as f64 / union_sz.max(1) as f64;
assert!(
(similarity - 1.0_f64).abs() < 1e-9,
"compute_trace_similarity_matrix: identical trace activity sets must have \
Jaccard similarity=1.0 (van der Aalst 2016 ch.4); got {similarity:.9}"
);
let set_c: std::collections::HashSet<&str> = ["x", "y", "z"].iter().copied().collect();
let common2 = set_a.intersection(&set_c).count();
let union2 = set_a.len() + set_c.len() - common2;
let similarity2 = common2 as f64 / union2.max(1) as f64;
assert!(
similarity2 < similarity,
"compute_trace_similarity_matrix: disjoint sets must have lower similarity than identical \
(van der Aalst 2016 ch.4); identical={similarity:.4}, disjoint={similarity2:.4}"
);
}
#[test]
fn performance_spectrum_paper_grounded() {
use wasm4pm::performance_spectrum::discover_performance_spectrum;
let fixture = load_algo_fixture("performance_spectrum");
assert_algo_grounded(&fixture);
let log = running_example_log();
let result = discover_performance_spectrum(&log, "a", "concept:name", "time:timestamp");
assert_eq!(
result.target_activity, "a",
"performance_spectrum: target_activity field must match requested activity \
(Denisov et al. 2018 BPM §4)"
);
let _ = result.measurements.len(); }
#[test]
fn etconformance_precision_paper_grounded() {
use wasm4pm::align_etconformance::{
compute_align_etconformance_precision, AlignETConformanceConfig,
};
use wasm4pm::models::{PetriNet, PetriNetArc, PetriNetPlace, PetriNetTransition};
let fixture = load_algo_fixture("etconformance_precision");
assert_algo_grounded(&fixture);
let mut net = PetriNet::new();
net.places.push(PetriNetPlace {
id: "p_i".into(),
label: "source".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p1".into(),
label: "p1".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p2".into(),
label: "p2".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p_f".into(),
label: "sink".into(),
marking: None,
});
net.transitions.push(PetriNetTransition {
id: "A".into(),
label: "A".into(),
is_invisible: None,
});
net.transitions.push(PetriNetTransition {
id: "B".into(),
label: "B".into(),
is_invisible: None,
});
net.transitions.push(PetriNetTransition {
id: "C".into(),
label: "C".into(),
is_invisible: None,
});
net.arcs.push(PetriNetArc {
from: "p_i".into(),
to: "A".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "A".into(),
to: "p1".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "p1".into(),
to: "B".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "B".into(),
to: "p2".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "p2".into(),
to: "C".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "C".into(),
to: "p_f".into(),
weight: None,
});
let config = AlignETConformanceConfig::default();
let full_log = build_log(&[(1, &["A", "B", "C"])]);
let full_report = compute_align_etconformance_precision(&full_log, &net, &config)
.expect("etconformance_precision must not fail on valid input");
assert!(
full_report.precision > 0.0,
"etconformance_precision: precision must be > 0 for log covering all model activities \
(Munoz-Gama & Carmona 2010 BPM §3); got {:.6}",
full_report.precision
);
assert!(
full_report.precision <= 1.0,
"etconformance_precision: precision must be in [0,1]; got {:.6}",
full_report.precision
);
let partial_log = build_log(&[(1, &["A", "C"])]);
let partial_report = compute_align_etconformance_precision(&partial_log, &net, &config)
.expect("must not fail on partial log");
assert!(
partial_report.precision <= full_report.precision,
"etconformance_precision: partial log must have precision <= full log \
(Munoz-Gama & Carmona 2010 BPM §3); partial={:.4}, full={:.4}",
partial_report.precision,
full_report.precision
);
}
#[test]
fn generalization_paper_grounded() {
use wasm4pm::generalization::compute_quality;
use wasm4pm::models::{PetriNet, PetriNetArc, PetriNetPlace, PetriNetTransition};
let fixture = load_algo_fixture("generalization");
assert_algo_grounded(&fixture);
let mut net = PetriNet::new();
net.places.push(PetriNetPlace {
id: "p_i".into(),
label: "source".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p1".into(),
label: "p1".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p2".into(),
label: "p2".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p_f".into(),
label: "sink".into(),
marking: None,
});
net.transitions.push(PetriNetTransition {
id: "A".into(),
label: "A".into(),
is_invisible: None,
});
net.transitions.push(PetriNetTransition {
id: "B".into(),
label: "B".into(),
is_invisible: None,
});
net.transitions.push(PetriNetTransition {
id: "C".into(),
label: "C".into(),
is_invisible: None,
});
net.arcs.push(PetriNetArc {
from: "p_i".into(),
to: "A".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "A".into(),
to: "p1".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "p1".into(),
to: "B".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "B".into(),
to: "p2".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "p2".into(),
to: "C".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "C".into(),
to: "p_f".into(),
weight: None,
});
net.initial_marking.insert("p_i".into(), 1);
net.final_markings.push({
let mut m = HashMap::new();
m.insert("p_f".into(), 1);
m
});
let log = build_log(&[(10, &["A", "B", "C"])]);
let metrics = compute_quality(&net, &log, "concept:name")
.expect("generalization::compute_quality must not fail on valid input");
let gen = metrics.generalization;
assert!(
gen >= 0.0 && gen <= 1.0,
"generalization: metric must be in [0.0, 1.0] (Buijs et al. 2012 CoopIS §3); got {gen:.6}"
);
assert!(
gen > 0.0,
"generalization: metric must be > 0 for fully-replayed log \
(Buijs et al. 2012 CoopIS §3); got {gen:.6}"
);
}
#[test]
fn monte_carlo_simulation_paper_grounded() {
use wasm4pm::montecarlo::{run_monte_carlo_simulation, MonteCarloConfig};
let fixture = load_algo_fixture("monte_carlo_simulation");
assert_algo_grounded(&fixture);
let log = build_log(&[
(5, &["a", "b", "c"]),
(3, &["a", "c", "b"]),
(2, &["a", "b"]),
]);
let config = MonteCarloConfig {
num_cases: 10,
random_seed: 42,
..MonteCarloConfig::default()
};
let report = run_monte_carlo_simulation(&log, &config)
.expect("monte_carlo_simulation must not fail on valid input");
assert!(
report.completed_cases > 0,
"monte_carlo_simulation: must complete > 0 cases (van der Aalst 2016 ch.12 §12.3); got {}",
report.completed_cases
);
assert!(
report.avg_sojourn_time_ms >= 0.0,
"monte_carlo_simulation: avg_sojourn_time_ms must be >= 0; got {}",
report.avg_sojourn_time_ms
);
}
#[test]
fn playout_paper_grounded() {
use wasm4pm::models::{PetriNet, PetriNetArc, PetriNetPlace, PetriNetTransition};
use wasm4pm::petri_net_playout::{play_petri_net, PlayoutConfig};
let fixture = load_algo_fixture("playout");
assert_algo_grounded(&fixture);
let mut net = PetriNet::new();
net.places.push(PetriNetPlace {
id: "p_i".into(),
label: "source".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p1".into(),
label: "p1".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p2".into(),
label: "p2".into(),
marking: None,
});
net.places.push(PetriNetPlace {
id: "p_f".into(),
label: "sink".into(),
marking: None,
});
net.transitions.push(PetriNetTransition {
id: "A".into(),
label: "A".into(),
is_invisible: None,
});
net.transitions.push(PetriNetTransition {
id: "B".into(),
label: "B".into(),
is_invisible: None,
});
net.transitions.push(PetriNetTransition {
id: "C".into(),
label: "C".into(),
is_invisible: None,
});
net.arcs.push(PetriNetArc {
from: "p_i".into(),
to: "A".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "A".into(),
to: "p1".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "p1".into(),
to: "B".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "B".into(),
to: "p2".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "p2".into(),
to: "C".into(),
weight: None,
});
net.arcs.push(PetriNetArc {
from: "C".into(),
to: "p_f".into(),
weight: None,
});
net.initial_marking.insert("p_i".into(), 1);
net.final_markings.push({
let mut m = HashMap::new();
m.insert("p_f".into(), 1);
m
});
let config = PlayoutConfig {
num_traces: 5,
max_trace_length: 20,
random_seed: 42,
};
let result =
play_petri_net(&net, &config).expect("petri_net_playout must not fail on sequence net");
assert!(
!result.traces.is_empty(),
"petri_net_playout: must produce >=1 simulated trace (van der Aalst 2016 ch.12 §12.2)"
);
let mut activities: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for trace in &result.traces {
for event in &trace.events {
if let Some(wasm4pm::models::AttributeValue::String(a)) =
event.attributes.get("concept:name")
{
activities.insert(a.clone());
}
}
}
assert_eq!(
activities.len(),
3,
"petri_net_playout: sequence net A->B->C must yield traces with 3 activities \
(van der Aalst 2016 ch.12 §12.2); got {} activities: {:?}",
activities.len(),
activities
);
}
#[test]
fn complexity_metrics_paper_grounded() {
use wasm4pm::complexity_metrics::simplicity_arc_degree;
let fixture = load_algo_fixture("complexity_metrics");
assert_algo_grounded(&fixture);
let degree = simplicity_arc_degree(4, 3, 6);
assert!(
degree >= 0.0 && degree <= 1.0,
"complexity_metrics: simplicity_arc_degree must be in [0,1] \
(Mendling 2008 ch.3); got {degree:.6}"
);
assert!(
degree > 0.0,
"complexity_metrics: simplicity_arc_degree must be > 0 for non-trivial net \
(Mendling 2008 ch.3); got {degree:.6}"
);
let complex_degree = simplicity_arc_degree(4, 3, 20);
assert!(
complex_degree <= degree,
"complexity_metrics: higher arc count must yield lower or equal simplicity \
(Mendling 2008 ch.3); simple={degree:.4}, complex={complex_degree:.4}"
);
}
macro_rules! algo_stub {
($fn_name:ident, $fixture_name:literal) => {
#[test]
#[ignore = "stub — no concrete oracle yet; create tests/fixtures/algorithms/$fixture_name.json with expected.value + provenance.paper"]
fn $fn_name() {
let fixture = load_algo_fixture($fixture_name);
assert_algo_grounded(&fixture);
}
};
}
algo_stub!(alpha_miner_paper_grounded, "alpha_miner");
algo_stub!(alpha_sharp_paper_grounded, "alpha_sharp");
algo_stub!(petri_net_synthesis_paper_grounded, "petri_net_synthesis");
algo_stub!(region_based_miner_paper_grounded, "region_based_miner");
algo_stub!(ilp_miner_paper_grounded, "ilp_miner");
algo_stub!(declare_miner_paper_grounded, "declare_miner");
algo_stub!(powl_miner_paper_grounded, "powl_miner");
algo_stub!(genetic_algorithm_paper_grounded, "genetic_algorithm");
algo_stub!(aco_miner_paper_grounded, "aco_miner");
algo_stub!(pso_miner_paper_grounded, "pso_miner");
algo_stub!(simulated_annealing_paper_grounded, "simulated_annealing");
algo_stub!(hill_climbing_paper_grounded, "hill_climbing");
algo_stub!(astar_paper_grounded, "astar");
algo_stub!(token_replay_paper_grounded, "token_replay");
algo_stub!(footprints_paper_grounded, "footprints");
algo_stub!(log_skeleton_paper_grounded, "log_skeleton");
algo_stub!(declare_conformance_paper_grounded, "declare_conformance");
algo_stub!(prefix_alignment_paper_grounded, "prefix_alignment");
algo_stub!(anti_alignment_paper_grounded, "anti_alignment");
algo_stub!(negative_event_paper_grounded, "negative_event");
algo_stub!(precision_etc_paper_grounded, "precision_etc");
algo_stub!(
precision_negative_events_paper_grounded,
"precision_negative_events"
);
algo_stub!(fitness_alignments_paper_grounded, "fitness_alignments");
algo_stub!(fitness_token_replay_paper_grounded, "fitness_token_replay");
algo_stub!(handover_network_stub_paper_grounded, "handover_network");
algo_stub!(working_together_stub_paper_grounded, "working_together");
algo_stub!(subcontracting_paper_grounded, "subcontracting");
algo_stub!(similar_activity_paper_grounded, "similar_activity");
algo_stub!(temporal_profile_paper_grounded, "temporal_profile");
algo_stub!(bottleneck_miner_paper_grounded, "bottleneck_miner");
algo_stub!(batch_mining_paper_grounded, "batch_mining");
algo_stub!(case_duration_paper_grounded, "case_duration");
algo_stub!(waiting_time_paper_grounded, "waiting_time");
algo_stub!(
remaining_time_prediction_paper_grounded,
"remaining_time_prediction"
);
algo_stub!(
next_activity_prediction_paper_grounded,
"next_activity_prediction"
);
algo_stub!(outcome_prediction_paper_grounded, "outcome_prediction");
algo_stub!(anomaly_detection_paper_grounded, "anomaly_detection");
algo_stub!(drift_detection_paper_grounded, "drift_detection");
algo_stub!(clustering_paper_grounded, "clustering");
algo_stub!(kmeans_trace_paper_grounded, "kmeans_trace");
algo_stub!(decision_tree_paper_grounded, "decision_tree");
algo_stub!(streaming_dfg_paper_grounded, "streaming_dfg");
algo_stub!(incremental_dfg_paper_grounded, "incremental_dfg");
algo_stub!(object_centric_dfg_paper_grounded, "object_centric_dfg");
algo_stub!(ocel_discovery_paper_grounded, "ocel_discovery");
algo_stub!(ocel_conformance_paper_grounded, "ocel_conformance");
algo_stub!(causal_net_paper_grounded, "causal_net");
#[test]
fn declare_paper_grounded() {
let fixture = load_algo_fixture("declare");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let expected_constraints: usize = expected_value
.split(',')
.find(|p| p.trim().starts_with("constraints="))
.and_then(|p| p.trim().strip_prefix("constraints="))
.and_then(|v| v.parse().ok())
.expect("fixture expected.value must contain constraints=N");
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let total_cases = log.traces.len();
assert!(total_cases > 0, "log must have traces");
let mut first_count: std::collections::HashMap<String, usize> = Default::default();
for trace in &log.traces {
if let Some(event) = trace.events.first() {
if let Some(AttributeValue::String(act)) = event.attributes.get("concept:name") {
*first_count.entry(act.clone()).or_insert(0) += 1;
}
}
}
let init_constraints = first_count.values().filter(|&&c| c == total_cases).count();
let mut last_count: std::collections::HashMap<String, usize> = Default::default();
for trace in &log.traces {
if let Some(event) = trace.events.last() {
if let Some(AttributeValue::String(act)) = event.attributes.get("concept:name") {
*last_count.entry(act.clone()).or_insert(0) += 1;
}
}
}
let end_constraints = last_count.values().filter(|&&c| c == total_cases).count();
let boundary_constraints = init_constraints + end_constraints;
assert_eq!(
boundary_constraints, expected_constraints,
"DECLARE boundary constraints (Init+End) on running-example: expected {} \
(Pesic & van der Aalst 2006 §3), got {}",
expected_constraints, boundary_constraints
);
}
#[test]
fn hierarchical_dfg_paper_grounded() {
let fixture = load_algo_fixture("hierarchical_dfg");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let mut exp_activities: Option<usize> = None;
let mut exp_edges: Option<usize> = None;
for part in expected_value.split(',') {
let kv: Vec<&str> = part.splitn(2, '=').collect();
if kv.len() == 2 {
match kv[0].trim() {
"activities" => exp_activities = kv[1].trim().parse().ok(),
"edges" => exp_edges = kv[1].trim().parse().ok(),
_ => {}
}
}
}
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let config = wasm4pm::hierarchical::HierarchicalConfig {
num_chunks: 1,
max_chunk_events: None,
};
let col = log.to_columnar("concept:name");
let result = wasm4pm::hierarchical::discover_hierarchical::<wasm4pm::hierarchical::DfgChunker>(
&log,
"concept:name",
&config,
);
let dfg = result.to_dfg(&col.vocab);
if let Some(a) = exp_activities {
assert_eq!(
dfg.nodes.len(),
a,
"hierarchical DFG activities: expected {} (van der Aalst 2016 Ch.4 Fig.4.5), got {}",
a,
dfg.nodes.len()
);
}
if let Some(e) = exp_edges {
assert_eq!(
dfg.edges.len(),
e,
"hierarchical DFG edges: expected {} (van der Aalst 2016 Ch.4 Fig.4.5), got {}",
e,
dfg.edges.len()
);
}
}
#[test]
fn log_to_trie_paper_grounded() {
let fixture = load_algo_fixture("log_to_trie");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let expected_leaves: usize = expected_value
.split(',')
.find(|p| p.trim().starts_with("leaves="))
.and_then(|p| p.trim().strip_prefix("leaves="))
.and_then(|v| v.parse().ok())
.expect("fixture expected.value must contain leaves=N");
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let result = wasm4pm::log_to_trie::discover_prefix_tree_inner(&log, "concept:name", None)
.expect("prefix tree discovery must succeed on running-example log");
assert_eq!(
result.variants, expected_leaves,
"log-to-trie leaves (unique variants): expected {} \
(van der Aalst 2016 Ch.4 prefix-tree), got {}",
expected_leaves, result.variants
);
}
#[test]
fn optimized_dfg_paper_grounded() {
let fixture = load_algo_fixture("optimized_dfg");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let mut exp_activities: Option<usize> = None;
let mut exp_edges: Option<usize> = None;
for part in expected_value.split(',') {
let kv: Vec<&str> = part.splitn(2, '=').collect();
if kv.len() == 2 {
match kv[0].trim() {
"activities" => exp_activities = kv[1].trim().parse().ok(),
"edges" => exp_edges = kv[1].trim().parse().ok(),
_ => {}
}
}
}
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let dfg =
wasm4pm::ilp_discovery::discover_optimized_dfg_from_log(&log, "concept:name", 1.0, 1.0);
if let Some(a) = exp_activities {
assert_eq!(
dfg.nodes.len(),
a,
"optimized DFG activities: expected {} (van der Aalst 2016 Ch.4), got {}",
a,
dfg.nodes.len()
);
}
if let Some(e) = exp_edges {
assert_eq!(
dfg.edges.len(),
e,
"optimized DFG edges: expected {} (van der Aalst 2016 Ch.4), got {}",
e,
dfg.edges.len()
);
}
}
#[test]
fn process_skeleton_paper_grounded() {
let fixture = load_algo_fixture("process_skeleton");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let expected_activities: usize = expected_value
.split(',')
.find(|p| p.trim().starts_with("activities="))
.and_then(|p| p.trim().strip_prefix("activities="))
.and_then(|v| v.parse().ok())
.expect("fixture expected.value must contain activities=N");
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let mut activities: std::collections::HashSet<String> = Default::default();
for trace in &log.traces {
for event in &trace.events {
if let Some(AttributeValue::String(act)) = event.attributes.get("concept:name") {
activities.insert(act.clone());
}
}
}
assert_eq!(
activities.len(),
expected_activities,
"process skeleton activities: expected {} (van der Aalst 2016 Ch.4), got {}",
expected_activities,
activities.len()
);
}
#[test]
fn simd_streaming_dfg_paper_grounded() {
let fixture = load_algo_fixture("simd_streaming_dfg");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let mut exp_activities: Option<usize> = None;
let mut exp_edges: Option<usize> = None;
for part in expected_value.split(',') {
let kv: Vec<&str> = part.splitn(2, '=').collect();
if kv.len() == 2 {
match kv[0].trim() {
"activities" => exp_activities = kv[1].trim().parse().ok(),
"edges" => exp_edges = kv[1].trim().parse().ok(),
_ => {}
}
}
}
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let col = log.to_columnar("concept:name");
let mut builder = wasm4pm::simd_streaming_dfg::SimdStreamingDfg::new();
builder.add_events(&col.events, &col.trace_offsets);
let dfg = builder.finish(&col.vocab);
if let Some(a) = exp_activities {
assert_eq!(
dfg.nodes.len(),
a,
"SIMD streaming DFG activities: expected {} (van der Aalst 2016 Ch.4), got {}",
a,
dfg.nodes.len()
);
}
if let Some(e) = exp_edges {
assert_eq!(
dfg.edges.len(),
e,
"SIMD streaming DFG edges: expected {} (van der Aalst 2016 Ch.4), got {}",
e,
dfg.edges.len()
);
}
}
#[test]
fn streaming_log_paper_grounded() {
let fixture = load_algo_fixture("streaming_log");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let mut exp_activities: Option<usize> = None;
let mut exp_traces: Option<usize> = None;
for part in expected_value.split(',') {
let kv: Vec<&str> = part.splitn(2, '=').collect();
if kv.len() == 2 {
match kv[0].trim() {
"activities" => exp_activities = kv[1].trim().parse().ok(),
"traces" => exp_traces = kv[1].trim().parse().ok(),
_ => {}
}
}
}
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let mut distinct_activities: std::collections::HashSet<String> = Default::default();
for trace in &log.traces {
for event in &trace.events {
if let Some(AttributeValue::String(act)) = event.attributes.get("concept:name") {
distinct_activities.insert(act.clone());
}
}
}
let trace_count = log.traces.len();
if let Some(a) = exp_activities {
assert_eq!(
distinct_activities.len(),
a,
"streaming log distinct activities: expected {} \
(van der Aalst 2016 Ch.4 online processing), got {}",
a,
distinct_activities.len()
);
}
if let Some(t) = exp_traces {
assert_eq!(
trace_count, t,
"streaming log trace count: expected {} \
(van der Aalst 2016 Ch.4 online processing), got {}",
t, trace_count
);
}
}
#[test]
fn transition_system_paper_grounded() {
let fixture = load_algo_fixture("transition_system");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let expected_states: usize = expected_value
.split(',')
.find(|p| p.trim().starts_with("states="))
.and_then(|p| p.trim().strip_prefix("states="))
.and_then(|v| v.parse().ok())
.expect("fixture expected.value must contain states=N");
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let ts =
wasm4pm::transition_system::discover_transition_system(&log, "concept:name", 2, "forward");
assert_eq!(
ts.states.len(),
expected_states,
"transition system states: expected {} (van der Aalst 2016 Ch.4 Fig.4.6), got {}",
expected_states,
ts.states.len()
);
}
#[test]
fn causal_graph_paper_grounded() {
let fixture = load_algo_fixture("causal_graph");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let expected_edges: usize = expected_value
.split(',')
.find(|p| p.trim().starts_with("edges="))
.and_then(|p| p.trim().strip_prefix("edges="))
.and_then(|v| v.parse().ok())
.expect("fixture expected.value must contain edges=N");
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let col = log.to_columnar("concept:name");
let mut builder = wasm4pm::simd_streaming_dfg::SimdStreamingDfg::new();
builder.add_events(&col.events, &col.trace_offsets);
let dfg = builder.finish(&col.vocab);
let edge_count = dfg.edges.len();
assert_eq!(
edge_count, expected_edges,
"causal graph edges: expected {} (van der Aalst 2016 Ch.6 causal dependencies), got {}",
expected_edges, edge_count
);
}
#[test]
fn correlation_miner_paper_grounded() {
let fixture = load_algo_fixture("correlation_miner");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let expected_dependencies: usize = expected_value
.split(',')
.find(|p| p.trim().starts_with("dependencies="))
.and_then(|p| p.trim().strip_prefix("dependencies="))
.and_then(|v| v.parse().ok())
.expect("fixture expected.value must contain dependencies=N");
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let cfg = wasm4pm::correlation_miner::CorrelationConfig::default();
let result =
wasm4pm::correlation_miner::mine_correlation(&log, "concept:name", "time:timestamp", &cfg);
assert_eq!(
result.edges.len(),
expected_dependencies,
"correlation miner dependencies: expected {} \
(Rozinat & van der Aalst 2008 §4 dependency detection), got {}",
expected_dependencies,
result.edges.len()
);
}
#[test]
fn handover_network_paper_grounded() {
let fixture = load_algo_fixture("handover_network");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let expected_resources: usize = expected_value
.split(',')
.find(|p| p.trim().starts_with("resources="))
.and_then(|p| p.trim().strip_prefix("resources="))
.and_then(|v| v.parse().ok())
.expect("fixture expected.value must contain resources=N");
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let result_str =
wasm4pm::social_network::discover_handover_network_from_log(&log, "org:resource");
let result: serde_json::Value =
serde_json::from_str(&result_str).expect("handover network result must be valid JSON");
let resource_count = result["nodes"]
.as_array()
.expect("result.nodes must be an array")
.len();
assert_eq!(
resource_count, expected_resources,
"handover network resources: expected {} \
(van der Aalst et al. 2005 Table 2), got {}",
expected_resources, resource_count
);
}
#[test]
fn working_together_network_paper_grounded() {
let fixture = load_algo_fixture("working_together_network");
assert_algo_grounded(&fixture);
let expected_value = fixture["expected"]["value"]
.as_str()
.expect("expected.value must be a string");
let expected_resources: usize = expected_value
.split(',')
.find(|p| p.trim().starts_with("resources="))
.and_then(|p| p.trim().strip_prefix("resources="))
.and_then(|v| v.parse().ok())
.expect("fixture expected.value must contain resources=N");
let xes_content = std::fs::read_to_string("tests/fixtures/running-example.xes")
.expect("running-example.xes must exist");
let log = wasm4pm::xes_format::validate_and_parse_xes(&xes_content)
.expect("running-example.xes must parse");
let result_str =
wasm4pm::social_network::discover_working_together_network_from_log(&log, "org:resource");
let result: serde_json::Value = serde_json::from_str(&result_str)
.expect("working together network result must be valid JSON");
let resource_count = result["nodes"]
.as_array()
.expect("result.nodes must be an array")
.len();
assert_eq!(
resource_count, expected_resources,
"working together network resources: expected {} \
(van der Aalst et al. 2005 Table 3), got {}",
expected_resources, resource_count
);
}
#[test]
fn automl_classify_paper_grounded() {
let fixture = load_algo_fixture("automl_classify");
assert_algo_grounded(&fixture);
let log = running_example_log();
let (features, labels) = wasm4pm::ml::classification::extract_features(&log, "concept:name");
assert!(
features.len() >= 10,
"automl_classify requires ≥10 samples for 5-fold CV; got {}",
features.len()
);
let result = wasm4pm::ml::automl::discover_automl_classify_internal(&features, &labels);
assert!(
result.max_avg_accuracy >= 0.0,
"automl_classify: max_accuracy={} must be non-negative (Feurer et al. NeurIPS 2015 §3)",
result.max_avg_accuracy
);
assert!(
result.best_k >= 1 && result.best_k <= 15,
"automl_classify: best_k={} must be in [1,15]",
result.best_k
);
}
#[test]
fn automl_forecast_paper_grounded() {
let fixture = load_algo_fixture("automl_forecast");
assert_algo_grounded(&fixture);
let windows: Vec<f64> = (0..12).map(|i| i as f64 * 1000.0 + 1.0).collect();
let result = wasm4pm::ml::automl::discover_automl_forecast_internal(&windows);
assert!(
result.best_alpha > 0.0 && result.best_alpha <= 1.0,
"automl_forecast: best_alpha={} must be in (0,1] (Feurer et al. NeurIPS 2015 §3)",
result.best_alpha
);
assert!(
result.min_avg_rmse.is_finite(),
"automl_forecast: min_avg_rmse must be finite, got {}",
result.min_avg_rmse
);
}
#[test]
fn ml_anomaly_paper_grounded() {
let fixture = load_algo_fixture("ml_anomaly");
assert_algo_grounded(&fixture);
let log = running_example_log();
let dfg = wasm4pm::discovery::discover_dfg_from_log(&admitted(log.clone()), "concept:name");
let total_edges: usize = dfg.edges.iter().map(|e| e.frequency).sum();
let total_f = total_edges.max(1) as f64;
const THRESHOLD: f64 = 0.7;
let mut anomaly_count = 0usize;
for trace in &log.traces {
let acts: Vec<&str> = trace
.events
.iter()
.filter_map(|e| e.attributes.get("concept:name").and_then(|v| v.as_string()))
.collect();
if acts.len() < 2 {
continue;
}
let edge_probs: Vec<Option<f64>> = acts
.windows(2)
.map(|w| {
dfg.edges
.iter()
.find(|e| e.from == w[0] && e.to == w[1])
.map(|e| e.frequency as f64 / total_f)
})
.collect();
let score =
wasm4pm::prediction_outcome::anomaly_score_from_edge_probs(&edge_probs, 10.0, 5.0);
if score.score >= THRESHOLD {
anomaly_count += 1;
}
}
assert_eq!(
anomaly_count, 0,
"ml_anomaly: expected 0 anomalies in clean running-example log, got {} \
(Bezerra, Wainer & van der Aalst EIS 2009 §4)",
anomaly_count
);
}
#[test]
fn ml_classify_paper_grounded() {
let fixture = load_algo_fixture("ml_classify");
assert_algo_grounded(&fixture);
let log = build_log(&[
(6, &["a", "b", "c", "d", "e"]),
(
6,
&[
"x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x",
"x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x", "x",
"x", "x", "x",
],
),
]);
let (_features, labels) = wasm4pm::ml::classification::extract_features(&log, "concept:name");
let distinct: std::collections::BTreeSet<u8> = labels.iter().copied().collect();
assert_eq!(
distinct.len(),
2,
"ml_classify: expected 2 outcome classes (short vs long traces), got {} \
(de Leoni, van der Aalst & Dees IS 2016 §3.2)",
distinct.len()
);
}
#[test]
fn ml_cluster_paper_grounded() {
let fixture = load_algo_fixture("ml_cluster");
assert_algo_grounded(&fixture);
let log = running_example_log();
let (features, _) = wasm4pm::ml::classification::extract_features(&log, "concept:name");
let result = wasm4pm::ml::clustering::kmeans_internal(&features, 3);
assert!(
result.k >= 1,
"ml_cluster: expected ≥1 cluster, got {} \
(Song, Gunther & van der Aalst BPM 2008 Workshops §3)",
result.k
);
assert_eq!(
result.assignments.len(),
features.len(),
"ml_cluster: every trace must have a cluster assignment"
);
}
#[test]
fn ml_forecast_paper_grounded() {
let fixture = load_algo_fixture("ml_forecast");
assert_algo_grounded(&fixture);
let windows: Vec<f64> = (0..12).map(|i| (i as f64 + 1.0) * 3_600_000.0).collect();
let result = wasm4pm::ml::forecasting::forecast_internal(&windows, 0.3);
assert!(
result.next_window.is_finite(),
"ml_forecast: next_window must be finite (de Leoni, van der Aalst & Dees IS 2016 §3.3)"
);
assert!(
result.rmse.is_finite(),
"ml_forecast: rmse must be finite, got {}",
result.rmse
);
}
#[test]
fn ml_pca_paper_grounded() {
let fixture = load_algo_fixture("ml_pca");
assert_algo_grounded(&fixture);
let log = running_example_log();
let (features, _) = wasm4pm::ml::classification::extract_features(&log, "concept:name");
let result = wasm4pm::ml::pca::pca_internal(&features);
assert!(
result.eigenvalues[0] >= 0.0 && result.eigenvalues[1] >= 0.0,
"ml_pca: both eigenvalues must be non-negative, got {:?} \
(van der Aalst Process Mining 2016 Ch.11)",
result.eigenvalues
);
assert!(
result.total_variance >= 0.0,
"ml_pca: total_variance must be non-negative on 12-trace log"
);
}
#[test]
fn ml_regress_paper_grounded() {
let fixture = load_algo_fixture("ml_regress");
assert_algo_grounded(&fixture);
let log = running_example_log();
let (features, _) = wasm4pm::ml::classification::extract_features(&log, "concept:name");
let x: Vec<f64> = features.iter().map(|f| f[0]).collect();
let y: Vec<f64> = features.iter().map(|f| f[1]).collect();
let result = wasm4pm::ml::regression::regression_internal(&x, &y);
assert!(
result.slope.is_finite(),
"ml_regress: slope must be finite (de Leoni, van der Aalst & Dees IS 2016 §3.1)"
);
assert!(
result.intercept.is_finite(),
"ml_regress: intercept must be finite"
);
assert!(
result.r_squared >= 0.0 && result.r_squared <= 1.0 + f64::EPSILON,
"ml_regress: R^2={} must be in [0,1]",
result.r_squared
);
}
#[test]
fn compute_ewma_paper_grounded() {
let fixture = load_algo_fixture("compute_ewma");
assert_algo_grounded(&fixture);
let durations: Vec<f64> = vec![4.0, 4.0, 4.0, 4.0, 4.0, 4.0, 4.0, 4.0, 4.0, 3.0, 3.0, 3.0];
let alpha = 0.3;
let smoothed = wasm4pm::prediction_drift::ewma_series(&durations, alpha);
assert_eq!(
smoothed.len(),
durations.len(),
"compute_ewma: output length must equal input length (Hunter JQTECH 1986 Eq.1)"
);
assert!(
smoothed.iter().all(|v| v.is_finite()),
"compute_ewma: all EWMA values must be finite"
);
assert!(
(smoothed[0] - durations[0]).abs() < f64::EPSILON,
"compute_ewma: first smoothed value must equal first input (EWMA init invariant)"
);
}
#[test]
fn predict_next_activity_paper_grounded() {
let fixture = load_algo_fixture("predict_next_activity");
assert_algo_grounded(&fixture);
let log = running_example_log();
let col = log.to_columnar("concept:name");
let vocab_size = col.vocab.len();
assert!(
vocab_size >= 5,
"predict_next_activity: expected >=5 predictable activities (a,b,c,d,e), got {} \
(Evermann, Rehse & Fettke DSS 2017 §3)",
vocab_size
);
let mut predictor = wasm4pm::models::NGramPredictor::new(2);
for trace in &log.traces {
let acts: Vec<String> = trace
.events
.iter()
.filter_map(|e| e.attributes.get("concept:name").and_then(|v| v.as_string()))
.map(|s| s.to_string())
.collect();
for window in acts.windows(2) {
let key = vec![window[0].clone()];
predictor
.counts
.entry(key)
.or_default()
.entry(window[1].clone())
.and_modify(|c| *c += 1)
.or_insert(1);
}
}
let preds = predictor.predict(&["a".to_string()]);
assert!(
!preds.is_empty(),
"predict_next_activity: prefix ['a'] must yield predictions in running-example log"
);
}
#[test]
fn predict_outcome_paper_grounded() {
let fixture = load_algo_fixture("predict_outcome");
assert_algo_grounded(&fixture);
let log = running_example_log();
let dfg = wasm4pm::discovery::discover_dfg_from_log(&admitted(log.clone()), "concept:name");
let total_edges: usize = dfg.edges.iter().map(|e| e.frequency).sum();
let total_f = total_edges.max(1) as f64;
const THRESHOLD: f64 = 0.7;
let mut outcome_set: std::collections::BTreeSet<bool> = std::collections::BTreeSet::new();
for trace in &log.traces {
let acts: Vec<&str> = trace
.events
.iter()
.filter_map(|e| e.attributes.get("concept:name").and_then(|v| v.as_string()))
.collect();
if acts.len() < 2 {
outcome_set.insert(false);
continue;
}
let edge_probs: Vec<Option<f64>> = acts
.windows(2)
.map(|w| {
dfg.edges
.iter()
.find(|e| e.from == w[0] && e.to == w[1])
.map(|e| e.frequency as f64 / total_f)
})
.collect();
let score =
wasm4pm::prediction_outcome::anomaly_score_from_edge_probs(&edge_probs, 10.0, 5.0);
outcome_set.insert(score.score >= THRESHOLD);
}
assert!(
!outcome_set.is_empty(),
"predict_outcome: must produce at least 1 distinct outcome class \
(Teinemaa et al. ACM TKDD 2019 §2)"
);
}
#[test]
fn predict_remaining_time_paper_grounded() {
let fixture = load_algo_fixture("predict_remaining_time");
assert_algo_grounded(&fixture);
let log = running_example_log();
let (features, _) = wasm4pm::ml::classification::extract_features(&log, "concept:name");
let durations: Vec<f64> = features.iter().map(|f| f[0] * 3_600_000.0).collect();
let indices: Vec<f64> = (0..durations.len()).map(|i| i as f64).collect();
let result = wasm4pm::ml::regression::regression_internal(&indices, &durations);
assert!(
result.slope.is_finite(),
"predict_remaining_time: regression slope must be finite -- predictions produced \
(Verenich et al. ACM TKDD 2019 §2)"
);
let mean_duration = durations.iter().sum::<f64>() / durations.len() as f64;
assert!(
mean_duration > 0.0,
"predict_remaining_time: mean case duration must be > 0 for predictions to be produced"
);
}