ellip_dev_utils/
test_report.rs

1/*
2 * Ellip is licensed under The 3-Clause BSD, see LICENSE.
3 * Copyright 2025 Sira Pornsiriprasert <code@psira.me>
4 */
5
6use num_traits::Float;
7use std::fmt::Debug;
8use tabled::{Table, Tabled, settings::Style};
9
10use crate::stats::Stats;
11
12/// Calculates max relative error in unit of epsilon
13pub fn rel_err<T: Float>(a: T, b: T) -> f64 {
14    let err_a = ((a - b) / a).abs();
15    let err_b = ((a - b) / b).abs();
16
17    let rel_err = err_a.max(err_b);
18    (rel_err / T::epsilon())
19        .to_f64()
20        .expect("Cannot convert to f64")
21}
22
23#[derive(Debug, Clone)]
24pub struct Case<T: Float> {
25    pub inputs: Vec<T>,
26    pub expected: T,
27}
28
29pub fn compute_errors_from_cases<T: Float + Debug>(
30    func: &dyn Fn(&Vec<T>) -> T,
31    cases: Vec<Case<T>>,
32) -> Vec<f64> {
33    cases
34        .iter()
35        .map(|case| {
36            if case.expected.is_finite() {
37                let res = func(&case.inputs);
38                let err = rel_err(res, case.expected);
39                // if err > 20.0 {
40                //     println!(
41                //         "Using parameters: {:?}, got={:?}, actual={:?} (error={:.2})",
42                //         &case.inputs, res, case.expected, err
43                //     );
44                // }
45                err
46            } else {
47                f64::NAN
48            }
49        })
50        .collect()
51}
52
53pub fn format_float(value: &f64) -> String {
54    if value.is_nan() {
55        "NAN".to_string()
56    } else if *value >= 1e3 {
57        format!("{:.2e}", value)
58    } else {
59        format!("{:.2}", value)
60    }
61}
62
63fn format_mu(value: &u64) -> String {
64    if *value >= 10000 {
65        format!("{:e}", value)
66    } else {
67        format!("{}", value)
68    }
69}
70
71#[derive(Tabled)]
72pub struct ErrorEntry<'a> {
73    #[tabled(rename = "Function")]
74    name: &'a str,
75    #[tabled(rename = "Mean (ε)", display = "format_float")]
76    mean: f64,
77    #[tabled(rename = "Median (ε)", display = "format_float")]
78    median: f64,
79    #[tabled(rename = "P99 (ε)", display = "format_float")]
80    p99: f64,
81    #[tabled(rename = "Max (ε)", display = "format_float")]
82    max: f64,
83    #[tabled(rename = "Variance (ε²)", display = "format_float")]
84    variance: f64,
85    #[tabled(rename = "μ (ε²)", display = "format_mu")]
86    mu: u64,
87}
88
89pub fn generate_error_entry_from_file<T: Float + Debug>(
90    file_path: &str,
91    func: &dyn Fn(&Vec<T>) -> T,
92) -> Stats {
93    let result = crate::parser::read_wolfram_data(file_path);
94    match result {
95        Ok(cases) => Stats::from_vec(&compute_errors_from_cases(func, cases)),
96        Err(_) => Stats::nan(),
97    }
98}
99
100pub fn generate_error_table(entries: &[(&str, u64, Stats, Stats)]) -> [String; 2] {
101    let f64_rows: Vec<ErrorEntry> = entries
102        .iter()
103        .map(|(name, mu, f64_stats, _)| ErrorEntry {
104            name,
105            mean: f64_stats.mean,
106            median: f64_stats.median,
107            p99: f64_stats.p99,
108            max: f64_stats.max,
109            variance: f64_stats.variance,
110            mu: *mu,
111        })
112        .collect();
113
114    let f32_rows: Vec<ErrorEntry> = entries
115        .iter()
116        .map(|(name, mu, _, f32_stats)| ErrorEntry {
117            name,
118            mean: f32_stats.mean,
119            median: f32_stats.median,
120            p99: f32_stats.p99,
121            max: f32_stats.max,
122            variance: f32_stats.variance,
123            mu: *mu,
124        })
125        .collect();
126
127    [
128        Table::new(f64_rows).with(Style::markdown()).to_string(),
129        Table::new(f32_rows).with(Style::markdown()).to_string(),
130    ]
131}
132
133#[macro_export]
134macro_rules! get_entry {
135    ($file_name: expr, $name: expr, $func: expr, $arg_count: tt, $mu: expr) => {{
136        let f64_entries = {
137            let file_path_f64 = concat!["tests/data/", $file_name, ".csv"];
138            ellip_dev_utils::func_wrapper!($func, f64, $arg_count);
139            ellip_dev_utils::test_report::generate_error_entry_from_file(
140                &file_path_f64,
141                &wrapped_func,
142            )
143        };
144        let f32_entries = {
145            let file_path_f32 = concat!["tests/data/f32/", $file_name, ".csv"];
146            ellip_dev_utils::func_wrapper!($func, f32, $arg_count);
147            ellip_dev_utils::test_report::generate_error_entry_from_file(
148                &file_path_f32,
149                &wrapped_func,
150            )
151        };
152
153        ($name, $mu, f64_entries, f32_entries)
154    }};
155}
156
157pub fn format_performance(value: &f64) -> String {
158    if value.is_nan() {
159        "NAN".to_string()
160    } else if *value < 1000.0 {
161        format!("{:.1} ns", value)
162    } else if *value < 1_000_000.0 {
163        format!("{:.1} μs", value / 1000.0)
164    } else {
165        format!("{:.1} ms", value / 1_000_000.0)
166    }
167}
168
169#[derive(Tabled)]
170pub struct SummaryEntry<'a> {
171    #[tabled(rename = "Function")]
172    name: &'a str,
173    #[tabled(rename = "Median Error (ε)", display = "format_float")]
174    median_error: f64,
175    #[tabled(rename = "Max Error (ε)", display = "format_float")]
176    max_error: f64,
177    #[tabled(rename = "Mean Performance", display = "format_performance")]
178    mean_performance: f64,
179}
180
181pub fn generate_summary_table(entries: &[(&str, Stats, f64)]) -> String {
182    let rows: Vec<SummaryEntry> = entries
183        .iter()
184        .map(|(name, stats, perf)| SummaryEntry {
185            name,
186            median_error: stats.median,
187            max_error: stats.max,
188            mean_performance: *perf / stats.n as f64,
189        })
190        .collect();
191
192    Table::new(rows).with(Style::markdown()).to_string()
193}
194
195#[macro_export]
196macro_rules! get_summary_entry {
197    ($group:expr, $name:expr, $func:expr, $arg_count:tt, $test_file_name:expr) => {{
198        use ellip_dev_utils::{
199            benchmark, file, parser, stats,
200            test_report::{self, Case},
201        };
202        use std::path::Path;
203
204        ellip_dev_utils::func_wrapper!($func, f64, $arg_count);
205
206        let test_paths = file::find_test_files($test_file_name, "wolfram");
207        let cases = test_paths
208            .iter()
209            .flat_map(|test_path| parser::read_wolfram_data(test_path.to_str().unwrap()).unwrap())
210            .collect::<Vec<Case<f64>>>();
211        let stats = stats::Stats::from_vec(&test_report::compute_errors_from_cases(
212            &wrapped_func,
213            cases,
214        ));
215
216        // Criterion directory structure: target/criterion/<group>/<func>/new/estimates.json
217        let estimates_path_buf = Path::new("target/criterion")
218            .join($group)
219            .join(stringify!($func))
220            .join("new")
221            .join("estimates.json");
222        let perf = benchmark::extract_criterion_mean(&estimates_path_buf).unwrap_or(f64::NAN);
223
224        ($name, stats, perf)
225    }};
226    ($group:expr, $name:expr, $func:expr, $arg_count:tt) => {{
227        get_summary_entry! {$group, $name, $func, $arg_count, stringify!($func)}
228    }};
229}