1use crate::record::Record;
13use std::collections::{BTreeMap, BTreeSet};
14
15pub const GATED_METRIC: &str = "instructions";
17
18const WALL_METRIC: &str = "wall_min_ms";
20
21pub type Key = (String, String, String);
29
30#[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 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 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 pub changes: Vec<Change>,
75 pub added: Vec<Key>,
79 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 pub fn is_empty(&self) -> bool {
95 self.changes.is_empty()
96 }
97}
98
99fn 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
123pub 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
151pub type Trend = BTreeMap<Key, Vec<f64>>;
153
154const BARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
156
157pub 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
186fn 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
199fn 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
207const 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 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
251fn table(c: &Comparison, trend: &Trend, gate_pct: f64) -> String {
253 let mut out = String::new();
254 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
347fn 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
569 fn one_point_draws_nothing() {
570 assert_eq!(sparkline(&[1.0]), "");
571 assert_eq!(sparkline(&[]), "");
572 }
573
574 #[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}