Skip to main content

tak_cli/
compare.rs

1//! Compare one commit's measurements against another's.
2//!
3//! This is the part that can block a pull request, so it is deliberately narrow
4//! about what it will fail on. Only instruction counts gate. Wall clock is
5//! reported beside them and never gated: on identical hardware it moves 4-20%
6//! run to run, so a threshold tight enough to catch a real regression fires
7//! constantly, and one loose enough to stay quiet catches nothing.
8//!
9//! Rendering lives in [`markdown`], separate from the comparison itself, so a
10//! chart or a different report format is a new function rather than a rewrite.
11
12use crate::record::Record;
13use std::collections::{BTreeMap, BTreeSet};
14
15/// The only metric a gate may fire on.
16pub const GATED_METRIC: &str = "instructions";
17
18/// The timing metric shown alongside it, for context only.
19const WALL_METRIC: &str = "wall_min_ms";
20
21/// What identifies a comparable series.
22///
23/// Runner is part of the key because it has to be: absolute counts shift
24/// between machine types by more than a real regression does, so comparing a
25/// measurement taken on one runner against another's is not a comparison at
26/// all. Two commits measured on different runners simply do not line up here,
27/// which is the correct outcome rather than a missing feature.
28pub type Key = (String, String, String);
29
30/// One metric, on one series, on both sides.
31#[derive(Debug, Clone, PartialEq)]
32pub struct Change {
33    pub bench: String,
34    pub tool: String,
35    pub runner: String,
36    pub metric: String,
37    pub base: f64,
38    pub head: f64,
39}
40
41impl Change {
42    /// Change as a percentage of the base, or `None` when the base is zero.
43    ///
44    /// `None` rather than `0.0`. Returning zero was worse than imprecise: the
45    /// gate compares this against the threshold, so a rise from a zero baseline
46    /// read as no change at all and passed silently, however large the head.
47    /// A percentage of nothing is undefined, and the type should say so.
48    pub fn pct(&self) -> Option<f64> {
49        if self.base == 0.0 {
50            return None;
51        }
52        Some((self.head - self.base) / self.base * 100.0)
53    }
54
55    /// Is this a regression the gate should fail on?
56    ///
57    /// From a zero base, any increase counts. There is no threshold that means
58    /// anything against zero, and passing would be the one outcome that is
59    /// certainly wrong.
60    pub fn regressed(&self, gate_pct: f64) -> bool {
61        if self.metric != GATED_METRIC {
62            return false;
63        }
64        match self.pct() {
65            Some(p) => p > gate_pct,
66            None => self.head > 0.0,
67        }
68    }
69}
70
71#[derive(Debug, Default, PartialEq)]
72pub struct Comparison {
73    /// Every metric present on both sides, in a stable order.
74    pub changes: Vec<Change>,
75    /// Series measured on the head commit and not the base — a new benchmark,
76    /// or the first run on a new runner class. Reported, never gated: there is
77    /// nothing to compare against.
78    pub added: Vec<Key>,
79    /// Series on the base and not the head. Usually a benchmark that was
80    /// removed, occasionally a run that failed to record — worth surfacing
81    /// either way, because a silently vanishing benchmark stops gating.
82    pub removed: Vec<Key>,
83}
84
85impl Comparison {
86    pub fn regressions(&self, gate_pct: f64) -> Vec<&Change> {
87        self.changes
88            .iter()
89            .filter(|c| c.regressed(gate_pct))
90            .collect()
91    }
92
93    /// True when there is nothing to compare — no overlapping series at all.
94    pub fn is_empty(&self) -> bool {
95        self.changes.is_empty()
96    }
97}
98
99/// Fold records into one value per (series, metric), taking the minimum.
100///
101/// A commit can carry several records for the same series: CI re-run, a retry,
102/// a local run pushed alongside. The minimum is the right reducer for the same
103/// reason tak reports the minimum within a run — the extra work a machine
104/// sometimes does is one-sided. Averaging would let one noisy sample move a
105/// number that is supposed to be deterministic.
106fn index(records: &[Record]) -> BTreeMap<(Key, String), f64> {
107    let mut out: BTreeMap<(Key, String), f64> = BTreeMap::new();
108    for r in records {
109        let key: Key = (r.bench.clone(), r.tool.clone(), r.runner.clone());
110        for (metric, value) in &r.metrics {
111            out.entry((key.clone(), metric.clone()))
112                .and_modify(|existing| {
113                    if value < existing {
114                        *existing = *value;
115                    }
116                })
117                .or_insert(*value);
118        }
119    }
120    out
121}
122
123/// Compare two sets of records.
124pub fn compare(base: &[Record], head: &[Record]) -> Comparison {
125    let (b, h) = (index(base), index(head));
126
127    let mut changes = Vec::new();
128    for ((key, metric), head_value) in &h {
129        if let Some(base_value) = b.get(&(key.clone(), metric.clone())) {
130            changes.push(Change {
131                bench: key.0.clone(),
132                tool: key.1.clone(),
133                runner: key.2.clone(),
134                metric: metric.clone(),
135                base: *base_value,
136                head: *head_value,
137            });
138        }
139    }
140
141    let base_keys: BTreeSet<Key> = b.keys().map(|(k, _)| k.clone()).collect();
142    let head_keys: BTreeSet<Key> = h.keys().map(|(k, _)| k.clone()).collect();
143
144    Comparison {
145        changes,
146        added: head_keys.difference(&base_keys).cloned().collect(),
147        removed: base_keys.difference(&head_keys).cloned().collect(),
148    }
149}
150
151/// Recent values for each series, oldest first.
152pub type Trend = BTreeMap<Key, Vec<f64>>;
153
154/// Eight levels of block, low to high.
155const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
156
157/// A sparkline, scaled to the series' own range.
158///
159/// Per-series scaling is the only useful choice: an install benchmark at 100M
160/// instructions and a startup one at 7M share no axis, and a shared one would
161/// flatten every series but the largest into a straight line.
162///
163/// A flat series renders mid-height rather than at the floor. All-`▁` reads as
164/// "bottomed out" when it actually means "did not move", which for a
165/// deterministic metric is the best possible outcome and should not look like
166/// the worst.
167pub fn sparkline(values: &[f64]) -> String {
168    if values.len() < 2 {
169        return String::new();
170    }
171    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
172    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
173    if max <= min {
174        return BARS[BARS.len() / 2].to_string().repeat(values.len());
175    }
176    values
177        .iter()
178        .map(|v| {
179            let t = (v - min) / (max - min);
180            let i = (t * (BARS.len() - 1) as f64).round() as usize;
181            BARS[i.min(BARS.len() - 1)]
182        })
183        .collect()
184}
185
186/// `12345678` -> `12,345,678`
187fn thousands(v: f64) -> String {
188    let n = format!("{:.0}", v.abs());
189    let mut out = String::new();
190    for (i, c) in n.chars().enumerate() {
191        if i > 0 && (n.len() - i) % 3 == 0 {
192            out.push(',');
193        }
194        out.push(c);
195    }
196    if v < 0.0 { format!("-{out}") } else { out }
197}
198
199/// A percentage for display, or `new` when there is no base to compare to.
200fn signed_pct(p: Option<f64>) -> String {
201    match p {
202        Some(p) => format!("{}{:.2}%", if p >= 0.0 { "+" } else { "" }, p),
203        None => "new".to_string(),
204    }
205}
206
207/// Render a comparison as markdown, suitable for a PR comment or a terminal.
208///
209/// One format rather than two. A markdown table is readable unrendered, so a
210/// second plain-text renderer would be a second thing to keep correct for no
211/// gain.
212/// The line naming tak, appended unless the `credit` setting is off.
213///
214/// A report that turns up in someone's pull request should say what put it
215/// there: a reader who has never seen tak needs a way to find out, and a
216/// maintainer deciding whether to keep the comment needs to know what to turn
217/// off. Small, last, and one line — advertising that gets in the way of the
218/// numbers would be its own argument for removing it.
219const CREDIT: &str = "\n<sub>Measured by [tak](https://github.com/jdx/tak) — instruction-counted \
220     CLI benchmarks, stored in this repository's git notes.</sub>\n";
221
222pub fn markdown(c: &Comparison, trend: &Trend, gate_pct: f64, credit: bool) -> String {
223    let mut out = String::new();
224
225    if c.is_empty() {
226        // No early return. `added` and `removed` are often *most* interesting
227        // here — a runner migration produces exactly this state, and the useful
228        // half of the report is which benchmarks stopped being comparable.
229        out.push_str(
230            "**Nothing was compared, and so nothing was gated.** No series \
231             appears on both sides: either the base has no measurements \
232             recorded, or the two were measured on different runner classes, \
233             which are deliberately not comparable — counts shift between \
234             machine types by more than a real regression does.\n",
235        );
236    } else {
237        out.push_str(&table(c, trend, gate_pct));
238    }
239
240    out.push_str(&outliers(c));
241    out.push_str(
242        "\n<sub>Only instruction counts gate. Wall clock is shown for context — \
243         on identical hardware it moves 4-20% run to run.</sub>\n",
244    );
245    if credit {
246        out.push_str(CREDIT);
247    }
248    out
249}
250
251/// The comparison table and its verdict.
252fn table(c: &Comparison, trend: &Trend, gate_pct: f64) -> String {
253    let mut out = String::new();
254    // One row per series, both metrics side by side: reading them together is
255    // what tells you whether a wall-clock move is real.
256    let mut series: BTreeMap<Key, (Option<&Change>, Option<&Change>)> = BTreeMap::new();
257    for change in &c.changes {
258        let key = (
259            change.bench.clone(),
260            change.tool.clone(),
261            change.runner.clone(),
262        );
263        let slot = series.entry(key).or_insert((None, None));
264        match change.metric.as_str() {
265            GATED_METRIC => slot.0 = Some(change),
266            WALL_METRIC => slot.1 = Some(change),
267            _ => {}
268        }
269    }
270
271    let any_trend = series
272        .keys()
273        .any(|k| trend.get(k).is_some_and(|v| v.len() > 1));
274    if any_trend {
275        out.push_str("| benchmark | trend | instructions | Δ | wall (min) | Δ |\n");
276        out.push_str("|---|---|---:|---:|---:|---:|\n");
277    } else {
278        out.push_str("| benchmark | instructions | Δ | wall (min) | Δ |\n");
279        out.push_str("|---|---:|---:|---:|---:|\n");
280    }
281    for (key, (ins, wall)) in &series {
282        let (bench, tool, _runner) = key;
283        let name = if tool == "self" {
284            bench.clone()
285        } else {
286            format!("{bench} ({tool})")
287        };
288        let (ins_cell, ins_delta) = match ins {
289            Some(ch) => {
290                let flag = if ch.regressed(gate_pct) {
291                    " ⚠️"
292                } else {
293                    ""
294                };
295                (
296                    format!("{} → {}", thousands(ch.base), thousands(ch.head)),
297                    format!("**{}**{flag}", signed_pct(ch.pct())),
298                )
299            }
300            None => ("—".into(), "—".into()),
301        };
302        let (wall_cell, wall_delta) = match wall {
303            Some(ch) => (
304                format!("{:.2} → {:.2}ms", ch.base, ch.head),
305                signed_pct(ch.pct()),
306            ),
307            None => ("—".into(), "—".into()),
308        };
309        if any_trend {
310            let spark = trend
311                .get(key)
312                .map(|v| sparkline(v))
313                .filter(|s| !s.is_empty())
314                .map(|s| format!("`{s}`"))
315                .unwrap_or_else(|| "—".into());
316            out.push_str(&format!(
317                "| {name} | {spark} | {ins_cell} | {ins_delta} | {wall_cell} | {wall_delta} |\n"
318            ));
319        } else {
320            out.push_str(&format!(
321                "| {name} | {ins_cell} | {ins_delta} | {wall_cell} | {wall_delta} |\n"
322            ));
323        }
324    }
325
326    let regressions = c.regressions(gate_pct);
327    out.push('\n');
328    if regressions.is_empty() {
329        out.push_str(&format!(
330            "No instruction-count regression above {gate_pct}%.\n"
331        ));
332    } else {
333        out.push_str(&format!(
334            "**{} benchmark(s) above the {gate_pct}% gate:** {}\n",
335            regressions.len(),
336            regressions
337                .iter()
338                .map(|c| format!("`{}` {}", c.bench, signed_pct(c.pct())))
339                .collect::<Vec<_>>()
340                .join(", ")
341        ));
342    }
343
344    out
345}
346
347/// Series that exist on only one side. Always reported, including when nothing
348/// overlapped — that is the case where they matter most.
349fn outliers(c: &Comparison) -> String {
350    let mut out = String::new();
351    if !c.added.is_empty() {
352        out.push_str(&format!(
353            "\nNew, nothing to compare against: {}\n",
354            c.added
355                .iter()
356                .map(|(b, _, r)| format!("`{b}` on `{r}`"))
357                .collect::<Vec<_>>()
358                .join(", ")
359        ));
360    }
361    if !c.removed.is_empty() {
362        out.push_str(&format!(
363            "\nMeasured on the base but not here — a benchmark that stops \
364             running also stops gating: {}\n",
365            c.removed
366                .iter()
367                .map(|(b, _, r)| format!("`{b}` on `{r}`"))
368                .collect::<Vec<_>>()
369                .join(", ")
370        ));
371    }
372    out
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    fn rec(bench: &str, runner: &str, ins: f64, wall: f64) -> Record {
380        Record {
381            v: 1,
382            bench: bench.into(),
383            tool: "self".into(),
384            version: None,
385            runner: runner.into(),
386            ts: "2026-01-01T00:00:00Z".into(),
387            metrics: BTreeMap::from([
388                (GATED_METRIC.to_string(), ins),
389                (WALL_METRIC.to_string(), wall),
390            ]),
391        }
392    }
393
394    #[test]
395    fn a_rise_beyond_the_gate_is_a_regression() {
396        let c = compare(
397            &[rec("a", "gha", 1_000_000.0, 10.0)],
398            &[rec("a", "gha", 1_020_000.0, 10.0)],
399        );
400        assert_eq!(c.regressions(1.0).len(), 1, "2% should trip a 1% gate");
401        assert!(c.regressions(5.0).is_empty(), "2% should not trip 5%");
402    }
403
404    #[test]
405    fn an_improvement_never_gates() {
406        let c = compare(
407            &[rec("a", "gha", 1_000_000.0, 10.0)],
408            &[rec("a", "gha", 500_000.0, 10.0)],
409        );
410        assert!(c.regressions(1.0).is_empty());
411        let ins = c.changes.iter().find(|c| c.metric == GATED_METRIC).unwrap();
412        assert_eq!(ins.pct().unwrap().round(), -50.0);
413    }
414
415    /// The reason the gate is narrow. A doubling of wall clock is ordinary
416    /// noise on a shared runner and must not fail anyone's pull request.
417    #[test]
418    fn wall_clock_never_gates_however_bad_it_looks() {
419        let c = compare(
420            &[rec("a", "gha", 1_000_000.0, 10.0)],
421            &[rec("a", "gha", 1_000_000.0, 100.0)],
422        );
423        assert!(c.regressions(0.001).is_empty());
424        let wall = c.changes.iter().find(|c| c.metric == WALL_METRIC).unwrap();
425        assert_eq!(wall.pct().unwrap().round(), 900.0);
426    }
427
428    /// Different runner classes are different series. Comparing across them
429    /// would report a machine change as a code change.
430    #[test]
431    fn a_different_runner_is_not_a_comparison() {
432        let c = compare(
433            &[rec("a", "gha-linux", 1_000_000.0, 10.0)],
434            &[rec("a", "gha-macos", 2_000_000.0, 10.0)],
435        );
436        assert!(c.is_empty(), "nothing should line up");
437        assert_eq!(c.added.len(), 1);
438        assert_eq!(c.removed.len(), 1);
439        assert!(c.regressions(1.0).is_empty());
440
441        // The report has to say so. Asserting only on the struct let an early
442        // return hide both lists from every reader of the actual output.
443        let md = markdown(&c, &Trend::new(), 1.0, false);
444        assert!(md.contains("nothing was gated"), "{md}");
445        assert!(md.contains("gha-macos"), "the new runner is unnamed: {md}");
446        assert!(md.contains("gha-linux"), "the old runner is unnamed: {md}");
447    }
448
449    /// A head with nothing recorded is the quietest possible failure: every
450    /// benchmark stops gating at once and the gate still exits 0. It has to be
451    /// loud in the report, and it has to name what vanished.
452    #[test]
453    fn a_head_with_no_measurements_names_what_vanished() {
454        let c = compare(
455            &[
456                rec("a", "gha", 1_000_000.0, 10.0),
457                rec("b", "gha", 2.0, 2.0),
458            ],
459            &[],
460        );
461        let md = markdown(&c, &Trend::new(), 1.0, false);
462        assert!(c.regressions(1.0).is_empty());
463        assert!(md.contains("nothing was gated"), "{md}");
464        assert!(md.contains("`a`") && md.contains("`b`"), "{md}");
465        assert!(md.contains("stops gating"), "{md}");
466    }
467
468    /// The footnote about what gates belongs on every report, including the
469    /// ones with no table.
470    #[test]
471    fn the_gating_caveat_survives_an_empty_comparison() {
472        let md = markdown(&compare(&[], &[]), &Trend::new(), 1.0, false);
473        assert!(md.contains("Only instruction counts gate"), "{md}");
474    }
475
476    #[test]
477    fn a_new_benchmark_is_reported_but_not_gated() {
478        let c = compare(
479            &[rec("a", "gha", 1_000_000.0, 10.0)],
480            &[
481                rec("a", "gha", 1_000_000.0, 10.0),
482                rec("b", "gha", 9.9e9, 10.0),
483            ],
484        );
485        assert_eq!(
486            c.added,
487            vec![("b".to_string(), "self".to_string(), "gha".to_string())]
488        );
489        assert!(c.regressions(1.0).is_empty());
490    }
491
492    /// A benchmark that stops running stops gating, so its absence has to be
493    /// visible rather than simply making the table shorter.
494    #[test]
495    fn a_vanished_benchmark_is_surfaced() {
496        let c = compare(
497            &[
498                rec("a", "gha", 1_000_000.0, 10.0),
499                rec("b", "gha", 1.0, 1.0),
500            ],
501            &[rec("a", "gha", 1_000_000.0, 10.0)],
502        );
503        assert_eq!(c.removed.len(), 1);
504        assert!(markdown(&c, &Trend::new(), 1.0, false).contains("stops gating"));
505    }
506
507    /// Several records for one series collapse to the minimum, not the mean:
508    /// a noisy re-run must not move a number that is meant to be deterministic.
509    #[test]
510    fn duplicate_records_reduce_to_the_minimum() {
511        let c = compare(
512            &[rec("a", "gha", 1_000_000.0, 10.0)],
513            &[
514                rec("a", "gha", 3_000_000.0, 30.0),
515                rec("a", "gha", 1_000_000.0, 10.0),
516            ],
517        );
518        assert!(c.regressions(1.0).is_empty(), "the clean sample should win");
519    }
520
521    #[test]
522    fn nothing_in_common_says_so_rather_than_passing_quietly() {
523        let c = compare(&[], &[rec("a", "gha", 1.0, 1.0)]);
524        assert!(c.is_empty());
525        assert!(markdown(&c, &Trend::new(), 1.0, false).contains("nothing was gated"));
526    }
527
528    /// A percentage of zero is undefined, and the gate used to read that as
529    /// "no change" — so a base of zero disabled the gate for that benchmark
530    /// entirely, whatever the head measured.
531    #[test]
532    fn a_rise_from_a_zero_base_still_gates() {
533        let c = compare(
534            &[rec("a", "gha", 0.0, 10.0)],
535            &[rec("a", "gha", 5_000_000.0, 10.0)],
536        );
537        let ins = c.changes.iter().find(|c| c.metric == GATED_METRIC).unwrap();
538        assert_eq!(ins.pct(), None, "no percentage exists against zero");
539        assert_eq!(c.regressions(1.0).len(), 1, "it must still fail the gate");
540        assert!(markdown(&c, &Trend::new(), 1.0, false).contains("new"));
541    }
542
543    /// Zero to zero is not a regression; there is nothing to report.
544    #[test]
545    fn zero_to_zero_is_not_a_regression() {
546        let c = compare(&[rec("a", "gha", 0.0, 1.0)], &[rec("a", "gha", 0.0, 1.0)]);
547        assert!(c.regressions(1.0).is_empty());
548    }
549
550    #[test]
551    fn a_sparkline_spans_the_full_range() {
552        let s = sparkline(&[1.0, 2.0, 3.0, 4.0, 5.0]);
553        assert_eq!(s.chars().count(), 5);
554        assert_eq!(s.chars().next().unwrap(), '▁');
555        assert_eq!(s.chars().last().unwrap(), '█');
556    }
557
558    /// A deterministic metric that never moves is the best possible outcome.
559    /// Drawing it at the floor would make it look like the worst.
560    #[test]
561    fn a_flat_series_sits_mid_height() {
562        let s = sparkline(&[7.0, 7.0, 7.0]);
563        assert_eq!(s, "▅▅▅");
564    }
565
566    /// One point is not a trend, and a single bar would imply a shape that was
567    /// never measured.
568    #[test]
569    fn one_point_draws_nothing() {
570        assert_eq!(sparkline(&[1.0]), "");
571        assert_eq!(sparkline(&[]), "");
572    }
573
574    /// Each series is scaled to itself. A shared axis would flatten a 7M
575    /// startup benchmark into a straight line beside a 100M install one.
576    #[test]
577    fn series_are_scaled_independently() {
578        let small = sparkline(&[7_000_000.0, 7_100_000.0]);
579        let large = sparkline(&[100_000_000.0, 101_000_000.0]);
580        assert_eq!(small, large, "both rise by the same shape");
581    }
582
583    #[test]
584    fn the_trend_column_appears_only_when_there_is_a_trend() {
585        let c = compare(
586            &[rec("a", "gha", 1_000_000.0, 10.0)],
587            &[rec("a", "gha", 1_000_000.0, 10.0)],
588        );
589        assert!(!markdown(&c, &Trend::new(), 1.0, false).contains("| trend |"));
590
591        let mut trend = Trend::new();
592        trend.insert(
593            ("a".into(), "self".into(), "gha".into()),
594            vec![1.0, 2.0, 3.0],
595        );
596        let md = markdown(&c, &trend, 1.0, false);
597        assert!(md.contains("| trend |"), "{md}");
598        assert!(md.contains('█'), "{md}");
599    }
600
601    #[test]
602    fn thousands_separates() {
603        assert_eq!(thousands(1234567.0), "1,234,567");
604        assert_eq!(thousands(999.0), "999");
605        assert_eq!(thousands(1000.0), "1,000");
606    }
607}