Skip to main content

ruda_test_utils/correctness/
base.rs

1use std::path::Path;
2
3use crate::{
4    config::config,
5    correctness::{
6        color_printer::index_matches_filter,
7        render::print_tensors,
8        {DimFilter, TensorFilter},
9    },
10    test_tensor::read_host_data,
11    {HostData, ValidationResult},
12};
13
14/// Maximum number of individual mismatches to include in a `Fail` reason
15/// before we stop recording details and only update the aggregate stats.
16const DEFAULT_MAX_REPORTED_MISMATCHES: usize = 8;
17
18/// Check if two tensors are approximately equal
19pub fn assert_equals_approx(
20    // One of the tensor to compare
21    lhs: &HostData,
22    // One of the tensor to compare
23    rhs: &HostData,
24    // Maximum absolute difference between two values
25    epsilon: f32,
26) -> ValidationResult {
27    assert_equals_approx_inner(lhs, rhs, epsilon, None)
28}
29
30/// Check if two tensors are approximately equal within the given filter.
31///
32/// `filter` is anything iterable whose items convert into [`DimFilter`], so
33/// both `Vec<std::ops::Range<usize>>` and the canonical [`TensorFilter`]
34/// (matching the `ruda-test.toml` `[print] filter` syntax) work — one DSL for
35/// selective comparison and selective printing.
36pub fn assert_equals_approx_in_slice<I>(
37    // One of the tensor to compare
38    lhs: &HostData,
39    // One of the tensor to compare
40    rhs: &HostData,
41    // Maximum absolute difference between two values
42    epsilon: f32,
43    // If non-empty, only indices that match the filter are compared
44    filter: I,
45) -> ValidationResult
46where
47    I: IntoIterator,
48    I::Item: Into<DimFilter>,
49{
50    let filter: TensorFilter = filter.into_iter().map(Into::into).collect();
51    assert_equals_approx_inner(lhs, rhs, epsilon, Some(filter))
52}
53
54/// Compare two `HostData` blobs previously written to disk by
55/// [`crate::write_host_data`].
56///
57/// Returns `Error` when either file is missing or doesn't decode as a HostData
58/// blob — callers can surface that as "regenerate one of the reference files".
59/// Otherwise behaves identically to [`assert_equals_approx`] on the loaded
60/// pair.
61pub fn compare_host_data_files(
62    actual_path: &Path,
63    expected_path: &Path,
64    epsilon: f32,
65) -> ValidationResult {
66    let actual = match read_host_data(actual_path) {
67        Ok(v) => v,
68        Err(e) => {
69            return ValidationResult::Error(format!(
70                "failed to read host data file {}: {e}",
71                actual_path.display()
72            ));
73        }
74    };
75    let expected = match read_host_data(expected_path) {
76        Ok(v) => v,
77        Err(e) => {
78            return ValidationResult::Error(format!(
79                "failed to read host data file {}: {e}",
80                expected_path.display()
81            ));
82        }
83    };
84    assert_equals_approx(&actual, &expected, epsilon)
85}
86
87/// Check if two tensors are approximately equal
88/// Within the given slice, if some
89fn assert_equals_approx_inner(
90    // One of the tensor to compare
91    lhs: &HostData,
92    // One of the tensor to compare
93    rhs: &HostData,
94    // Maximum absolute difference between two values
95    epsilon: f32,
96    // If some, only indices matching the filter are compared
97    filter: Option<TensorFilter>,
98) -> ValidationResult {
99    // Route the diff through the unified renderer. It is a no-op when
100    // printing is disabled or when shapes differ — same code path as
101    // pretty-printing two unrelated tensors.
102    let print_cfg = &config().print;
103    print_tensors(print_cfg, "diff", &[lhs, rhs], Some(epsilon));
104
105    if lhs.shape != rhs.shape {
106        return ValidationResult::Fail(format!(
107            "Shape mismatch: got {:?}, expected {:?}",
108            lhs.shape, rhs.shape,
109        ));
110    }
111
112    let shape = &lhs.shape;
113    let in_print_mode = print_cfg.enabled;
114
115    let mut summary_visitor = SummaryCollector::new(DEFAULT_MAX_REPORTED_MISMATCHES);
116
117    // Up-front rank check on the comparison filter — bail out cleanly so a
118    // mistyped filter doesn't silently exclude every index.
119    if let Some(f) = &filter
120        && !f.is_empty()
121        && f.len() != shape.len()
122    {
123        return ValidationResult::Error(format!(
124            "Comparison filter rank mismatch. Got {}, expected {} (tensor shape {:?})",
125            f.len(),
126            shape.len(),
127            shape,
128        ));
129    }
130
131    let test_failed = compare_tensors(
132        lhs,
133        rhs,
134        shape,
135        epsilon,
136        &mut summary_visitor,
137        &mut Vec::new(),
138        filter.as_ref(),
139    );
140
141    if test_failed {
142        return ValidationResult::Fail(summary_visitor.report(shape, in_print_mode));
143    }
144
145    ValidationResult::Pass
146}
147
148#[derive(Debug)]
149pub(crate) enum ElemStatus {
150    #[allow(dead_code)] // fields read via Debug in failure messages
151    Correct {
152        got: f32,
153        delta: f32,
154        epsilon: f32,
155    },
156    Wrong(WrongStatus),
157}
158
159#[derive(Debug)]
160pub(crate) enum WrongStatus {
161    GotWrongValue {
162        got: f32,
163        expected: f32,
164        delta: f32,
165        epsilon: f32,
166    },
167    ExpectedNan {
168        got: f32,
169    },
170    GotNan {
171        expected: f32,
172    },
173}
174
175pub(crate) trait CompareVisitor {
176    fn visit(&mut self, index: &[usize], status: ElemStatus);
177}
178
179/// Collects up to `max_reported` individual mismatches plus aggregate stats
180/// (total compared, total mismatched, max/sum of |Δ|, worst index) to build a
181/// detailed failure message without truncating useful debug info.
182pub(crate) struct SummaryCollector {
183    pub max_reported: usize,
184    pub mismatches: Vec<(Vec<usize>, WrongStatus)>,
185    pub total: usize,
186    pub mismatched: usize,
187    pub max_abs_delta: f32,
188    pub sum_abs_delta: f64,
189    pub worst_index: Option<Vec<usize>>,
190}
191
192impl SummaryCollector {
193    fn new(max_reported: usize) -> Self {
194        Self {
195            max_reported,
196            mismatches: Vec::new(),
197            total: 0,
198            mismatched: 0,
199            max_abs_delta: 0.0,
200            sum_abs_delta: 0.0,
201            worst_index: None,
202        }
203    }
204
205    fn record_delta(&mut self, index: &[usize], delta: f32) {
206        if delta.is_finite() {
207            self.sum_abs_delta += delta as f64;
208        }
209        if delta > self.max_abs_delta || self.worst_index.is_none() {
210            self.max_abs_delta = delta;
211            self.worst_index = Some(index.to_vec());
212        }
213    }
214
215    pub(crate) fn report(&self, shape: &[usize], skip_examples: bool) -> String {
216        let mut out = String::new();
217        out.push_str("Got incorrect results: ");
218        out.push_str(&format!(
219            "{}/{} elements mismatched",
220            self.mismatched, self.total
221        ));
222
223        if self.mismatched > 0 {
224            let mean = if self.mismatched > 0 {
225                self.sum_abs_delta / self.mismatched as f64
226            } else {
227                0.0
228            };
229            out.push_str(&format!(
230                " (max |Δ|={:.6}, mean |Δ|={:.6}",
231                self.max_abs_delta, mean
232            ));
233            if let Some(idx) = &self.worst_index {
234                out.push_str(&format!(", worst at {:?}", idx));
235            }
236            out.push(')');
237            out.push_str(&format!(" — shape={:?}", shape));
238        }
239
240        // In Print modes, the per-element output is on stdout already; don't
241        // re-list examples in the panic message.
242        if skip_examples || self.mismatches.is_empty() {
243            return out;
244        }
245
246        out.push_str("\nFirst mismatches:");
247        for (idx, w) in &self.mismatches {
248            out.push_str(&format!("\n  {:?}: {}", idx, format_wrong(w)));
249        }
250        if self.mismatched > self.mismatches.len() {
251            out.push_str(&format!(
252                "\n  ... and {} more",
253                self.mismatched - self.mismatches.len()
254            ));
255        }
256
257        out
258    }
259}
260
261impl CompareVisitor for SummaryCollector {
262    fn visit(&mut self, index: &[usize], status: ElemStatus) {
263        self.total += 1;
264        if let ElemStatus::Wrong(w) = status {
265            self.mismatched += 1;
266            let delta = match &w {
267                WrongStatus::GotWrongValue { delta, .. } => *delta,
268                WrongStatus::ExpectedNan { .. } | WrongStatus::GotNan { .. } => f32::INFINITY,
269            };
270            self.record_delta(index, delta);
271            if self.mismatches.len() < self.max_reported {
272                self.mismatches.push((index.to_vec(), w));
273            }
274        }
275    }
276}
277
278fn format_wrong(w: &WrongStatus) -> String {
279    match w {
280        WrongStatus::GotWrongValue {
281            got,
282            expected,
283            delta,
284            epsilon,
285        } => format!("got {got}, expected {expected}, |Δ|={delta} > ε={epsilon}",),
286        WrongStatus::ExpectedNan { got } => format!("got {got}, expected NaN"),
287        WrongStatus::GotNan { expected } => format!("got NaN, expected {expected}"),
288    }
289}
290
291#[inline]
292fn compare_elem(got: f32, expected: f32, epsilon: f32) -> ElemStatus {
293    let epsilon = (epsilon * expected).abs().max(epsilon);
294
295    // NaN check: pass if both are NaN
296    if got.is_nan() && expected.is_nan() {
297        return ElemStatus::Correct {
298            got,
299            delta: 0.,
300            epsilon,
301        };
302    }
303
304    // NaN mismatch
305    if got.is_nan() || expected.is_nan() {
306        return if expected.is_nan() {
307            ElemStatus::Wrong(WrongStatus::ExpectedNan { got })
308        } else {
309            ElemStatus::Wrong(WrongStatus::GotNan { expected })
310        };
311    }
312
313    // Infinite check: pass if both inf with same sign
314    if got.is_infinite() && expected.is_infinite() {
315        if got.signum() == expected.signum() {
316            return ElemStatus::Correct {
317                got,
318                delta: 0.,
319                epsilon,
320            };
321        } else {
322            return ElemStatus::Wrong(WrongStatus::GotWrongValue {
323                got,
324                expected,
325                delta: f32::INFINITY,
326                epsilon,
327            });
328        }
329    }
330
331    // Regular numeric comparison
332    let delta = (got - expected).abs();
333    if delta <= epsilon {
334        ElemStatus::Correct {
335            got,
336            delta,
337            epsilon,
338        }
339    } else {
340        ElemStatus::Wrong(WrongStatus::GotWrongValue {
341            got,
342            expected,
343            delta,
344            epsilon,
345        })
346    }
347}
348
349fn compare_tensors(
350    actual: &HostData,
351    expected: &HostData,
352    shape: &[usize],
353    epsilon: f32,
354    visitor: &mut dyn CompareVisitor,
355    index: &mut Vec<usize>,
356    filter: Option<&TensorFilter>,
357) -> bool {
358    let mut failed = false;
359
360    let dim = index.len();
361    if dim == shape.len() {
362        // Skip elements that don't match the filter (an empty filter matches
363        // every index).
364        if let Some(filter) = filter
365            && !filter.is_empty()
366            && !index_matches_filter(index, filter)
367        {
368            return false;
369        }
370
371        let got = actual.get_f32(index);
372        let exp = expected.get_f32(index);
373        let status = compare_elem(got, exp, epsilon);
374
375        if matches!(status, ElemStatus::Wrong(_)) {
376            failed = true;
377        }
378
379        visitor.visit(index, status);
380        return failed;
381    }
382
383    // Recurse over full dimension — filter check happens at leaf
384    for i in 0..shape[dim] {
385        index.push(i);
386        if compare_tensors(actual, expected, shape, epsilon, visitor, index, filter) {
387            failed = true;
388        }
389        index.pop();
390    }
391
392    failed
393}