rustkernel_procint/
conformance.rs

1//! Conformance checking kernel.
2//!
3//! This module provides conformance checking between event logs and process models:
4//! - Token-based replay on DFG
5//! - Petri net replay
6//! - Fitness and precision calculation
7//! - Deviation detection and classification
8
9use crate::types::{
10    AlignmentStep, ConformanceResult, ConformanceStats, Deviation, DeviationType,
11    DirectlyFollowsGraph, EventLog, PetriNet, Trace,
12};
13use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
14use std::collections::HashMap;
15
16// ============================================================================
17// Conformance Checking Kernel
18// ============================================================================
19
20/// Conformance checking kernel.
21///
22/// Checks how well traces in an event log conform to a process model (DFG or Petri net).
23#[derive(Debug, Clone)]
24pub struct ConformanceChecking {
25    metadata: KernelMetadata,
26}
27
28impl Default for ConformanceChecking {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl ConformanceChecking {
35    /// Create a new conformance checking kernel.
36    #[must_use]
37    pub fn new() -> Self {
38        Self {
39            metadata: KernelMetadata::ring("procint/conformance", Domain::ProcessIntelligence)
40                .with_description("Multi-model conformance checking")
41                .with_throughput(50_000)
42                .with_latency_us(20.0),
43        }
44    }
45
46    /// Check conformance of a trace against a DFG.
47    pub fn check_dfg(trace: &Trace, dfg: &DirectlyFollowsGraph) -> ConformanceResult {
48        let mut deviations = Vec::new();
49        let mut alignment = Vec::new();
50        let mut sync_moves = 0u64;
51        let mut log_moves = 0u64;
52
53        let mut events: Vec<_> = trace.events.iter().collect();
54        events.sort_by_key(|e| e.timestamp);
55
56        if events.is_empty() {
57            return ConformanceResult {
58                case_id: trace.case_id.clone(),
59                is_conformant: true,
60                fitness: 1.0,
61                precision: 1.0,
62                deviations,
63                alignment: Some(alignment),
64            };
65        }
66
67        // Check start activity
68        let first_activity = &events[0].activity;
69        if !dfg.start_activities.contains_key(first_activity) {
70            deviations.push(Deviation {
71                event_index: 0,
72                activity: first_activity.clone(),
73                deviation_type: DeviationType::UnexpectedActivity,
74                description: format!("'{}' is not a valid start activity", first_activity),
75            });
76            log_moves += 1;
77            alignment.push(AlignmentStep {
78                log_move: Some(first_activity.clone()),
79                model_move: None,
80                sync: false,
81                cost: 1,
82            });
83        } else {
84            sync_moves += 1;
85            alignment.push(AlignmentStep {
86                log_move: Some(first_activity.clone()),
87                model_move: Some(first_activity.clone()),
88                sync: true,
89                cost: 0,
90            });
91        }
92
93        // Check directly-follows relationships
94        for (i, window) in events.windows(2).enumerate() {
95            let source = &window[0].activity;
96            let target = &window[1].activity;
97
98            if dfg.edge(source, target).is_some() {
99                // Valid transition
100                sync_moves += 1;
101                alignment.push(AlignmentStep {
102                    log_move: Some(target.clone()),
103                    model_move: Some(target.clone()),
104                    sync: true,
105                    cost: 0,
106                });
107            } else {
108                // Invalid transition
109                deviations.push(Deviation {
110                    event_index: i + 1,
111                    activity: target.clone(),
112                    deviation_type: DeviationType::WrongOrder,
113                    description: format!("No edge from '{}' to '{}' in model", source, target),
114                });
115                log_moves += 1;
116                alignment.push(AlignmentStep {
117                    log_move: Some(target.clone()),
118                    model_move: None,
119                    sync: false,
120                    cost: 1,
121                });
122            }
123        }
124
125        // Check end activity
126        let last_activity = &events[events.len() - 1].activity;
127        if !dfg.end_activities.contains_key(last_activity) {
128            deviations.push(Deviation {
129                event_index: events.len() - 1,
130                activity: last_activity.clone(),
131                deviation_type: DeviationType::UnexpectedActivity,
132                description: format!("'{}' is not a valid end activity", last_activity),
133            });
134        }
135
136        let total_moves = sync_moves + log_moves;
137        let fitness = if total_moves > 0 {
138            sync_moves as f64 / total_moves as f64
139        } else {
140            1.0
141        };
142
143        // Calculate precision based on how many valid options exist
144        let precision = Self::calculate_dfg_precision(&events, dfg);
145
146        ConformanceResult {
147            case_id: trace.case_id.clone(),
148            is_conformant: deviations.is_empty(),
149            fitness,
150            precision,
151            deviations,
152            alignment: Some(alignment),
153        }
154    }
155
156    /// Check conformance of a trace against a Petri net.
157    pub fn check_petri_net(trace: &Trace, net: &PetriNet) -> ConformanceResult {
158        let mut deviations = Vec::new();
159        let mut alignment = Vec::new();
160        let mut marking = net.initial_marking.clone();
161
162        let mut events: Vec<_> = trace.events.iter().collect();
163        events.sort_by_key(|e| e.timestamp);
164
165        let mut sync_moves = 0u64;
166        let mut log_moves = 0u64;
167        let mut model_moves = 0u64;
168
169        for (i, event) in events.iter().enumerate() {
170            // Find transition for this activity
171            let transition = net
172                .transitions
173                .iter()
174                .find(|t| t.label.as_ref() == Some(&event.activity));
175
176            match transition {
177                Some(t) => {
178                    // Check if transition is enabled
179                    let enabled = net
180                        .arcs
181                        .iter()
182                        .filter(|a| a.target == t.id)
183                        .all(|a| marking.get(&a.source).copied().unwrap_or(0) >= a.weight);
184
185                    if enabled {
186                        // Fire the transition
187                        for arc in net.arcs.iter().filter(|a| a.target == t.id) {
188                            if let Some(tokens) = marking.get_mut(&arc.source) {
189                                *tokens = tokens.saturating_sub(arc.weight);
190                            }
191                        }
192                        for arc in net.arcs.iter().filter(|a| a.source == t.id) {
193                            *marking.entry(arc.target.clone()).or_insert(0) += arc.weight;
194                        }
195
196                        sync_moves += 1;
197                        alignment.push(AlignmentStep {
198                            log_move: Some(event.activity.clone()),
199                            model_move: Some(t.id.clone()),
200                            sync: true,
201                            cost: 0,
202                        });
203                    } else {
204                        // Transition not enabled - deviation
205                        deviations.push(Deviation {
206                            event_index: i,
207                            activity: event.activity.clone(),
208                            deviation_type: DeviationType::WrongOrder,
209                            description: format!("Transition for '{}' not enabled", event.activity),
210                        });
211                        log_moves += 1;
212                        alignment.push(AlignmentStep {
213                            log_move: Some(event.activity.clone()),
214                            model_move: None,
215                            sync: false,
216                            cost: 1,
217                        });
218                    }
219                }
220                None => {
221                    // No transition for this activity
222                    deviations.push(Deviation {
223                        event_index: i,
224                        activity: event.activity.clone(),
225                        deviation_type: DeviationType::UnexpectedActivity,
226                        description: format!("No transition for activity '{}'", event.activity),
227                    });
228                    log_moves += 1;
229                    alignment.push(AlignmentStep {
230                        log_move: Some(event.activity.clone()),
231                        model_move: None,
232                        sync: false,
233                        cost: 1,
234                    });
235                }
236            }
237        }
238
239        // Check if we reached final marking
240        let reached_final = net
241            .final_marking
242            .iter()
243            .all(|(place, &tokens)| marking.get(place).copied().unwrap_or(0) >= tokens);
244
245        if !reached_final && !net.final_marking.is_empty() {
246            // Need model moves to reach final marking
247            model_moves += 1;
248        }
249
250        let total_moves = sync_moves + log_moves + model_moves;
251        let fitness = if total_moves > 0 {
252            sync_moves as f64 / total_moves as f64
253        } else {
254            1.0
255        };
256
257        let precision = if sync_moves + log_moves > 0 {
258            sync_moves as f64 / (sync_moves + log_moves) as f64
259        } else {
260            1.0
261        };
262
263        ConformanceResult {
264            case_id: trace.case_id.clone(),
265            is_conformant: deviations.is_empty() && reached_final,
266            fitness,
267            precision,
268            deviations,
269            alignment: Some(alignment),
270        }
271    }
272
273    /// Calculate conformance statistics for an entire log.
274    pub fn check_log_dfg(log: &EventLog, dfg: &DirectlyFollowsGraph) -> ConformanceStats {
275        let mut total_fitness = 0.0;
276        let mut total_precision = 0.0;
277        let mut conformant_count = 0u64;
278        let mut deviation_counts: HashMap<DeviationType, u64> = HashMap::new();
279
280        for trace in log.traces.values() {
281            let result = Self::check_dfg(trace, dfg);
282
283            total_fitness += result.fitness;
284            total_precision += result.precision;
285
286            if result.is_conformant {
287                conformant_count += 1;
288            }
289
290            for deviation in result.deviations {
291                *deviation_counts
292                    .entry(deviation.deviation_type)
293                    .or_insert(0) += 1;
294            }
295        }
296
297        let trace_count = log.trace_count() as u64;
298        let avg_fitness = if trace_count > 0 {
299            total_fitness / trace_count as f64
300        } else {
301            0.0
302        };
303        let avg_precision = if trace_count > 0 {
304            total_precision / trace_count as f64
305        } else {
306            0.0
307        };
308
309        ConformanceStats {
310            trace_count,
311            conformant_count,
312            avg_fitness,
313            avg_precision,
314            deviation_counts,
315        }
316    }
317
318    /// Calculate DFG precision for a trace.
319    fn calculate_dfg_precision(
320        events: &[&crate::types::ProcessEvent],
321        dfg: &DirectlyFollowsGraph,
322    ) -> f64 {
323        if events.len() < 2 {
324            return 1.0;
325        }
326
327        let mut total_options = 0u64;
328        let mut used_options = 0u64;
329
330        for event in events {
331            let activity = &event.activity;
332            let outgoing = dfg.outgoing(activity);
333            if !outgoing.is_empty() {
334                total_options += outgoing.len() as u64;
335                used_options += 1; // Only one option used
336            }
337        }
338
339        if total_options > 0 {
340            used_options as f64 / total_options as f64
341        } else {
342            1.0
343        }
344    }
345
346    /// Detect specific deviation patterns.
347    pub fn classify_deviations(result: &ConformanceResult) -> DeviationSummary {
348        let mut summary = DeviationSummary::default();
349
350        for deviation in &result.deviations {
351            match deviation.deviation_type {
352                DeviationType::UnexpectedActivity => summary.unexpected_activities += 1,
353                DeviationType::MissingActivity => summary.missing_activities += 1,
354                DeviationType::WrongOrder => summary.wrong_order += 1,
355                DeviationType::UnexpectedRepetition => summary.unexpected_repetitions += 1,
356            }
357        }
358
359        summary.total = result.deviations.len() as u64;
360        summary
361    }
362
363    /// Find the most common deviation patterns across a log.
364    pub fn find_common_deviations(
365        log: &EventLog,
366        dfg: &DirectlyFollowsGraph,
367        top_n: usize,
368    ) -> Vec<CommonDeviation> {
369        let mut deviation_patterns: HashMap<String, u64> = HashMap::new();
370
371        for trace in log.traces.values() {
372            let result = Self::check_dfg(trace, dfg);
373            for deviation in result.deviations {
374                let pattern = format!("{:?}:{}", deviation.deviation_type, deviation.activity);
375                *deviation_patterns.entry(pattern).or_insert(0) += 1;
376            }
377        }
378
379        let mut patterns: Vec<_> = deviation_patterns.into_iter().collect();
380        patterns.sort_by(|a, b| b.1.cmp(&a.1));
381
382        patterns
383            .into_iter()
384            .take(top_n)
385            .map(|(pattern, count)| {
386                let activity = pattern.split(':').nth(1).unwrap_or("").to_string();
387                CommonDeviation {
388                    pattern,
389                    activity,
390                    count,
391                }
392            })
393            .collect()
394    }
395}
396
397impl GpuKernel for ConformanceChecking {
398    fn metadata(&self) -> &KernelMetadata {
399        &self.metadata
400    }
401}
402
403/// Summary of deviation types.
404#[derive(Debug, Clone, Default)]
405pub struct DeviationSummary {
406    /// Total deviations.
407    pub total: u64,
408    /// Unexpected activity count.
409    pub unexpected_activities: u64,
410    /// Missing activity count.
411    pub missing_activities: u64,
412    /// Wrong order count.
413    pub wrong_order: u64,
414    /// Unexpected repetition count.
415    pub unexpected_repetitions: u64,
416}
417
418/// A common deviation pattern.
419#[derive(Debug, Clone)]
420pub struct CommonDeviation {
421    /// Pattern description.
422    pub pattern: String,
423    /// Activity involved.
424    pub activity: String,
425    /// Occurrence count.
426    pub count: u64,
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use crate::dfg::DFGConstruction;
433    use crate::types::ProcessEvent;
434
435    fn create_test_log() -> EventLog {
436        let mut log = EventLog::new("test_log".to_string());
437
438        // Trace 1: A -> B -> C -> D
439        for (i, activity) in ["A", "B", "C", "D"].iter().enumerate() {
440            log.add_event(ProcessEvent {
441                id: i as u64,
442                case_id: "case1".to_string(),
443                activity: activity.to_string(),
444                timestamp: (i as u64 + 1) * 1000,
445                resource: None,
446                attributes: HashMap::new(),
447            });
448        }
449
450        // Trace 2: A -> B -> C -> D
451        for (i, activity) in ["A", "B", "C", "D"].iter().enumerate() {
452            log.add_event(ProcessEvent {
453                id: (i + 10) as u64,
454                case_id: "case2".to_string(),
455                activity: activity.to_string(),
456                timestamp: (i as u64 + 1) * 1000,
457                resource: None,
458                attributes: HashMap::new(),
459            });
460        }
461
462        log
463    }
464
465    fn create_conformant_trace() -> Trace {
466        Trace {
467            case_id: "conformant".to_string(),
468            events: vec![
469                ProcessEvent {
470                    id: 1,
471                    case_id: "conformant".to_string(),
472                    activity: "A".to_string(),
473                    timestamp: 1000,
474                    resource: None,
475                    attributes: HashMap::new(),
476                },
477                ProcessEvent {
478                    id: 2,
479                    case_id: "conformant".to_string(),
480                    activity: "B".to_string(),
481                    timestamp: 2000,
482                    resource: None,
483                    attributes: HashMap::new(),
484                },
485                ProcessEvent {
486                    id: 3,
487                    case_id: "conformant".to_string(),
488                    activity: "C".to_string(),
489                    timestamp: 3000,
490                    resource: None,
491                    attributes: HashMap::new(),
492                },
493                ProcessEvent {
494                    id: 4,
495                    case_id: "conformant".to_string(),
496                    activity: "D".to_string(),
497                    timestamp: 4000,
498                    resource: None,
499                    attributes: HashMap::new(),
500                },
501            ],
502            attributes: HashMap::new(),
503        }
504    }
505
506    fn create_non_conformant_trace() -> Trace {
507        Trace {
508            case_id: "non_conformant".to_string(),
509            events: vec![
510                ProcessEvent {
511                    id: 1,
512                    case_id: "non_conformant".to_string(),
513                    activity: "A".to_string(),
514                    timestamp: 1000,
515                    resource: None,
516                    attributes: HashMap::new(),
517                },
518                ProcessEvent {
519                    id: 2,
520                    case_id: "non_conformant".to_string(),
521                    activity: "D".to_string(), // Skips B and C
522                    timestamp: 2000,
523                    resource: None,
524                    attributes: HashMap::new(),
525                },
526            ],
527            attributes: HashMap::new(),
528        }
529    }
530
531    #[test]
532    fn test_conformance_metadata() {
533        let kernel = ConformanceChecking::new();
534        assert_eq!(kernel.metadata().id, "procint/conformance");
535        assert_eq!(kernel.metadata().domain, Domain::ProcessIntelligence);
536    }
537
538    #[test]
539    fn test_conformant_trace_dfg() {
540        let log = create_test_log();
541        let dfg_result = DFGConstruction::compute(&log);
542        let trace = create_conformant_trace();
543
544        let result = ConformanceChecking::check_dfg(&trace, &dfg_result.dfg);
545
546        assert!(result.is_conformant);
547        assert_eq!(result.fitness, 1.0);
548        assert!(result.deviations.is_empty());
549    }
550
551    #[test]
552    fn test_non_conformant_trace_dfg() {
553        let log = create_test_log();
554        let dfg_result = DFGConstruction::compute(&log);
555        let trace = create_non_conformant_trace();
556
557        let result = ConformanceChecking::check_dfg(&trace, &dfg_result.dfg);
558
559        assert!(!result.is_conformant);
560        assert!(result.fitness < 1.0);
561        assert!(!result.deviations.is_empty());
562    }
563
564    #[test]
565    fn test_fitness_calculation() {
566        let log = create_test_log();
567        let dfg_result = DFGConstruction::compute(&log);
568
569        // Trace with one deviation
570        let trace = Trace {
571            case_id: "partial".to_string(),
572            events: vec![
573                ProcessEvent {
574                    id: 1,
575                    case_id: "partial".to_string(),
576                    activity: "A".to_string(),
577                    timestamp: 1000,
578                    resource: None,
579                    attributes: HashMap::new(),
580                },
581                ProcessEvent {
582                    id: 2,
583                    case_id: "partial".to_string(),
584                    activity: "B".to_string(),
585                    timestamp: 2000,
586                    resource: None,
587                    attributes: HashMap::new(),
588                },
589                ProcessEvent {
590                    id: 3,
591                    case_id: "partial".to_string(),
592                    activity: "X".to_string(), // Unknown activity
593                    timestamp: 3000,
594                    resource: None,
595                    attributes: HashMap::new(),
596                },
597            ],
598            attributes: HashMap::new(),
599        };
600
601        let result = ConformanceChecking::check_dfg(&trace, &dfg_result.dfg);
602
603        // Fitness should be between 0 and 1
604        assert!(result.fitness > 0.0 && result.fitness < 1.0);
605    }
606
607    #[test]
608    fn test_log_conformance_stats() {
609        let log = create_test_log();
610        let dfg_result = DFGConstruction::compute(&log);
611
612        let stats = ConformanceChecking::check_log_dfg(&log, &dfg_result.dfg);
613
614        assert_eq!(stats.trace_count, 2);
615        assert_eq!(stats.conformant_count, 2);
616        assert_eq!(stats.avg_fitness, 1.0);
617    }
618
619    #[test]
620    fn test_alignment_steps() {
621        let log = create_test_log();
622        let dfg_result = DFGConstruction::compute(&log);
623        let trace = create_conformant_trace();
624
625        let result = ConformanceChecking::check_dfg(&trace, &dfg_result.dfg);
626
627        let alignment = result.alignment.unwrap();
628        assert_eq!(alignment.len(), 4); // A, B, C, D
629        assert!(alignment.iter().all(|s| s.sync));
630    }
631
632    #[test]
633    fn test_deviation_classification() {
634        let log = create_test_log();
635        let dfg_result = DFGConstruction::compute(&log);
636        let trace = create_non_conformant_trace();
637
638        let result = ConformanceChecking::check_dfg(&trace, &dfg_result.dfg);
639        let summary = ConformanceChecking::classify_deviations(&result);
640
641        assert!(summary.total > 0);
642    }
643
644    #[test]
645    fn test_petri_net_conformance() {
646        let mut net = PetriNet::new("test_net".to_string());
647
648        // Simple sequence: p1 -> t1(A) -> p2 -> t2(B) -> p3
649        net.add_place("p1".to_string(), "Start".to_string());
650        net.add_place("p2".to_string(), "Middle".to_string());
651        net.add_place("p3".to_string(), "End".to_string());
652
653        net.add_transition("t1".to_string(), Some("A".to_string()));
654        net.add_transition("t2".to_string(), Some("B".to_string()));
655
656        net.add_arc("p1".to_string(), "t1".to_string(), 1);
657        net.add_arc("t1".to_string(), "p2".to_string(), 1);
658        net.add_arc("p2".to_string(), "t2".to_string(), 1);
659        net.add_arc("t2".to_string(), "p3".to_string(), 1);
660
661        net.initial_marking.insert("p1".to_string(), 1);
662        net.final_marking.insert("p3".to_string(), 1);
663
664        // Conformant trace
665        let trace = Trace {
666            case_id: "pn_test".to_string(),
667            events: vec![
668                ProcessEvent {
669                    id: 1,
670                    case_id: "pn_test".to_string(),
671                    activity: "A".to_string(),
672                    timestamp: 1000,
673                    resource: None,
674                    attributes: HashMap::new(),
675                },
676                ProcessEvent {
677                    id: 2,
678                    case_id: "pn_test".to_string(),
679                    activity: "B".to_string(),
680                    timestamp: 2000,
681                    resource: None,
682                    attributes: HashMap::new(),
683                },
684            ],
685            attributes: HashMap::new(),
686        };
687
688        let result = ConformanceChecking::check_petri_net(&trace, &net);
689
690        assert!(result.is_conformant);
691        assert_eq!(result.fitness, 1.0);
692    }
693
694    #[test]
695    fn test_empty_trace() {
696        let log = create_test_log();
697        let dfg_result = DFGConstruction::compute(&log);
698
699        let trace = Trace {
700            case_id: "empty".to_string(),
701            events: Vec::new(),
702            attributes: HashMap::new(),
703        };
704
705        let result = ConformanceChecking::check_dfg(&trace, &dfg_result.dfg);
706
707        assert!(result.is_conformant);
708        assert_eq!(result.fitness, 1.0);
709    }
710
711    #[test]
712    fn test_common_deviations() {
713        let mut log = EventLog::new("deviation_log".to_string());
714
715        // Multiple traces with same deviation pattern
716        for case_id in ["case1", "case2", "case3"] {
717            log.add_event(ProcessEvent {
718                id: 1,
719                case_id: case_id.to_string(),
720                activity: "A".to_string(),
721                timestamp: 1000,
722                resource: None,
723                attributes: HashMap::new(),
724            });
725            log.add_event(ProcessEvent {
726                id: 2,
727                case_id: case_id.to_string(),
728                activity: "X".to_string(), // Unknown
729                timestamp: 2000,
730                resource: None,
731                attributes: HashMap::new(),
732            });
733        }
734
735        // Build DFG from different log
736        let model_log = create_test_log();
737        let dfg_result = DFGConstruction::compute(&model_log);
738
739        let common = ConformanceChecking::find_common_deviations(&log, &dfg_result.dfg, 5);
740
741        assert!(!common.is_empty());
742        assert!(common[0].count >= 3); // X appears in all 3 traces
743    }
744}