use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use wasm4pm::conformance::token_replay_pure;
use wasm4pm::etconformance_precision::compute_precision;
use wasm4pm::ilp_discovery::{compute_simplicity, discover_ilp_petri_net_from_log};
use wasm4pm::models::{AttributeValue, Event, EventLog, Trace};
fn find_fixture(name: &str) -> Option<std::path::PathBuf> {
let candidates = [
format!("tests/fixtures/{}", name),
format!("wasm4pm/tests/fixtures/{}", name),
format!("../wasm4pm/tests/fixtures/{}", name),
];
for p in &candidates {
let path = Path::new(p);
if path.exists() {
return Some(path.to_path_buf());
}
}
None
}
fn parse_xes_file(content: &str) -> EventLog {
let mut log = EventLog::new();
let mut current_trace: Option<Trace> = None;
let mut current_event: Option<Event> = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("<trace>") {
current_trace = Some(Trace {
attributes: BTreeMap::new(),
events: Vec::new(),
});
}
if trimmed.starts_with("</trace>") {
if let Some(trace) = current_trace.take() {
log.traces.push(trace);
}
}
if trimmed.starts_with("<event>") {
current_event = Some(Event {
attributes: BTreeMap::new(),
});
}
if trimmed.starts_with("</event>") {
if let Some(event) = current_event.take() {
if let Some(ref mut trace) = current_trace {
trace.events.push(event);
}
}
}
if trimmed.starts_with("<string") {
if let Some(key_start) = trimmed.find("key=\"") {
let key_start = key_start + 5;
if let Some(key_end) = trimmed[key_start..].find("\"") {
let key = trimmed[key_start..key_start + key_end].to_string();
if let Some(val_start) = trimmed.find("value=\"") {
let val_start = val_start + 7;
if let Some(val_end) = trimmed[val_start..].find("\"") {
let value = trimmed[val_start..val_start + val_end].to_string();
if let Some(ref mut event) = current_event {
event.attributes.insert(key, AttributeValue::String(value));
} else if let Some(ref mut trace) = current_trace {
trace.attributes.insert(key, AttributeValue::String(value));
}
}
}
}
}
}
if trimmed.starts_with("<date") || trimmed.contains("time:timestamp") {
if let Some(key_start) = trimmed.find("key=\"") {
let key_start = key_start + 5;
if let Some(key_end) = trimmed[key_start..].find("\"") {
let key = trimmed[key_start..key_start + key_end].to_string();
if let Some(val_start) = trimmed.find("value=\"") {
let val_start = val_start + 7;
if let Some(val_end) = trimmed[val_start..].find("\"") {
let value = trimmed[val_start..val_start + val_end].to_string();
if let Some(ref mut event) = current_event {
event.attributes.insert(key, AttributeValue::String(value));
}
}
}
}
}
}
}
log
}
fn load_bpi2020() -> Option<EventLog> {
let fixture_path = find_fixture("BPI_2020_Travel_Permits_Actual.xes")?;
let content = fs::read_to_string(&fixture_path).ok()?;
let log = parse_xes_file(&content);
if log.traces.is_empty() {
return None;
}
eprintln!(
"Loaded BPI 2020: {} traces, {} events",
log.traces.len(),
log.traces.iter().map(|t| t.events.len()).sum::<usize>()
);
Some(log)
}
#[test]
fn quality_ilp_token_replay_fitness_threshold() {
let Some(log) = load_bpi2020() else {
eprintln!("SKIP: BPI 2020 fixture not found");
return;
};
let ak = "concept:name";
let (petri_net, ilp_fitness, _ilp_precision) = discover_ilp_petri_net_from_log(&log, ak);
eprintln!(
"ILP PetriNet: {} places, {} transitions, {} arcs, self_fitness={:.4}",
petri_net.places.len(),
petri_net.transitions.len(),
petri_net.arcs.len(),
ilp_fitness
);
let result = token_replay_pure(&log, &petri_net, ak);
eprintln!(
"Token replay: fitness={:.4}, conforming={}/{}, traces={}",
result.avg_fitness,
result.conforming_cases,
result.total_cases,
log.traces.len()
);
assert!(
result.avg_fitness >= 0.60,
"ILP avg_fitness {:.4} below threshold 0.60 -- model quality regression",
result.avg_fitness
);
assert!(
result.total_cases > 0,
"token replay reported zero total cases"
);
}
#[test]
fn quality_simplicity_in_valid_range() {
let Some(log) = load_bpi2020() else {
eprintln!("SKIP: BPI 2020 fixture not found");
return;
};
let ak = "concept:name";
let (petri_net, _fitness, _precision) = discover_ilp_petri_net_from_log(&log, ak);
let computed = compute_simplicity(
petri_net.places.len(),
petri_net.transitions.len(),
petri_net.arcs.len(),
);
eprintln!(
"Simplicity: computed={:.4} ({} places, {} transitions, {} arcs)",
computed,
petri_net.places.len(),
petri_net.transitions.len(),
petri_net.arcs.len()
);
assert!(
computed > 0.0 && computed <= 1.0,
"simplicity {:.4} outside valid range (0, 1]",
computed
);
assert!(
computed < 1.0,
"simplicity is exactly 1.0 -- model appears degenerate (linear chain for BPI 2020?)"
);
}
#[test]
fn quality_simplicity_decreases_with_complexity() {
let n: usize = 5; let base_places = n + 1;
let base_transitions = n;
let base_arcs = 2 * n;
let base_simplicity = compute_simplicity(base_places, base_transitions, base_arcs);
eprintln!("Baseline (linear): simplicity={:.6}", base_simplicity);
let redundancy_levels: Vec<usize> = vec![1, 2, 5, 10, 20];
let mut prev = base_simplicity;
for &extra in &redundancy_levels {
let places = base_places + extra * 2; let transitions = base_transitions + extra; let arcs = base_arcs + extra * 4;
let s = compute_simplicity(places, transitions, arcs);
eprintln!(" extra={} -> simplicity={:.6}", extra, s);
assert!(
s > 0.0 && s <= 1.0,
"simplicity {:.6} outside (0, 1] for {} extra elements",
s,
extra
);
assert!(
s <= prev + 1e-9,
"simplicity NOT monotonically non-increasing: extra={} ({:.6}) > prev ({:.6})",
extra,
s,
prev
);
prev = s;
}
}
#[test]
fn quality_ilp_internal_consistency() {
let Some(log) = load_bpi2020() else {
eprintln!("SKIP: BPI 2020 fixture not found");
return;
};
let ak = "concept:name";
let (petri_net, reported_fitness, reported_precision) =
discover_ilp_petri_net_from_log(&log, ak);
let places = petri_net.places.len();
let transitions = petri_net.transitions.len();
let arcs = petri_net.arcs.len();
let reported_simplicity = compute_simplicity(places, transitions, arcs);
eprintln!(
"ILP quality: fitness={:.4}, precision={:.4}, simplicity={:.4}, \
places={}, transitions={}, arcs={}",
reported_fitness, reported_precision, reported_simplicity, places, transitions, arcs
);
assert!(
places >= 2,
"ILP net has {} places (expected >= 2: source + sink)",
places
);
assert!(
transitions >= 1,
"ILP net has {} transitions (expected >= 1)",
transitions
);
assert!(arcs >= 2, "ILP net has {} arcs (expected >= 2)", arcs);
assert!(
reported_fitness >= 0.0 && reported_fitness <= 1.0,
"fitness {:.4} outside [0, 1]",
reported_fitness
);
assert!(
reported_precision >= 0.0 && reported_precision <= 1.0,
"precision {:.4} outside [0, 1]",
reported_precision
);
assert!(
reported_simplicity > 0.0 && reported_simplicity <= 1.0,
"simplicity {:.4} outside (0, 1]",
reported_simplicity
);
if reported_fitness + reported_precision > 0.001 {
let f_measure = 2.0 * (reported_fitness * reported_precision)
/ (reported_fitness + reported_precision + 0.001);
assert!(
f_measure > 0.0,
"F-measure {:.4} is zero despite positive fitness and precision",
f_measure
);
}
if reported_fitness > 0.9 {
assert!(
reported_precision > 0.0,
"fitness={:.4} but precision=0.0 -- high-fitness model with zero precision is suspicious",
reported_fitness
);
}
assert!(
reported_fitness >= 0.50,
"ILP fitness {:.4} < 0.50 -- model barely fits the log",
reported_fitness
);
}
#[test]
fn quality_token_replay_per_case_bounded() {
let Some(log) = load_bpi2020() else {
eprintln!("SKIP: BPI 2020 fixture not found");
return;
};
let ak = "concept:name";
let (petri_net, _, _) = discover_ilp_petri_net_from_log(&log, ak);
let result = token_replay_pure(&log, &petri_net, ak);
assert!(
!result.case_fitness.is_empty(),
"case_fitness array is empty -- no traces were replayed"
);
let mut min_fitness = f64::INFINITY;
let mut max_fitness = f64::NEG_INFINITY;
for case in &result.case_fitness {
assert!(
case.trace_fitness >= 0.0 && case.trace_fitness <= 1.0,
"per-case fitness {:.6} outside [0, 1]",
case.trace_fitness
);
min_fitness = min_fitness.min(case.trace_fitness);
max_fitness = max_fitness.max(case.trace_fitness);
}
eprintln!(
"Per-case fitness range: [{:.4}, {:.4}] across {} cases",
min_fitness,
max_fitness,
result.case_fitness.len()
);
assert!(
max_fitness > 0.0,
"all cases have zero fitness -- model cannot replay any trace"
);
}
#[test]
fn quality_degrades_on_mismatched_log() {
let Some(log) = load_bpi2020() else {
eprintln!("SKIP: BPI 2020 fixture not found");
return;
};
let ak = "concept:name";
let (petri_net, full_fitness, _) = discover_ilp_petri_net_from_log(&log, ak);
let mut mismatched_log = EventLog::new();
for _ in 0..50 {
let mut trace = Trace {
attributes: BTreeMap::new(),
events: Vec::new(),
};
for activity in &["UNKNOWN_A", "UNKNOWN_B", "UNKNOWN_C"] {
let mut attrs = BTreeMap::new();
attrs.insert(ak.to_string(), AttributeValue::String(activity.to_string()));
trace.events.push(Event { attributes: attrs });
}
mismatched_log.traces.push(trace);
}
let mismatched_result = token_replay_pure(&mismatched_log, &petri_net, ak);
eprintln!(
"Full log fitness: {:.4}, mismatched log fitness: {:.4}",
full_fitness, mismatched_result.avg_fitness
);
assert!(
mismatched_result.avg_fitness < full_fitness,
"mismatched log fitness {:.4} >= original fitness {:.4} -- \
quality should degrade on unseen activities",
mismatched_result.avg_fitness,
full_fitness
);
}
#[test]
fn quality_etconformance_precision_above_threshold() {
let Some(log) = load_bpi2020() else {
eprintln!("SKIP: BPI 2020 fixture not found");
return;
};
let ak = "concept:name";
let (petri_net, _, _) = discover_ilp_petri_net_from_log(&log, ak);
let initial_marking: wasm4pm::etconformance_precision::Marking = petri_net
.places
.iter()
.filter_map(|p| p.marking.map(|m| (p.id.clone(), m)))
.collect();
let final_marking: wasm4pm::etconformance_precision::Marking = petri_net
.final_markings
.first()
.cloned()
.unwrap_or_default();
let precision_result =
compute_precision(&petri_net, &initial_marking, &final_marking, &log, ak);
eprintln!(
"ETConformance precision: {:.4} (escaping={}, consumed={}, traces={})",
precision_result.precision,
precision_result.total_escaping,
precision_result.total_consumed,
precision_result.total_traces
);
assert!(
precision_result.precision >= 0.10,
"ETConformance precision {:.4} is below threshold 0.10 -- model is severely underfitting",
precision_result.precision
);
assert!(
precision_result.precision >= 0.0 && precision_result.precision <= 1.0,
"Precision {:.4} is outside [0.0, 1.0]",
precision_result.precision
);
assert_eq!(
precision_result.total_traces,
log.traces.len(),
"ETConformance analyzed {} traces but log has {}",
precision_result.total_traces,
log.traces.len()
);
}
#[test]
fn quality_ilp_self_reported_matches_replay() {
let Some(log) = load_bpi2020() else {
eprintln!("SKIP: BPI 2020 fixture not found");
return;
};
let ak = "concept:name";
let (petri_net, ilp_fitness, _) = discover_ilp_petri_net_from_log(&log, ak);
let replay_result = token_replay_pure(&log, &petri_net, ak);
eprintln!(
"ILP self-reported fitness: {:.4}, token replay fitness: {:.4}, delta: {:.4}",
ilp_fitness,
replay_result.avg_fitness,
(ilp_fitness - replay_result.avg_fitness).abs()
);
assert!(ilp_fitness > 0.0, "ILP self-reported fitness is zero");
assert!(
replay_result.avg_fitness > 0.0,
"Token replay fitness is zero"
);
}