use crate::pipeline::catalog::{breed_category, BreedCategory};
use crate::pipeline::fitness::BreedFitnessEvaluator;
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
const ACTIVITY_CAP: f64 = 12.0;
const OBJECT_TYPE_CAP: f64 = 6.0;
const CATEGORY_COUNT: f64 = 7.0;
#[derive(Debug, Clone, Deserialize)]
pub struct OcelRelationship {
#[serde(rename = "objectId")]
pub object_id: String,
#[serde(default)]
pub qualifier: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OcelEvent {
#[serde(default)]
pub id: String,
#[serde(rename = "type", default)]
pub activity: String,
#[serde(default)]
pub time: String,
#[serde(default)]
pub relationships: Vec<OcelRelationship>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OcelObject {
#[serde(default)]
pub id: String,
#[serde(rename = "type", default)]
pub object_type: String,
#[serde(default)]
pub relationships: Vec<OcelRelationship>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OcelLog {
#[serde(default)]
pub events: Vec<OcelEvent>,
#[serde(default)]
pub objects: Vec<OcelObject>,
}
pub fn parse_ocel_log(text: &str) -> Option<OcelLog> {
serde_json::from_str::<OcelLog>(text).ok()
}
pub fn read_ocel_log(path: &str) -> Option<OcelLog> {
let text = std::fs::read_to_string(path).ok()?;
parse_ocel_log(&text)
}
#[derive(Debug, Clone, PartialEq)]
pub struct LogProfile {
pub activity_variety: f64,
pub object_type_spread: f64,
pub temporal_density: f64,
pub divergence: f64,
pub convergence: f64,
pub df_density: f64,
}
impl LogProfile {
pub fn empty() -> Self {
Self {
activity_variety: 0.0,
object_type_spread: 0.0,
temporal_density: 0.0,
divergence: 0.0,
convergence: 0.0,
df_density: 0.0,
}
}
pub fn from_log(log: &OcelLog) -> Self {
if log.events.is_empty() {
return Self::empty();
}
let obj_type: HashMap<&str, &str> = log
.objects
.iter()
.map(|o| (o.id.as_str(), o.object_type.as_str()))
.collect();
let activities: HashSet<&str> = log.events.iter().map(|e| e.activity.as_str()).collect();
let mut object_types: HashSet<&str> =
log.objects.iter().map(|o| o.object_type.as_str()).collect();
let mut traces: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
let mut convergent_events = 0usize;
for ev in &log.events {
let mut per_type: HashMap<&str, usize> = HashMap::new();
for rel in &ev.relationships {
let oid = rel.object_id.as_str();
traces
.entry(oid)
.or_default()
.push((ev.time.as_str(), ev.activity.as_str()));
if let Some(t) = obj_type.get(oid) {
object_types.insert(t);
*per_type.entry(*t).or_default() += 1;
}
}
if per_type.values().any(|&c| c >= 2) {
convergent_events += 1;
}
}
let mut df_pairs: HashSet<(&str, &str)> = HashSet::new();
let mut traces_multi = 0usize;
let mut traces_divergent = 0usize;
let total_traces = traces.len().max(1);
for trace in traces.values_mut() {
trace.sort_by(|a, b| a.0.cmp(b.0));
if trace.len() >= 2 {
traces_multi += 1;
}
let mut counts: HashMap<&str, usize> = HashMap::new();
for (_, act) in trace.iter() {
*counts.entry(*act).or_default() += 1;
}
if counts.values().any(|&c| c >= 2) {
traces_divergent += 1;
}
for win in trace.windows(2) {
df_pairs.insert((win[0].1, win[1].1));
}
}
let act_count = activities.len().max(1) as f64;
let df_density = (df_pairs.len() as f64 / (act_count * act_count)).clamp(0.0, 1.0);
Self {
activity_variety: (activities.len() as f64 / ACTIVITY_CAP).clamp(0.0, 1.0),
object_type_spread: (object_types.len() as f64 / OBJECT_TYPE_CAP).clamp(0.0, 1.0),
temporal_density: (traces_multi as f64 / total_traces as f64).clamp(0.0, 1.0),
divergence: (traces_divergent as f64 / total_traces as f64).clamp(0.0, 1.0),
convergence: (convergent_events as f64 / log.events.len() as f64).clamp(0.0, 1.0),
df_density,
}
}
fn coverage(breeds: &[String]) -> (f64, bool) {
let mut cats: Vec<BreedCategory> = Vec::new();
for b in breeds {
let c = breed_category(b);
if !cats.contains(&c) {
cats.push(c);
}
}
let has_temporal = cats.contains(&BreedCategory::Temporal);
(
(cats.len() as f64 / CATEGORY_COUNT).clamp(0.0, 1.0),
has_temporal,
)
}
pub fn demand_match(&self, breeds: &[String]) -> f64 {
if breeds.is_empty() {
return 0.0;
}
let (coverage, has_temporal) = Self::coverage(breeds);
let temporal_demand = ((self.temporal_density + self.divergence) / 2.0).clamp(0.0, 1.0);
let breadth_demand =
((self.object_type_spread + self.convergence + self.df_density) / 3.0).clamp(0.0, 1.0);
let variety_demand = self.activity_variety;
let temporal_term = if has_temporal {
1.0
} else {
1.0 - temporal_demand
};
let breadth_term = 1.0 - (breadth_demand - coverage).max(0.0);
let variety_term = 1.0 - (variety_demand - coverage).max(0.0);
(0.4 * temporal_term + 0.4 * breadth_term + 0.2 * variety_term).clamp(0.0, 1.0)
}
}
#[derive(Debug)]
pub struct LogGroundedFitnessEvaluator {
pub profile: LogProfile,
}
impl BreedFitnessEvaluator for LogGroundedFitnessEvaluator {
fn evaluate(&self, breeds: &[String]) -> f64 {
self.profile.demand_match(breeds)
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"events": [
{"id":"e1","type":"place order","time":"2026-06-01T08:00:00Z","relationships":[{"objectId":"o1","qualifier":"order"},{"objectId":"c1","qualifier":"customer"}]},
{"id":"e2","type":"pick item","time":"2026-06-01T09:15:00Z","relationships":[{"objectId":"o1","qualifier":"order"},{"objectId":"i1","qualifier":"item"}]},
{"id":"e3","type":"pick item","time":"2026-06-01T09:42:00Z","relationships":[{"objectId":"o1","qualifier":"order"},{"objectId":"i2","qualifier":"item"}]},
{"id":"e4","type":"deliver order","time":"2026-06-02T11:30:00Z","relationships":[{"objectId":"o1","qualifier":"order"},{"objectId":"c1","qualifier":"customer"}]}
],
"objects": [
{"id":"o1","type":"Order"},
{"id":"i1","type":"Item"},
{"id":"i2","type":"Item"},
{"id":"c1","type":"Customer"}
]
}"#;
fn sample() -> OcelLog {
parse_ocel_log(SAMPLE).expect("sample OCEL must parse")
}
#[test]
fn parses_events_and_objects() {
let log = sample();
assert_eq!(log.events.len(), 4);
assert_eq!(log.objects.len(), 4);
assert_eq!(log.events[0].activity, "place order");
}
#[test]
fn garbage_and_absent_sources_are_none() {
assert!(parse_ocel_log("not json at all").is_none());
assert!(read_ocel_log("/no/such/path/at/all.ocel.json").is_none());
}
#[test]
fn empty_log_yields_zero_profile() {
let log = parse_ocel_log(r#"{"events":[],"objects":[]}"#).unwrap();
assert_eq!(LogProfile::from_log(&log), LogProfile::empty());
}
#[test]
fn profile_fields_are_bounded() {
let p = LogProfile::from_log(&sample());
for v in [
p.activity_variety,
p.object_type_spread,
p.temporal_density,
p.divergence,
p.convergence,
p.df_density,
] {
assert!((0.0..=1.0).contains(&v), "signal {v} out of [0,1]");
}
}
#[test]
fn divergence_detects_repeated_activity_per_object() {
let p = LogProfile::from_log(&sample());
assert!(
p.divergence > 0.0,
"repeated per-object activity must register divergence"
);
assert!(
p.temporal_density > 0.0,
"multi-event traces must register temporal density"
);
}
#[test]
fn temporal_breed_helps_when_log_is_temporal() {
let p = LogProfile::from_log(&sample());
let without = vec!["asp".to_string(), "cbr".to_string()];
let with = vec!["asp".to_string(), "ltl_monitor".to_string()];
assert!(
p.demand_match(&with) > p.demand_match(&without),
"a Temporal breed must raise fitness on a temporally-demanding log"
);
}
#[test]
fn broader_coverage_helps_on_complex_log() {
let p = LogProfile::from_log(&sample());
let narrow = vec!["asp".to_string()];
let broad = vec![
"asp".to_string(),
"cbr".to_string(),
"bayesian_network".to_string(),
"strips".to_string(),
];
assert!(
p.demand_match(&broad) >= p.demand_match(&narrow),
"broader category coverage must not reduce fitness on a complex log"
);
}
#[test]
fn empty_pipeline_scores_zero() {
let p = LogProfile::from_log(&sample());
assert_eq!(p.demand_match(&[]), 0.0);
}
#[test]
fn read_round_trips_through_a_temp_file() {
let mut path = std::env::temp_dir();
path.push(format!("tpot2-ocel-{}.json", std::process::id()));
std::fs::write(&path, SAMPLE).unwrap();
let log = read_ocel_log(path.to_str().unwrap()).expect("temp OCEL must read");
assert_eq!(log.events.len(), 4);
let _ = std::fs::remove_file(&path);
}
#[test]
fn evaluator_matches_profile_demand() {
let profile = LogProfile::from_log(&sample());
let breeds = vec!["asp".to_string(), "ltl_monitor".to_string()];
let evaluator = LogGroundedFitnessEvaluator {
profile: profile.clone(),
};
assert_eq!(evaluator.evaluate(&breeds), profile.demand_match(&breeds));
}
}