use crate::error::EvalResult;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ProcessEventData {
pub event_id: String,
pub case_id: String,
pub activity: String,
pub timestamp: i64,
pub object_id: Option<String>,
pub is_terminal: bool,
pub is_creation: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventSequenceThresholds {
pub min_monotonicity: f64,
pub min_lifecycle_completeness: f64,
pub max_negative_duration_rate: f64,
}
impl Default for EventSequenceThresholds {
fn default() -> Self {
Self {
min_monotonicity: 0.99,
min_lifecycle_completeness: 0.90,
max_negative_duration_rate: 0.01,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventSequenceAnalysis {
pub timestamp_monotonicity: f64,
pub object_lifecycle_completeness: f64,
pub negative_duration_count: usize,
pub negative_duration_rate: f64,
pub avg_case_duration: f64,
pub duration_cv: f64,
pub total_events: usize,
pub total_cases: usize,
pub passes: bool,
pub issues: Vec<String>,
}
pub struct EventSequenceAnalyzer {
thresholds: EventSequenceThresholds,
}
impl EventSequenceAnalyzer {
pub fn new() -> Self {
Self {
thresholds: EventSequenceThresholds::default(),
}
}
pub fn with_thresholds(thresholds: EventSequenceThresholds) -> Self {
Self { thresholds }
}
pub fn analyze(&self, events: &[ProcessEventData]) -> EvalResult<EventSequenceAnalysis> {
let mut issues = Vec::new();
if events.is_empty() {
return Ok(EventSequenceAnalysis {
timestamp_monotonicity: 1.0,
object_lifecycle_completeness: 1.0,
negative_duration_count: 0,
negative_duration_rate: 0.0,
avg_case_duration: 0.0,
duration_cv: 0.0,
total_events: 0,
total_cases: 0,
passes: true,
issues: Vec::new(),
});
}
let mut by_case: HashMap<&str, Vec<&ProcessEventData>> = HashMap::new();
for event in events {
by_case
.entry(event.case_id.as_str())
.or_default()
.push(event);
}
for case_events in by_case.values_mut() {
case_events.sort_by_key(|e| e.timestamp);
}
let mut monotonic_cases = 0usize;
let mut total_negative = 0usize;
let mut total_pairs = 0usize;
for case_events in by_case.values() {
let mut is_monotonic = true;
for pair in case_events.windows(2) {
total_pairs += 1;
if pair[1].timestamp < pair[0].timestamp {
is_monotonic = false;
total_negative += 1;
}
}
if is_monotonic {
monotonic_cases += 1;
}
}
let total_cases = by_case.len();
let timestamp_monotonicity = if total_cases > 0 {
monotonic_cases as f64 / total_cases as f64
} else {
1.0
};
let negative_duration_rate = if total_pairs > 0 {
total_negative as f64 / total_pairs as f64
} else {
0.0
};
let mut objects: HashMap<&str, (bool, bool)> = HashMap::new(); for event in events {
if let Some(ref obj_id) = event.object_id {
let entry = objects.entry(obj_id.as_str()).or_insert((false, false));
if event.is_creation {
entry.0 = true;
}
if event.is_terminal {
entry.1 = true;
}
}
}
let complete_objects = objects.values().filter(|(c, t)| *c && *t).count();
let object_lifecycle_completeness = if objects.is_empty() {
1.0
} else {
complete_objects as f64 / objects.len() as f64
};
let case_durations: Vec<f64> = by_case
.values()
.filter_map(|case_events| {
if case_events.len() < 2 {
return None;
}
let first = case_events.first().map(|e| e.timestamp)?;
let last = case_events.last().map(|e| e.timestamp)?;
Some((last - first) as f64)
})
.collect();
let avg_case_duration = if case_durations.is_empty() {
0.0
} else {
case_durations.iter().sum::<f64>() / case_durations.len() as f64
};
let duration_cv = if case_durations.len() >= 2 && avg_case_duration > 0.0 {
let variance = case_durations
.iter()
.map(|d| (d - avg_case_duration).powi(2))
.sum::<f64>()
/ (case_durations.len() - 1) as f64;
variance.sqrt() / avg_case_duration
} else {
0.0
};
if timestamp_monotonicity < self.thresholds.min_monotonicity {
issues.push(format!(
"Timestamp monotonicity {:.3} < {:.3}",
timestamp_monotonicity, self.thresholds.min_monotonicity
));
}
if object_lifecycle_completeness < self.thresholds.min_lifecycle_completeness {
issues.push(format!(
"Object lifecycle completeness {:.3} < {:.3}",
object_lifecycle_completeness, self.thresholds.min_lifecycle_completeness
));
}
if negative_duration_rate > self.thresholds.max_negative_duration_rate {
issues.push(format!(
"Negative duration rate {:.3} > {:.3}",
negative_duration_rate, self.thresholds.max_negative_duration_rate
));
}
let passes = issues.is_empty();
Ok(EventSequenceAnalysis {
timestamp_monotonicity,
object_lifecycle_completeness,
negative_duration_count: total_negative,
negative_duration_rate,
avg_case_duration,
duration_cv,
total_events: events.len(),
total_cases,
passes,
issues,
})
}
}
impl Default for EventSequenceAnalyzer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_valid_sequence() {
let analyzer = EventSequenceAnalyzer::new();
let events = vec![
ProcessEventData {
event_id: "E1".to_string(),
case_id: "C1".to_string(),
activity: "Create PO".to_string(),
timestamp: 1000,
object_id: Some("OBJ1".to_string()),
is_terminal: false,
is_creation: true,
},
ProcessEventData {
event_id: "E2".to_string(),
case_id: "C1".to_string(),
activity: "Approve PO".to_string(),
timestamp: 2000,
object_id: Some("OBJ1".to_string()),
is_terminal: false,
is_creation: false,
},
ProcessEventData {
event_id: "E3".to_string(),
case_id: "C1".to_string(),
activity: "Close PO".to_string(),
timestamp: 3000,
object_id: Some("OBJ1".to_string()),
is_terminal: true,
is_creation: false,
},
];
let result = analyzer.analyze(&events).unwrap();
assert!(result.passes);
assert_eq!(result.timestamp_monotonicity, 1.0);
assert_eq!(result.object_lifecycle_completeness, 1.0);
}
#[test]
fn test_out_of_order() {
let analyzer = EventSequenceAnalyzer::new();
let events = vec![
ProcessEventData {
event_id: "E1".to_string(),
case_id: "C1".to_string(),
activity: "Step A".to_string(),
timestamp: 2000, object_id: None,
is_terminal: false,
is_creation: false,
},
ProcessEventData {
event_id: "E2".to_string(),
case_id: "C1".to_string(),
activity: "Step B".to_string(),
timestamp: 1000, object_id: None,
is_terminal: false,
is_creation: false,
},
];
let result = analyzer.analyze(&events).unwrap();
assert_eq!(result.negative_duration_count, 0); }
#[test]
fn test_empty() {
let analyzer = EventSequenceAnalyzer::new();
let result = analyzer.analyze(&[]).unwrap();
assert!(result.passes);
}
}