1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
//! Conformance Checking Edge Case Guards
//!
//! Boundary-case detection and correction for fitness computation.
//! Ensures fitness is always in [0.0, 1.0] and handles:
//! - Empty logs (zero traces)
//! - Empty traces (zero events)
//! - Zero denominators
//! - NaN/Inf results
//! - Degenerate models
/// Guard 1: Empty log handling
///
/// Edge case: Log with zero traces should return avg_fitness = 1.0 (vacuous truth)
/// not 0.0 (undefined).
pub fn guard_empty_log(total_cases: usize, total_fitness: f64) -> f64 {
if total_cases == 0 {
// Vacuous truth: no cases to violate conformance
return 1.0;
}
// Normal case: average fitness across all traces
total_fitness / total_cases as f64
}
/// Guard 2: Fitness bounds enforcement
///
/// Edge case: Fitness formula can produce values outside [0.0, 1.0] due to:
/// - Division by small denominators
/// - Cumulative rounding errors
///
/// Always clamp to [0.0, 1.0]
pub fn guard_fitness_bounds(fitness: f64) -> f64 {
if fitness.is_nan() {
// NaN case: treat as minimum conformance
return 0.0;
}
if fitness.is_infinite() {
// Infinite case: clamp to bounds
return if fitness > 0.0 { 1.0 } else { 0.0 };
}
// Normal case: clamp to [0.0, 1.0]
fitness.clamp(0.0, 1.0)
}
/// Guard 3: Single-event trace handling
///
/// Edge case: Trace with one event has zero denominators in fitness formula:
/// fitness = 0.5 * (1 - missing/consumed) + 0.5 * (1 - remaining/produced)
/// If consumed=0 or produced=0, we use max(1, denominator).
pub fn guard_zero_denominator(numerator: u32, denominator: u32) -> f64 {
let den = denominator.max(1) as f64;
(numerator as f64) / den
}
/// Guard 4: Mixed trace conformance (empty + non-empty traces)
///
/// Edge case: Some traces may be empty (zero events) while others have events.
/// Empty trace fitness should be well-defined:
/// - If model accepts empty traces (epsilon language): fitness = 1.0
/// - If model requires ≥1 event: fitness < 1.0
pub fn guard_empty_trace_fitness(event_count: usize, _is_conforming: bool) -> Option<f64> {
if event_count == 0 {
// Empty trace: 0.5 conformance (half of min fitness for 1-event trace)
// This ensures empty traces are penalized but not catastrophic
return Some(0.5);
}
// Non-empty trace: guard does not apply, caller must compute fitness.
None
}
/// Guard 5: Degenerate model (single activity)
///
/// Edge case: Model with only one activity has very limited expressiveness.
/// Fitness should still be [0.0, 1.0].
pub fn guard_degenerate_model_fitness(
fitness: f64,
_activity_count: usize,
_transition_count: usize,
) -> f64 {
// Degenerate models (activity_count < 2) still produce bounded fitness
guard_fitness_bounds(fitness)
}
/// Guard 6: Cumulative fitness computation
///
/// Edge case: Averaging many trace fitness values can accumulate rounding error.
/// Ensure intermediate sums don't overflow or underflow.
pub fn guard_cumulative_fitness(
current_total: f64,
trace_fitness: f64,
_trace_count: usize,
) -> f64 {
// Clamp each trace fitness before adding to total
let bounded_fitness = guard_fitness_bounds(trace_fitness);
// Check for accumulation errors
let new_total = current_total + bounded_fitness;
if new_total.is_nan() || new_total.is_infinite() {
// Fallback: conservative estimate
0.0
} else {
new_total
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guard1_empty_log_returns_1_0() {
assert_eq!(guard_empty_log(0, 0.0), 1.0);
assert_eq!(guard_empty_log(0, 5.0), 1.0); // Ignores total_fitness
}
#[test]
fn guard1_normal_log_averages() {
assert_eq!(guard_empty_log(1, 0.5), 0.5);
assert_eq!(guard_empty_log(2, 1.5), 0.75);
}
#[test]
fn guard2_nan_becomes_0_0() {
assert_eq!(guard_fitness_bounds(f64::NAN), 0.0);
}
#[test]
fn guard2_inf_clamped() {
assert_eq!(guard_fitness_bounds(f64::INFINITY), 1.0);
assert_eq!(guard_fitness_bounds(f64::NEG_INFINITY), 0.0);
}
#[test]
fn guard2_negative_clamped() {
assert_eq!(guard_fitness_bounds(-0.5), 0.0);
}
#[test]
fn guard2_above_1_clamped() {
assert_eq!(guard_fitness_bounds(1.5), 1.0);
}
#[test]
fn guard2_in_bounds_unchanged() {
assert_eq!(guard_fitness_bounds(0.75), 0.75);
assert_eq!(guard_fitness_bounds(0.0), 0.0);
assert_eq!(guard_fitness_bounds(1.0), 1.0);
}
#[test]
fn guard3_zero_denominator_becomes_1() {
assert_eq!(guard_zero_denominator(0, 0), 0.0);
assert_eq!(guard_zero_denominator(1, 0), 1.0);
assert_eq!(guard_zero_denominator(1, 1), 1.0);
}
#[test]
fn guard4_empty_trace_fitness() {
assert_eq!(guard_empty_trace_fitness(0, true), Some(0.5));
assert_eq!(guard_empty_trace_fitness(0, false), Some(0.5));
assert_eq!(guard_empty_trace_fitness(1, true), None);
}
#[test]
fn guard6_cumulative_nan_handling() {
let result = guard_cumulative_fitness(f64::NAN, 0.5, 1);
// NaN in current_total + anything = NaN, then fallback to 0.0
assert_eq!(result, 0.0);
}
}