use std::collections::HashMap;
use wasm4pm::advanced_algorithms::discover_heuristic_miner_from_log;
use wasm4pm::algorithms::discover_dfg_filtered_from_log;
use wasm4pm::algorithms::discover_footprints_from_log;
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::social_network::{
discover_handover_network_from_log, discover_working_together_network_from_log,
};
use wasm4pm::temporal_profile::discover_temporal_profile_from_log;
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-01T00:{:02}:00Z", i)),
);
trace.events.push(Event { attributes: attrs });
}
log.traces.push(trace);
case_idx += 1;
}
}
log
}
fn controlled_log() -> EventLog {
build_log(&[
(10, &["Start", "Register", "Approve", "End"]),
(5, &["Start", "Register", "Reject", "End"]),
])
}
#[test]
fn ga_fitness_in_range() {
let log = controlled_log();
let (_, f) = discover_genetic_algorithm_from_log(&log, "concept:name", 20, 30)
.expect("GA must produce a result for non-empty log");
assert!(
(0.0..=1.0).contains(&f),
"GA fitness {:.4} outside [0, 1]",
f
);
}
#[test]
fn ga_convergence_more_generations_never_worse() {
let log = build_log(&[
(10, &["Start", "Register", "Approve", "End"]),
(5, &["Start", "Register", "Reject", "End"]),
]);
let (_, f1) =
discover_genetic_algorithm_from_log(&log, "concept:name", 20, 1).expect("GA must succeed");
let (_, f100) = discover_genetic_algorithm_from_log(&log, "concept:name", 20, 100)
.expect("GA must succeed");
assert!(
f100 >= f1 - 1e-9,
"GA: 100-gen fitness {:.4} < 1-gen fitness {:.4} — elitism invariant violated",
f100,
f1
);
}
#[test]
fn ga_deterministic_same_seed() {
let log = controlled_log();
let (_, f1) =
discover_genetic_algorithm_from_log(&log, "concept:name", 20, 50).expect("GA must succeed");
let (_, f2) =
discover_genetic_algorithm_from_log(&log, "concept:name", 20, 50).expect("GA must succeed");
assert_eq!(
f1, f2,
"GA is not deterministic: different fitness {:.6} vs {:.6} on identical inputs",
f1, f2
);
}
#[test]
fn ga_output_structure_valid() {
let log = controlled_log();
let (dfg, _) =
discover_genetic_algorithm_from_log(&log, "concept:name", 20, 30).expect("GA must succeed");
assert!(
!dfg.nodes.is_empty(),
"GA DFG must contain at least one node"
);
let activities = ["Start", "Register", "Approve", "Reject", "End"];
for node in &dfg.nodes {
assert!(
activities.contains(&node.id.as_str()),
"GA node '{}' is not from log vocabulary — stub returning random data?",
node.id
);
}
}
#[test]
fn pso_fitness_in_range() {
let log = controlled_log();
let (_, f) = discover_pso_algorithm_from_log(&log, "concept:name", 20, 30)
.expect("PSO must produce a result for non-empty log");
assert!(
(0.0..=1.0).contains(&f),
"PSO fitness {:.4} outside [0, 1]",
f
);
}
#[test]
fn pso_convergence_more_iterations_never_worse() {
let log = build_log(&[
(10, &["Start", "Register", "Approve", "End"]),
(5, &["Start", "Register", "Reject", "End"]),
]);
let (_, f5) =
discover_pso_algorithm_from_log(&log, "concept:name", 20, 5).expect("PSO must succeed");
let (_, f50) =
discover_pso_algorithm_from_log(&log, "concept:name", 20, 50).expect("PSO must succeed");
assert!(
f50 >= f5 - 1e-9,
"PSO: 50-iter fitness {:.4} < 5-iter fitness {:.4} — global-best monotone violated",
f50,
f5
);
}
#[test]
fn pso_deterministic_same_seed() {
let log = controlled_log();
let (_, f1) =
discover_pso_algorithm_from_log(&log, "concept:name", 20, 50).expect("PSO must succeed");
let (_, f2) =
discover_pso_algorithm_from_log(&log, "concept:name", 20, 50).expect("PSO must succeed");
assert_eq!(f1, f2, "PSO is not deterministic");
}
#[test]
fn sa_fitness_in_range() {
let log = controlled_log();
let (_, f) = discover_simulated_annealing_from_log(&log, "concept:name", 1.0, 0.95);
assert!(
(0.0..=1.0).contains(&f),
"SA fitness {:.4} outside [0, 1]",
f
);
}
#[test]
fn sa_slow_cooling_not_much_worse_than_fast() {
let log = build_log(&[
(10, &["Start", "Register", "Approve", "End"]),
(5, &["Start", "Register", "Reject", "End"]),
]);
let (_, f_fast) = discover_simulated_annealing_from_log(&log, "concept:name", 1.0, 0.50);
let (_, f_slow) = discover_simulated_annealing_from_log(&log, "concept:name", 1.0, 0.99);
assert!(
f_slow >= f_fast - 0.05,
"SA slow-cooling fitness {:.4} is >5% worse than fast-cooling {:.4}",
f_slow,
f_fast
);
}
#[test]
fn sa_best_tracking_nonnegative() {
let log = controlled_log();
let (_, f) = discover_simulated_annealing_from_log(&log, "concept:name", 0.9, 0.95);
assert!(f >= 0.0, "SA returned negative fitness {:.4}", f);
}
#[test]
fn sa_deterministic_same_seed() {
let log = controlled_log();
let (_, f1) = discover_simulated_annealing_from_log(&log, "concept:name", 1.0, 0.95);
let (_, f2) = discover_simulated_annealing_from_log(&log, "concept:name", 1.0, 0.95);
assert_eq!(f1, f2, "SA is not deterministic");
}
#[test]
fn hill_climbing_never_increases_edge_count() {
let log = build_log(&[
(10, &["Start", "Register", "Approve", "End"]),
(5, &["Start", "Register", "Reject", "End"]),
]);
let max_edges = 5usize; let dfg = discover_hill_climbing_from_log(&log, "concept:name");
assert!(
dfg.edges.len() <= max_edges,
"Hill climbing output {} edges > max possible {} — edges were ADDED, not only removed",
dfg.edges.len(),
max_edges
);
}
#[test]
fn hill_climbing_preserves_all_essential_edges() {
let log = build_log(&[(10, &["A", "B", "C", "D"])]);
let dfg = discover_hill_climbing_from_log(&log, "concept:name");
assert_eq!(
dfg.edges.len(),
3,
"Hill climbing removed an essential edge from single-variant log \
(got {} edges, expected 3: A→B, B→C, C→D)",
dfg.edges.len()
);
}
#[test]
fn hill_climbing_deterministic() {
let log = controlled_log();
let dfg1 = discover_hill_climbing_from_log(&log, "concept:name");
let dfg2 = discover_hill_climbing_from_log(&log, "concept:name");
assert_eq!(
dfg1.edges.len(),
dfg2.edges.len(),
"Hill climbing is not deterministic"
);
}
#[test]
fn astar_terminates_and_bounded() {
let log = controlled_log();
let (dfg, iters) = discover_astar_from_log(&log, "concept:name", 500);
assert!(
iters <= 500,
"A* ran {} iterations > max_iterations=500",
iters
);
assert!(
!dfg.nodes.is_empty(),
"A* DFG must have at least one node from log activities"
);
}
#[test]
fn astar_finds_at_least_one_edge() {
let log = controlled_log();
let (dfg, _) = discover_astar_from_log(&log, "concept:name", 1000);
assert!(
!dfg.edges.is_empty(),
"A* found 0 edges for a non-trivial log with 5 high-frequency edges \
— fitness threshold may be too aggressive or algorithm is a stub"
);
}
#[test]
fn ilp_transitions_match_activities() {
let log = build_log(&[
(10, &["Start", "Register", "Approve", "End"]),
(5, &["Start", "Register", "Reject", "End"]),
]);
let activities = 5usize; let (pn, _, _) = discover_ilp_petri_net_from_log(&log, "concept:name");
assert_eq!(
pn.transitions.len(),
activities,
"ILP must produce exactly 1 transition per unique activity (expected {}, got {})",
activities,
pn.transitions.len()
);
}
#[test]
fn ilp_source_place_has_initial_token() {
let log = build_log(&[
(10, &["Start", "Register", "Approve", "End"]),
(5, &["Start", "Register", "Reject", "End"]),
]);
let (pn, _, _) = discover_ilp_petri_net_from_log(&log, "concept:name");
let source_token = pn.initial_marking.get("p_source").copied().unwrap_or(0);
assert_eq!(
source_token, 1,
"source place must have initial marking = 1 (sound workflow net invariant)"
);
}
#[test]
fn ilp_fitness_and_precision_in_range() {
let log = build_log(&[
(10, &["Start", "Register", "Approve", "End"]),
(5, &["Start", "Register", "Reject", "End"]),
]);
let (_, fitness, precision) = discover_ilp_petri_net_from_log(&log, "concept:name");
assert!(
(0.0..=1.0).contains(&fitness),
"ILP fitness {:.4} outside [0, 1]",
fitness
);
assert!(
(0.0..=1.0).contains(&precision),
"ILP precision {:.4} outside [0, 1]",
precision
);
}
#[test]
fn ilp_perfect_fitness_on_fitting_log() {
let log = build_log(&[(10, &["A", "B", "C", "D"])]);
let (_, fitness, _) = discover_ilp_petri_net_from_log(&log, "concept:name");
assert!(
fitness >= 0.8,
"ILP fitness on single-variant fitting log must be >= 0.8, got {:.4}",
fitness
);
}
#[test]
fn ilp_detects_parallel_and_split() {
let log = build_log(&[(10, &["A", "B", "D"]), (10, &["A", "C", "D"])]);
let (pn, fitness, _) = discover_ilp_petri_net_from_log(&log, "concept:name");
assert!(
fitness >= 0.5,
"ILP must achieve fitness >= 0.5 on parallel-split log, got {:.4}",
fitness
);
assert!(
pn.places.len() >= 2,
"ILP must produce at least source and sink places"
);
assert_eq!(
pn.transitions.len(),
4,
"ILP must produce 4 transitions (A,B,C,D), got {}",
pn.transitions.len()
);
}
#[test]
fn ilp_detects_self_loop_place() {
let log = build_log(&[(10, &["A", "A", "B"])]);
let (pn, _, _) = discover_ilp_petri_net_from_log(&log, "concept:name");
let has_loop_place = pn.places.iter().any(|p| p.id.contains("loop"));
assert!(
has_loop_place,
"L1L activity A should produce a self-loop place"
);
}
#[test]
fn ilp_output_is_valid_petri_net() {
let log = build_log(&[(10, &["X", "Y", "Z"])]);
let (pn, _, _) = discover_ilp_petri_net_from_log(&log, "concept:name");
let source = pn.places.iter().find(|p| p.id == "p_source");
assert!(source.is_some(), "Petri net must have p_source place");
assert_eq!(
source.unwrap().marking,
Some(1),
"p_source must have initial marking 1"
);
assert_eq!(pn.transitions.len(), 3, "Must have 3 transitions for X,Y,Z");
assert!(!pn.arcs.is_empty(), "Petri net must have arcs");
}
#[test]
fn alpha_plus_plus_output_is_petri_net() {
use wasm4pm::algorithms::discover_alpha_plus_plus_from_log;
let log = build_log(&[(10, &["A", "B", "C"]), (5, &["A", "C", "B"])]);
let admitted = wasm4pm_compat::admission::Admission::<_, ()>::new(log).into_evidence();
let pn = discover_alpha_plus_plus_from_log(&admitted, "concept:name", 0.0)
.expect("alpha_plus_plus must succeed");
assert!(!pn.places.is_empty(), "Alpha++ must produce places");
assert!(
!pn.transitions.is_empty(),
"Alpha++ must produce transitions"
);
assert!(!pn.arcs.is_empty(), "Alpha++ must produce arcs");
assert!(
pn.places.iter().any(|p| p.id == "p_source"),
"Alpha++ Petri net must have p_source"
);
}
#[test]
fn dfg_edges_have_positive_frequency() {
let log = controlled_log();
let admitted = wasm4pm_compat::admission::Admission::<_, ()>::new(log).into_evidence();
let dfg = discover_dfg_from_log(&admitted, "concept:name");
assert!(
!dfg.edges.is_empty(),
"DFG must have at least one edge for a non-trivial log"
);
for edge in &dfg.edges {
assert!(
edge.frequency > 0,
"DFG edge {}→{} has frequency 0",
edge.from,
edge.to
);
}
}
#[test]
fn dfg_filtered_threshold_monotone() {
let log = controlled_log();
let admitted = wasm4pm_compat::admission::Admission::<_, ()>::new(log.clone()).into_evidence();
let unfiltered = discover_dfg_filtered_from_log(&admitted, "concept:name", 0);
let dfg = discover_dfg_from_log(&admitted, "concept:name");
assert_eq!(
unfiltered.edges.len(),
dfg.edges.len(),
"filtered(min=0) edge count must equal unfiltered"
);
let filtered = discover_dfg_filtered_from_log(&admitted, "concept:name", 999_999);
assert!(
filtered.edges.is_empty(),
"filtered(min=999999) must produce no edges"
);
}
#[test]
fn heuristic_miner_fewer_edges_than_dfg() {
let log = controlled_log();
let admitted = wasm4pm_compat::admission::Admission::<_, ()>::new(log).into_evidence();
let dfg = discover_dfg_from_log(&admitted, "concept:name");
let hm = discover_heuristic_miner_from_log(&admitted.value, "concept:name", 0.5);
assert!(
hm.edges.len() <= dfg.edges.len(),
"heuristic miner (threshold=0.5) must have ≤ DFG edges; got hm={} dfg={}",
hm.edges.len(),
dfg.edges.len()
);
}
#[test]
fn footprints_causal_antisymmetric() {
use wasm4pm::algorithms::FootprintRelation;
let log = build_log(&[(5, &["A", "B", "C"]), (5, &["A", "C", "B"])]);
let admitted = wasm4pm_compat::admission::Admission::<_, ()>::new(log).into_evidence();
let fp = discover_footprints_from_log(&admitted, "concept:name");
let n = fp.activities.len();
for i in 0..n {
for j in 0..n {
if fp.matrix[i][j] == FootprintRelation::Causal {
assert_ne!(
fp.matrix[j][i],
FootprintRelation::Causal,
"footprint antisymmetry violated: {}→{} is Causal AND {}→{} is Causal",
fp.activities[i],
fp.activities[j],
fp.activities[j],
fp.activities[i]
);
}
}
}
}
#[test]
fn aco_fitness_in_range() {
let log = controlled_log();
let result = discover_aco_algorithm_from_log(&log, "concept:name", 5, 10);
let (dfg, fitness) = result.expect("ACO must find a solution on controlled_log");
assert!(
(0.0..=1.0).contains(&fitness),
"ACO final_fitness out of [0,1]: {:.4}",
fitness
);
assert!(!dfg.nodes.is_empty(), "ACO DFG must have nodes");
}
#[test]
fn aco_deterministic_same_seed() {
let log = controlled_log();
let r1 = discover_aco_algorithm_from_log(&log, "concept:name", 5, 5);
let r2 = discover_aco_algorithm_from_log(&log, "concept:name", 5, 5);
let (_, f1) = r1.expect("ACO run 1 failed");
let (_, f2) = r2.expect("ACO run 2 failed");
assert_eq!(
f1.to_bits(),
f2.to_bits(),
"ACO must be deterministic: f1={:.6} f2={:.6}",
f1,
f2
);
}
#[test]
fn temporal_profile_nonnegative_durations() {
use wasm4pm::models::AttributeValue;
let mut log = EventLog::new();
let base_ms: i64 = 1_700_000_000_000;
for i in 0..5usize {
let mut trace = Trace::new();
trace.attributes.insert(
"concept:name".to_string(),
AttributeValue::String(format!("case-{i}")),
);
for (j, act) in ["A", "B", "C"].iter().enumerate() {
let ts_ms = base_ms + (i as i64 * 10_000) + (j as i64 * 3_600_000);
let ts_str = format!(
"{}-{:02}-{:02}T{:02}:{:02}:{:02}+00:00",
2023,
11,
1 + (ts_ms / 86_400_000 % 28) as u32,
(ts_ms / 3_600_000 % 24) as u32,
(ts_ms / 60_000 % 60) as u32,
(ts_ms / 1_000 % 60) as u32,
);
let mut event = Event::new();
event.attributes.insert(
"concept:name".to_string(),
AttributeValue::String(act.to_string()),
);
event
.attributes
.insert("time:timestamp".to_string(), AttributeValue::Date(ts_str));
trace.events.push(event);
}
log.traces.push(trace);
}
let profile = discover_temporal_profile_from_log(&log, "concept:name", "time:timestamp");
assert!(
!profile.pairs.is_empty(),
"temporal profile must have at least one pair"
);
for ((a, b), (mean, _stdev, _cnt)) in &profile.pairs {
assert!(
*mean >= 0.0,
"temporal profile mean for {}→{} is negative: {:.2}",
a,
b,
mean
);
}
}
#[test]
fn social_handover_single_resource_is_zero() {
let mut log = EventLog::new();
let mut trace = Trace::new();
trace.attributes.insert(
"concept:name".to_string(),
AttributeValue::String("case-1".to_string()),
);
for act in &["A", "B", "C"] {
let mut event = Event::new();
event.attributes.insert(
"concept:name".to_string(),
AttributeValue::String(act.to_string()),
);
event.attributes.insert(
"org:resource".to_string(),
AttributeValue::String("Alice".to_string()),
);
trace.events.push(event);
}
log.traces.push(trace);
let json_str = discover_handover_network_from_log(&log, "org:resource");
let v: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
let edges = v["edges"].as_array().expect("edges array");
assert!(
edges.is_empty(),
"single-resource log must have 0 handover edges, got {}",
edges.len()
);
}
#[test]
fn social_working_together_same_trace_produces_edge() {
let mut log = EventLog::new();
let mut trace = Trace::new();
trace.attributes.insert(
"concept:name".to_string(),
AttributeValue::String("case-1".to_string()),
);
for (act, res) in &[("A", "Alice"), ("B", "Bob")] {
let mut event = Event::new();
event.attributes.insert(
"concept:name".to_string(),
AttributeValue::String(act.to_string()),
);
event.attributes.insert(
"org:resource".to_string(),
AttributeValue::String(res.to_string()),
);
trace.events.push(event);
}
log.traces.push(trace);
let json_str = discover_working_together_network_from_log(&log, "org:resource");
let v: serde_json::Value = serde_json::from_str(&json_str).expect("valid JSON");
let edges = v["edges"].as_array().expect("edges array");
assert_eq!(
edges.len(),
1,
"two resources in one trace → exactly 1 co-occurrence edge"
);
let cnt = edges[0]["co_occurrences"].as_u64().unwrap_or(0);
assert_eq!(cnt, 1, "co_occurrences must be 1");
}
#[test]
fn hc_prunes_below_dfg() {
let log = build_log(&[
(20, &["Start", "Process", "End"]),
(20, &["Start", "Process", "Review", "End"]),
]);
let admitted = wasm4pm_compat::admission::Admission::<_, ()>::new(log.clone()).into_evidence();
let dfg = discover_dfg_from_log(&admitted, "concept:name");
let hc = discover_hill_climbing_from_log(&log, "concept:name");
assert!(
hc.edges.len() <= dfg.edges.len(),
"HC edge count {} must be ≤ DFG edge count {}",
hc.edges.len(),
dfg.edges.len()
);
assert!(
!hc.edges.is_empty(),
"HC must produce at least one edge for a non-trivial log"
);
for edge in &hc.edges {
assert!(
edge.frequency > 0,
"HC edge {}→{} has frequency 0 (frequencies must be real)",
edge.from,
edge.to
);
}
let max_freq = hc.edges.iter().map(|e| e.frequency).max().unwrap_or(0);
assert!(
max_freq >= 20,
"HC dominant edge frequency must be ≥ 20 (observed 20 times); got max={}",
max_freq
);
}
#[test]
fn astar_beyond_100_edges() {
let log = build_log(&[
(
5,
&["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"],
),
(
5,
&["A", "C", "B", "D", "F", "E", "G", "I", "H", "J", "L", "K"],
),
(
5,
&["L", "K", "J", "I", "H", "G", "F", "E", "D", "C", "B", "A"],
),
]);
let (dfg, _iters) = discover_astar_from_log(&log, "concept:name", 500);
assert!(
!dfg.edges.is_empty(),
"A* must produce at least one edge on a large log"
);
let node_ids: std::collections::HashSet<&str> =
dfg.nodes.iter().map(|n| n.id.as_str()).collect();
for edge in &dfg.edges {
assert!(
node_ids.contains(edge.from.as_str()),
"A* edge from={} references unknown node",
edge.from
);
assert!(
node_ids.contains(edge.to.as_str()),
"A* edge to={} references unknown node",
edge.to
);
}
}
#[test]
fn ga_edges_have_real_frequency() {
let log = build_log(&[(20, &["A", "B", "C", "D"])]);
let (ga_dfg, _) =
discover_genetic_algorithm_from_log(&log, "concept:name", 20, 30).expect("GA must succeed");
let max_freq = ga_dfg.edges.iter().map(|e| e.frequency).max().unwrap_or(0);
assert!(
max_freq > 1,
"GA edges must have real frequency (> 1 for repeated-trace log); got max={}",
max_freq
);
let (pso_dfg, _) =
discover_pso_algorithm_from_log(&log, "concept:name", 10, 20).expect("PSO must succeed");
let pso_max = pso_dfg.edges.iter().map(|e| e.frequency).max().unwrap_or(0);
assert!(
pso_max > 1,
"PSO edges must have real frequency; got max={}",
pso_max
);
let (aco_dfg, _) =
discover_aco_algorithm_from_log(&log, "concept:name", 30, 30).expect("ACO must succeed");
for edge in &aco_dfg.edges {
assert!(
edge.frequency > 0,
"ACO edge {}→{} has frequency 0 (must be > 0 for observed edges)",
edge.from,
edge.to
);
}
if !aco_dfg.edges.is_empty() {
let aco_max = aco_dfg.edges.iter().map(|e| e.frequency).max().unwrap_or(0);
assert!(
aco_max > 1,
"ACO edges must carry real frequency (> 1); got max={}",
aco_max
);
}
}
#[test]
fn inductive_parallel_cut_fires() {
let log = build_log(&[(5, &["A", "B", "C"]), (5, &["A", "C", "B"])]);
let admitted = wasm4pm_compat::admission::Admission::<_, ()>::new(log).into_evidence();
let json_str = discover_inductive_miner_from_log(&admitted, "concept:name");
let v: serde_json::Value =
serde_json::from_str(&json_str).expect("inductive miner must return valid JSON");
fn collect_operators(node: &serde_json::Value, ops: &mut Vec<String>) {
if let Some(t) = node["node_type"].as_str() {
ops.push(t.to_string());
}
if let Some(children) = node["children"].as_array() {
for child in children {
collect_operators(child, ops);
}
}
}
let mut operators = Vec::new();
collect_operators(&v["root"], &mut operators);
assert!(
operators.contains(&"parallel".to_string()),
"Inductive Miner must produce a 'parallel' node for a log with bidirectional \
B↔C edges; got operators: {:?}",
operators
);
}
#[test]
fn astar_max_iter_1_returns_non_empty_when_fitness_positive() {
let log = controlled_log();
let (dfg, iters) = discover_astar_from_log(&log, "concept:name", 1);
assert_eq!(
iters, 1,
"A* must consume exactly 1 iteration when budget is 1; got {}",
iters
);
assert!(
!dfg.edges.is_empty(),
"A* with max_iterations=1 returned 0 edges — regression to score-of-popped-node bug"
);
}
#[test]
fn astar_more_iter_never_fewer_edges() {
let log = controlled_log();
let (dfg1, _) = discover_astar_from_log(&log, "concept:name", 1);
let (dfg_more, _) = discover_astar_from_log(&log, "concept:name", 20);
assert!(
dfg_more.edges.len() >= dfg1.edges.len(),
"A* @ 20 iter has {} edges < 1 iter has {} edges — best-tracking regressed",
dfg_more.edges.len(),
dfg1.edges.len()
);
}
#[test]
fn pso_global_best_at_least_as_good_as_initial_spawn() {
let log = controlled_log();
let (_, f1) =
discover_pso_algorithm_from_log(&log, "concept:name", 30, 1).expect("PSO must succeed");
let (_, f50) =
discover_pso_algorithm_from_log(&log, "concept:name", 30, 50).expect("PSO must succeed");
assert!(
f50 >= f1 - 1e-9,
"PSO global-best regressed: 1-iter={:.4} 50-iter={:.4}",
f1,
f50
);
assert!(
f50 > 0.0,
"PSO produced fitness=0 on a non-trivial log — destructive pBest pull?",
);
}
#[test]
fn pso_iterations_improve_global_best() {
let log = build_log(&[
(10, &["A", "B", "C", "D", "E"]),
(10, &["A", "B", "D", "C", "E"]),
(5, &["A", "C", "B", "D", "E"]),
]);
let (_, f_low) =
discover_pso_algorithm_from_log(&log, "concept:name", 30, 2).expect("PSO must succeed");
let (_, f_high) =
discover_pso_algorithm_from_log(&log, "concept:name", 30, 50).expect("PSO must succeed");
assert!(
f_high >= f_low - 1e-9,
"PSO with more iterations regressed: low={:.4} high={:.4}",
f_low,
f_high
);
}