Skip to main content

stuffr_cli/bench/
score.rs

1//! Scoring and winners for `stuffr bench`. Pure: no I/O, no timing.
2//!
3//! Each metric (`bytes_out`, `compress_ns`, `decompress_ns`) is scored
4//! `best / value` over the rows that succeeded, a value of 0 counting as 1.
5//! `balance` is the mean of the three scores. Failed rows score `None` and
6//! never win. Ties go to the smaller `bytes_out`, then to the earlier row.
7
8/// What one successful format/level run produced.
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct Measured {
11    pub bytes_out: u64,
12    pub compress_ns: u64,
13    pub decompress_ns: u64,
14}
15
16/// The result of one row: measured, or failed with a reason.
17#[derive(Clone, Debug, PartialEq)]
18pub enum Outcome {
19    Ok(Measured),
20    Failed(String),
21}
22
23/// One line of the table. `level` is `None` for codecs without levels
24/// (lz4, snappy). `backend` is `"c"` or `"pure"`: which implementation of
25/// the format this build ran (`levels::backend_of`).
26#[derive(Clone, Debug, PartialEq)]
27pub struct Row {
28    pub format: &'static str,
29    pub level: Option<i32>,
30    pub backend: &'static str,
31    pub outcome: Outcome,
32}
33
34/// Indices into the rows slice, one per column; `None` when no row succeeded.
35#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub struct Winners {
37    pub smallest: Option<usize>,
38    pub fastest_compress: Option<usize>,
39    pub fastest_decompress: Option<usize>,
40    pub balance: Option<usize>,
41}
42
43fn measured(row: &Row) -> Option<&Measured> {
44    match &row.outcome {
45        Outcome::Ok(m) => Some(m),
46        Outcome::Failed(_) => None,
47    }
48}
49
50/// `best / value`, a zero counting as 1 on both sides.
51fn ratio(best: u64, value: u64) -> f64 {
52    best.max(1) as f64 / value.max(1) as f64
53}
54
55/// The balance score of every row: the mean of the three per-metric scores,
56/// `None` for a failed row.
57pub fn balance_scores(rows: &[Row]) -> Vec<Option<f64>> {
58    let best = |f: fn(&Measured) -> u64| rows.iter().filter_map(measured).map(f).min();
59    let (Some(b_out), Some(b_c), Some(b_d)) = (
60        best(|m| m.bytes_out),
61        best(|m| m.compress_ns),
62        best(|m| m.decompress_ns),
63    ) else {
64        return vec![None; rows.len()];
65    };
66    rows.iter()
67        .map(|row| {
68            measured(row).map(|m| {
69                (ratio(b_out, m.bytes_out)
70                    + ratio(b_c, m.compress_ns)
71                    + ratio(b_d, m.decompress_ns))
72                    / 3.0
73            })
74        })
75        .collect()
76}
77
78/// The winner of each column. Ties go to the smaller `bytes_out`, then to
79/// the earlier row.
80pub fn winners(rows: &[Row]) -> Winners {
81    let scores = balance_scores(rows);
82    let by_min = |key: fn(&Measured) -> u64| {
83        rows.iter()
84            .enumerate()
85            .filter_map(|(i, r)| measured(r).map(|m| (i, m)))
86            .min_by_key(|(i, m)| (key(m), m.bytes_out, *i))
87            .map(|(i, _)| i)
88    };
89    let balance = rows
90        .iter()
91        .enumerate()
92        .filter_map(|(i, r)| Some((i, measured(r)?, scores[i]?)))
93        .reduce(|best, cand| {
94            // Replace only on a strictly higher score, or an equal score
95            // with strictly smaller output; otherwise the earlier row stays.
96            if cand.2 > best.2 || (cand.2 == best.2 && cand.1.bytes_out < best.1.bytes_out) {
97                cand
98            } else {
99                best
100            }
101        })
102        .map(|(i, _, _)| i);
103    Winners {
104        smallest: by_min(|m| m.bytes_out),
105        fastest_compress: by_min(|m| m.compress_ns),
106        fastest_decompress: by_min(|m| m.decompress_ns),
107        balance,
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn ok(bytes_out: u64, compress_ns: u64, decompress_ns: u64) -> Row {
116        Row {
117            format: "x",
118            level: Some(1),
119            backend: "pure",
120            outcome: Outcome::Ok(Measured {
121                bytes_out,
122                compress_ns,
123                decompress_ns,
124            }),
125        }
126    }
127
128    fn failed() -> Row {
129        Row {
130            format: "x",
131            level: None,
132            backend: "pure",
133            outcome: Outcome::Failed("boom".into()),
134        }
135    }
136
137    fn abc() -> Vec<Row> {
138        vec![ok(100, 10, 10), ok(50, 20, 20), ok(200, 5, 40)]
139    }
140
141    fn close(got: Option<f64>, want: f64) {
142        let got = got.expect("a score");
143        assert!((got - want).abs() < 1e-9, "{got} vs {want}");
144    }
145
146    #[test]
147    fn balance_weights_the_three_metrics_equally() {
148        let rows = abc();
149        let s = balance_scores(&rows);
150        close(s[0], (0.5 + 0.5 + 1.0) / 3.0);
151        close(s[1], (1.0 + 0.25 + 0.5) / 3.0);
152        close(s[2], (0.25 + 1.0 + 0.25) / 3.0);
153        assert_eq!(winners(&rows).balance, Some(0));
154    }
155
156    #[test]
157    fn winners_name_each_column() {
158        let w = winners(&abc());
159        assert_eq!(w.smallest, Some(1));
160        assert_eq!(w.fastest_compress, Some(2));
161        assert_eq!(w.fastest_decompress, Some(0));
162    }
163
164    #[test]
165    fn failed_rows_never_score_or_win() {
166        let mut rows = abc();
167        rows.insert(0, failed());
168        assert_eq!(balance_scores(&rows)[0], None);
169        let w = winners(&rows);
170        for idx in [
171            w.smallest,
172            w.fastest_compress,
173            w.fastest_decompress,
174            w.balance,
175        ] {
176            assert!(idx.expect("a winner") > 0);
177        }
178    }
179
180    #[test]
181    fn ties_go_to_the_smaller_output() {
182        let rows = vec![ok(100, 10, 10), ok(60, 10, 10)];
183        assert_eq!(winners(&rows).fastest_compress, Some(1));
184        assert_eq!(winners(&rows).fastest_decompress, Some(1));
185        assert_eq!(winners(&rows).balance, Some(1));
186    }
187
188    /// Equal mean scores reached by different metrics: the later row wins
189    /// on its smaller output, where "earlier row" alone would pick the first.
190    #[test]
191    fn a_balance_tie_goes_to_the_smaller_output() {
192        let rows = vec![ok(100, 10, 40), ok(50, 20, 40)];
193        let s = balance_scores(&rows);
194        assert_eq!(s[0], s[1], "a real tie");
195        assert_eq!(winners(&rows).balance, Some(1));
196    }
197
198    #[test]
199    fn ties_on_everything_go_to_the_earlier_row() {
200        let rows = vec![ok(7, 7, 7), ok(7, 7, 7)];
201        let w = winners(&rows);
202        assert_eq!(w.smallest, Some(0));
203        assert_eq!(w.balance, Some(0));
204    }
205
206    #[test]
207    fn all_failed_gives_no_winners() {
208        let rows = vec![failed(), failed()];
209        assert_eq!(winners(&rows), Winners::default());
210        assert_eq!(balance_scores(&rows), vec![None, None]);
211        assert_eq!(winners(&[]), Winners::default());
212    }
213
214    #[test]
215    fn zero_values_do_not_divide_by_zero() {
216        let rows = vec![ok(10, 10, 10), ok(0, 0, 0)];
217        for s in balance_scores(&rows) {
218            assert!(s.expect("a score").is_finite());
219        }
220        // Best in every column, a zero counting as 1 on both sides.
221        assert_eq!(balance_scores(&rows)[1], Some(1.0));
222        let w = winners(&rows);
223        assert_eq!(w.smallest, Some(1));
224        assert_eq!(w.fastest_compress, Some(1));
225        assert_eq!(w.fastest_decompress, Some(1));
226        assert_eq!(w.balance, Some(1));
227    }
228}