1use 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#[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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; }
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 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 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#[derive(Debug, Clone, Default)]
405pub struct DeviationSummary {
406 pub total: u64,
408 pub unexpected_activities: u64,
410 pub missing_activities: u64,
412 pub wrong_order: u64,
414 pub unexpected_repetitions: u64,
416}
417
418#[derive(Debug, Clone)]
420pub struct CommonDeviation {
421 pub pattern: String,
423 pub activity: String,
425 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 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 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(), 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 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(), 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 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); 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 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 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 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(), timestamp: 2000,
730 resource: None,
731 attributes: HashMap::new(),
732 });
733 }
734
735 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); }
744}