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 error in unit of epsilon
13pub fn err_func<T: Float>(a: T, b: T) -> f64 {
14    let abs_err = (a - b).abs();
15    if abs_err < T::epsilon() {
16        return 0.0;
17    }
18
19    let a = a.abs();
20    let b = b.abs();
21
22    (abs_err / a.max(b) / T::epsilon())
23        .to_f64()
24        .expect("Cannot convert to f64")
25}
26
27#[derive(Debug, Clone)]
28pub struct Case<T: Float> {
29    pub inputs: Vec<T>,
30    pub expected: T,
31}
32
33pub fn compute_errors_from_cases<T: Float + Debug>(
34    func: &dyn Fn(&Vec<T>) -> T,
35    cases: Vec<Case<T>>,
36) -> Vec<f64> {
37    cases
38        .iter()
39        .map(|case| {
40            if case.expected.is_finite() {
41                let res = func(&case.inputs);
42                let error = err_func(res, case.expected);
43                // if e > 20.0 {
44                //     println!(
45                //         "Using parameters: {:?}, got={:?}, actual={:?} (error={:.2})",
46                //         &case.inputs, res, case.expected, e
47                //     );
48                // }
49                error
50            } else {
51                f64::NAN
52            }
53        })
54        .collect()
55}
56
57pub fn format_float(value: &f64) -> String {
58    if value.is_nan() {
59        "NAN".to_string()
60    } else if *value >= 1e3 {
61        format!("{:.2e}", value)
62    } else {
63        format!("{:.2}", value)
64    }
65}
66
67fn format_mu(value: &u64) -> String {
68    if *value >= 10000 {
69        format!("{:e}", value)
70    } else {
71        format!("{}", value)
72    }
73}
74
75#[derive(Tabled)]
76pub struct ErrorEntry<'a> {
77    #[tabled(rename = "Function")]
78    name: &'a str,
79    #[tabled(rename = "Mean (ε)", display = "format_float")]
80    mean: f64,
81    #[tabled(rename = "Median (ε)", display = "format_float")]
82    median: f64,
83    #[tabled(rename = "P99 (ε)", display = "format_float")]
84    p99: f64,
85    #[tabled(rename = "Max (ε)", display = "format_float")]
86    max: f64,
87    #[tabled(rename = "Variance (ε²)", display = "format_float")]
88    variance: f64,
89    #[tabled(rename = "μ (ε)", display = "format_mu")]
90    mu: u64,
91}
92
93pub fn generate_error_entry_from_file<T: Float + Debug>(
94    file_path: &str,
95    func: &dyn Fn(&Vec<T>) -> T,
96) -> Stats {
97    let result = crate::parser::read_wolfram_data(file_path);
98    match result {
99        Ok(cases) => Stats::from_vec(&compute_errors_from_cases(func, cases)),
100        Err(_) => Stats::nan(),
101    }
102}
103
104pub fn generate_error_table(entries: &[(&str, u64, Stats, Stats)]) -> [String; 2] {
105    let f64_rows: Vec<ErrorEntry> = entries
106        .iter()
107        .map(|(name, mu, f64_stats, _)| ErrorEntry {
108            name,
109            mean: f64_stats.mean,
110            median: f64_stats.median,
111            p99: f64_stats.p99,
112            max: f64_stats.max,
113            variance: f64_stats.variance,
114            mu: *mu,
115        })
116        .collect();
117
118    let f32_rows: Vec<ErrorEntry> = entries
119        .iter()
120        .map(|(name, mu, _, f32_stats)| ErrorEntry {
121            name,
122            mean: f32_stats.mean,
123            median: f32_stats.median,
124            p99: f32_stats.p99,
125            max: f32_stats.max,
126            variance: f32_stats.variance,
127            mu: *mu,
128        })
129        .collect();
130
131    [
132        Table::new(f64_rows).with(Style::markdown()).to_string(),
133        Table::new(f32_rows).with(Style::markdown()).to_string(),
134    ]
135}
136
137#[macro_export]
138macro_rules! get_entry {
139    ($file_name: expr, $name: expr, $func: expr, $arg_count: tt, $mu: expr) => {{
140        let f64_entries = {
141            let file_path_f64 = concat!["tests/data/", $file_name, ".csv"];
142            ellip_dev_utils::func_wrapper!($func, f64, $arg_count);
143            ellip_dev_utils::test_report::generate_error_entry_from_file(
144                &file_path_f64,
145                &wrapped_func,
146            )
147        };
148        let f32_entries = {
149            let file_path_f32 = concat!["tests/data/f32/", $file_name, ".csv"];
150            ellip_dev_utils::func_wrapper!($func, f32, $arg_count);
151            ellip_dev_utils::test_report::generate_error_entry_from_file(
152                &file_path_f32,
153                &wrapped_func,
154            )
155        };
156
157        ($name, $mu, f64_entries, f32_entries)
158    }};
159}
160
161pub fn format_performance(value: &f64) -> String {
162    if value.is_nan() {
163        "NAN".to_string()
164    } else if *value < 1000.0 {
165        format!("{:.1} ns", value)
166    } else if *value < 1_000_000.0 {
167        format!("{:.1} μs", value / 1000.0)
168    } else {
169        format!("{:.1} ms", value / 1_000_000.0)
170    }
171}
172
173#[derive(Tabled)]
174pub struct SummaryEntry<'a> {
175    #[tabled(rename = "Function")]
176    name: &'a str,
177    #[tabled(rename = "Median Error (ε)", display = "format_float")]
178    median_error: f64,
179    #[tabled(rename = "Max Error (ε)", display = "format_float")]
180    max_error: f64,
181    #[tabled(rename = "Mean Performance", display = "format_performance")]
182    mean_performance: f64,
183}
184
185pub fn generate_summary_table(entries: &[(&str, Stats, f64)]) -> String {
186    let rows: Vec<SummaryEntry> = entries
187        .iter()
188        .map(|(name, stats, perf)| SummaryEntry {
189            name,
190            median_error: stats.median,
191            max_error: stats.max,
192            mean_performance: *perf / stats.n as f64,
193        })
194        .collect();
195
196    Table::new(rows).with(Style::markdown()).to_string()
197}
198
199#[macro_export]
200macro_rules! get_summary_entry {
201    ($group:expr, $name:expr, $func:expr, $arg_count:tt, $test_file_name:expr) => {{
202        use ellip_dev_utils::{
203            benchmark, file, parser, stats,
204            test_report::{self, Case},
205        };
206        use std::path::Path;
207
208        ellip_dev_utils::func_wrapper!($func, f64, $arg_count);
209
210        let test_paths = file::find_test_files($test_file_name, "wolfram");
211        let cases = test_paths
212            .iter()
213            .flat_map(|test_path| parser::read_wolfram_data(test_path.to_str().unwrap()).unwrap())
214            .collect::<Vec<Case<f64>>>();
215        let stats = stats::Stats::from_vec(&test_report::compute_errors_from_cases(
216            &wrapped_func,
217            cases,
218        ));
219
220        // Criterion directory structure: target/criterion/<group>/<func>/new/estimates.json
221        let estimates_path_buf = Path::new("target/criterion")
222            .join($group)
223            .join(stringify!($func))
224            .join("new")
225            .join("estimates.json");
226        let perf = benchmark::extract_criterion_mean(&estimates_path_buf).unwrap_or(f64::NAN);
227
228        ($name, stats, perf)
229    }};
230    ($group:expr, $name:expr, $func:expr, $arg_count:tt) => {{
231        get_summary_entry! {$group, $name, $func, $arg_count, stringify!($func)}
232    }};
233}
234
235pub fn format_exp(value: &f64) -> String {
236    if value.is_nan() {
237        "NAN".to_string()
238    } else if *value >= 1e3 {
239        format!("${:.2e}$", value).replace("e", "*10^")
240    } else {
241        format!("${:.2}$", value)
242    }
243}
244
245#[derive(Tabled)]
246pub struct AccuracyEntry<'a> {
247    #[tabled(rename = "**Function**")]
248    name: &'a str,
249    #[tabled(rename = "**Median (ε)**", display = "format_exp")]
250    median: f64,
251    #[tabled(rename = "**Max (ε)**", display = "format_exp")]
252    max: f64,
253    #[tabled(rename = "**Variance (ε²)**", display = "format_exp")]
254    variance: f64,
255}
256
257pub fn generate_accuracy_summary_table(entries: &[(&str, Stats)]) -> String {
258    let rows: Vec<AccuracyEntry> = entries
259        .iter()
260        .map(|(name, stats)| AccuracyEntry {
261            name,
262            median: stats.median,
263            max: stats.max,
264            variance: stats.variance,
265        })
266        .collect();
267
268    Table::new(rows).with(Style::ascii()).to_string()
269}
270
271#[macro_export]
272macro_rules! get_accuracy_entry {
273    ($group:expr, $name:expr, $func:expr, $arg_count:tt, $test_file_name:expr) => {{
274        use ellip_dev_utils::{
275            file, parser, stats,
276            test_report::{self, Case},
277        };
278        use std::path::Path;
279
280        ellip_dev_utils::func_wrapper!($func, f64, $arg_count);
281
282        let test_paths = file::find_test_files($test_file_name, "wolfram");
283        let cases = test_paths
284            .iter()
285            .flat_map(|test_path| parser::read_wolfram_data(test_path.to_str().unwrap()).unwrap())
286            .collect::<Vec<Case<f64>>>();
287        let stats = stats::Stats::from_vec(&test_report::compute_errors_from_cases(
288            &wrapped_func,
289            cases,
290        ));
291
292        ($name, stats)
293    }};
294    ($group:expr, $name:expr, $func:expr, $arg_count:tt) => {{
295        get_accuracy_entry! {$group, $name, $func, $arg_count, stringify!($func)}
296    }};
297}