Skip to main content

ringkernel_procint/analytics/
kpi_tracking.rs

1//! KPI tracking for process intelligence.
2//!
3//! Tracks key performance indicators across the process mining pipeline.
4
5use std::collections::VecDeque;
6use std::time::{Duration, Instant};
7
8/// KPI tracker for real-time metrics.
9#[derive(Debug)]
10pub struct KPITracker {
11    /// Events per second (recent window).
12    throughput_samples: VecDeque<(Instant, u64)>,
13    /// Average case duration samples.
14    duration_samples: VecDeque<f32>,
15    /// Fitness scores.
16    fitness_samples: VecDeque<f32>,
17    /// Window size for rolling averages.
18    window_size: usize,
19    /// Last update time.
20    last_update: Instant,
21    /// Current KPIs.
22    pub current: ProcessKPIs,
23}
24
25impl Default for KPITracker {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl KPITracker {
32    /// Create a new KPI tracker.
33    pub fn new() -> Self {
34        Self {
35            throughput_samples: VecDeque::with_capacity(100),
36            duration_samples: VecDeque::with_capacity(100),
37            fitness_samples: VecDeque::with_capacity(100),
38            window_size: 100,
39            last_update: Instant::now(),
40            current: ProcessKPIs::default(),
41        }
42    }
43
44    /// Set window size for rolling averages.
45    pub fn with_window_size(mut self, size: usize) -> Self {
46        self.window_size = size;
47        self
48    }
49
50    /// Record events processed.
51    pub fn record_events(&mut self, count: u64) {
52        let now = Instant::now();
53        self.throughput_samples.push_back((now, count));
54
55        // Remove old samples (older than 10 seconds)
56        while let Some(&(time, _)) = self.throughput_samples.front() {
57            if now.duration_since(time) > Duration::from_secs(10) {
58                self.throughput_samples.pop_front();
59            } else {
60                break;
61            }
62        }
63
64        self.update_throughput();
65        self.last_update = now;
66    }
67
68    /// Record case duration.
69    pub fn record_duration(&mut self, duration_ms: f32) {
70        self.duration_samples.push_back(duration_ms);
71        if self.duration_samples.len() > self.window_size {
72            self.duration_samples.pop_front();
73        }
74        self.update_duration();
75    }
76
77    /// Record fitness score.
78    pub fn record_fitness(&mut self, fitness: f32) {
79        self.fitness_samples.push_back(fitness);
80        if self.fitness_samples.len() > self.window_size {
81            self.fitness_samples.pop_front();
82        }
83        self.update_fitness();
84    }
85
86    /// Update all KPIs.
87    pub fn update(&mut self, events: u64, duration_ms: f32, fitness: f32) {
88        self.record_events(events);
89        self.record_duration(duration_ms);
90        self.record_fitness(fitness);
91    }
92
93    fn update_throughput(&mut self) {
94        if self.throughput_samples.len() < 2 {
95            return;
96        }
97
98        let (first_time, _) = self.throughput_samples.front().unwrap();
99        let (last_time, _) = self.throughput_samples.back().unwrap();
100        let elapsed = last_time.duration_since(*first_time).as_secs_f64();
101
102        if elapsed > 0.0 {
103            let total_events: u64 = self.throughput_samples.iter().map(|(_, c)| c).sum();
104            self.current.events_per_second = total_events as f64 / elapsed;
105        }
106    }
107
108    fn update_duration(&mut self) {
109        if self.duration_samples.is_empty() {
110            return;
111        }
112
113        let sum: f32 = self.duration_samples.iter().sum();
114        self.current.avg_case_duration_ms = sum / self.duration_samples.len() as f32;
115
116        // Calculate min/max
117        self.current.min_duration_ms = self
118            .duration_samples
119            .iter()
120            .copied()
121            .fold(f32::MAX, f32::min);
122        self.current.max_duration_ms = self
123            .duration_samples
124            .iter()
125            .copied()
126            .fold(f32::MIN, f32::max);
127    }
128
129    fn update_fitness(&mut self) {
130        if self.fitness_samples.is_empty() {
131            return;
132        }
133
134        let sum: f32 = self.fitness_samples.iter().sum();
135        self.current.avg_fitness = sum / self.fitness_samples.len() as f32;
136    }
137
138    /// Set pattern count directly.
139    pub fn set_pattern_count(&mut self, count: u64) {
140        self.current.pattern_count = count;
141    }
142
143    /// Set cases completed.
144    pub fn set_cases_completed(&mut self, count: u64) {
145        self.current.cases_completed = count;
146    }
147
148    /// Set conformance rate.
149    pub fn set_conformance_rate(&mut self, rate: f32) {
150        self.current.conformance_rate = rate;
151    }
152
153    /// Get current KPIs.
154    pub fn kpis(&self) -> &ProcessKPIs {
155        &self.current
156    }
157
158    /// Reset tracker.
159    pub fn reset(&mut self) {
160        self.throughput_samples.clear();
161        self.duration_samples.clear();
162        self.fitness_samples.clear();
163        self.current = ProcessKPIs::default();
164    }
165}
166
167/// Process KPIs snapshot.
168#[derive(Debug, Clone, Default)]
169pub struct ProcessKPIs {
170    /// Events processed per second.
171    pub events_per_second: f64,
172    /// Average case duration in milliseconds.
173    pub avg_case_duration_ms: f32,
174    /// Minimum case duration.
175    pub min_duration_ms: f32,
176    /// Maximum case duration.
177    pub max_duration_ms: f32,
178    /// Average fitness score.
179    pub avg_fitness: f32,
180    /// Number of patterns detected.
181    pub pattern_count: u64,
182    /// Cases completed.
183    pub cases_completed: u64,
184    /// Conformance rate (% conformant).
185    pub conformance_rate: f32,
186}
187
188impl ProcessKPIs {
189    /// Format events per second for display.
190    pub fn format_throughput(&self) -> String {
191        if self.events_per_second >= 1_000_000.0 {
192            format!("{:.2}M/s", self.events_per_second / 1_000_000.0)
193        } else if self.events_per_second >= 1_000.0 {
194            format!("{:.2}K/s", self.events_per_second / 1_000.0)
195        } else {
196            format!("{:.0}/s", self.events_per_second)
197        }
198    }
199
200    /// Format duration for display.
201    pub fn format_duration(&self) -> String {
202        if self.avg_case_duration_ms >= 60000.0 {
203            format!("{:.1}m", self.avg_case_duration_ms / 60000.0)
204        } else if self.avg_case_duration_ms >= 1000.0 {
205            format!("{:.1}s", self.avg_case_duration_ms / 1000.0)
206        } else {
207            format!("{:.0}ms", self.avg_case_duration_ms)
208        }
209    }
210
211    /// Format fitness as percentage.
212    pub fn format_fitness(&self) -> String {
213        format!("{:.1}%", self.avg_fitness * 100.0)
214    }
215
216    /// Get health status based on KPIs.
217    pub fn health_status(&self) -> HealthStatus {
218        if self.avg_fitness >= 0.95 && self.conformance_rate >= 0.90 {
219            HealthStatus::Excellent
220        } else if self.avg_fitness >= 0.80 && self.conformance_rate >= 0.70 {
221            HealthStatus::Good
222        } else if self.avg_fitness >= 0.50 {
223            HealthStatus::Warning
224        } else {
225            HealthStatus::Critical
226        }
227    }
228}
229
230/// Process health status.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum HealthStatus {
233    /// Excellent health (fitness > 90%).
234    Excellent,
235    /// Good health (fitness > 75%).
236    Good,
237    /// Warning status (fitness > 50%).
238    Warning,
239    /// Critical status (fitness <= 50%).
240    Critical,
241}
242
243impl HealthStatus {
244    /// Get color for UI (RGB).
245    pub fn color(&self) -> [u8; 3] {
246        match self {
247            HealthStatus::Excellent => [40, 167, 69], // Green
248            HealthStatus::Good => [23, 162, 184],     // Cyan
249            HealthStatus::Warning => [255, 193, 7],   // Yellow
250            HealthStatus::Critical => [220, 53, 69],  // Red
251        }
252    }
253
254    /// Get name.
255    pub fn name(&self) -> &'static str {
256        match self {
257            HealthStatus::Excellent => "Excellent",
258            HealthStatus::Good => "Good",
259            HealthStatus::Warning => "Warning",
260            HealthStatus::Critical => "Critical",
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    #[test]
270    fn test_kpi_tracking() {
271        let mut tracker = KPITracker::new();
272
273        tracker.record_events(1000);
274        tracker.record_duration(5000.0);
275        tracker.record_fitness(0.95);
276
277        let kpis = tracker.kpis();
278        assert_eq!(kpis.avg_fitness, 0.95);
279        assert_eq!(kpis.avg_case_duration_ms, 5000.0);
280    }
281
282    #[test]
283    fn test_throughput_formatting() {
284        let mut kpis = ProcessKPIs {
285            events_per_second: 1_500_000.0,
286            ..Default::default()
287        };
288        assert!(kpis.format_throughput().contains("M/s"));
289
290        kpis.events_per_second = 42_000.0;
291        assert!(kpis.format_throughput().contains("K/s"));
292    }
293
294    #[test]
295    fn test_health_status() {
296        let mut kpis = ProcessKPIs {
297            avg_fitness: 0.98,
298            conformance_rate: 0.95,
299            ..Default::default()
300        };
301        assert_eq!(kpis.health_status(), HealthStatus::Excellent);
302
303        kpis.avg_fitness = 0.40;
304        assert_eq!(kpis.health_status(), HealthStatus::Critical);
305    }
306}