Skip to main content

wasm4pm_types/
conformance.rs

1use serde::{Deserialize, Serialize};
2
3/// Result of token-based replay conformance checking
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
5pub struct TokenReplayResult {
6    pub fitness: f64,
7    pub produced_tokens: usize,
8    pub consumed_tokens: usize,
9    pub missing_tokens: usize,
10    pub remaining_tokens: usize,
11}
12
13impl TokenReplayResult {
14    pub fn new(
15        fitness: f64,
16        produced_tokens: usize,
17        consumed_tokens: usize,
18        missing_tokens: usize,
19        remaining_tokens: usize,
20    ) -> Self {
21        TokenReplayResult {
22            fitness,
23            produced_tokens,
24            consumed_tokens,
25            missing_tokens,
26            remaining_tokens,
27        }
28    }
29
30    pub fn calculate_fitness(
31        produced: usize,
32        consumed: usize,
33        missing: usize,
34        remaining: usize,
35    ) -> f64 {
36        let denom = (produced + remaining).max(1) as f64;
37        let num = consumed.saturating_sub(missing) as f64;
38        // All inputs are usize, so num/denom is finite and in [0, +inf). No
39        // NaN is possible here, but clamping defensively keeps the invariant.
40        clamp_finite(num / denom, 0.0, 1.0)
41    }
42}
43
44/// NaN-safe clamp. Returns `lo` for NaN, matches `f64::clamp` for finite values.
45///
46/// PR #54 NaN class: the stdlib `f64::clamp` *panics* on NaN inputs (and was
47/// previously documented as such). The original code below called
48/// `precision.clamp(0.0, 1.0)` where `precision` came from caller-supplied f64
49/// — passing `f64::NAN` would crash production. We coerce NaN to `lo` because
50/// "no information" is the safest conservative fitness/precision value.
51fn clamp_finite(x: f64, lo: f64, hi: f64) -> f64 {
52    if x.is_nan() || x < lo {
53        lo
54    } else if x > hi {
55        hi
56    } else {
57        x
58    }
59}
60
61/// Detailed conformance checking result
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
63pub struct ConformanceResult {
64    pub fitness: f64,
65    pub precision: Option<f64>,
66    pub generalization: Option<f64>,
67    pub simplicity: Option<f64>,
68    pub total_traces: usize,
69    pub fitting_traces: usize,
70    pub deviating_traces: usize,
71}
72
73impl ConformanceResult {
74    pub fn new(
75        fitness: f64,
76        total_traces: usize,
77        fitting_traces: usize,
78        deviating_traces: usize,
79    ) -> Self {
80        ConformanceResult {
81            fitness,
82            precision: None,
83            generalization: None,
84            simplicity: None,
85            total_traces,
86            fitting_traces,
87            deviating_traces,
88        }
89    }
90
91    pub fn with_precision(mut self, precision: f64) -> Self {
92        // PR #54: f64::clamp panics on NaN; route through clamp_finite.
93        self.precision = Some(clamp_finite(precision, 0.0, 1.0));
94        self
95    }
96
97    pub fn with_generalization(mut self, generalization: f64) -> Self {
98        self.generalization = Some(clamp_finite(generalization, 0.0, 1.0));
99        self
100    }
101
102    pub fn with_simplicity(mut self, simplicity: f64) -> Self {
103        self.simplicity = Some(clamp_finite(simplicity, 0.0, 1.0));
104        self
105    }
106
107    pub fn conformance_rate(&self) -> f64 {
108        if self.total_traces == 0 {
109            0.0
110        } else {
111            self.fitting_traces as f64 / self.total_traces as f64
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn test_token_replay_fitness() {
122        let fitness = TokenReplayResult::calculate_fitness(100, 95, 5, 10);
123        assert!((fitness - 0.8181818).abs() < 0.001); // (95 - 5) / (100 + 10) = 90/110
124    }
125
126    #[test]
127    fn test_conformance_result() {
128        let result = ConformanceResult::new(0.95, 100, 95, 5);
129        assert_eq!(result.conformance_rate(), 0.95);
130        assert_eq!(result.fitting_traces, 95);
131    }
132
133    /// Rank-1 (mathematical theorem): clamp_finite must NEVER panic and must
134    /// satisfy `lo <= clamp_finite(x, lo, hi) <= hi` for every f64 input.
135    /// Regression for PR #54 NaN class: the stdlib `f64::clamp` panics if any
136    /// of {x, lo, hi} is NaN, so the `precision.clamp(0.0, 1.0)` call on a
137    /// caller-supplied NaN previously crashed.
138    #[test]
139    fn clamp_finite_handles_nan_and_inf() {
140        assert_eq!(clamp_finite(f64::NAN, 0.0, 1.0), 0.0);
141        assert_eq!(clamp_finite(f64::INFINITY, 0.0, 1.0), 1.0);
142        assert_eq!(clamp_finite(f64::NEG_INFINITY, 0.0, 1.0), 0.0);
143        assert_eq!(clamp_finite(0.5, 0.0, 1.0), 0.5);
144        assert_eq!(clamp_finite(-1.0, 0.0, 1.0), 0.0);
145        assert_eq!(clamp_finite(2.0, 0.0, 1.0), 1.0);
146    }
147
148    /// Rank-2 (domain contract): ConformanceResult builders must accept NaN
149    /// without panicking and store the conservative lower bound.
150    #[test]
151    fn conformance_builders_do_not_panic_on_nan() {
152        let r = ConformanceResult::new(0.5, 10, 5, 5)
153            .with_precision(f64::NAN)
154            .with_generalization(f64::NAN)
155            .with_simplicity(f64::NAN);
156        assert_eq!(r.precision, Some(0.0));
157        assert_eq!(r.generalization, Some(0.0));
158        assert_eq!(r.simplicity, Some(0.0));
159    }
160}