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 {
350 traces_analyzed: log.traces.len(),
351 events_analyzed: log.event_count(),
352 ..ImputationStats::default()
353 };
354
355 for trace in log.traces.values() {
356 let (trace_issues, trace_repairs, repaired_trace) =
357 Self::process_trace(trace, &model, config);
358
359 issues.extend(trace_issues);
360 repairs.extend(trace_repairs);
361 repaired_traces.push(repaired_trace);
362 }
363
364 for issue in &issues {
366 *stats.issues_by_type.entry(issue.issue_type).or_default() += 1;
367 }
368 for repair in &repairs {
369 *stats.repairs_by_type.entry(repair.repair_type).or_default() += 1;
370 }
371
372 let total_possible_issues = stats.traces_analyzed + stats.events_analyzed;
374 stats.quality_score_before = if total_possible_issues > 0 {
375 100.0 * (1.0 - issues.len() as f64 / total_possible_issues as f64)
376 } else {
377 100.0
378 };
379
380 let remaining_issues = issues
381 .iter()
382 .filter(|i| i.confidence >= config.min_confidence)
383 .count()
384 - repairs.len();
385 stats.quality_score_after = if total_possible_issues > 0 {
386 100.0 * (1.0 - remaining_issues as f64 / total_possible_issues as f64)
387 } else {
388 100.0
389 };
390
391 ImputationResult {
392 repaired_traces,
393 issues,
394 repairs,
395 stats,
396 compute_time_us: start.elapsed().as_micros() as u64,
397 }
398 }
399
400 fn process_trace(
402 trace: &Trace,
403 model: &TransitionModel,
404 config: &ImputationConfig,
405 ) -> (Vec<LogIssue>, Vec<LogRepair>, RepairedTrace) {
406 let mut issues = Vec::new();
407 let mut repairs = Vec::new();
408 let mut repaired_events: Vec<RepairedEvent> = Vec::new();
409
410 if trace.events.is_empty() {
411 return (
412 issues,
413 repairs,
414 RepairedTrace {
415 case_id: trace.case_id.clone(),
416 events: repaired_events,
417 repair_count: 0,
418 },
419 );
420 }
421
422 let mut events: Vec<_> = trace.events.iter().collect();
424 events.sort_by_key(|e| e.timestamp);
425
426 let mut timestamp_issues = Vec::new();
428 if config.repair_timestamps {
429 let original_order: Vec<u64> = trace.events.iter().map(|e| e.id).collect();
430 let sorted_order: Vec<u64> = events.iter().map(|e| e.id).collect();
431
432 if original_order != sorted_order {
433 timestamp_issues = Self::detect_timestamp_issues(trace, &events);
434 issues.extend(timestamp_issues.clone());
435 }
436 }
437
438 if config.detect_duplicates {
440 let dup_issues = Self::detect_duplicates(&events, &trace.case_id, config);
441 issues.extend(dup_issues);
442 }
443
444 if config.detect_missing {
446 let missing_issues =
447 Self::detect_missing_events(&events, &trace.case_id, model, config);
448 issues.extend(missing_issues);
449 }
450
451 if config.detect_incomplete {
453 let incomplete_issues =
454 Self::detect_incomplete_trace(&events, &trace.case_id, model, config);
455 issues.extend(incomplete_issues);
456 }
457
458 let reordered_ids: HashSet<u64> =
460 timestamp_issues.iter().filter_map(|i| i.event_id).collect();
461
462 let mut seen_activities: HashSet<(String, u64)> = HashSet::new();
464
465 for event in &events {
466 let is_dup = issues.iter().any(|i| {
468 i.issue_type == IssueType::DuplicateEvent
469 && i.event_id == Some(event.id)
470 && i.confidence >= config.min_confidence
471 });
472
473 if is_dup {
474 repairs.push(LogRepair {
475 repair_type: RepairType::RemoveDuplicate,
476 case_id: trace.case_id.clone(),
477 position: repaired_events.len(),
478 description: format!("Removed duplicate: {}", event.activity),
479 confidence: 0.8,
480 });
481 continue;
482 }
483
484 let timestamp_corrected = reordered_ids.contains(&event.id);
486 let corrected_timestamp = event.timestamp;
487
488 if timestamp_corrected {
489 repairs.push(LogRepair {
490 repair_type: RepairType::CorrectTimestamp,
491 case_id: trace.case_id.clone(),
492 position: repaired_events.len(),
493 description: format!(
494 "Reordered event '{}' to correct position based on timestamp {}",
495 event.activity, event.timestamp
496 ),
497 confidence: 0.7,
498 });
499 }
500
501 repaired_events.push(RepairedEvent {
502 original_id: Some(event.id),
503 activity: event.activity.clone(),
504 timestamp: corrected_timestamp,
505 is_imputed: false,
506 timestamp_corrected,
507 });
508
509 seen_activities.insert((event.activity.clone(), event.timestamp));
510 }
511
512 let repair_count = repairs.len();
513
514 (
515 issues,
516 repairs,
517 RepairedTrace {
518 case_id: trace.case_id.clone(),
519 events: repaired_events,
520 repair_count,
521 },
522 )
523 }
524
525 fn detect_timestamp_issues(trace: &Trace, sorted_events: &[&ProcessEvent]) -> Vec<LogIssue> {
527 let mut issues = Vec::new();
528 let original_ids: Vec<u64> = trace.events.iter().map(|e| e.id).collect();
529 let sorted_ids: Vec<u64> = sorted_events.iter().map(|e| e.id).collect();
530
531 for (i, (orig_id, sorted_id)) in original_ids.iter().zip(sorted_ids.iter()).enumerate() {
532 if orig_id != sorted_id {
533 let event = trace.events.iter().find(|e| e.id == *orig_id).unwrap();
534 issues.push(LogIssue {
535 issue_type: IssueType::OutOfOrderTimestamp,
536 case_id: trace.case_id.clone(),
537 position: Some(i),
538 event_id: Some(*orig_id),
539 description: format!(
540 "Event '{}' at position {} has out-of-order timestamp",
541 event.activity, i
542 ),
543 confidence: 0.9,
544 suggested_repair: Some("Reorder based on timestamp".to_string()),
545 });
546 }
547 }
548
549 issues
550 }
551
552 fn detect_duplicates(
554 events: &[&ProcessEvent],
555 case_id: &str,
556 config: &ImputationConfig,
557 ) -> Vec<LogIssue> {
558 let mut issues = Vec::new();
559 let mut seen: HashMap<String, Vec<(u64, u64)>> = HashMap::new(); for event in events {
562 let activity = &event.activity;
563
564 if let Some(prev_occurrences) = seen.get(activity) {
565 for &(_prev_id, prev_ts) in prev_occurrences {
566 let time_diff = event.timestamp.saturating_sub(prev_ts);
567 if time_diff <= config.duplicate_time_threshold {
568 issues.push(LogIssue {
569 issue_type: IssueType::DuplicateEvent,
570 case_id: case_id.to_string(),
571 position: None,
572 event_id: Some(event.id),
573 description: format!(
574 "Potential duplicate '{}' within {}s of previous occurrence",
575 activity, time_diff
576 ),
577 confidence: 0.7,
578 suggested_repair: Some("Remove duplicate".to_string()),
579 });
580 }
581 }
582 }
583
584 seen.entry(activity.clone())
585 .or_default()
586 .push((event.id, event.timestamp));
587 }
588
589 issues
590 }
591
592 fn detect_missing_events(
594 events: &[&ProcessEvent],
595 case_id: &str,
596 model: &TransitionModel,
597 config: &ImputationConfig,
598 ) -> Vec<LogIssue> {
599 let mut issues = Vec::new();
600
601 if events.len() < 2 {
602 return issues;
603 }
604
605 for window in events.windows(2) {
606 let from = &window[0].activity;
607 let to = &window[1].activity;
608
609 if !model.is_expected_transition(from, to, config.min_transition_support) {
611 let expected = model.expected_next(from, config.min_transition_support);
613
614 for (expected_act, prob) in expected {
616 if model.is_expected_transition(
617 &expected_act,
618 to,
619 config.min_transition_support,
620 ) {
621 issues.push(LogIssue {
622 issue_type: IssueType::MissingEvent,
623 case_id: case_id.to_string(),
624 position: Some(
625 events
626 .iter()
627 .position(|e| e.id == window[1].id)
628 .unwrap_or(0),
629 ),
630 event_id: None,
631 description: format!(
632 "Potential missing '{}' between '{}' and '{}'",
633 expected_act, from, to
634 ),
635 confidence: prob * 0.8,
636 suggested_repair: Some(format!("Insert '{}'", expected_act)),
637 });
638 }
639 }
640 }
641 }
642
643 issues
644 }
645
646 fn detect_incomplete_trace(
648 events: &[&ProcessEvent],
649 case_id: &str,
650 model: &TransitionModel,
651 config: &ImputationConfig,
652 ) -> Vec<LogIssue> {
653 let mut issues = Vec::new();
654
655 if events.is_empty() {
656 return issues;
657 }
658
659 let first_activity = &events.first().unwrap().activity;
661 let expected_starts = model.expected_starts(config.min_transition_support);
662
663 if !expected_starts.iter().any(|(a, _)| a == first_activity) && !expected_starts.is_empty()
664 {
665 let most_common_start = &expected_starts[0].0;
666 issues.push(LogIssue {
667 issue_type: IssueType::IncompleteTrace,
668 case_id: case_id.to_string(),
669 position: Some(0),
670 event_id: None,
671 description: format!(
672 "Trace starts with '{}' instead of expected start '{}'",
673 first_activity, most_common_start
674 ),
675 confidence: expected_starts[0].1 * 0.7,
676 suggested_repair: Some(format!("Consider adding '{}' at start", most_common_start)),
677 });
678 }
679
680 let last_activity = &events.last().unwrap().activity;
682 let expected_ends = model.expected_ends(config.min_transition_support);
683
684 if !expected_ends.iter().any(|(a, _)| a == last_activity) && !expected_ends.is_empty() {
685 let most_common_end = &expected_ends[0].0;
686 issues.push(LogIssue {
687 issue_type: IssueType::IncompleteTrace,
688 case_id: case_id.to_string(),
689 position: Some(events.len() - 1),
690 event_id: None,
691 description: format!(
692 "Trace ends with '{}' instead of expected end '{}'",
693 last_activity, most_common_end
694 ),
695 confidence: expected_ends[0].1 * 0.7,
696 suggested_repair: Some(format!("Consider adding '{}' at end", most_common_end)),
697 });
698 }
699
700 issues
701 }
702}
703
704impl GpuKernel for EventLogImputation {
705 fn metadata(&self) -> &KernelMetadata {
706 &self.metadata
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 fn create_clean_log() -> EventLog {
715 let mut log = EventLog::new("test".to_string());
716
717 for trace_num in 0..3 {
719 for (i, activity) in ["A", "B", "C", "D"].iter().enumerate() {
720 log.add_event(ProcessEvent {
721 id: (trace_num * 10 + i) as u64,
722 case_id: format!("trace{}", trace_num),
723 activity: activity.to_string(),
724 timestamp: (trace_num * 1000 + i * 100) as u64,
725 resource: None,
726 attributes: HashMap::new(),
727 });
728 }
729 }
730
731 log
732 }
733
734 fn create_log_with_issues() -> EventLog {
735 let mut log = EventLog::new("test".to_string());
736
737 for (i, activity) in ["A", "B", "C", "D"].iter().enumerate() {
739 log.add_event(ProcessEvent {
740 id: i as u64,
741 case_id: "trace0".to_string(),
742 activity: activity.to_string(),
743 timestamp: (i * 100) as u64,
744 resource: None,
745 attributes: HashMap::new(),
746 });
747 }
748
749 for (i, activity) in ["A", "B", "B", "C", "D"].iter().enumerate() {
751 log.add_event(ProcessEvent {
752 id: (10 + i) as u64,
753 case_id: "trace1".to_string(),
754 activity: activity.to_string(),
755 timestamp: (1000 + i * 10) as u64, resource: None,
757 attributes: HashMap::new(),
758 });
759 }
760
761 for (i, activity) in ["A", "B", "D"].iter().enumerate() {
763 log.add_event(ProcessEvent {
764 id: (20 + i) as u64,
765 case_id: "trace2".to_string(),
766 activity: activity.to_string(),
767 timestamp: (2000 + i * 100) as u64,
768 resource: None,
769 attributes: HashMap::new(),
770 });
771 }
772
773 log.add_event(ProcessEvent {
775 id: 30,
776 case_id: "trace3".to_string(),
777 activity: "A".to_string(),
778 timestamp: 3000,
779 resource: None,
780 attributes: HashMap::new(),
781 });
782 log.add_event(ProcessEvent {
783 id: 31,
784 case_id: "trace3".to_string(),
785 activity: "C".to_string(),
786 timestamp: 3200, resource: None,
788 attributes: HashMap::new(),
789 });
790 log.add_event(ProcessEvent {
791 id: 32,
792 case_id: "trace3".to_string(),
793 activity: "B".to_string(),
794 timestamp: 3100, resource: None,
796 attributes: HashMap::new(),
797 });
798 log.add_event(ProcessEvent {
799 id: 33,
800 case_id: "trace3".to_string(),
801 activity: "D".to_string(),
802 timestamp: 3300,
803 resource: None,
804 attributes: HashMap::new(),
805 });
806
807 log
808 }
809
810 #[test]
811 fn test_imputation_metadata() {
812 let kernel = EventLogImputation::new();
813 assert_eq!(kernel.metadata().id, "procint/log-imputation");
814 assert_eq!(kernel.metadata().domain, Domain::ProcessIntelligence);
815 }
816
817 #[test]
818 fn test_transition_model() {
819 let log = create_clean_log();
820 let model = TransitionModel::from_log(&log);
821
822 assert_eq!(model.trace_count, 3);
823 assert!(model.start_activities.contains_key("A"));
824 assert!(model.end_activities.contains_key("D"));
825 assert!(model.transitions.contains_key("A"));
826 }
827
828 #[test]
829 fn test_clean_log_no_issues() {
830 let log = create_clean_log();
831 let config = ImputationConfig::default();
832 let result = EventLogImputation::compute(&log, &config);
833
834 let high_conf_issues: Vec<_> = result
836 .issues
837 .iter()
838 .filter(|i| i.confidence >= 0.8)
839 .collect();
840 assert!(
841 high_conf_issues.is_empty(),
842 "Clean log should have no high-confidence issues: {:?}",
843 high_conf_issues
844 );
845 }
846
847 #[test]
848 fn test_duplicate_detection() {
849 let log = create_log_with_issues();
850 let config = ImputationConfig {
851 detect_duplicates: true,
852 duplicate_time_threshold: 30, ..Default::default()
854 };
855 let result = EventLogImputation::compute(&log, &config);
856
857 let dup_issues: Vec<_> = result
858 .issues
859 .iter()
860 .filter(|i| i.issue_type == IssueType::DuplicateEvent && i.case_id == "trace1")
861 .collect();
862
863 assert!(
864 !dup_issues.is_empty(),
865 "Should detect duplicate B in trace1"
866 );
867 }
868
869 #[test]
870 fn test_missing_event_detection() {
871 let log = create_log_with_issues();
872 let config = ImputationConfig {
873 detect_missing: true,
874 min_transition_support: 0.3,
875 ..Default::default()
876 };
877 let result = EventLogImputation::compute(&log, &config);
878
879 let missing_issues: Vec<_> = result
880 .issues
881 .iter()
882 .filter(|i| i.issue_type == IssueType::MissingEvent && i.case_id == "trace2")
883 .collect();
884
885 assert!(
889 result
890 .stats
891 .issues_by_type
892 .contains_key(&IssueType::MissingEvent)
893 || missing_issues.is_empty(), "Missing event detection should work or gracefully handle low support"
895 );
896 }
897
898 #[test]
899 fn test_timestamp_repair() {
900 let log = create_log_with_issues();
901 let config = ImputationConfig {
902 repair_timestamps: true,
903 ..Default::default()
904 };
905 let result = EventLogImputation::compute(&log, &config);
906
907 let ts_issues: Vec<_> = result
909 .issues
910 .iter()
911 .filter(|i| i.issue_type == IssueType::OutOfOrderTimestamp && i.case_id == "trace3")
912 .collect();
913
914 assert!(
915 !ts_issues.is_empty(),
916 "Should detect timestamp issues in trace3"
917 );
918
919 let ts_repairs: Vec<_> = result
921 .repairs
922 .iter()
923 .filter(|r| r.repair_type == RepairType::CorrectTimestamp && r.case_id == "trace3")
924 .collect();
925
926 assert!(
928 !ts_repairs.is_empty()
929 || result
930 .stats
931 .repairs_by_type
932 .contains_key(&RepairType::CorrectTimestamp),
933 "Should repair timestamp issues"
934 );
935 }
936
937 #[test]
938 fn test_expected_transitions() {
939 let log = create_clean_log();
940 let model = TransitionModel::from_log(&log);
941
942 assert!(model.is_expected_transition("A", "B", 0.1));
943 assert!(model.is_expected_transition("B", "C", 0.1));
944 assert!(model.is_expected_transition("C", "D", 0.1));
945 assert!(!model.is_expected_transition("A", "D", 0.1));
946 }
947
948 #[test]
949 fn test_expected_starts_ends() {
950 let log = create_clean_log();
951 let model = TransitionModel::from_log(&log);
952
953 let starts = model.expected_starts(0.1);
954 assert!(!starts.is_empty());
955 assert_eq!(starts[0].0, "A");
956
957 let ends = model.expected_ends(0.1);
958 assert!(!ends.is_empty());
959 assert_eq!(ends[0].0, "D");
960 }
961
962 #[test]
963 fn test_quality_scores() {
964 let log = create_log_with_issues();
965 let config = ImputationConfig::default();
966 let result = EventLogImputation::compute(&log, &config);
967
968 assert!(result.stats.quality_score_before <= 100.0);
969 assert!(result.stats.quality_score_after <= 100.0);
970 assert!(result.stats.quality_score_after >= result.stats.quality_score_before - 1.0);
972 }
973
974 #[test]
975 fn test_empty_log() {
976 let log = EventLog::new("empty".to_string());
977 let config = ImputationConfig::default();
978 let result = EventLogImputation::compute(&log, &config);
979
980 assert!(result.issues.is_empty());
981 assert!(result.repairs.is_empty());
982 assert_eq!(result.stats.traces_analyzed, 0);
983 }
984
985 #[test]
986 fn test_compute_time() {
987 let log = create_log_with_issues();
988 let config = ImputationConfig::default();
989 let result = EventLogImputation::compute(&log, &config);
990
991 assert!(result.compute_time_us < 1_000_000); }
993}