use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use wasm4pm::conformance::token_replay_pure;
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 {
if Path::new(p).exists() {
return Some(Path::new(p).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)
}
fn make_synthetic_log(activity_key: &str, traces: &[&[&str]]) -> EventLog {
EventLog {
attributes: BTreeMap::new(),
traces: traces
.iter()
.map(|activities| Trace {
attributes: BTreeMap::new(),
events: activities
.iter()
.map(|&a| {
let mut attrs = BTreeMap::new();
attrs.insert(
activity_key.to_string(),
AttributeValue::String(a.to_string()),
);
Event { attributes: attrs }
})
.collect(),
})
.collect(),
}
}
#[test]
fn negative_minimal_log_model_cannot_replay_complex_log() {
let ak = "concept:name";
let trivial_log = make_synthetic_log(ak, &[&["A", "B"]]);
let (trivial_net, _, _) = discover_ilp_petri_net_from_log(&trivial_log, ak);
let self_result = token_replay_pure(&trivial_log, &trivial_net, ak);
let complex_log =
make_synthetic_log(ak, &[&["A", "B", "C"], &["A", "C", "B"], &["C", "A", "B"]]);
let cross_result = token_replay_pure(&complex_log, &trivial_net, ak);
eprintln!(
"Self fitness (trivial model): {:.4}, cross fitness (complex log): {:.4}",
self_result.avg_fitness, cross_result.avg_fitness
);
assert!(
cross_result.avg_fitness <= self_result.avg_fitness,
"complex log fitness {:.4} should be <= trivial self fitness {:.4}",
cross_result.avg_fitness,
self_result.avg_fitness
);
assert!(
cross_result.avg_fitness < 1.0,
"cross-replay fitness {:.4} should be < 1.0 -- unseen activities should cause deviations",
cross_result.avg_fitness
);
assert!(
self_result.avg_fitness >= 0.75,
"self fitness on trivial model should be >= 0.75, got {:.4}",
self_result.avg_fitness
);
}
#[test]
fn negative_subset_model_worse_than_full_model() {
let Some(full_log) = load_bpi2020() else {
eprintln!("SKIP: BPI 2020 fixture not found");
return;
};
let ak = "concept:name";
let subset_size = 50.min(full_log.traces.len());
let subset_log = EventLog {
attributes: BTreeMap::new(),
traces: full_log.traces[..subset_size].to_vec(),
};
let (subset_net, _, _) = discover_ilp_petri_net_from_log(&subset_log, ak);
let (full_net, _, _) = discover_ilp_petri_net_from_log(&full_log, ak);
let subset_result = token_replay_pure(&full_log, &subset_net, ak);
let full_result = token_replay_pure(&full_log, &full_net, ak);
eprintln!(
"Subset model ({} traces) fitness on full log: {:.4}",
subset_size, subset_result.avg_fitness
);
eprintln!(
"Full model ({} traces) fitness on full log: {:.4}",
full_log.traces.len(),
full_result.avg_fitness
);
assert!(
subset_result.avg_fitness >= 0.50,
"subset model fitness {:.4} < 0.50 -- model from 50 traces should cover basic behavior",
subset_result.avg_fitness
);
assert!(
full_result.avg_fitness >= 0.50,
"full model fitness {:.4} < 0.50 -- model from all traces should be usable",
full_result.avg_fitness
);
assert!(
full_net.arcs.len() >= subset_net.arcs.len(),
"full model ({} arcs) should have >= arcs than subset model ({} arcs)",
full_net.arcs.len(),
subset_net.arcs.len()
);
}
#[test]
fn negative_simplicity_never_increases_with_complexity() {
let transitions: usize = 1;
let min_places = 2;
let min_arcs = 2;
let baseline = compute_simplicity(min_places, transitions, min_arcs);
assert!(
(baseline - 1.0).abs() < 1e-9,
"simplicity of minimal net should be 1.0, got {:.6}",
baseline
);
let redundancy_levels: Vec<usize> = vec![1, 5, 10, 50, 100, 500, 1000];
let mut prev_val = baseline;
for &extra in &redundancy_levels {
let places = min_places + extra;
let arcs = min_arcs + extra * 2;
let current = compute_simplicity(places, transitions, arcs);
eprintln!(
" places={}, arcs={} -> simplicity={:.6}",
places, arcs, current
);
assert!(
current <= prev_val + 1e-9,
"simplicity({} places, {} arcs) = {:.6} > prev {:.6} -- \
simplicity MUST be monotonically non-increasing with complexity",
places,
arcs,
current,
prev_val
);
prev_val = current;
}
assert!(
prev_val < 0.1,
"simplicity with high redundancy = {:.4} >= 0.1 -- should be lower",
prev_val
);
}
#[test]
fn negative_fitness_bounded_on_adversarial_logs() {
let ak = "concept:name";
{
let log = make_synthetic_log(ak, &[&["A"]]);
let (net, _, _) = discover_ilp_petri_net_from_log(&log, ak);
let result = token_replay_pure(&log, &net, ak);
assert!(
result.avg_fitness >= 0.0 && result.avg_fitness <= 1.0,
"single-event fitness {:.4} outside [0, 1]",
result.avg_fitness
);
}
{
let log = make_synthetic_log(ak, &[&[]]);
let (net, _, _) = discover_ilp_petri_net_from_log(&log, ak);
let result = token_replay_pure(&log, &net, ak);
assert!(
result.avg_fitness >= 0.0 && result.avg_fitness <= 1.0,
"empty-trace fitness {:.4} outside [0, 1]",
result.avg_fitness
);
}
{
let identical: Vec<&str> = vec!["A", "B", "C"];
let traces: Vec<&[&str]> = (0..100).map(|_| identical.as_slice()).collect();
let log = make_synthetic_log(ak, &traces);
let (net, _, _) = discover_ilp_petri_net_from_log(&log, ak);
let result = token_replay_pure(&log, &net, ak);
assert!(
result.avg_fitness >= 0.0 && result.avg_fitness <= 1.0,
"identical-traces fitness {:.4} outside [0, 1]",
result.avg_fitness
);
assert!(
result.avg_fitness >= 0.83,
"identical-traces fitness {:.4} < 0.83 -- uniform log should fit perfectly",
result.avg_fitness
);
}
{
let random_trace: Vec<&str> = (0..20)
.map(|i| Box::leak(format!("ACT_{}", i).into_boxed_str()) as &str)
.collect();
let traces: Vec<&[&str]> = (0..10).map(|_| random_trace.as_slice()).collect();
let log = make_synthetic_log(ak, &traces);
let (net, _, _) = discover_ilp_petri_net_from_log(&log, ak);
let result = token_replay_pure(&log, &net, ak);
assert!(
result.avg_fitness >= 0.0 && result.avg_fitness <= 1.0,
"random-activities fitness {:.4} outside [0, 1]",
result.avg_fitness
);
}
}
#[test]
fn negative_unknown_activities_zero_conformance() {
let ak = "concept:name";
let model_log = make_synthetic_log(ak, &[&["X", "Y", "Z"], &["X", "Z", "Y"], &["Y", "X", "Z"]]);
let (net, _, _) = discover_ilp_petri_net_from_log(&model_log, ak);
let alien_log = make_synthetic_log(ak, &[&["ALPHA", "BETA", "GAMMA"], &["ALPHA", "GAMMA"]]);
let result = token_replay_pure(&alien_log, &net, ak);
eprintln!(
"Alien activities replay: fitness={:.4}, conforming={}",
result.avg_fitness, result.conforming_cases
);
assert_eq!(
result.conforming_cases, 0,
"expected 0 conforming cases for completely unknown activities, got {}",
result.conforming_cases
);
assert!(
result.avg_fitness < 0.5,
"fitness {:.4} for unknown activities should be < 0.5",
result.avg_fitness
);
}
#[test]
fn negative_simplicity_boundary_conditions() {
let s0 = compute_simplicity(0, 0, 0);
assert!(
(s0 - 1.0).abs() < 1e-9,
"simplicity(0,0,0) = {:.6}, expected 1.0",
s0
);
let s1 = compute_simplicity(2, 1, 2);
assert!(
(s1 - 1.0).abs() < 1e-6,
"simplicity(2,1,2) = {:.6}, expected 1.0 (minimal linear net)",
s1
);
let s_redundant = compute_simplicity(100, 10, 10_000);
assert!(
s_redundant > 0.0,
"simplicity(100,10,10000) = {:.10}, should be > 0",
s_redundant
);
assert!(
s_redundant < 0.50,
"simplicity(100,10,10000) = {:.6}, should be < 0.50 (highly redundant)",
s_redundant
);
let s2 = compute_simplicity(3, 2, 4);
assert!(
s2 > 0.0 && s2 < 1.0,
"simplicity(3,2,4) = {:.6}, should be in (0, 1)",
s2
);
}