rustkernel_procint/
partial_order.rs

1//! Partial order analysis kernel.
2//!
3//! This module provides partial order analysis for event logs:
4//! - Concurrent activity detection
5//! - Sequential relationship extraction
6//! - Exclusive activity pair identification
7//! - Parallelism score calculation
8
9use crate::types::{EventLog, PartialOrderResult, Trace};
10use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
11use std::collections::{HashMap, HashSet};
12
13// ============================================================================
14// Partial Order Analysis Kernel
15// ============================================================================
16
17/// Partial order analysis kernel.
18///
19/// Analyzes event logs to detect concurrency, sequentiality, and exclusivity
20/// between activities based on their occurrence patterns across traces.
21#[derive(Debug, Clone)]
22pub struct PartialOrderAnalysis {
23    metadata: KernelMetadata,
24}
25
26impl Default for PartialOrderAnalysis {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl PartialOrderAnalysis {
33    /// Create a new partial order analysis kernel.
34    #[must_use]
35    pub fn new() -> Self {
36        Self {
37            metadata: KernelMetadata::batch("procint/partial-order", Domain::ProcessIntelligence)
38                .with_description("Partial order and concurrency analysis")
39                .with_throughput(50_000)
40                .with_latency_us(100.0),
41        }
42    }
43
44    /// Analyze partial orders in an event log.
45    pub fn analyze(log: &EventLog, config: &PartialOrderConfig) -> PartialOrderResult {
46        let activities: Vec<String> = log
47            .activities()
48            .into_iter()
49            .map(|s| s.to_string())
50            .collect();
51
52        if activities.is_empty() {
53            return PartialOrderResult {
54                concurrent_pairs: Vec::new(),
55                sequential_pairs: Vec::new(),
56                exclusive_pairs: Vec::new(),
57                parallelism_score: 0.0,
58            };
59        }
60
61        // Build co-occurrence and ordering matrices
62        let mut cooccurrence: HashMap<(String, String), u64> = HashMap::new();
63        let mut before_count: HashMap<(String, String), u64> = HashMap::new();
64        let mut after_count: HashMap<(String, String), u64> = HashMap::new();
65
66        for trace in log.traces.values() {
67            let mut sorted_events: Vec<_> = trace.events.iter().collect();
68            sorted_events.sort_by_key(|e| e.timestamp);
69
70            // Activities in this trace
71            let trace_activities: HashSet<_> =
72                sorted_events.iter().map(|e| e.activity.clone()).collect();
73
74            // Count co-occurrences
75            for a1 in &trace_activities {
76                for a2 in &trace_activities {
77                    if a1 != a2 {
78                        let key = (a1.clone(), a2.clone());
79                        *cooccurrence.entry(key).or_insert(0) += 1;
80                    }
81                }
82            }
83
84            // Count ordering relationships
85            for i in 0..sorted_events.len() {
86                for j in (i + 1)..sorted_events.len() {
87                    let a1 = &sorted_events[i].activity;
88                    let a2 = &sorted_events[j].activity;
89
90                    if a1 != a2 {
91                        *before_count.entry((a1.clone(), a2.clone())).or_insert(0) += 1;
92                        *after_count.entry((a2.clone(), a1.clone())).or_insert(0) += 1;
93                    }
94                }
95            }
96        }
97
98        let trace_count = log.trace_count() as u64;
99        let mut concurrent_pairs = Vec::new();
100        let mut sequential_pairs = Vec::new();
101        let mut exclusive_pairs = Vec::new();
102
103        // Analyze each pair of activities
104        for i in 0..activities.len() {
105            for j in (i + 1)..activities.len() {
106                let a1 = &activities[i];
107                let a2 = &activities[j];
108
109                let co = cooccurrence
110                    .get(&(a1.clone(), a2.clone()))
111                    .copied()
112                    .unwrap_or(0);
113                let ab = before_count
114                    .get(&(a1.clone(), a2.clone()))
115                    .copied()
116                    .unwrap_or(0);
117                let ba = before_count
118                    .get(&(a2.clone(), a1.clone()))
119                    .copied()
120                    .unwrap_or(0);
121
122                // Check for exclusivity (never co-occur)
123                if co == 0 && trace_count > 0 {
124                    exclusive_pairs.push((a1.clone(), a2.clone()));
125                    continue;
126                }
127
128                // Check for sequentiality (one always before the other)
129                let seq_threshold = (config.sequence_threshold * co as f64) as u64;
130
131                if ab >= seq_threshold && ba == 0 {
132                    sequential_pairs.push((a1.clone(), a2.clone()));
133                } else if ba >= seq_threshold && ab == 0 {
134                    sequential_pairs.push((a2.clone(), a1.clone()));
135                } else if ab > 0 && ba > 0 {
136                    // Both orderings observed - potential concurrency
137                    let concurrent_ratio = (ab.min(ba) as f64) / (ab.max(ba) as f64);
138                    if concurrent_ratio >= config.concurrency_threshold {
139                        concurrent_pairs.push((a1.clone(), a2.clone()));
140                    }
141                }
142            }
143        }
144
145        // Calculate parallelism score
146        let total_pairs = activities.len() * (activities.len() - 1) / 2;
147        let parallelism_score = if total_pairs > 0 {
148            concurrent_pairs.len() as f64 / total_pairs as f64
149        } else {
150            0.0
151        };
152
153        PartialOrderResult {
154            concurrent_pairs,
155            sequential_pairs,
156            exclusive_pairs,
157            parallelism_score,
158        }
159    }
160
161    /// Analyze partial orders in a single trace.
162    pub fn analyze_trace(trace: &Trace) -> TracePartialOrder {
163        let mut sorted_events: Vec<_> = trace.events.iter().collect();
164        sorted_events.sort_by_key(|e| e.timestamp);
165
166        let mut ordering_graph: HashMap<String, HashSet<String>> = HashMap::new();
167        let mut concurrent_with: HashMap<String, HashSet<String>> = HashMap::new();
168
169        // Build ordering relationships
170        for i in 0..sorted_events.len() {
171            for j in (i + 1)..sorted_events.len() {
172                let a1 = &sorted_events[i].activity;
173                let a2 = &sorted_events[j].activity;
174
175                if a1 != a2 {
176                    // Check if timestamps are close enough to be concurrent
177                    let time_diff = sorted_events[j]
178                        .timestamp
179                        .saturating_sub(sorted_events[i].timestamp);
180
181                    if time_diff == 0 {
182                        // Same timestamp - concurrent
183                        concurrent_with
184                            .entry(a1.clone())
185                            .or_default()
186                            .insert(a2.clone());
187                        concurrent_with
188                            .entry(a2.clone())
189                            .or_default()
190                            .insert(a1.clone());
191                    } else {
192                        // Sequential
193                        ordering_graph
194                            .entry(a1.clone())
195                            .or_default()
196                            .insert(a2.clone());
197                    }
198                }
199            }
200        }
201
202        TracePartialOrder {
203            ordering_graph,
204            concurrent_with,
205        }
206    }
207
208    /// Detect loops in the process based on repeated activity patterns.
209    pub fn detect_loops(log: &EventLog) -> Vec<LoopPattern> {
210        let mut loop_patterns: HashMap<Vec<String>, u64> = HashMap::new();
211
212        for trace in log.traces.values() {
213            let mut sorted_events: Vec<_> = trace.events.iter().collect();
214            sorted_events.sort_by_key(|e| e.timestamp);
215
216            let activities: Vec<String> =
217                sorted_events.iter().map(|e| e.activity.clone()).collect();
218
219            // Find repeated subsequences
220            for window_size in 2..=activities.len().min(5) {
221                for start in 0..activities.len().saturating_sub(window_size * 2 - 1) {
222                    let pattern: Vec<String> = activities[start..start + window_size].to_vec();
223
224                    // Check if pattern repeats
225                    let next_start = start + window_size;
226                    if next_start + window_size <= activities.len() {
227                        let next_pattern: Vec<String> =
228                            activities[next_start..next_start + window_size].to_vec();
229                        if pattern == next_pattern {
230                            *loop_patterns.entry(pattern).or_insert(0) += 1;
231                        }
232                    }
233                }
234            }
235        }
236
237        loop_patterns
238            .into_iter()
239            .filter(|(_, count)| *count >= 2)
240            .map(|(activities, count)| LoopPattern {
241                activities,
242                occurrence_count: count,
243            })
244            .collect()
245    }
246
247    /// Calculate activity independence scores.
248    pub fn calculate_independence(log: &EventLog) -> HashMap<(String, String), f64> {
249        let activities: Vec<String> = log
250            .activities()
251            .into_iter()
252            .map(|s| s.to_string())
253            .collect();
254        let mut independence_scores: HashMap<(String, String), f64> = HashMap::new();
255
256        if activities.is_empty() || log.trace_count() == 0 {
257            return independence_scores;
258        }
259
260        // Count how often activities appear in the same trace
261        let mut cooccurrence_count: HashMap<(String, String), u64> = HashMap::new();
262        let mut activity_count: HashMap<String, u64> = HashMap::new();
263
264        for trace in log.traces.values() {
265            let trace_activities: HashSet<_> =
266                trace.events.iter().map(|e| e.activity.clone()).collect();
267
268            for activity in &trace_activities {
269                *activity_count.entry(activity.clone()).or_insert(0) += 1;
270            }
271
272            for a1 in &trace_activities {
273                for a2 in &trace_activities {
274                    if a1 < a2 {
275                        *cooccurrence_count
276                            .entry((a1.clone(), a2.clone()))
277                            .or_insert(0) += 1;
278                    }
279                }
280            }
281        }
282
283        let trace_count = log.trace_count() as f64;
284
285        // Calculate independence using PMI-like measure
286        for i in 0..activities.len() {
287            for j in (i + 1)..activities.len() {
288                let a1 = &activities[i];
289                let a2 = &activities[j];
290
291                let key = if a1 < a2 {
292                    (a1.clone(), a2.clone())
293                } else {
294                    (a2.clone(), a1.clone())
295                };
296
297                let p_a1 = activity_count.get(a1).copied().unwrap_or(0) as f64 / trace_count;
298                let p_a2 = activity_count.get(a2).copied().unwrap_or(0) as f64 / trace_count;
299                let p_joint =
300                    cooccurrence_count.get(&key).copied().unwrap_or(0) as f64 / trace_count;
301
302                // Independence score: how different from expected co-occurrence
303                let expected = p_a1 * p_a2;
304                let independence = if expected > 0.0 && p_joint > 0.0 {
305                    1.0 - (p_joint / expected).min(1.0)
306                } else if p_joint == 0.0 {
307                    1.0 // Never co-occur = independent
308                } else {
309                    0.0
310                };
311
312                independence_scores.insert((a1.clone(), a2.clone()), independence);
313            }
314        }
315
316        independence_scores
317    }
318}
319
320impl GpuKernel for PartialOrderAnalysis {
321    fn metadata(&self) -> &KernelMetadata {
322        &self.metadata
323    }
324}
325
326/// Configuration for partial order analysis.
327#[derive(Debug, Clone)]
328pub struct PartialOrderConfig {
329    /// Threshold for considering activities concurrent (0.0-1.0).
330    /// Higher values require more balanced bidirectional ordering.
331    pub concurrency_threshold: f64,
332    /// Threshold for considering activities sequential (0.0-1.0).
333    /// Proportion of traces where ordering must be consistent.
334    pub sequence_threshold: f64,
335}
336
337impl Default for PartialOrderConfig {
338    fn default() -> Self {
339        Self {
340            concurrency_threshold: 0.5,
341            sequence_threshold: 0.8,
342        }
343    }
344}
345
346/// Partial order information for a single trace.
347#[derive(Debug, Clone)]
348pub struct TracePartialOrder {
349    /// Ordering graph: activity -> activities that come after it.
350    pub ordering_graph: HashMap<String, HashSet<String>>,
351    /// Concurrent activities: activity -> activities concurrent with it.
352    pub concurrent_with: HashMap<String, HashSet<String>>,
353}
354
355/// A detected loop pattern.
356#[derive(Debug, Clone)]
357pub struct LoopPattern {
358    /// Activities in the loop.
359    pub activities: Vec<String>,
360    /// Number of occurrences.
361    pub occurrence_count: u64,
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::types::ProcessEvent;
368
369    fn create_test_log() -> EventLog {
370        let mut log = EventLog::new("test_log".to_string());
371
372        // Trace 1: A -> B -> C -> D
373        for (i, activity) in ["A", "B", "C", "D"].iter().enumerate() {
374            log.add_event(ProcessEvent {
375                id: i as u64,
376                case_id: "case1".to_string(),
377                activity: activity.to_string(),
378                timestamp: (i as u64 + 1) * 1000,
379                resource: None,
380                attributes: HashMap::new(),
381            });
382        }
383
384        // Trace 2: A -> B -> C -> D (same order)
385        for (i, activity) in ["A", "B", "C", "D"].iter().enumerate() {
386            log.add_event(ProcessEvent {
387                id: (i + 10) as u64,
388                case_id: "case2".to_string(),
389                activity: activity.to_string(),
390                timestamp: (i as u64 + 1) * 1000,
391                resource: None,
392                attributes: HashMap::new(),
393            });
394        }
395
396        // Trace 3: A -> C -> B -> D (B and C swapped - concurrent)
397        for (i, activity) in ["A", "C", "B", "D"].iter().enumerate() {
398            log.add_event(ProcessEvent {
399                id: (i + 20) as u64,
400                case_id: "case3".to_string(),
401                activity: activity.to_string(),
402                timestamp: (i as u64 + 1) * 1000,
403                resource: None,
404                attributes: HashMap::new(),
405            });
406        }
407
408        log
409    }
410
411    fn create_exclusive_log() -> EventLog {
412        let mut log = EventLog::new("exclusive_log".to_string());
413
414        // Trace 1: A -> B -> C
415        for (i, activity) in ["A", "B", "C"].iter().enumerate() {
416            log.add_event(ProcessEvent {
417                id: i as u64,
418                case_id: "case1".to_string(),
419                activity: activity.to_string(),
420                timestamp: (i as u64 + 1) * 1000,
421                resource: None,
422                attributes: HashMap::new(),
423            });
424        }
425
426        // Trace 2: A -> D -> E (different path)
427        for (i, activity) in ["A", "D", "E"].iter().enumerate() {
428            log.add_event(ProcessEvent {
429                id: (i + 10) as u64,
430                case_id: "case2".to_string(),
431                activity: activity.to_string(),
432                timestamp: (i as u64 + 1) * 1000,
433                resource: None,
434                attributes: HashMap::new(),
435            });
436        }
437
438        log
439    }
440
441    #[test]
442    fn test_partial_order_metadata() {
443        let kernel = PartialOrderAnalysis::new();
444        assert_eq!(kernel.metadata().id, "procint/partial-order");
445        assert_eq!(kernel.metadata().domain, Domain::ProcessIntelligence);
446    }
447
448    #[test]
449    fn test_concurrent_detection() {
450        let log = create_test_log();
451        let config = PartialOrderConfig::default();
452        let result = PartialOrderAnalysis::analyze(&log, &config);
453
454        // B and C should be detected as concurrent (appear in both orders)
455        let bc_concurrent = result
456            .concurrent_pairs
457            .iter()
458            .any(|(a, b)| (a == "B" && b == "C") || (a == "C" && b == "B"));
459        assert!(bc_concurrent, "B and C should be concurrent");
460    }
461
462    #[test]
463    fn test_sequential_detection() {
464        let log = create_test_log();
465        let config = PartialOrderConfig::default();
466        let result = PartialOrderAnalysis::analyze(&log, &config);
467
468        // A should be before B, C, D in all traces
469        let a_before_d = result
470            .sequential_pairs
471            .iter()
472            .any(|(a, b)| a == "A" && b == "D");
473        assert!(a_before_d, "A should be sequential before D");
474    }
475
476    #[test]
477    fn test_exclusive_detection() {
478        let log = create_exclusive_log();
479        let config = PartialOrderConfig::default();
480        let result = PartialOrderAnalysis::analyze(&log, &config);
481
482        // B and D should be exclusive (never in same trace)
483        let bd_exclusive = result
484            .exclusive_pairs
485            .iter()
486            .any(|(a, b)| (a == "B" && b == "D") || (a == "D" && b == "B"));
487        assert!(bd_exclusive, "B and D should be exclusive");
488    }
489
490    #[test]
491    fn test_parallelism_score() {
492        let log = create_test_log();
493        let config = PartialOrderConfig::default();
494        let result = PartialOrderAnalysis::analyze(&log, &config);
495
496        // Should have some parallelism due to B/C concurrency
497        assert!(result.parallelism_score >= 0.0 && result.parallelism_score <= 1.0);
498    }
499
500    #[test]
501    fn test_empty_log() {
502        let log = EventLog::new("empty".to_string());
503        let config = PartialOrderConfig::default();
504        let result = PartialOrderAnalysis::analyze(&log, &config);
505
506        assert!(result.concurrent_pairs.is_empty());
507        assert!(result.sequential_pairs.is_empty());
508        assert!(result.exclusive_pairs.is_empty());
509        assert_eq!(result.parallelism_score, 0.0);
510    }
511
512    #[test]
513    fn test_trace_partial_order() {
514        let trace = crate::types::Trace {
515            case_id: "test".to_string(),
516            events: vec![
517                ProcessEvent {
518                    id: 1,
519                    case_id: "test".to_string(),
520                    activity: "A".to_string(),
521                    timestamp: 1000,
522                    resource: None,
523                    attributes: HashMap::new(),
524                },
525                ProcessEvent {
526                    id: 2,
527                    case_id: "test".to_string(),
528                    activity: "B".to_string(),
529                    timestamp: 2000,
530                    resource: None,
531                    attributes: HashMap::new(),
532                },
533                ProcessEvent {
534                    id: 3,
535                    case_id: "test".to_string(),
536                    activity: "C".to_string(),
537                    timestamp: 3000,
538                    resource: None,
539                    attributes: HashMap::new(),
540                },
541            ],
542            attributes: HashMap::new(),
543        };
544
545        let result = PartialOrderAnalysis::analyze_trace(&trace);
546
547        // A should come before B and C
548        assert!(
549            result
550                .ordering_graph
551                .get("A")
552                .map_or(false, |s| s.contains("B"))
553        );
554        assert!(
555            result
556                .ordering_graph
557                .get("A")
558                .map_or(false, |s| s.contains("C"))
559        );
560    }
561
562    #[test]
563    fn test_loop_detection() {
564        let mut log = EventLog::new("loop_log".to_string());
565
566        // Trace with loop: A -> B -> C -> B -> C -> D
567        for (i, activity) in ["A", "B", "C", "B", "C", "D"].iter().enumerate() {
568            log.add_event(ProcessEvent {
569                id: i as u64,
570                case_id: "case1".to_string(),
571                activity: activity.to_string(),
572                timestamp: (i as u64 + 1) * 1000,
573                resource: None,
574                attributes: HashMap::new(),
575            });
576        }
577
578        // Another trace with same loop
579        for (i, activity) in ["A", "B", "C", "B", "C", "D"].iter().enumerate() {
580            log.add_event(ProcessEvent {
581                id: (i + 10) as u64,
582                case_id: "case2".to_string(),
583                activity: activity.to_string(),
584                timestamp: (i as u64 + 1) * 1000,
585                resource: None,
586                attributes: HashMap::new(),
587            });
588        }
589
590        let loops = PartialOrderAnalysis::detect_loops(&log);
591
592        // Should detect B -> C loop
593        let bc_loop = loops.iter().any(|l| l.activities == vec!["B", "C"]);
594        assert!(bc_loop, "Should detect B -> C loop pattern");
595    }
596
597    #[test]
598    fn test_independence_calculation() {
599        let log = create_exclusive_log();
600        let independence = PartialOrderAnalysis::calculate_independence(&log);
601
602        // B and D never co-occur, should have high independence
603        let bd_key = ("B".to_string(), "D".to_string());
604        if let Some(&score) = independence.get(&bd_key) {
605            assert_eq!(score, 1.0, "B and D should be fully independent");
606        }
607    }
608
609    #[test]
610    fn test_config_thresholds() {
611        let log = create_test_log();
612
613        // Strict config - fewer concurrent pairs
614        let strict_config = PartialOrderConfig {
615            concurrency_threshold: 0.9,
616            sequence_threshold: 0.95,
617        };
618        let strict_result = PartialOrderAnalysis::analyze(&log, &strict_config);
619
620        // Loose config - more concurrent pairs
621        let loose_config = PartialOrderConfig {
622            concurrency_threshold: 0.3,
623            sequence_threshold: 0.5,
624        };
625        let loose_result = PartialOrderAnalysis::analyze(&log, &loose_config);
626
627        // Loose config should detect at least as many concurrent pairs
628        assert!(loose_result.concurrent_pairs.len() >= strict_result.concurrent_pairs.len());
629    }
630}