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