1use crate::types::{EventLog, ProcessEvent, Trace};
10use rustkernel_core::traits::GpuKernel;
11use rustkernel_core::{domain::Domain, kernel::KernelMetadata};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::time::Instant;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
22pub enum IssueType {
23 MissingEvent,
25 DuplicateEvent,
27 OutOfOrderTimestamp,
29 MissingAttribute,
31 IncompleteTrace,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct LogIssue {
38 pub issue_type: IssueType,
40 pub case_id: String,
42 pub position: Option<usize>,
44 pub event_id: Option<u64>,
46 pub description: String,
48 pub confidence: f64,
50 pub suggested_repair: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct LogRepair {
57 pub repair_type: RepairType,
59 pub case_id: String,
61 pub position: usize,
63 pub description: String,
65 pub confidence: f64,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
71pub enum RepairType {
72 InsertEvent,
74 RemoveDuplicate,
76 CorrectTimestamp,
78 AddAttribute,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ImputationConfig {
85 pub detect_missing: bool,
87 pub detect_duplicates: bool,
89 pub repair_timestamps: bool,
91 pub detect_incomplete: bool,
93 pub min_confidence: f64,
95 pub duplicate_time_threshold: u64,
97 pub min_transition_support: f64,
99}
100
101impl Default for ImputationConfig {
102 fn default() -> Self {
103 Self {
104 detect_missing: true,
105 detect_duplicates: true,
106 repair_timestamps: true,
107 detect_incomplete: true,
108 min_confidence: 0.5,
109 duplicate_time_threshold: 60, min_transition_support: 0.1, }
112 }
113}
114
115#[derive(Debug, Clone, Default, Serialize, Deserialize)]
117pub struct ImputationStats {
118 pub traces_analyzed: usize,
120 pub events_analyzed: usize,
122 pub issues_by_type: HashMap<IssueType, usize>,
124 pub repairs_by_type: HashMap<RepairType, usize>,
126 pub quality_score_before: f64,
128 pub quality_score_after: f64,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct ImputationResult {
135 pub repaired_traces: Vec<RepairedTrace>,
137 pub issues: Vec<LogIssue>,
139 pub repairs: Vec<LogRepair>,
141 pub stats: ImputationStats,
143 pub compute_time_us: u64,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct RepairedTrace {
150 pub case_id: String,
152 pub events: Vec<RepairedEvent>,
154 pub repair_count: usize,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct RepairedEvent {
161 pub original_id: Option<u64>,
163 pub activity: String,
165 pub timestamp: u64,
167 pub is_imputed: bool,
169 pub timestamp_corrected: bool,
171}
172
173#[derive(Debug, Clone, Default)]
175pub struct TransitionModel {
176 pub transitions: HashMap<String, HashMap<String, u64>>,
178 pub start_activities: HashMap<String, u64>,
180 pub end_activities: HashMap<String, u64>,
182 pub activity_counts: HashMap<String, u64>,
184 pub trace_count: u64,
186 pub avg_durations: HashMap<(String, String), f64>,
188}
189
190impl TransitionModel {
191 pub fn from_log(log: &EventLog) -> Self {
193 let mut model = Self::default();
194
195 for trace in log.traces.values() {
196 if trace.events.is_empty() {
197 continue;
198 }
199
200 model.trace_count += 1;
201
202 let events: Vec<_> = trace.events.iter().collect();
203
204 if let Some(first) = events.first() {
206 *model
207 .start_activities
208 .entry(first.activity.clone())
209 .or_default() += 1;
210 }
211 if let Some(last) = events.last() {
212 *model
213 .end_activities
214 .entry(last.activity.clone())
215 .or_default() += 1;
216 }
217
218 for event in &events {
220 *model
221 .activity_counts
222 .entry(event.activity.clone())
223 .or_default() += 1;
224 }
225
226 for window in events.windows(2) {
228 let from = window[0].activity.clone();
229 let to = window[1].activity.clone();
230 let duration = window[1].timestamp.saturating_sub(window[0].timestamp) as f64;
231
232 *model
233 .transitions
234 .entry(from.clone())
235 .or_default()
236 .entry(to.clone())
237 .or_default() += 1;
238
239 let key = (from, to);
241 model
242 .avg_durations
243 .entry(key)
244 .and_modify(|avg| *avg = (*avg + duration) / 2.0)
245 .or_insert(duration);
246 }
247 }
248
249 model
250 }
251
252 pub fn expected_next(&self, from: &str, min_support: f64) -> Vec<(String, f64)> {
254 let min_count = (self.trace_count as f64 * min_support) as u64;
255
256 if let Some(nexts) = self.transitions.get(from) {
257 let total: u64 = nexts.values().sum();
258 let mut results: Vec<_> = nexts
259 .iter()
260 .filter(|&(_, count)| *count >= min_count.max(1))
261 .map(|(act, count)| (act.clone(), *count as f64 / total as f64))
262 .collect();
263 results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
264 results
265 } else {
266 Vec::new()
267 }
268 }
269
270 pub fn is_expected_transition(&self, from: &str, to: &str, min_support: f64) -> bool {
272 let min_count = (self.trace_count as f64 * min_support) as u64;
273
274 self.transitions
275 .get(from)
276 .and_then(|nexts| nexts.get(to))
277 .map(|&count| count >= min_count.max(1))
278 .unwrap_or(false)
279 }
280
281 pub fn expected_starts(&self, min_support: f64) -> Vec<(String, f64)> {
283 let min_count = (self.trace_count as f64 * min_support) as u64;
284 let total: u64 = self.start_activities.values().sum();
285
286 let mut results: Vec<_> = self
287 .start_activities
288 .iter()
289 .filter(|&(_, count)| *count >= min_count.max(1))
290 .map(|(act, count)| (act.clone(), *count as f64 / total as f64))
291 .collect();
292 results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
293 results
294 }
295
296 pub fn expected_ends(&self, min_support: f64) -> Vec<(String, f64)> {
298 let min_count = (self.trace_count as f64 * min_support) as u64;
299 let total: u64 = self.end_activities.values().sum();
300
301 let mut results: Vec<_> = self
302 .end_activities
303 .iter()
304 .filter(|&(_, count)| *count >= min_count.max(1))
305 .map(|(act, count)| (act.clone(), *count as f64 / total as f64))
306 .collect();
307 results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
308 results
309 }
310}
311
312#[derive(Debug, Clone)]
317pub struct EventLogImputation {
318 metadata: KernelMetadata,
319}
320
321impl Default for EventLogImputation {
322 fn default() -> Self {
323 Self::new()
324 }
325}
326
327impl EventLogImputation {
328 #[must_use]
330 pub fn new() -> Self {
331 Self {
332 metadata: KernelMetadata::batch("procint/log-imputation", Domain::ProcessIntelligence)
333 .with_description("Event log quality detection and repair")
334 .with_throughput(50_000)
335 .with_latency_us(100.0),
336 }
337 }
338
339 pub fn compute(log: &EventLog, config: &ImputationConfig) -> ImputationResult {
341 let start = Instant::now();
342
343 let model = TransitionModel::from_log(log);
345
346 let mut issues = Vec::new();
347 let mut repairs = Vec::new();
348 let mut repaired_traces = Vec::new();
349 let mut stats = ImputationStats::default();
350
351 stats.traces_analyzed = log.traces.len();
352 stats.events_analyzed = log.event_count();
353
354 for trace in log.traces.values() {
355 let (trace_issues, trace_repairs, repaired_trace) =
356 Self::process_trace(trace, &model, config);
357
358 issues.extend(trace_issues);
359 repairs.extend(trace_repairs);
360 repaired_traces.push(repaired_trace);
361 }
362
363 for issue in &issues {
365 *stats.issues_by_type.entry(issue.issue_type).or_default() += 1;
366 }
367 for repair in &repairs {
368 *stats.repairs_by_type.entry(repair.repair_type).or_default() += 1;
369 }
370
371 let total_possible_issues = stats.traces_analyzed + stats.events_analyzed;
373 stats.quality_score_before = if total_possible_issues > 0 {
374 100.0 * (1.0 - issues.len() as f64 / total_possible_issues as f64)
375 } else {
376 100.0
377 };
378
379 let remaining_issues = issues
380 .iter()
381 .filter(|i| i.confidence >= config.min_confidence)
382 .count()
383 - repairs.len();
384 stats.quality_score_after = if total_possible_issues > 0 {
385 100.0 * (1.0 - remaining_issues as f64 / total_possible_issues as f64)
386 } else {
387 100.0
388 };
389
390 ImputationResult {
391 repaired_traces,
392 issues,
393 repairs,
394 stats,
395 compute_time_us: start.elapsed().as_micros() as u64,
396 }
397 }
398
399 fn process_trace(
401 trace: &Trace,
402 model: &TransitionModel,
403 config: &ImputationConfig,
404 ) -> (Vec<LogIssue>, Vec<LogRepair>, RepairedTrace) {
405 let mut issues = Vec::new();
406 let mut repairs = Vec::new();
407 let mut repaired_events: Vec<RepairedEvent> = Vec::new();
408
409 if trace.events.is_empty() {
410 return (
411 issues,
412 repairs,
413 RepairedTrace {
414 case_id: trace.case_id.clone(),
415 events: repaired_events,
416 repair_count: 0,
417 },
418 );
419 }
420
421 let mut events: Vec<_> = trace.events.iter().collect();
423 events.sort_by_key(|e| e.timestamp);
424
425 let mut timestamp_issues = Vec::new();
427 if config.repair_timestamps {
428 let original_order: Vec<u64> = trace.events.iter().map(|e| e.id).collect();
429 let sorted_order: Vec<u64> = events.iter().map(|e| e.id).collect();
430
431 if original_order != sorted_order {
432 timestamp_issues = Self::detect_timestamp_issues(trace, &events);
433 issues.extend(timestamp_issues.clone());
434 }
435 }
436
437 if config.detect_duplicates {
439 let dup_issues = Self::detect_duplicates(&events, &trace.case_id, config);
440 issues.extend(dup_issues);
441 }
442
443 if config.detect_missing {
445 let missing_issues =
446 Self::detect_missing_events(&events, &trace.case_id, model, config);
447 issues.extend(missing_issues);
448 }
449
450 if config.detect_incomplete {
452 let incomplete_issues =
453 Self::detect_incomplete_trace(&events, &trace.case_id, model, config);
454 issues.extend(incomplete_issues);
455 }
456
457 let reordered_ids: HashSet<u64> =
459 timestamp_issues.iter().filter_map(|i| i.event_id).collect();
460
461 let mut seen_activities: HashSet<(String, u64)> = HashSet::new();
463
464 for event in &events {
465 let is_dup = issues.iter().any(|i| {
467 i.issue_type == IssueType::DuplicateEvent
468 && i.event_id == Some(event.id)
469 && i.confidence >= config.min_confidence
470 });
471
472 if is_dup {
473 repairs.push(LogRepair {
474 repair_type: RepairType::RemoveDuplicate,
475 case_id: trace.case_id.clone(),
476 position: repaired_events.len(),
477 description: format!("Removed duplicate: {}", event.activity),
478 confidence: 0.8,
479 });
480 continue;
481 }
482
483 let timestamp_corrected = reordered_ids.contains(&event.id);
485 let corrected_timestamp = event.timestamp;
486
487 if timestamp_corrected {
488 repairs.push(LogRepair {
489 repair_type: RepairType::CorrectTimestamp,
490 case_id: trace.case_id.clone(),
491 position: repaired_events.len(),
492 description: format!(
493 "Reordered event '{}' to correct position based on timestamp {}",
494 event.activity, event.timestamp
495 ),
496 confidence: 0.7,
497 });
498 }
499
500 repaired_events.push(RepairedEvent {
501 original_id: Some(event.id),
502 activity: event.activity.clone(),
503 timestamp: corrected_timestamp,
504 is_imputed: false,
505 timestamp_corrected,
506 });
507
508 seen_activities.insert((event.activity.clone(), event.timestamp));
509 }
510
511 let repair_count = repairs.len();
512
513 (
514 issues,
515 repairs,
516 RepairedTrace {
517 case_id: trace.case_id.clone(),
518 events: repaired_events,
519 repair_count,
520 },
521 )
522 }
523
524 fn detect_timestamp_issues(trace: &Trace, sorted_events: &[&ProcessEvent]) -> Vec<LogIssue> {
526 let mut issues = Vec::new();
527 let original_ids: Vec<u64> = trace.events.iter().map(|e| e.id).collect();
528 let sorted_ids: Vec<u64> = sorted_events.iter().map(|e| e.id).collect();
529
530 for (i, (orig_id, sorted_id)) in original_ids.iter().zip(sorted_ids.iter()).enumerate() {
531 if orig_id != sorted_id {
532 let event = trace.events.iter().find(|e| e.id == *orig_id).unwrap();
533 issues.push(LogIssue {
534 issue_type: IssueType::OutOfOrderTimestamp,
535 case_id: trace.case_id.clone(),
536 position: Some(i),
537 event_id: Some(*orig_id),
538 description: format!(
539 "Event '{}' at position {} has out-of-order timestamp",
540 event.activity, i
541 ),
542 confidence: 0.9,
543 suggested_repair: Some("Reorder based on timestamp".to_string()),
544 });
545 }
546 }
547
548 issues
549 }
550
551 fn detect_duplicates(
553 events: &[&ProcessEvent],
554 case_id: &str,
555 config: &ImputationConfig,
556 ) -> Vec<LogIssue> {
557 let mut issues = Vec::new();
558 let mut seen: HashMap<String, Vec<(u64, u64)>> = HashMap::new(); for event in events {
561 let activity = &event.activity;
562
563 if let Some(prev_occurrences) = seen.get(activity) {
564 for &(_prev_id, prev_ts) in prev_occurrences {
565 let time_diff = event.timestamp.saturating_sub(prev_ts);
566 if time_diff <= config.duplicate_time_threshold {
567 issues.push(LogIssue {
568 issue_type: IssueType::DuplicateEvent,
569 case_id: case_id.to_string(),
570 position: None,
571 event_id: Some(event.id),
572 description: format!(
573 "Potential duplicate '{}' within {}s of previous occurrence",
574 activity, time_diff
575 ),
576 confidence: 0.7,
577 suggested_repair: Some("Remove duplicate".to_string()),
578 });
579 }
580 }
581 }
582
583 seen.entry(activity.clone())
584 .or_default()
585 .push((event.id, event.timestamp));
586 }
587
588 issues
589 }
590
591 fn detect_missing_events(
593 events: &[&ProcessEvent],
594 case_id: &str,
595 model: &TransitionModel,
596 config: &ImputationConfig,
597 ) -> Vec<LogIssue> {
598 let mut issues = Vec::new();
599
600 if events.len() < 2 {
601 return issues;
602 }
603
604 for window in events.windows(2) {
605 let from = &window[0].activity;
606 let to = &window[1].activity;
607
608 if !model.is_expected_transition(from, to, config.min_transition_support) {
610 let expected = model.expected_next(from, config.min_transition_support);
612
613 for (expected_act, prob) in expected {
615 if model.is_expected_transition(
616 &expected_act,
617 to,
618 config.min_transition_support,
619 ) {
620 issues.push(LogIssue {
621 issue_type: IssueType::MissingEvent,
622 case_id: case_id.to_string(),
623 position: Some(
624 events
625 .iter()
626 .position(|e| e.id == window[1].id)
627 .unwrap_or(0),
628 ),
629 event_id: None,
630 description: format!(
631 "Potential missing '{}' between '{}' and '{}'",
632 expected_act, from, to
633 ),
634 confidence: prob * 0.8,
635 suggested_repair: Some(format!("Insert '{}'", expected_act)),
636 });
637 }
638 }
639 }
640 }
641
642 issues
643 }
644
645 fn detect_incomplete_trace(
647 events: &[&ProcessEvent],
648 case_id: &str,
649 model: &TransitionModel,
650 config: &ImputationConfig,
651 ) -> Vec<LogIssue> {
652 let mut issues = Vec::new();
653
654 if events.is_empty() {
655 return issues;
656 }
657
658 let first_activity = &events.first().unwrap().activity;
660 let expected_starts = model.expected_starts(config.min_transition_support);
661
662 if !expected_starts.iter().any(|(a, _)| a == first_activity) && !expected_starts.is_empty()
663 {
664 let most_common_start = &expected_starts[0].0;
665 issues.push(LogIssue {
666 issue_type: IssueType::IncompleteTrace,
667 case_id: case_id.to_string(),
668 position: Some(0),
669 event_id: None,
670 description: format!(
671 "Trace starts with '{}' instead of expected start '{}'",
672 first_activity, most_common_start
673 ),
674 confidence: expected_starts[0].1 * 0.7,
675 suggested_repair: Some(format!("Consider adding '{}' at start", most_common_start)),
676 });
677 }
678
679 let last_activity = &events.last().unwrap().activity;
681 let expected_ends = model.expected_ends(config.min_transition_support);
682
683 if !expected_ends.iter().any(|(a, _)| a == last_activity) && !expected_ends.is_empty() {
684 let most_common_end = &expected_ends[0].0;
685 issues.push(LogIssue {
686 issue_type: IssueType::IncompleteTrace,
687 case_id: case_id.to_string(),
688 position: Some(events.len() - 1),
689 event_id: None,
690 description: format!(
691 "Trace ends with '{}' instead of expected end '{}'",
692 last_activity, most_common_end
693 ),
694 confidence: expected_ends[0].1 * 0.7,
695 suggested_repair: Some(format!("Consider adding '{}' at end", most_common_end)),
696 });
697 }
698
699 issues
700 }
701}
702
703impl GpuKernel for EventLogImputation {
704 fn metadata(&self) -> &KernelMetadata {
705 &self.metadata
706 }
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 fn create_clean_log() -> EventLog {
714 let mut log = EventLog::new("test".to_string());
715
716 for trace_num in 0..3 {
718 for (i, activity) in ["A", "B", "C", "D"].iter().enumerate() {
719 log.add_event(ProcessEvent {
720 id: (trace_num * 10 + i) as u64,
721 case_id: format!("trace{}", trace_num),
722 activity: activity.to_string(),
723 timestamp: (trace_num * 1000 + i * 100) as u64,
724 resource: None,
725 attributes: HashMap::new(),
726 });
727 }
728 }
729
730 log
731 }
732
733 fn create_log_with_issues() -> EventLog {
734 let mut log = EventLog::new("test".to_string());
735
736 for (i, activity) in ["A", "B", "C", "D"].iter().enumerate() {
738 log.add_event(ProcessEvent {
739 id: i as u64,
740 case_id: "trace0".to_string(),
741 activity: activity.to_string(),
742 timestamp: (i * 100) as u64,
743 resource: None,
744 attributes: HashMap::new(),
745 });
746 }
747
748 for (i, activity) in ["A", "B", "B", "C", "D"].iter().enumerate() {
750 log.add_event(ProcessEvent {
751 id: (10 + i) as u64,
752 case_id: "trace1".to_string(),
753 activity: activity.to_string(),
754 timestamp: (1000 + i * 10) as u64, resource: None,
756 attributes: HashMap::new(),
757 });
758 }
759
760 for (i, activity) in ["A", "B", "D"].iter().enumerate() {
762 log.add_event(ProcessEvent {
763 id: (20 + i) as u64,
764 case_id: "trace2".to_string(),
765 activity: activity.to_string(),
766 timestamp: (2000 + i * 100) as u64,
767 resource: None,
768 attributes: HashMap::new(),
769 });
770 }
771
772 log.add_event(ProcessEvent {
774 id: 30,
775 case_id: "trace3".to_string(),
776 activity: "A".to_string(),
777 timestamp: 3000,
778 resource: None,
779 attributes: HashMap::new(),
780 });
781 log.add_event(ProcessEvent {
782 id: 31,
783 case_id: "trace3".to_string(),
784 activity: "C".to_string(),
785 timestamp: 3200, resource: None,
787 attributes: HashMap::new(),
788 });
789 log.add_event(ProcessEvent {
790 id: 32,
791 case_id: "trace3".to_string(),
792 activity: "B".to_string(),
793 timestamp: 3100, resource: None,
795 attributes: HashMap::new(),
796 });
797 log.add_event(ProcessEvent {
798 id: 33,
799 case_id: "trace3".to_string(),
800 activity: "D".to_string(),
801 timestamp: 3300,
802 resource: None,
803 attributes: HashMap::new(),
804 });
805
806 log
807 }
808
809 #[test]
810 fn test_imputation_metadata() {
811 let kernel = EventLogImputation::new();
812 assert_eq!(kernel.metadata().id, "procint/log-imputation");
813 assert_eq!(kernel.metadata().domain, Domain::ProcessIntelligence);
814 }
815
816 #[test]
817 fn test_transition_model() {
818 let log = create_clean_log();
819 let model = TransitionModel::from_log(&log);
820
821 assert_eq!(model.trace_count, 3);
822 assert!(model.start_activities.contains_key("A"));
823 assert!(model.end_activities.contains_key("D"));
824 assert!(model.transitions.contains_key("A"));
825 }
826
827 #[test]
828 fn test_clean_log_no_issues() {
829 let log = create_clean_log();
830 let config = ImputationConfig::default();
831 let result = EventLogImputation::compute(&log, &config);
832
833 let high_conf_issues: Vec<_> = result
835 .issues
836 .iter()
837 .filter(|i| i.confidence >= 0.8)
838 .collect();
839 assert!(
840 high_conf_issues.is_empty(),
841 "Clean log should have no high-confidence issues: {:?}",
842 high_conf_issues
843 );
844 }
845
846 #[test]
847 fn test_duplicate_detection() {
848 let log = create_log_with_issues();
849 let config = ImputationConfig {
850 detect_duplicates: true,
851 duplicate_time_threshold: 30, ..Default::default()
853 };
854 let result = EventLogImputation::compute(&log, &config);
855
856 let dup_issues: Vec<_> = result
857 .issues
858 .iter()
859 .filter(|i| i.issue_type == IssueType::DuplicateEvent && i.case_id == "trace1")
860 .collect();
861
862 assert!(
863 !dup_issues.is_empty(),
864 "Should detect duplicate B in trace1"
865 );
866 }
867
868 #[test]
869 fn test_missing_event_detection() {
870 let log = create_log_with_issues();
871 let config = ImputationConfig {
872 detect_missing: true,
873 min_transition_support: 0.3,
874 ..Default::default()
875 };
876 let result = EventLogImputation::compute(&log, &config);
877
878 let missing_issues: Vec<_> = result
879 .issues
880 .iter()
881 .filter(|i| i.issue_type == IssueType::MissingEvent && i.case_id == "trace2")
882 .collect();
883
884 assert!(
888 result
889 .stats
890 .issues_by_type
891 .contains_key(&IssueType::MissingEvent)
892 || missing_issues.is_empty(), "Missing event detection should work or gracefully handle low support"
894 );
895 }
896
897 #[test]
898 fn test_timestamp_repair() {
899 let log = create_log_with_issues();
900 let config = ImputationConfig {
901 repair_timestamps: true,
902 ..Default::default()
903 };
904 let result = EventLogImputation::compute(&log, &config);
905
906 let ts_issues: Vec<_> = result
908 .issues
909 .iter()
910 .filter(|i| i.issue_type == IssueType::OutOfOrderTimestamp && i.case_id == "trace3")
911 .collect();
912
913 assert!(
914 !ts_issues.is_empty(),
915 "Should detect timestamp issues in trace3"
916 );
917
918 let ts_repairs: Vec<_> = result
920 .repairs
921 .iter()
922 .filter(|r| r.repair_type == RepairType::CorrectTimestamp && r.case_id == "trace3")
923 .collect();
924
925 assert!(
927 !ts_repairs.is_empty()
928 || result
929 .stats
930 .repairs_by_type
931 .contains_key(&RepairType::CorrectTimestamp),
932 "Should repair timestamp issues"
933 );
934 }
935
936 #[test]
937 fn test_expected_transitions() {
938 let log = create_clean_log();
939 let model = TransitionModel::from_log(&log);
940
941 assert!(model.is_expected_transition("A", "B", 0.1));
942 assert!(model.is_expected_transition("B", "C", 0.1));
943 assert!(model.is_expected_transition("C", "D", 0.1));
944 assert!(!model.is_expected_transition("A", "D", 0.1));
945 }
946
947 #[test]
948 fn test_expected_starts_ends() {
949 let log = create_clean_log();
950 let model = TransitionModel::from_log(&log);
951
952 let starts = model.expected_starts(0.1);
953 assert!(!starts.is_empty());
954 assert_eq!(starts[0].0, "A");
955
956 let ends = model.expected_ends(0.1);
957 assert!(!ends.is_empty());
958 assert_eq!(ends[0].0, "D");
959 }
960
961 #[test]
962 fn test_quality_scores() {
963 let log = create_log_with_issues();
964 let config = ImputationConfig::default();
965 let result = EventLogImputation::compute(&log, &config);
966
967 assert!(result.stats.quality_score_before <= 100.0);
968 assert!(result.stats.quality_score_after <= 100.0);
969 assert!(result.stats.quality_score_after >= result.stats.quality_score_before - 1.0);
971 }
972
973 #[test]
974 fn test_empty_log() {
975 let log = EventLog::new("empty".to_string());
976 let config = ImputationConfig::default();
977 let result = EventLogImputation::compute(&log, &config);
978
979 assert!(result.issues.is_empty());
980 assert!(result.repairs.is_empty());
981 assert_eq!(result.stats.traces_analyzed, 0);
982 }
983
984 #[test]
985 fn test_compute_time() {
986 let log = create_log_with_issues();
987 let config = ImputationConfig::default();
988 let result = EventLogImputation::compute(&log, &config);
989
990 assert!(result.compute_time_us < 1_000_000); }
992}