use crate::models::{DFGNode, DirectlyFollowsRelation, DFG};
use crate::streaming::{
impl_activity_interner, ActivityInterner, Interner, StreamStats, StreamingAlgorithm,
};
use rustc_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
const DEFAULT_NOISE_THRESHOLD: f64 = 0.0;
#[derive(Debug, Clone)]
pub struct StreamingHillClimbingBuilder {
pub interner: Interner,
pub activity_counts: Vec<usize>,
pub edge_counts: FxHashMap<(u32, u32), usize>,
pub start_counts: FxHashMap<u32, usize>,
pub end_counts: FxHashMap<u32, usize>,
pub event_count: usize,
pub trace_count: usize,
pub open_traces: HashMap<String, Vec<u32>>,
closed_traces: Vec<Vec<u32>>,
noise_threshold: f64,
}
impl_activity_interner!(StreamingHillClimbingBuilder);
impl StreamingHillClimbingBuilder {
pub fn new() -> Self {
StreamingHillClimbingBuilder {
interner: Interner::new(),
activity_counts: Vec::new(),
edge_counts: FxHashMap::default(),
start_counts: FxHashMap::default(),
end_counts: FxHashMap::default(),
event_count: 0,
trace_count: 0,
open_traces: HashMap::new(),
closed_traces: Vec::new(),
noise_threshold: DEFAULT_NOISE_THRESHOLD,
}
}
pub fn with_noise_threshold(mut self, threshold: f64) -> Self {
self.noise_threshold = threshold.clamp(0.0, 1.0);
self
}
pub fn to_dfg(&self) -> DFG {
if self.closed_traces.is_empty() {
return DFG::new();
}
let max_freq = self.edge_counts.values().copied().max().unwrap_or(1);
let mut current_edges: FxHashSet<(u32, u32)> = if self.noise_threshold > 0.0 {
self.edge_counts
.iter()
.filter(|&(_, &count)| count as f64 / max_freq as f64 >= self.noise_threshold)
.map(|(&k, _)| k)
.collect()
} else {
self.edge_counts.keys().copied().collect()
};
if current_edges.is_empty() {
return DFG::new();
}
let mut improved = true;
while improved && current_edges.len() > 1 {
improved = false;
let mut removal_cost: FxHashMap<(u32, u32), usize> = FxHashMap::default();
for trace in &self.closed_traces {
if trace.len() < 2 {
continue;
}
let mut pair_counts: FxHashMap<(u32, u32), usize> = FxHashMap::default();
for i in 0..trace.len() - 1 {
let pair = (trace[i], trace[i + 1]);
if current_edges.contains(&pair) {
*pair_counts.entry(pair).or_insert(0) += 1;
}
}
for (&pair, &count) in &pair_counts {
if count == 1 {
*removal_cost.entry(pair).or_insert(0) += 1;
}
}
}
if let Some((&worst_edge, _)) = removal_cost.iter().min_by_key(|(_, &v)| v) {
if removal_cost[&worst_edge] == 0 {
current_edges.remove(&worst_edge);
improved = true;
}
}
}
let mut dfg = DFG::new();
dfg.nodes = self
.interner
.vocab()
.iter()
.enumerate()
.map(|(i, name)| DFGNode {
id: name.clone(),
label: name.clone(),
frequency: self.activity_counts.get(i).copied().unwrap_or(0),
})
.collect();
dfg.edges = current_edges
.iter()
.filter_map(|&edge| {
let freq = self.edge_counts.get(&edge)?;
Some(DirectlyFollowsRelation {
from: self.interner.get(edge.0).unwrap_or("").to_string(),
to: self.interner.get(edge.1).unwrap_or("").to_string(),
frequency: *freq,
})
})
.collect();
for (&id, &cnt) in &self.start_counts {
if let Some(name) = self.interner.get(id) {
dfg.start_activities.insert(name.to_string(), cnt);
}
}
for (&id, &cnt) in &self.end_counts {
if let Some(name) = self.interner.get(id) {
dfg.end_activities.insert(name.to_string(), cnt);
}
}
dfg
}
}
impl StreamingAlgorithm for StreamingHillClimbingBuilder {
type Model = DFG;
fn new() -> Self {
Self::new()
}
fn add_event(&mut self, case_id: &str, activity: &str) {
let id = self.intern(activity);
self.open_traces
.entry(case_id.to_owned())
.or_default()
.push(id);
if id as usize >= self.activity_counts.len() {
self.activity_counts.resize(id as usize + 1, 0);
}
self.event_count += 1;
}
fn close_trace(&mut self, case_id: &str) -> bool {
let Some(events) = self.open_traces.remove(case_id) else {
return false;
};
if events.is_empty() {
return true;
}
for &id in &events {
self.activity_counts[id as usize] += 1;
}
for pair in events.windows(2) {
*self.edge_counts.entry((pair[0], pair[1])).or_insert(0) += 1;
}
*self.start_counts.entry(events[0]).or_insert(0) += 1;
if let Some(last) = events.last() {
*self.end_counts.entry(*last).or_insert(0) += 1;
}
self.closed_traces.push(events);
self.trace_count += 1;
true
}
fn snapshot(&self) -> Self::Model {
self.to_dfg()
}
fn stats(&self) -> StreamStats {
let open_trace_events: usize = self.open_traces.values().map(|v| v.len()).sum();
let closed_trace_events: usize = self.closed_traces.iter().map(|v| v.len()).sum();
let memory_bytes = self.open_traces.capacity()
* (std::mem::size_of::<String>() + std::mem::size_of::<Vec<u32>>())
+ open_trace_events * std::mem::size_of::<u32>()
+ self.closed_traces.capacity() * std::mem::size_of::<Vec<u32>>()
+ closed_trace_events * std::mem::size_of::<u32>()
+ self.activity_counts.capacity() * std::mem::size_of::<usize>()
+ self.edge_counts.capacity()
* (std::mem::size_of::<(u32, u32)>() + std::mem::size_of::<usize>());
StreamStats {
event_count: self.event_count,
trace_count: self.trace_count,
open_traces: self.open_traces.len(),
memory_bytes,
activities: self.interner.len(),
}
}
fn open_trace_ids(&self) -> Vec<String> {
self.open_traces.keys().cloned().collect()
}
}
impl Default for StreamingHillClimbingBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hill_climbing_basic() {
let mut stream = StreamingHillClimbingBuilder::new();
stream.add_event("case1", "A");
stream.add_event("case1", "B");
stream.add_event("case1", "C");
stream.close_trace("case1");
let stats = stream.stats();
assert_eq!(stats.event_count, 3);
assert_eq!(stats.trace_count, 1);
let dfg = stream.snapshot();
assert!(dfg.edges.iter().any(|e| e.from == "A" && e.to == "B"));
assert!(dfg.edges.iter().any(|e| e.from == "B" && e.to == "C"));
}
#[test]
fn test_hill_climbing_noise_pruning() {
let mut stream = StreamingHillClimbingBuilder::new().with_noise_threshold(0.3);
for i in 0..5 {
stream.add_event(&format!("c{}", i), "A");
stream.add_event(&format!("c{}", i), "B");
stream.close_trace(&format!("c{}", i));
}
stream.add_event("c_noise", "A");
stream.add_event("c_noise", "C");
stream.close_trace("c_noise");
let dfg = stream.snapshot();
let has_ab = dfg.edges.iter().any(|e| e.from == "A" && e.to == "B");
let has_ac = dfg.edges.iter().any(|e| e.from == "A" && e.to == "C");
assert!(has_ab, "should keep strong edge A→B");
assert!(!has_ac, "should prune weak edge A→C with threshold 0.3");
}
#[test]
fn test_hill_climbing_empty() {
let stream = StreamingHillClimbingBuilder::new();
let dfg = stream.snapshot();
assert!(dfg.edges.is_empty());
assert!(dfg.nodes.is_empty());
}
#[test]
fn test_hill_climbing_no_pruning_with_low_threshold() {
let mut stream = StreamingHillClimbingBuilder::new().with_noise_threshold(0.0);
stream.add_event("c1", "A");
stream.add_event("c1", "B");
stream.close_trace("c1");
let dfg = stream.snapshot();
assert_eq!(
dfg.edges.len(),
1,
"should keep all edges with zero threshold"
);
}
#[test]
fn test_hill_climbing_greedy_order() {
let mut stream = StreamingHillClimbingBuilder::new();
stream.add_event("c1", "A");
stream.add_event("c1", "B");
stream.add_event("c1", "C");
stream.close_trace("c1");
stream.add_event("c2", "A");
stream.add_event("c2", "D");
stream.add_event("c2", "E");
stream.close_trace("c2");
let dfg = stream.snapshot();
assert_eq!(
dfg.edges.len(),
4,
"should discover all 4 edges across both traces"
);
}
#[test]
fn test_hill_climbing_matches_batch_behavior() {
let mut stream = StreamingHillClimbingBuilder::new();
for i in 0..3 {
stream.add_event(&format!("c{}", i), "A");
stream.add_event(&format!("c{}", i), "B");
stream.add_event(&format!("c{}", i), "C");
stream.close_trace(&format!("c{}", i));
}
let dfg = stream.snapshot();
assert_eq!(dfg.edges.len(), 2, "should have exactly 2 edges");
assert!(dfg.edges.iter().any(|e| e.from == "A" && e.to == "B"));
assert!(dfg.edges.iter().any(|e| e.from == "B" && e.to == "C"));
}
}